Index: framspy/FramsticksLibCompetition.py
===================================================================
--- framspy/FramsticksLibCompetition.py	(revision 1206)
+++ framspy/FramsticksLibCompetition.py	(revision 1206)
@@ -0,0 +1,101 @@
+import sys
+from time import perf_counter, strftime
+from typing import List  # to be able to specify a type hint of list(something)
+import numpy as np
+from FramsticksLib import FramsticksLib
+from base64 import urlsafe_b64encode
+
+
+class FramsticksLibCompetition(FramsticksLib):
+	"""A proxy to FramsticksLib.py with the same interface, but recording the highest achieved fitness and limiting the number of evaluation calls.
+	Use it in the same way as FramsticksLib.py.
+	For the use in competition, remember to call end() when your algorithm completes.
+	To run a working example, follow these four steps:
+	- set STORE_ALL_PART_COORDS = 0 in recording-body-coords.sim,
+	- set SIMPLE_FITNESS_FORMAT = False below,
+	- edit FramsticksEvolution.py in a few places so that it only uses the FramsticksLibCompetition class instead of FramsticksLib,
+	- run: python FramsticksEvolution.py -path %DIR_WITH_FRAMS_LIBRARY%  -sim eval-allcriteria.sim;recording-body-coords.sim  -opt COGpath  -generations 20
+	See also: https://www.framsticks.com/gecco-competition
+	"""
+
+	COMPETITOR_ID = 'AliceTeam'
+	SIMPLE_FITNESS_FORMAT = True  # set to False only if you want compatibility with existing sources of optimization algorithms such as FramsticksEvolution.py. Otherwise (for True), you will just get a simple number as fitness.
+	FITNESS_DICT_KEY = 'COGpath'  # only used for SIMPLE_FITNESS_FORMAT = False
+
+	MAX_EVALUATIONS = 100_000  # 100k
+	MAX_TIME = 60 * 60 * 1  # 1h (excluding evaluation time)
+
+	TEST_FUNCTION = 3
+
+	_best_fitness = None
+	_best_solution = None
+	_evaluation_count = 0
+	_evaluation_time = 0  # used to exclude solution evaluation time from the total running time
+	_time0 = perf_counter()
+
+
+	def _evaluate_path(self, path):
+		path = np.array(path)
+		if self.TEST_FUNCTION == 3:
+			return np.linalg.norm(path[0] - path[-1])  # simple example: returns distance between COG locations of birth and death.
+		elif self.TEST_FUNCTION == 4:
+			return np.linalg.norm(path[0] - path[-1]) * np.mean(np.maximum(0, path[:, 2]))  # simple example: run far and have COG high above ground!
+		elif self.TEST_FUNCTION == 5:
+			return 1000 - np.linalg.norm(np.linspace(0, 10, len(path), endpoint=True) - path[:, 2]) / np.sqrt(len(path))  # simple example: z coordinate of the COG should grow linearly from 0 to 1 during lifespan. Returns RMSE as a deviation measure (negative because we are maximizing, and offset to ensure positive outcomes so there is no clash with other optimization code that may assume that negative fitness indicates an invalid genotype).
+		raise RuntimeError('TEST_FUNCTION==%s not implemented!' % self.TEST_FUNCTION)
+
+
+	def _evaluate_single_genotype(self, genotype):
+		self._evaluation_count += 1
+		if self._evaluation_count > self.MAX_EVALUATIONS or perf_counter() - self._time0 - self._evaluation_time > self.MAX_TIME:
+			print('The allowed time or the number of evaluations exceeded')
+			self.end()  # exits the program
+		result = super().evaluate([genotype])  # sample result for invalid genotype: [{'num': 172, 'name': 'Agoha Syhy', 'evaluations': None}]
+		valid_result = result[0]['evaluations'] is not None
+		fitness = self._evaluate_path(result[0]['evaluations']['']['data->bodyrecording']) if valid_result else None
+		if fitness is not None and (self._best_fitness is None or self._best_fitness < fitness):
+			self._best_fitness = fitness
+			self._best_solution = genotype
+		if self.SIMPLE_FITNESS_FORMAT:
+			return fitness
+		else:  # update existing structure (vector of dict of dict...)
+			if valid_result:
+				result[0]['evaluations'][''][self.FITNESS_DICT_KEY] = fitness
+			else:
+				# result[0]['evaluations'] = {'': {self.FITNESS_KEY: fitness}}  # [{'num': 260, 'name': 'Imepak Syhy', 'evaluations': {'': {'path': None}}}]
+				pass  # leave 'result' as it is, the caller expects such an incomplete structure (with 'evaluations': None) on evaluation failure
+			return result[0]
+
+
+	def evaluate(self, genotype_list: List[str]):
+		"""
+		:return: a list of fitness values (see also SIMPLE_FITNESS_FORMAT), with None for genotypes that are not valid.
+		"""
+		if len(genotype_list) > self.MAX_EVALUATIONS:
+			raise RuntimeError('Too many genotypes to evaluate in one batch: %d' % len(genotype_list))
+		eval_time0 = perf_counter()
+		fitnesses = [self._evaluate_single_genotype(genotype) for genotype in genotype_list]
+		self._evaluation_time += perf_counter() - eval_time0
+		return fitnesses
+
+
+	def end(self):
+		print('Finishing... best solution =', self._best_fitness)
+
+		filename = urlsafe_b64encode(self.COMPETITOR_ID.encode()).decode() + ".results"
+		competitor = "".join(x for x in self.COMPETITOR_ID if x.isalnum())
+
+		s = strftime("%Y-%m-%d %H:%M")
+		s += "\t%s\t%d\t%d" % (competitor, self.TEST_FUNCTION, self._evaluation_count)
+
+		total_running_time = perf_counter() - self._time0
+		s += "\t%g\t%g" % (total_running_time, total_running_time - self._evaluation_time)
+
+		s += "\t" + str(self._best_fitness)
+		s += "\t" + str(self._best_solution)
+		print(s)
+		with open(filename, "a") as outfile:  # append
+			outfile.write(s)
+			outfile.write("\n")
+		print("Saved '%s' (%s)" % (filename, competitor))
+		sys.exit()  # only call end() once
