source: framspy/FramsticksCLI.py @ 1019

Last change on this file since 1019 was 1019, checked in by Maciej Komosinski, 3 months ago

Better filename extensions for files with results; .gen was misleading in some cases

File size: 15.3 KB
Line 
1from subprocess import Popen, PIPE, check_output
2from enum import Enum
3from typing import List  # to be able to specify a type hint of list(something)
4from itertools import count  # for tracking multiple instances
5import json
6import sys, os
7import argparse
8import numpy as np
9
10
11class FramsticksCLI:
12        """Runs Framsticks CLI (command-line) executable and communicates with it using standard input and output.
13        You can perform basic operations like mutation, crossover, and evaluation of genotypes.
14        This way you can perform evolution controlled by python as well as access and manipulate genotypes.
15        You can even design and use in evolution your own genetic representation implemented entirely in python.
16
17        You need to provide one or two parameters when you run this class: the path to Framsticks CLI
18        and the name of the Framsticks CLI executable (if it is non-standard). See::
19                FramsticksCLI.py -h"""
20
21        PRINT_FRAMSTICKS_OUTPUT: bool = False  # set to True for debugging
22        DETERMINISTIC: bool = False  # set to True to have the same results on each run
23
24        GENO_SAVE_FILE_FORMAT = Enum('GENO_SAVE_FILE_FORMAT', 'NATIVEFRAMS RAWGENO')  # how to save genotypes
25        OUTPUT_DIR = "scripts_output"
26        STDOUT_ENDOPER_MARKER = "FileObject.write"  # we look for this message on Framsticks CLI stdout to detect when Framsticks created a file with the result we expect
27
28        FILE_PREFIX = 'framspy_'
29
30        RANDOMIZE_CMD = "Math.randomize();"
31        SETEXPEDEF_CMD = "Simulator.expdef=\"standard-eval\";"
32        GETSIMPLEST_CMD = "getsimplest"
33        GETSIMPLEST_FILE = "simplest.gen"
34        EVALUATE_CMD = "evaluate eval-allcriteria.sim"
35        EVALUATE_FILE = "genos_eval.json"
36        CROSSOVER_CMD = "crossover"
37        CROSSOVER_FILE = "child.gen"
38        DISSIMIL_CMD = "dissimil"
39        DISSIMIL_FILE = "dissimilarity_matrix.tsv"  # tab-separated values
40        ISVALID_CMD = "isvalid"
41        ISVALID_FILE = "validity.txt"
42        MUTATE_CMD = "mutate"
43        MUTATE_FILE = "mutant.gen"
44
45        CLI_INPUT_FILE = "genotypes.gen"
46
47        _next_instance_id = count(0)  # "static" counter incremented when a new instance is created. Used to ensure unique filenames for each instance.
48
49
50        def __init__(self, framspath, framsexe, pid=""):
51                self.pid = pid if pid is not None else ""
52                self.id = next(FramsticksCLI._next_instance_id)
53                self.frams_path = framspath
54                self.frams_exe = framsexe if framsexe is not None else 'frams.exe' if os.name == "nt" else 'frams.linux'
55                self.writing_path = None
56                mainpath = os.path.join(self.frams_path, self.frams_exe)
57                exe_call = [mainpath, '-Q', '-s', '-c', '-icliutils.ini']  # -c will be ignored in Windows Framsticks (this option is meaningless because the Windows version does not support color console, so no need to deactivate this feature using -c)
58                exe_call_to_get_version = [mainpath, '-V']
59                exe_call_to_get_path = [mainpath, '-?']
60                try:
61                        print("\n".join(self.__readAllOutput(exe_call_to_get_version)))
62                        help = self.__readAllOutput(exe_call_to_get_path)
63                        for helpline in help:
64                                if 'dDIRECTORY' in helpline:
65                                        self.writing_path = helpline.split("'")[1]
66                except FileNotFoundError:
67                        print("Could not find Framsticks executable ('%s') in the given location ('%s')." % (self.frams_exe, self.frams_path))
68                        sys.exit(1)
69                print("Temporary files with results will be saved in detected writable working directory '%s'" % self.writing_path)
70                self.__spawnFramsticksCLI(exe_call)
71
72
73        def __readAllOutput(self, command):
74                frams_process = Popen(command, stdout=PIPE, stderr=PIPE, stdin=PIPE)
75                return [line.decode('utf-8').rstrip() for line in iter(frams_process.stdout.readlines())]
76
77
78        def __spawnFramsticksCLI(self, args):
79                # the child app (Framsticks CLI) should not buffer outputs and we need to immediately read its stdout, hence we use pexpect/wexpect
80                print('Spawning Framsticks CLI for continuous stdin/stdout communication... ', end='')
81                if os.name == "nt":  # Windows:
82                        import wexpect  # https://pypi.org/project/wexpect/
83                        # https://github.com/raczben/wexpect/tree/master/examples
84                        self.child = wexpect.spawn(' '.join(args))
85                else:
86                        import pexpect  # https://pexpect.readthedocs.io/en/stable/
87                        self.child = pexpect.spawn(' '.join(args))
88                self.child.setecho(False)  # ask the communication to not copy to stdout what we write to stdin
89                print('OK.')
90
91                self.__readFromFramsCLIUntil("UserScripts.autoload")
92                print('Performing a basic test 1/3... ', end='')
93                assert self.getSimplest("1") == "X"
94                print('OK.')
95                print('Performing a basic test 2/3... ', end='')
96                assert self.isValid("X[0:0]") is True
97                print('OK.')
98                print('Performing a basic test 3/3... ', end='')
99                assert self.isValid("X[0:0],") is False
100                print('OK.')
101                if not self.DETERMINISTIC:
102                        self.sendDirectCommand(self.RANDOMIZE_CMD)
103                self.sendDirectCommand(self.SETEXPEDEF_CMD)
104
105
106        def closeFramsticksCLI(self):
107                # End gracefully by sending end-of-file character: ^Z or ^D
108                # Without the -Q argument ("quiet mode"), Framsticks CLI would print "Shell closed." for goodbye.
109                self.child.sendline(chr(26 if os.name == "nt" else 4))
110
111
112        def __getPrefixedFilename(self, filename: str) -> str:
113                # Returns filename with unique instance id appended so there is no clash when many instances of this class use the same Framsticks CLI executable
114                return FramsticksCLI.FILE_PREFIX + self.pid + str(chr(ord('A') + self.id)) + '_' + filename
115
116
117        def __saveGenotypeToFile(self, genotype, name, mode, saveformat):
118                relname = self.__getPrefixedFilename(name)
119                absname = os.path.join(self.writing_path, relname)
120                if mode == 'd':  # special mode, 'delete'
121                        if os.path.exists(absname):
122                                os.remove(absname)
123                else:
124                        outfile = open(absname, mode)
125                        if saveformat == self.GENO_SAVE_FILE_FORMAT["RAWGENO"]:
126                                outfile.write(genotype)
127                        else:
128                                outfile.write("org:\n")
129                                outfile.write("genotype:~\n")
130                                outfile.write(genotype + "~\n\n")  # TODO proper quoting of special characters in genotype...
131                        outfile.close()
132                return relname, absname
133
134
135        def __readFromFramsCLIUntil(self, until_marker: str) -> str:
136                output = ""
137                while True:
138                        self.child.expect('\r\n' if os.name == "nt" else '\n')
139                        msg = str(self.child.before)
140                        if self.PRINT_FRAMSTICKS_OUTPUT or msg.startswith("[ERROR]") or msg.startswith("[CRITICAL]"):
141                                print(msg)
142                        if until_marker in msg:
143                                break
144                        else:
145                                output += msg + '\n'
146                return output
147
148
149        def __runCommand(self, command, genotypes, result_file_name, saveformat) -> List[str]:
150                filenames_rel = []  # list of file names with input data for the command
151                filenames_abs = []  # same list but absolute paths actually used
152                if saveformat == self.GENO_SAVE_FILE_FORMAT["RAWGENO"]:
153                        for i in range(len(genotypes)):
154                                # plain text format = must have a separate file for each genotype
155                                rel, abs = self.__saveGenotypeToFile(genotypes[i], "genotype" + str(i) + ".gen", "w", self.GENO_SAVE_FILE_FORMAT["RAWGENO"])
156                                filenames_rel.append(rel)
157                                filenames_abs.append(abs)
158                elif saveformat == self.GENO_SAVE_FILE_FORMAT["NATIVEFRAMS"]:
159                        self.__saveGenotypeToFile(None, self.CLI_INPUT_FILE, 'd', None)  # 'd'elete: ensure there is nothing left from the last run of the program because we "a"ppend to file in the loop below
160                        for i in range(len(genotypes)):
161                                rel, abs = self.__saveGenotypeToFile(genotypes[i], self.CLI_INPUT_FILE, "a", self.GENO_SAVE_FILE_FORMAT["NATIVEFRAMS"])
162                        #  since we use the same file in the loop above, add this file only once (i.e., outside of the loop)
163                        filenames_rel.append(rel)
164                        filenames_abs.append(abs)
165
166                result_file_name = self.__getPrefixedFilename(result_file_name)
167                cmd = command + " " + " ".join(filenames_rel) + " " + result_file_name
168                self.child.sendline(cmd)
169                self.__readFromFramsCLIUntil(self.STDOUT_ENDOPER_MARKER)
170                filenames_abs.append(os.path.join(self.writing_path, self.OUTPUT_DIR, result_file_name))
171                return filenames_abs  # last element is a path to the file containing results
172
173
174        def __cleanUpCommandResults(self, filenames):
175                """Deletes files with results just created by the command."""
176                for name in filenames:
177                        os.remove(name)
178
179
180        sendDirectCommand_counter = count(0)  # an internal counter for the sendDirectCommand() method; should be static within that method but python does not allow
181
182
183        def sendDirectCommand(self, command: str) -> str:
184                """Sends any command to Framsticks CLI. Use when you know Framsticks and its scripting language, Framscript.
185
186                Returns:
187                        The output of the command, likely with extra \\n because for each entered command, Framsticks CLI responds with a (muted in Quiet mode) prompt and a \\n.
188                """
189                self.child.sendline(command.strip())
190                next(FramsticksCLI.sendDirectCommand_counter)
191                STDOUT_ENDOPER_MARKER = "uniqe-marker-" + str(FramsticksCLI.sendDirectCommand_counter)
192                self.child.sendline("Simulator.print(\"%s\");" % STDOUT_ENDOPER_MARKER)
193                return self.__readFromFramsCLIUntil(STDOUT_ENDOPER_MARKER)
194
195
196        def getSimplest(self, genetic_format) -> str:
197                assert len(genetic_format) == 1, "Genetic format should be a single character"
198                files = self.__runCommand(self.GETSIMPLEST_CMD + " " + genetic_format + " ", [], self.GETSIMPLEST_FILE, self.GENO_SAVE_FILE_FORMAT["RAWGENO"])
199                with open(files[-1]) as f:
200                        genotype = "".join(f.readlines())
201                self.__cleanUpCommandResults(files)
202                return genotype
203
204
205        def evaluate(self, genotype: str):
206                """
207                Returns:
208                        Dictionary -- genotype evaluated with self.EVALUATE_COMMAND. Note that for whatever reason (e.g. incorrect genotype),
209                        the dictionary you will get may be empty or partially empty and may not have the fields you expected, so handle such cases properly.
210                """
211                files = self.__runCommand(self.EVALUATE_CMD, [genotype], self.EVALUATE_FILE, self.GENO_SAVE_FILE_FORMAT["NATIVEFRAMS"])
212                with open(files[-1]) as f:
213                        data = json.load(f)
214                if len(data) > 0:
215                        self.__cleanUpCommandResults(files)
216                        return data
217                else:
218                        print("Evaluating genotype: no performance data was returned in", self.EVALUATE_FILE)  # we do not delete files here
219                        return None
220
221
222        def mutate(self, genotype: str) -> str:
223                """
224                Returns:
225                        The genotype of the mutated individual. Empty string if the mutation failed.
226                """
227                files = self.__runCommand(self.MUTATE_CMD, [genotype], self.MUTATE_FILE, self.GENO_SAVE_FILE_FORMAT["RAWGENO"])
228                with open(files[-1]) as f:
229                        newgenotype = "".join(f.readlines())
230                self.__cleanUpCommandResults(files)
231                return newgenotype
232
233
234        def crossOver(self, genotype_parent1: str, genotype_parent2: str) -> str:
235                """
236                Returns:
237                        The genotype of the offspring. Empty string if the crossing over failed.
238                """
239                files = self.__runCommand(self.CROSSOVER_CMD, [genotype_parent1, genotype_parent2], self.CROSSOVER_FILE, self.GENO_SAVE_FILE_FORMAT["RAWGENO"])
240                with open(files[-1]) as f:
241                        child_genotype = "".join(f.readlines())
242                self.__cleanUpCommandResults(files)
243                return child_genotype
244
245
246        def dissimilarity(self, genotype_list: List[str]) -> np.ndarray:
247                """
248                Returns:
249                        A square array with dissimilarities of each pair of genotypes.
250                """
251                files = self.__runCommand(self.DISSIMIL_CMD, genotype_list, self.DISSIMIL_FILE, self.GENO_SAVE_FILE_FORMAT["NATIVEFRAMS"])
252                with open(files[-1]) as f:
253                        dissimilarity_matrix = np.genfromtxt(f, dtype=np.float64, comments='#', encoding=None, delimiter='\t')
254                # We would like to skip column #1 while reading and read everything else, but... https://stackoverflow.com/questions/36091686/exclude-columns-from-genfromtxt-with-numpy
255                # This would be too complicated, so strings (names) in column #1 become NaN as floats (unless they accidentally are valid numbers) - not great, not terrible
256                square_matrix = dissimilarity_matrix[:, 2:]  # get rid of two first columns (fitness and name)
257                EXPECTED_SHAPE = (len(genotype_list), len(genotype_list))
258                # print(square_matrix)
259                assert square_matrix.shape == EXPECTED_SHAPE, f"Not a correct dissimilarity matrix, expected {EXPECTED_SHAPE} "
260                for i in range(len(square_matrix)):
261                        assert square_matrix[i][i] == 0, "Not a correct dissimilarity matrix, diagonal expected to be 0"
262                assert (square_matrix == square_matrix.T).all(), "Probably not a correct dissimilarity matrix, expecting symmetry, verify this"  # could introduce tolerance in comparison (e.g. class field DISSIMIL_DIFF_TOLERANCE=10^-5) so that miniscule differences do not fail here
263                self.__cleanUpCommandResults(files)
264                return square_matrix
265
266
267        def isValid(self, genotype: str) -> bool:
268                files = self.__runCommand(self.ISVALID_CMD, [genotype], self.ISVALID_FILE, self.GENO_SAVE_FILE_FORMAT["RAWGENO"])
269                with open(files[-1]) as f:
270                        valid = f.readline() == "1"
271                self.__cleanUpCommandResults(files)
272                return valid
273
274
275def parseArguments():
276        parser = argparse.ArgumentParser(description='Run this program with "python -u %s" if you want to disable buffering of its output.' % sys.argv[0])
277        parser.add_argument('-path', type=ensureDir, required=True, help='Path to Framsticks CLI without trailing slash.')
278        parser.add_argument('-exe', required=False, help='Executable name. If not given, "frams.exe" or "frams.linux" is assumed.')
279        parser.add_argument('-genformat', required=False, help='Genetic format for the demo run, for example 4, 9, or S. If not given, f1 is assumed.')
280        parser.add_argument('-pid', required=False, help='Unique ID of this process. Only relevant when you run multiple instances of this class simultaneously but as separate processes, and they use the same Framsticks CLI executable. This value will be appended to the names of created files to avoid conflicts.')
281        return parser.parse_args()
282
283
284def ensureDir(string):
285        if os.path.isdir(string):
286                return string
287        else:
288                raise NotADirectoryError(string)
289
290
291if __name__ == "__main__":
292        # A demo run.
293
294        # TODO ideas:
295        # - check_validity with three levels (invalid, corrected, valid)
296        # - "vectorize" some operations (isvalid, evaluate) so that a number of genotypes is handled in one call
297        # - use threads for non-blocking reading from frams' stdout and thus not relying on specific strings printed by frams
298        # - a pool of binaries run at the same time, balance load - in particular evaluation
299        # - if we read genotypes in "org:" format anywhere: import https://pypi.org/project/framsreader/0.1.2/ and use it if successful,
300        #    if not then print a message "framsreader not available, using simple internal method to save a genotype" and proceed as it is now.
301        #    So far we don't read, but we should use the proper writer to handle all special cases like quoting etc.
302
303        parsed_args = parseArguments()
304        framsCLI = FramsticksCLI(parsed_args.path, parsed_args.exe, parsed_args.pid)
305
306        print("Sending a direct command to Framsticks CLI that calculates \"4\"+2 yields", repr(framsCLI.sendDirectCommand("Simulator.print(\"4\"+2);")))
307
308        simplest = framsCLI.getSimplest('1' if parsed_args.genformat is None else parsed_args.genformat)
309        print("\tSimplest genotype:", simplest)
310        parent1 = framsCLI.mutate(simplest)
311        parent2 = parent1
312        MUTATE_COUNT = 10
313        for x in range(MUTATE_COUNT):  # example of a chain of 20 mutations
314                parent2 = framsCLI.mutate(parent2)
315        print("\tParent1 (mutated simplest):", parent1)
316        print("\tParent2 (Parent1 mutated %d times):" % MUTATE_COUNT, parent2)
317        offspring = framsCLI.crossOver(parent1, parent2)
318        print("\tCrossover (Offspring):", offspring)
319        print('\tDissimilarity of Parent1 and Offspring:', framsCLI.dissimilarity([parent1, offspring])[0, 1])
320        print('\tPerformance of Offspring:', framsCLI.evaluate(offspring))
321        print('\tValidity of Offspring:', framsCLI.isValid(offspring))
322
323        framsCLI.closeFramsticksCLI()
Note: See TracBrowser for help on using the repository browser.