1 | from typing import List # to be able to specify a type hint of list(something)
|
---|
2 | import json
|
---|
3 | import sys, os
|
---|
4 | import argparse
|
---|
5 | import numpy as np
|
---|
6 | import frams
|
---|
7 |
|
---|
8 |
|
---|
9 | class FramsticksLib:
|
---|
10 | """Communicates directly with Framsticks library (.dll or .so or .dylib).
|
---|
11 | You can perform basic operations like mutation, crossover, and evaluation of genotypes.
|
---|
12 | This way you can perform evolution controlled by python as well as access and manipulate genotypes.
|
---|
13 | You can even design and use in evolution your own genetic representation implemented entirely in python,
|
---|
14 | or access and control the simulation and simulated creatures step by step.
|
---|
15 |
|
---|
16 | Should you want to modify or extend this class, first see and test the examples in frams-test.py.
|
---|
17 |
|
---|
18 | You need to provide one or two parameters when you run this class: the path to Framsticks where .dll/.so/.dylib resides
|
---|
19 | and, optionally, the name of the Framsticks dll/so/dylib (if it is non-standard). See::
|
---|
20 | FramsticksLib.py -h"""
|
---|
21 |
|
---|
22 | PRINT_FRAMSTICKS_OUTPUT: bool = False # set to True for debugging
|
---|
23 | DETERMINISTIC: bool = False # set to True to have the same results in each run
|
---|
24 |
|
---|
25 | GENOTYPE_INVALID = "/*invalid*/" # this is how genotype invalidity is represented in Framsticks
|
---|
26 | EVALUATION_SETTINGS_FILE = [ # all files MUST be compatible with the standard-eval expdef. The order they are loaded in is important!
|
---|
27 | "eval-allcriteria.sim", # a good trade-off in performance sampling period ("perfperiod") for vertpos and velocity
|
---|
28 | # "deterministic.sim", # turns off random noise (added for robustness) so that each evaluation yields identical performance values (causes "overfitting")
|
---|
29 | # "sample-period-2.sim", # short performance sampling period so performance (e.g. vertical position) is sampled more often
|
---|
30 | # "sample-period-longest.sim", # increased performance sampling period so distance and velocity are measured rectilinearly
|
---|
31 | ]
|
---|
32 |
|
---|
33 |
|
---|
34 | # This function is not needed because in Python, "For efficiency reasons, each module is only imported once per interpreter session."
|
---|
35 | # @staticmethod
|
---|
36 | # def getFramsModuleInstance():
|
---|
37 | # """If some other party needs access to the frams module to directly access or modify Framsticks objects,
|
---|
38 | # use this function to avoid importing the "frams" module multiple times and avoid potentially initializing
|
---|
39 | # it many times."""
|
---|
40 | # return frams
|
---|
41 |
|
---|
42 | def __init__(self, frams_path, frams_lib_name, sim_settings_files):
|
---|
43 | self.dissim_measure_density_distribution = None # will be initialized only when necessary (for rare dissimilarity methods)
|
---|
44 |
|
---|
45 | if frams_lib_name is None:
|
---|
46 | frams.init(frams_path) # could add support for setting alternative directories using -D and -d
|
---|
47 | else:
|
---|
48 | frams.init(frams_path, "-L" + frams_lib_name) # could add support for setting alternative directories using -D and -d
|
---|
49 |
|
---|
50 | print('Available objects:', dir(frams))
|
---|
51 | print()
|
---|
52 |
|
---|
53 | simplest = self.getSimplest("1")
|
---|
54 | if not (simplest == "X" and type(simplest) is str):
|
---|
55 | raise RuntimeError('Failed getSimplest() test.')
|
---|
56 | if not (self.isValid(["X[0:0],", "X[0:0]", "X[1:0]"]) == [False, True, False]):
|
---|
57 | raise RuntimeError('Failed isValid() test.')
|
---|
58 |
|
---|
59 | if not self.DETERMINISTIC:
|
---|
60 | frams.Math.randomize()
|
---|
61 | frams.Simulator.expdef = "standard-eval" # this expdef (or fully compatible) must be used by EVALUATION_SETTINGS_FILE
|
---|
62 | if sim_settings_files is not None:
|
---|
63 | self.EVALUATION_SETTINGS_FILE = sim_settings_files.split(";") # override defaults. str becomes list
|
---|
64 | print('Basic tests OK. Using settings:', self.EVALUATION_SETTINGS_FILE)
|
---|
65 | print()
|
---|
66 |
|
---|
67 | for simfile in self.EVALUATION_SETTINGS_FILE:
|
---|
68 | ec = frams.MessageCatcher.new() # catch potential errors, warnings, messages - just to detect if there are ERRORs
|
---|
69 | ec.store = 2; # store all, because they are caught by MessageCatcher and will not appear in output (which we want)
|
---|
70 | frams.Simulator.ximport(simfile, 4 + 8 + 16)
|
---|
71 | ec.close()
|
---|
72 | print(ec.messages) # output all caught messages
|
---|
73 | if ec.error_count._value() > 0:
|
---|
74 | raise ValueError("Problem while importing file '%s'" % simfile) # make missing files or incorrect paths fatal because error messages are easy to overlook in output, and these errors would not prevent Framsticks simulator from performing genetic operations, starting and running in evaluate()
|
---|
75 |
|
---|
76 |
|
---|
77 | def getSimplest(self, genetic_format) -> str:
|
---|
78 | return frams.GenMan.getSimplest(genetic_format).genotype._string()
|
---|
79 |
|
---|
80 |
|
---|
81 | def evaluate(self, genotype_list: List[str]):
|
---|
82 | """
|
---|
83 | Returns:
|
---|
84 | List of dictionaries containing the performance of genotypes evaluated using self.EVALUATION_SETTINGS_FILE.
|
---|
85 | Note that for whatever reason (e.g. incorrect genotype), the dictionaries you will get may be empty or
|
---|
86 | partially empty and may not have the fields you expected, so handle such cases properly.
|
---|
87 | """
|
---|
88 | assert isinstance(genotype_list, list) # because in python, str has similar capabilities as list and here it would pretend to work too, so to avoid any ambiguity
|
---|
89 |
|
---|
90 | if not self.PRINT_FRAMSTICKS_OUTPUT:
|
---|
91 | ec = frams.MessageCatcher.new() # mute potential errors, warnings, messages
|
---|
92 | ec.store = 2; # store all, because they are caught by MessageCatcher and will not appear in output
|
---|
93 |
|
---|
94 | frams.GenePools[0].clear()
|
---|
95 | for g in genotype_list:
|
---|
96 | frams.GenePools[0].add(g)
|
---|
97 | frams.ExpProperties.evalsavefile = "" # no need to store results in a file - we will get evaluations directly from Genotype's "data" field
|
---|
98 | frams.Simulator.init()
|
---|
99 | frams.Simulator.start()
|
---|
100 |
|
---|
101 | # step = frams.Simulator.step # cache reference to avoid repeated lookup in the loop (just for performance)
|
---|
102 | # while frams.Simulator.running._int(): # standard-eval.expdef sets running to 0 when the evaluation is complete
|
---|
103 | # step()
|
---|
104 | frams.Simulator.eval("while(Simulator.running) Simulator.step();") # fastest
|
---|
105 | # Timing for evaluating a single simple creature 100x:
|
---|
106 | # - python step without caching: 2.2s
|
---|
107 | # - python step with caching : 1.6s
|
---|
108 | # - pure FramScript and eval() : 0.4s
|
---|
109 |
|
---|
110 | if not self.PRINT_FRAMSTICKS_OUTPUT:
|
---|
111 | ec.close()
|
---|
112 | if ec.error_count._value() > 0:
|
---|
113 | print(ec.messages) # if errors occurred, output all caught messages for debugging
|
---|
114 | raise RuntimeError("[ERROR] %d error(s) and %d warning(s) while evaluating %d genotype(s)" % (ec.error_count._value(), ec.warning_count._value(), len(genotype_list))) # make errors fatal; by default they stop the simulation anyway so let's not use potentially incorrect or partial results and fix the cause first.
|
---|
115 |
|
---|
116 | results = []
|
---|
117 | for g in frams.GenePools[0]:
|
---|
118 | serialized_dict = frams.String.serialize(g.data[frams.ExpProperties.evalsavedata._value()])
|
---|
119 | evaluations = json.loads(serialized_dict._string()) # Framsticks native ExtValue's get converted to native python types such as int, float, list, str.
|
---|
120 | # now, for consistency with FramsticksCLI.py, add "num" and "name" keys that are missing because we got data directly from Genotype, not from the file produced by standard-eval.expdef's function printStats(). What we do below is what printStats() does.
|
---|
121 | result = {"num": g.num._value(), "name": g.name._value(), "evaluations": evaluations}
|
---|
122 | results.append(result)
|
---|
123 |
|
---|
124 | return results
|
---|
125 |
|
---|
126 |
|
---|
127 | def mutate(self, genotype_list: List[str]) -> List[str]:
|
---|
128 | """
|
---|
129 | Returns:
|
---|
130 | The genotype(s) of the mutated source genotype(s). self.GENOTYPE_INVALID for genotypes whose mutation failed (for example because the source genotype was invalid).
|
---|
131 | """
|
---|
132 | assert isinstance(genotype_list, list) # because in python, str has similar capabilities as list and here it would pretend to work too, so to avoid any ambiguity
|
---|
133 |
|
---|
134 | mutated = []
|
---|
135 | for g in genotype_list:
|
---|
136 | mutated.append(frams.GenMan.mutate(frams.Geno.newFromString(g)).genotype._string())
|
---|
137 | if len(genotype_list) != len(mutated):
|
---|
138 | raise RuntimeError("Submitted %d genotypes, received %d mutants" % (len(genotype_list), len(mutated)))
|
---|
139 | return mutated
|
---|
140 |
|
---|
141 |
|
---|
142 | def crossOver(self, genotype_parent1: str, genotype_parent2: str) -> str:
|
---|
143 | """
|
---|
144 | Returns:
|
---|
145 | The genotype of the offspring. self.GENOTYPE_INVALID if the crossing over failed.
|
---|
146 | """
|
---|
147 | return frams.GenMan.crossOver(frams.Geno.newFromString(genotype_parent1), frams.Geno.newFromString(genotype_parent2)).genotype._string()
|
---|
148 |
|
---|
149 |
|
---|
150 | def dissimilarity(self, genotype_list: List[str], method: int) -> np.ndarray:
|
---|
151 | """
|
---|
152 | :param method: -1 = genetic Levenshtein distance; 0, 1, 2 = phenetic dissimilarity (SimilMeasureGreedy, SimilMeasureHungarian, SimilMeasureDistribution); -2, -3 = phenetic density distribution (count, frequency).
|
---|
153 | :return: A square array with dissimilarities of each pair of genotypes.
|
---|
154 | """
|
---|
155 | assert isinstance(genotype_list, list) # because in python, str has similar capabilities as list and here it would pretend to work too, so to avoid any ambiguity
|
---|
156 |
|
---|
157 | # if you want to override what EVALUATION_SETTINGS_FILE sets, you can do it below:
|
---|
158 | # frams.SimilMeasureHungarian.simil_partgeom = 1
|
---|
159 | # frams.SimilMeasureHungarian.simil_weightedMDS = 1
|
---|
160 |
|
---|
161 | n = len(genotype_list)
|
---|
162 | square_matrix = np.zeros((n, n))
|
---|
163 |
|
---|
164 | if method in (0, 1, 2): # Framsticks phenetic dissimilarity methods
|
---|
165 | frams.SimilMeasure.simil_type = method
|
---|
166 | genos = [] # prepare an array of Geno objects so that we don't need to convert raw strings to Geno objects all the time in loops
|
---|
167 | for g in genotype_list:
|
---|
168 | genos.append(frams.Geno.newFromString(g))
|
---|
169 | frams_evaluateDistance = frams.SimilMeasure.evaluateDistance # cache function reference for better performance in loops
|
---|
170 | for i in range(n):
|
---|
171 | for j in range(n): # maybe calculate only one triangle if you really need a 2x speedup
|
---|
172 | square_matrix[i][j] = frams_evaluateDistance(genos[i], genos[j])._double()
|
---|
173 | elif method == -1:
|
---|
174 | import Levenshtein
|
---|
175 | for i in range(n):
|
---|
176 | for j in range(n): # maybe calculate only one triangle if you really need a 2x speedup
|
---|
177 | square_matrix[i][j] = Levenshtein.distance(genotype_list[i], genotype_list[j])
|
---|
178 | elif method in (-2, -3):
|
---|
179 | if self.dissim_measure_density_distribution is None:
|
---|
180 | from dissimilarity.densityDistribution import DensityDistribution
|
---|
181 | self.dissim_measure_density_distribution = DensityDistribution(frams)
|
---|
182 | self.dissim_measure_density_distribution.frequency = (method == -3)
|
---|
183 | square_matrix = self.dissim_measure_density_distribution.getDissimilarityMatrix(genotype_list)
|
---|
184 | else:
|
---|
185 | raise ValueError("Don't know what to do with dissimilarity method = %d" % method)
|
---|
186 |
|
---|
187 | for i in range(n):
|
---|
188 | assert square_matrix[i][i] == 0, "Not a correct dissimilarity matrix, diagonal expected to be 0"
|
---|
189 | non_symmetric_diff = square_matrix - square_matrix.T
|
---|
190 | non_symmetric_count = np.count_nonzero(non_symmetric_diff)
|
---|
191 | if non_symmetric_count > 0:
|
---|
192 | non_symmetric_diff_abs = np.abs(non_symmetric_diff)
|
---|
193 | max_pos1d = np.argmax(non_symmetric_diff_abs) # location of the largest discrepancy
|
---|
194 | max_pos2d_XY = np.unravel_index(max_pos1d, non_symmetric_diff_abs.shape) # 2D coordinates of the largest discrepancy
|
---|
195 | max_pos2d_YX = max_pos2d_XY[1], max_pos2d_XY[0] # 2D coordinates of the largest discrepancy mirror
|
---|
196 | worst_guy_XY = square_matrix[max_pos2d_XY] # this distance and the other below (its mirror) are most different
|
---|
197 | worst_guy_YX = square_matrix[max_pos2d_YX]
|
---|
198 | print("[WARN] Dissimilarity matrix: expecting symmetry, but %g out of %d pairs were asymmetrical, max difference was %g (%g %%)" %
|
---|
199 | (non_symmetric_count / 2,
|
---|
200 | n * (n - 1) / 2,
|
---|
201 | non_symmetric_diff_abs[max_pos2d_XY],
|
---|
202 | non_symmetric_diff_abs[max_pos2d_XY] * 100 / ((worst_guy_XY + worst_guy_YX) / 2))) # max diff is not necessarily max %
|
---|
203 | return square_matrix
|
---|
204 |
|
---|
205 |
|
---|
206 | def getRandomGenotype(self, initial_genotype: str, parts_min: int, parts_max: int, neurons_min: int, neurons_max: int, iter_max: int, return_even_if_failed: bool):
|
---|
207 | """
|
---|
208 | Some algorithms require a "random solution". To this end, this method generates a random framstick genotype.
|
---|
209 |
|
---|
210 | :param initial_genotype: if not a specific genotype (which could facilitate greater variability of returned genotypes), try `getSimplest(format)`.
|
---|
211 | :param iter_max: how many mutations can be used to generate a random genotype that fullfills target numbers of parts and neurons.
|
---|
212 | :param return_even_if_failed: if the target numbers of parts and neurons was not achieved, return the closest genotype that was found? Set it to False first to experimentally adjust `iter_max` so that in most calls this function returns a genotype with target numbers of parts and neurons, and then you can set this parameter to True if target numbers of parts and neurons are not absolutely required.
|
---|
213 | :returns: a valid genotype or None if failed and `return_even_if_failed` is False.
|
---|
214 | """
|
---|
215 |
|
---|
216 |
|
---|
217 | def estimate_diff(g: str):
|
---|
218 | if not self.isValidCreature([g])[0]:
|
---|
219 | return None, None
|
---|
220 | m = frams.Model.newFromString(g)
|
---|
221 | numparts = m.numparts._value()
|
---|
222 | numneurons = m.numneurons._value()
|
---|
223 | diff_parts = abs(target_parts - numparts)
|
---|
224 | diff_neurons = abs(target_neurons - numneurons)
|
---|
225 | in_target_range = (parts_min <= numparts <= parts_max) and (neurons_min <= numneurons <= neurons_max) # less demanding than precisely reaching target_parts and target_neurons
|
---|
226 | return diff_parts + diff_neurons, in_target_range
|
---|
227 |
|
---|
228 |
|
---|
229 | # try to find a genotype that matches the number of parts and neurons randomly selected from the provided min..max range
|
---|
230 | # (even if we fail to match this precise target, the goal will be achieved if the found genotype manages to be within min..max ranges for parts and neurons)
|
---|
231 | target_parts = np.random.default_rng().integers(parts_min, parts_max + 1)
|
---|
232 | target_neurons = np.random.default_rng().integers(neurons_min, neurons_max + 1)
|
---|
233 |
|
---|
234 | if not self.isValidCreature([initial_genotype])[0]:
|
---|
235 | raise ValueError("Initial genotype '%s' is invalid" % initial_genotype)
|
---|
236 |
|
---|
237 | g = initial_genotype
|
---|
238 | for i in range(iter_max // 2): # a sequence of iter_max/2 undirected mutations starting from initial_genotype
|
---|
239 | g_new = self.mutate([g])[0]
|
---|
240 | if self.isValidCreature([g_new])[0]: # valid mutation
|
---|
241 | g = g_new
|
---|
242 |
|
---|
243 | best_diff, best_in_target_range = estimate_diff(g)
|
---|
244 | for i in range(iter_max // 2): # a sequence of iter_max/2 mutations, only accepting those which approach target numbers of parts and neurons
|
---|
245 | g_new = self.mutate([g])[0]
|
---|
246 | diff, in_target_range = estimate_diff(g_new)
|
---|
247 | if diff is not None and diff <= best_diff: # valid mutation and better or as good as current
|
---|
248 | g = g_new
|
---|
249 | best_diff = diff
|
---|
250 | best_in_target_range = in_target_range
|
---|
251 | # print(diff, best_diff) # print progress approaching target numbers of parts and neurons
|
---|
252 |
|
---|
253 | if best_in_target_range or return_even_if_failed:
|
---|
254 | return g # best found so far (closest to target numbers of parts and neurons)
|
---|
255 | return None
|
---|
256 |
|
---|
257 |
|
---|
258 | def isValid(self, genotype_list: List[str]) -> List[bool]:
|
---|
259 | """
|
---|
260 | :returns: genetic validity (i.e., not based on trying to build creatures from provided genotypes). For a more thorough check, see isValidCreature().
|
---|
261 | """
|
---|
262 | assert isinstance(genotype_list, list) # because in python, str has similar capabilities as list and here it would pretend to work too, so to avoid any ambiguity
|
---|
263 | valid = []
|
---|
264 | for g in genotype_list:
|
---|
265 | valid.append(frams.Geno.newFromString(g).is_valid._int() == 1)
|
---|
266 | if len(genotype_list) != len(valid):
|
---|
267 | raise RuntimeError("Tested %d genotypes, received %d validity values" % (len(genotype_list), len(valid)))
|
---|
268 | return valid
|
---|
269 |
|
---|
270 |
|
---|
271 | def isValidCreature(self, genotype_list: List[str]) -> List[bool]:
|
---|
272 | """
|
---|
273 | :returns: validity of the genotype when revived. Apart from genetic validity, this includes detecting problems that may arise when building a Creature from Genotype, such as multiple muscles of the same type in the same location in body, e.g. 'X[@][@]'.
|
---|
274 | """
|
---|
275 |
|
---|
276 | # Genetic validity and simulator validity are two separate properties (in particular, genetic validity check is implemented by the author of a given genetic format and operators).
|
---|
277 | # Thus, the subset of genotypes valid genetically and valid in simulation may be overlapping.
|
---|
278 | # For example, 'X[]' or 'Xr' are considered invalid by the genetic checker, but the f1->f0 converter will ignore meaningless genes and produce a valid f0 genotype.
|
---|
279 | # On the other hand, 'X[@][@]' or 'X[|][|]' are valid genetically, but not possible to simulate.
|
---|
280 | # For simplicity of usage (so that one does not need to check both properties separately using both functions), let's make one validity a subset of the other.
|
---|
281 | # The genetic check in the first lines of the "for" loop makes this function at least as demanding as isValid().
|
---|
282 |
|
---|
283 | assert isinstance(genotype_list, list) # because in python, str has similar capabilities as list and here it would pretend to work too, so to avoid any ambiguity
|
---|
284 |
|
---|
285 | pop = frams.Populations[0] # assuming rules from population #0 (self-colision settings are population-dependent and can influence creature build success/failure)
|
---|
286 |
|
---|
287 | valid = []
|
---|
288 | for g in genotype_list:
|
---|
289 | if frams.Geno.newFromString(g).is_valid._int() != 1:
|
---|
290 | valid.append(False) # invalid according to genetic check
|
---|
291 | else:
|
---|
292 | can_add = pop.canAdd(g, 1, 1) # First "1" means to treat warnings during build as build failures - this allows detecting problems when building Creature from Genotype. Second "1" means mute emitted errors, warnings, messages. Returns 1 (ok, could add) or 0 (there were some problems building Creature from Genotype)
|
---|
293 | valid.append(can_add._int() == 1)
|
---|
294 |
|
---|
295 | if len(genotype_list) != len(valid):
|
---|
296 | raise RuntimeError("Tested %d genotypes, received %d validity values" % (len(genotype_list), len(valid)))
|
---|
297 | return valid
|
---|
298 |
|
---|
299 |
|
---|
300 | def parseArguments():
|
---|
301 | parser = argparse.ArgumentParser(description='Run this program with "python -u %s" if you want to disable buffering of its output.' % sys.argv[0])
|
---|
302 | parser.add_argument('-path', type=ensureDir, required=True, help='Path to the Framsticks library (.dll or .so or .dylib) without trailing slash.')
|
---|
303 | parser.add_argument('-lib', required=False, help='Library name. If not given, "frams-objects.dll" (or .so or .dylib) is assumed depending on the platform.')
|
---|
304 | parser.add_argument('-simsettings', required=False, help="The name of the .sim file with settings for evaluation, mutation, crossover, and similarity estimation. If not given, \"eval-allcriteria.sim\" is assumed by default. Must be compatible with the \"standard-eval\" expdef. If you want to provide more files, separate them with a semicolon ';'.")
|
---|
305 | 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.')
|
---|
306 | return parser.parse_args()
|
---|
307 |
|
---|
308 |
|
---|
309 | def ensureDir(string):
|
---|
310 | if os.path.isdir(string):
|
---|
311 | return string
|
---|
312 | else:
|
---|
313 | raise NotADirectoryError(string)
|
---|
314 |
|
---|
315 |
|
---|
316 | if __name__ == "__main__":
|
---|
317 | # A demo run.
|
---|
318 |
|
---|
319 | # TODO ideas:
|
---|
320 | # - check_validity with three levels (invalid, corrected, valid)
|
---|
321 | # - a pool of binaries running simultaneously, balance load - in particular evaluation
|
---|
322 |
|
---|
323 | parsed_args = parseArguments()
|
---|
324 | framsLib = FramsticksLib(parsed_args.path, parsed_args.lib, parsed_args.simsettings)
|
---|
325 |
|
---|
326 | print("Sending a direct command to Framsticks library that calculates \"4\"+2 yields", frams.Simulator.eval("return \"4\"+2;"))
|
---|
327 |
|
---|
328 | simplest = framsLib.getSimplest('1' if parsed_args.genformat is None else parsed_args.genformat)
|
---|
329 | print("\tSimplest genotype:", simplest)
|
---|
330 | parent1 = framsLib.mutate([simplest])[0]
|
---|
331 | parent2 = parent1
|
---|
332 | MUTATE_COUNT = 10
|
---|
333 | for x in range(MUTATE_COUNT): # example of a chain of 10 mutations
|
---|
334 | parent2 = framsLib.mutate([parent2])[0]
|
---|
335 | print("\tParent1 (mutated simplest):", parent1)
|
---|
336 | print("\tParent2 (Parent1 mutated %d times):" % MUTATE_COUNT, parent2)
|
---|
337 | offspring = framsLib.crossOver(parent1, parent2)
|
---|
338 | print("\tCrossover (Offspring):", offspring)
|
---|
339 | print('\tDissimilarity of Parent1 and Offspring:', framsLib.dissimilarity([parent1, offspring], 1)[0, 1])
|
---|
340 | print('\tPerformance of Offspring:', framsLib.evaluate([offspring]))
|
---|
341 | print('\tValidity (genetic) of Parent1, Parent 2, and Offspring:', framsLib.isValid([parent1, parent2, offspring]))
|
---|
342 | print('\tValidity (simulation) of Parent1, Parent 2, and Offspring:', framsLib.isValidCreature([parent1, parent2, offspring]))
|
---|
343 | print('\tRandom genotype:', framsLib.getRandomGenotype(simplest, 2, 6, 2, 4, 100, True))
|
---|