1 | from subprocess import Popen, PIPE, check_output
|
---|
2 | from enum import Enum
|
---|
3 | from typing import List
|
---|
4 | from itertools import count # for tracking multiple instances
|
---|
5 | import json
|
---|
6 | import sys, os
|
---|
7 | import argparse
|
---|
8 | import numpy as np
|
---|
9 |
|
---|
10 |
|
---|
11 | class 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 = "rnd" + "\n"
|
---|
31 | SETEXPEDEF_CMD = "expdef standard-eval" + "\n"
|
---|
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.gen"
|
---|
40 | ISVALID_CMD = "isvalid"
|
---|
41 | ISVALID_FILE = "validity.gen"
|
---|
42 | MUTATE_CMD = "mutate"
|
---|
43 | MUTATE_FILE = "mutant.gen"
|
---|
44 |
|
---|
45 | CLI_INPUT_FILE = "genotypes.gen"
|
---|
46 |
|
---|
47 | _last_instance_id = count(0) # "static" counter incremented when a new instance is created. Used for unique filenames
|
---|
48 |
|
---|
49 |
|
---|
50 | def __init__(self, framspath, framsexe):
|
---|
51 | self.id = next(FramsticksCLI._last_instance_id)
|
---|
52 | self.frams_path = framspath
|
---|
53 | self.frams_exe = framsexe if framsexe is not None else 'frams.exe' if os.name == "nt" else 'frams.linux'
|
---|
54 | self.writing_path = None
|
---|
55 | mainpath = os.path.join(self.frams_path, self.frams_exe)
|
---|
56 | 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)
|
---|
57 | exe_call_to_get_version = [mainpath, '-V']
|
---|
58 | exe_call_to_get_path = [mainpath, '-?']
|
---|
59 | try:
|
---|
60 | print("\n".join(self.__readAllOutput(exe_call_to_get_version)))
|
---|
61 | help = self.__readAllOutput(exe_call_to_get_path)
|
---|
62 | for helpline in help:
|
---|
63 | if 'dDIRECTORY' in helpline:
|
---|
64 | self.writing_path = helpline.split("'")[1]
|
---|
65 | except FileNotFoundError:
|
---|
66 | print("Could not find Framsticks executable ('%s') in the given location ('%s')." % (self.frams_exe, self.frams_path))
|
---|
67 | sys.exit(1)
|
---|
68 | print("Temporary files with results will be saved in detected writable working directory '%s'" % self.writing_path)
|
---|
69 | self.__spawnFramsticksCLI(exe_call)
|
---|
70 |
|
---|
71 |
|
---|
72 | def __readAllOutput(self, command):
|
---|
73 | frams_process = Popen(command, stdout=PIPE, stderr=PIPE, stdin=PIPE)
|
---|
74 | return [line.decode('utf-8').rstrip() for line in iter(frams_process.stdout.readlines())]
|
---|
75 |
|
---|
76 |
|
---|
77 | def __spawnFramsticksCLI(self, args):
|
---|
78 | # the child app (Framsticks CLI) should not buffer outputs and we need to immediately read its stdout, hence we use pexpect/wexpect
|
---|
79 | print('Spawning Framsticks CLI for continuous stdin/stdout communication... ', end='')
|
---|
80 | if os.name == "nt": # Windows:
|
---|
81 | import wexpect # https://pypi.org/project/wexpect/
|
---|
82 | # https://github.com/raczben/wexpect/tree/master/examples
|
---|
83 | self.child = wexpect.spawn(' '.join(args))
|
---|
84 | else:
|
---|
85 | import pexpect # https://pexpect.readthedocs.io/en/stable/
|
---|
86 | self.child = pexpect.spawn(' '.join(args))
|
---|
87 | self.child.setecho(False) # linux only
|
---|
88 | print('OK.')
|
---|
89 |
|
---|
90 | self.__readFromFramsCLIUntil("UserScripts.autoload")
|
---|
91 | print('Performing a basic test 1/3... ', end='')
|
---|
92 | assert self.getSimplest("1") == "X"
|
---|
93 | print('OK.')
|
---|
94 | print('Performing a basic test 2/3... ', end='')
|
---|
95 | assert self.isValid("X[0:0]") is True
|
---|
96 | print('OK.')
|
---|
97 | print('Performing a basic test 3/3... ', end='')
|
---|
98 | assert self.isValid("X[0:0],") is False
|
---|
99 | print('OK.')
|
---|
100 | if not self.DETERMINISTIC:
|
---|
101 | self.child.sendline(self.RANDOMIZE_CMD)
|
---|
102 | self.child.sendline(self.SETEXPEDEF_CMD)
|
---|
103 |
|
---|
104 |
|
---|
105 | def closeFramsticksCLI(self):
|
---|
106 | # End gracefully by sending end-of-file character: ^Z or ^D
|
---|
107 | # Without -Q argument ("quiet mode"), Framsticks CLI would print "Shell closed." for goodbye.
|
---|
108 | self.child.sendline(chr(26 if os.name == "nt" else 4))
|
---|
109 |
|
---|
110 |
|
---|
111 | def __getPrefixedFilename(self, filename: str) -> str:
|
---|
112 | # Returns filename with unique instance id appended so there is no clash when many instances of this class use the same Framsticks CLI executable
|
---|
113 | return FramsticksCLI.FILE_PREFIX + str(chr(ord('A') + self.id)) + '_' + filename
|
---|
114 |
|
---|
115 |
|
---|
116 | def __saveGenotypeToFile(self, genotype, name, mode, saveformat):
|
---|
117 | relname = self.__getPrefixedFilename(name)
|
---|
118 | absname = os.path.join(self.writing_path, relname)
|
---|
119 | if mode == 'd': # special mode, 'delete'
|
---|
120 | if os.path.exists(absname):
|
---|
121 | os.remove(absname)
|
---|
122 | else:
|
---|
123 | outfile = open(absname, mode)
|
---|
124 | if saveformat == self.GENO_SAVE_FILE_FORMAT["RAWGENO"]:
|
---|
125 | outfile.write(genotype)
|
---|
126 | else:
|
---|
127 | outfile.write("org:\n")
|
---|
128 | outfile.write("genotype:~\n")
|
---|
129 | outfile.write(genotype + "~\n\n") # TODO proper quoting of special characters in genotype...
|
---|
130 | outfile.close()
|
---|
131 | return relname, absname
|
---|
132 |
|
---|
133 |
|
---|
134 | def __readFromFramsCLIUntil(self, until_marker: str):
|
---|
135 | while True:
|
---|
136 | self.child.expect('\n')
|
---|
137 | msg = str(self.child.before)
|
---|
138 | if self.PRINT_FRAMSTICKS_OUTPUT or msg.startswith("[ERROR]"):
|
---|
139 | print(msg)
|
---|
140 | if until_marker in msg:
|
---|
141 | break
|
---|
142 |
|
---|
143 |
|
---|
144 | def __runCommand(self, command, genotypes, result_file_name, saveformat) -> List[str]:
|
---|
145 | filenames_rel = [] # list of file names with input data for the command
|
---|
146 | filenames_abs = [] # same list but absolute paths actually used
|
---|
147 | if saveformat == self.GENO_SAVE_FILE_FORMAT["RAWGENO"]:
|
---|
148 | for i in range(len(genotypes)):
|
---|
149 | # plain text format = must have a separate file for each genotype
|
---|
150 | rel, abs = self.__saveGenotypeToFile(genotypes[i], "genotype" + str(i) + ".gen", "w", self.GENO_SAVE_FILE_FORMAT["RAWGENO"])
|
---|
151 | filenames_rel.append(rel)
|
---|
152 | filenames_abs.append(abs)
|
---|
153 | elif saveformat == self.GENO_SAVE_FILE_FORMAT["NATIVEFRAMS"]:
|
---|
154 | 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
|
---|
155 | for i in range(len(genotypes)):
|
---|
156 | rel, abs = self.__saveGenotypeToFile(genotypes[i], self.CLI_INPUT_FILE, "a", self.GENO_SAVE_FILE_FORMAT["NATIVEFRAMS"])
|
---|
157 | # since we use the same file in the loop above, add this file only once (i.e., outside of the loop)
|
---|
158 | filenames_rel.append(rel)
|
---|
159 | filenames_abs.append(abs)
|
---|
160 |
|
---|
161 | result_file_name = self.__getPrefixedFilename(result_file_name)
|
---|
162 | cmd = command + " " + " ".join(filenames_rel) + " " + result_file_name
|
---|
163 | self.child.sendline(cmd + '\n')
|
---|
164 | self.__readFromFramsCLIUntil(self.STDOUT_ENDOPER_MARKER)
|
---|
165 | filenames_abs.append(os.path.join(self.writing_path, self.OUTPUT_DIR, result_file_name))
|
---|
166 | return filenames_abs # last element is a path to the file containing results
|
---|
167 |
|
---|
168 |
|
---|
169 | def __cleanUpCommandResults(self, filenames):
|
---|
170 | """Deletes files with results just created by the command."""
|
---|
171 | for name in filenames:
|
---|
172 | os.remove(name)
|
---|
173 |
|
---|
174 |
|
---|
175 | def getSimplest(self, genetic_format) -> str:
|
---|
176 | assert len(genetic_format) == 1, "Genetic format should be a single character"
|
---|
177 | files = self.__runCommand(self.GETSIMPLEST_CMD + " " + genetic_format + " ", [], self.GETSIMPLEST_FILE, self.GENO_SAVE_FILE_FORMAT["RAWGENO"])
|
---|
178 | with open(files[-1]) as f:
|
---|
179 | genotype = "".join(f.readlines())
|
---|
180 | self.__cleanUpCommandResults(files)
|
---|
181 | return genotype
|
---|
182 |
|
---|
183 |
|
---|
184 | def evaluate(self, genotype: str):
|
---|
185 | """
|
---|
186 | Returns:
|
---|
187 | Dictionary -- genotype evaluated with self.EVALUATE_COMMAND. Note that for whatever reason (e.g. incorrect genotype),
|
---|
188 | the dictionary you will get may be empty or partially empty and may not have the fields you expected, so handle such cases properly.
|
---|
189 | """
|
---|
190 | files = self.__runCommand(self.EVALUATE_CMD, [genotype], self.EVALUATE_FILE, self.GENO_SAVE_FILE_FORMAT["NATIVEFRAMS"])
|
---|
191 | with open(files[-1]) as f:
|
---|
192 | data = json.load(f)
|
---|
193 | if len(data) > 0:
|
---|
194 | self.__cleanUpCommandResults(files)
|
---|
195 | return data
|
---|
196 | else:
|
---|
197 | print("Evaluating genotype: no performance data was returned in", self.EVALUATE_FILE) # we do not delete files here
|
---|
198 | return None
|
---|
199 |
|
---|
200 |
|
---|
201 | def mutate(self, genotype: str) -> str:
|
---|
202 | files = self.__runCommand(self.MUTATE_CMD, [genotype], self.MUTATE_FILE, self.GENO_SAVE_FILE_FORMAT["RAWGENO"])
|
---|
203 | with open(files[-1]) as f:
|
---|
204 | newgenotype = "".join(f.readlines())
|
---|
205 | self.__cleanUpCommandResults(files)
|
---|
206 | return newgenotype
|
---|
207 |
|
---|
208 |
|
---|
209 | def crossOver(self, genotype1: str, genotype2: str) -> str:
|
---|
210 | files = self.__runCommand(self.CROSSOVER_CMD, [genotype1, genotype2], self.CROSSOVER_FILE, self.GENO_SAVE_FILE_FORMAT["RAWGENO"])
|
---|
211 | with open(files[-1]) as f:
|
---|
212 | child_genotype = "".join(f.readlines())
|
---|
213 | self.__cleanUpCommandResults(files)
|
---|
214 | return child_genotype
|
---|
215 |
|
---|
216 |
|
---|
217 | def dissimilarity(self, genotype1: str, genotype2: str) -> float:
|
---|
218 | files = self.__runCommand(self.DISSIMIL_CMD, [genotype1, genotype2], self.DISSIMIL_FILE, self.GENO_SAVE_FILE_FORMAT["NATIVEFRAMS"])
|
---|
219 | with open(files[-1]) as f:
|
---|
220 | dissimilarity_matrix = np.genfromtxt(f, dtype=np.float64, comments='#', encoding=None, delimiter='\t')
|
---|
221 | # 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
|
---|
222 | # 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
|
---|
223 | EXPECTED_SHAPE = (2, 4)
|
---|
224 | assert dissimilarity_matrix.shape == EXPECTED_SHAPE, f"Not a correct dissimilarity matrix, expected {EXPECTED_SHAPE} "
|
---|
225 | for i in range(len(dissimilarity_matrix)):
|
---|
226 | assert dissimilarity_matrix[i][i + 2] == 0, "Not a correct dissimilarity matrix, diagonal expected to be 0"
|
---|
227 | assert dissimilarity_matrix[0][3] == dissimilarity_matrix[1][2], "Probably not a correct dissimilarity matrix, expecting symmetry, verify this"
|
---|
228 | self.__cleanUpCommandResults(files)
|
---|
229 | return dissimilarity_matrix[0][3]
|
---|
230 |
|
---|
231 |
|
---|
232 | def isValid(self, genotype: str) -> bool:
|
---|
233 | files = self.__runCommand(self.ISVALID_CMD, [genotype], self.ISVALID_FILE, self.GENO_SAVE_FILE_FORMAT["RAWGENO"])
|
---|
234 | with open(files[-1]) as f:
|
---|
235 | valid = f.readline() == "1"
|
---|
236 | self.__cleanUpCommandResults(files)
|
---|
237 | return valid
|
---|
238 |
|
---|
239 |
|
---|
240 | def parseArguments():
|
---|
241 | parser = argparse.ArgumentParser(description='Run this program with "python -u %s" if you want to disable buffering of its output.' % sys.argv[0])
|
---|
242 | parser.add_argument('-path', type=ensureDir, required=True, help='Path to Framsticks CLI without trailing slash.')
|
---|
243 | parser.add_argument('-exe', required=False, help='Executable name. If not given, "frams.exe" or "frams.linux" is assumed.')
|
---|
244 | 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.')
|
---|
245 | return parser.parse_args()
|
---|
246 |
|
---|
247 |
|
---|
248 | def ensureDir(string):
|
---|
249 | if os.path.isdir(string):
|
---|
250 | return string
|
---|
251 | else:
|
---|
252 | raise NotADirectoryError(string)
|
---|
253 |
|
---|
254 |
|
---|
255 | if __name__ == "__main__":
|
---|
256 | # A demo run.
|
---|
257 |
|
---|
258 | # TODO ideas:
|
---|
259 | # - check_validity with three levels (invalid, corrected, valid)
|
---|
260 | # - "vectorize" some operations (isvalid, evaluate) so that a number of genotypes is handled in one call
|
---|
261 | # - use threads for non-blocking reading from frams' stdout and thus not relying on specific strings printed by frams
|
---|
262 | # - a pool of binaries run at the same time, balance load - in particular evaluation
|
---|
263 | # - if we read genotypes in "org:" format anywhere: import https://pypi.org/project/framsreader/0.1.2/ and use it if successful,
|
---|
264 | # if not then print a message "framsreader not available, using simple internal method to save a genotype" and proceed as it is now.
|
---|
265 | # So far we don't read, but we should use the proper writer to handle all special cases like quoting etc.
|
---|
266 |
|
---|
267 | parsed_args = parseArguments()
|
---|
268 | framsCLI = FramsticksCLI(parsed_args.path, parsed_args.exe)
|
---|
269 |
|
---|
270 | simplest = framsCLI.getSimplest('1' if parsed_args.genformat is None else parsed_args.genformat)
|
---|
271 | print("\tSimplest genotype:", simplest)
|
---|
272 | parent1 = framsCLI.mutate(simplest)
|
---|
273 | parent2 = parent1
|
---|
274 | MUTATE_COUNT = 10
|
---|
275 | for x in range(MUTATE_COUNT): # example of a chain of 20 mutations
|
---|
276 | parent2 = framsCLI.mutate(parent2)
|
---|
277 | print("\tParent1 (mutated simplest):", parent1)
|
---|
278 | print("\tParent2 (Parent1 mutated %d times):" % MUTATE_COUNT, parent2)
|
---|
279 | offspring = framsCLI.crossOver(parent1, parent2)
|
---|
280 | print("\tCrossover (Offspring):", offspring)
|
---|
281 | print('\tDissimilarity of Parent1 and Offspring:', framsCLI.dissimilarity(offspring, parent1))
|
---|
282 | print('\tPerformance of Offspring:', framsCLI.evaluate(offspring))
|
---|
283 | print('\tValidity of Offspring:', framsCLI.isValid(offspring))
|
---|
284 |
|
---|
285 | framsCLI.closeFramsticksCLI()
|
---|