Browse Source

Create some blog posts

Eren Yilmaz 1 year ago
parent
commit
3f0f5df48a

+ 1 - 0
.gitignore

@@ -19,3 +19,4 @@ backend/img/plots
 *time_recoder_config.py
 *time_recoder_config.py
 ~$*
 ~$*
 /time_recoder/ValheimDataBase.py
 /time_recoder/ValheimDataBase.py
+blog_posts

+ 117 - 0
blog_writer.py

@@ -0,0 +1,117 @@
+import datetime
+import json
+import openai
+import os
+from typing import Optional
+
+from tool_lib.util import EBC
+
+
+class Topic(EBC):
+    def __init__(self, topic_name: str, topic_detailed_description=''):
+        self.topic_name = topic_name
+        self.topic_detailed_description = topic_detailed_description
+
+
+class Template(EBC):
+    def __init__(self, template_name: str, template_content: str, formatting_kwargs: Optional[dict] = None):
+        self.template_name = template_name
+        self.template_content = template_content
+        if formatting_kwargs is None:
+            formatting_kwargs = {}
+        self.formatting_kwargs = formatting_kwargs
+
+    def update_formatting_kwargs(self, **kwargs):
+        self.formatting_kwargs.update(kwargs)
+
+    def format(self, topic: Topic):
+        return self.template_content.format(topic_name=topic.topic_name, topic_detailed_description=topic.topic_detailed_description, **self.formatting_kwargs)
+
+
+class EnDBlogTemplate(Template):
+    def __init__(self,
+                 min_words=400,
+                 max_words=600):
+        template_name = 'end_blog_template'
+        template_content = '''For context: You are writing a blog post for publication on the website of our company EnD UG.
+We specialize in tour planning software and are proud that our product is fast, reliable and easy to use.
+It allows our customers to plan the driving routes of their employees automatically while optimizing the driving time, fuel usage and timeliness.
+It also allows our customers to plan the driving routes of their employees manually, while giving them suggestions on how to improve the route.
+
+Please write a blog post about the topic of "{topic_name}" in a length of {min_words} to {max_words}. {topic_detailed_description}
+
+Make sure that the blog post is written in a way that is easy to understand for visitors to our website, including potential customers, but technically detailed enough to show that the writer has expertise in the topic.
+It should be written in an informative and factual way and not be promotional.
+Do not address the reader directly. Write in third person. In particular, avoid phrases like "At EnD UG we ..." or "As a business manager you ..." or "our [company, tool, software, ...]".
+Include a title and format the output as markdown. In particular, after each sentence, add a newline, and an additional one after paragraphs. 
+Do not include URLs, images or references to other blog posts.
+'''
+        super().__init__(template_name, template_content, {'min_words': min_words, 'max_words': max_words})
+
+
+class TechnicalBlogTemplate(Template):
+    def __init__(self,
+                 min_words=400,
+                 max_words=600):
+        mean_words = (min_words + max_words) / 2
+        template_name = 'technical_blog_template'
+        template_content = '''Please write a blog post about the topic of "{topic_name}" in a length of {min_words} to {max_words}, ideally around {mean_words}. {topic_detailed_description}
+
+Make sure that the blog post is written in a way that is easy to understand, but technically detailed enough to show that the writer has expertise in the topic.
+It should be written in an informative and factual way and not be promotional.
+Do not address the reader directly. Write in third person.
+Include a title and format the output as markdown. In particular, after each sentence, add a newline, and an additional one after paragraphs.
+Structure sections with headings.
+Do not include URLs, images or references to other blog posts.
+'''
+        super().__init__(template_name, template_content, {'min_words': min_words, 'max_words': max_words, 'mean_words': mean_words})
+
+
+class BlogCreator(EBC):
+    def __init__(self, template: Template, topic: Topic):
+        self.template = template
+        self.topic = topic
+
+    def request_template(self):
+        return self.template.template_content
+
+    def write(self):
+        message = [{"role": "user",
+                    "content": self.request()}]
+        response = openai.ChatCompletion.create(
+            model="gpt-3.5-turbo",
+            messages=message,
+            temperature=0.2,
+            max_tokens=1000,
+            frequency_penalty=0.0
+        )
+        return response['choices'][0]['message']['content']
+
+    def request(self):
+        return self.template.format(self.topic)
+
+    def to_disk(self):
+        to_file = os.path.abspath(f'blog_posts/{topic.topic_name}_{datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}.json')
+        os.makedirs(os.path.dirname(to_file), exist_ok=True)
+        result = {'blogger': self.to_json(),
+                  'dt_created': datetime.datetime.now().timestamp(),
+                  'request': self.request(),
+                  'result': self.write()}
+        with open(to_file, 'w', encoding='utf-8') as f:
+            json.dump(result, f, indent=4)
+        with open(to_file.replace('.json', '.md'), 'w', encoding='utf-8') as f:
+            f.write(result['result'])
+        print(f'Wrote {to_file}')
+
+
+if __name__ == '__main__':
+    topics = [
+        # Topic('Travel Salesman'),
+        # Topic('k-opt Algorithm for the Traveling Salesman Problem'),
+        # Topic('Linear Programming'),
+        # Topic('Mixed Linear Programming'),
+        # Topic('Clustering'),
+        # Topic('OpenAPI and Swagger'),
+    ]
+    for topic in topics:
+        BlogCreator(TechnicalBlogTemplate(), topic).to_disk()

+ 0 - 0
tool_lib/__init__.py


+ 93 - 0
tool_lib/compact_dict_string.py

@@ -0,0 +1,93 @@
+def compact_object_string(o, max_line_length=120, indent=0, max_depth=2 ** 32):
+    if max_depth == 0:
+        return ' ' * indent + '...'
+    if isinstance(o, list):
+        return compact_list_string(o, max_line_length, indent=indent, max_depth=max_depth)
+    elif isinstance(o, tuple):
+        return compact_tuple_string(o, max_line_length, indent=indent, max_depth=max_depth)
+    elif isinstance(o, dict):
+        return compact_dict_string(o, max_line_length, indent=indent, max_depth=max_depth)
+    elif isinstance(o, str):
+        return '"' + o + '"'
+    else:
+        return ' ' * indent + str(o)
+
+
+def compact_list_string(xs: list, max_line_length=120, indent=0, closing=']', opening='[', max_depth=2 ** 32):
+    # try to fit everything in one line with the default str method
+    single_line_result = ' ' * indent + str(xs)
+    if len(single_line_result) <= max_line_length:
+        return single_line_result
+
+    # not extra lines for [ and ]
+    multi_line_result_right = ' ' * indent + opening
+    for x in xs:
+        prefix = ''
+        multi_line_result_right += prefix + f'{compact_object_string(x, max_line_length=max_line_length, indent=indent + 1 + len(prefix), max_depth=max_depth - 1)}'.strip()
+        multi_line_result_right += ',\n' + ' ' * indent
+    multi_line_result_right = multi_line_result_right[:-len(',\n' + ' ' * indent)] + closing
+
+    # extra lines for [ and ]
+    multi_line_result_below = ' ' * indent + opening
+    for x in xs:
+        prefix = ''
+        multi_line_result_below += '\n' + ' ' * (indent + 2)
+        multi_line_result_below += prefix + f'{compact_object_string(x, max_line_length=max_line_length, indent=indent + 2 + len(prefix), max_depth=max_depth - 1)}'.strip()
+    multi_line_result_below += ',\n' + ' ' * indent
+    multi_line_result_below = multi_line_result_below + closing
+
+    if len(multi_line_result_right.splitlines()) < len(multi_line_result_below.splitlines()):
+        return multi_line_result_right
+    else:
+        return multi_line_result_below
+
+
+def compact_tuple_string(xs: tuple, max_line_length=120, indent=0, max_depth=2 ** 32):
+    return compact_list_string(list(xs), max_line_length, indent, closing=')', opening='(')
+
+
+def compact_dict_string(d: dict, max_line_length=120, indent=0, max_depth=2 ** 32):
+    # try to fit everything in one line with the default str method
+    single_line_result = ' ' * indent + str(d).replace("'", '"')
+    if len(single_line_result) <= max_line_length:
+        return single_line_result
+
+    # try to put compact value string next to the key strings
+    multi_line_result_right = ' ' * indent + '{'
+    for k, v in d.items():
+        if isinstance(k, str):
+            prefix = '"' + str(k) + '"' + ': '
+        else:
+            prefix = str(k) + ': '
+        multi_line_result_right += prefix + f'{compact_object_string(v, max_line_length=max_line_length, indent=indent + 1 + len(prefix), max_depth=max_depth - 1)}'.strip()
+        multi_line_result_right += ',\n' + ' ' * indent
+    multi_line_result_right = multi_line_result_right[:-len(',\n' + ' ' * indent)] + '}'
+
+    # try to put compact value string below key strings
+    multi_line_result_below = ' ' * indent + '{'
+    multi_line_result_below += '\n' + ' ' * (indent + 2)
+    for k, v in d.items():
+        prefix = '"' + str(k) + '"' + ': '
+        multi_line_result_below += prefix + f'{compact_object_string(v, max_line_length=max_line_length, indent=indent + 2 + len(prefix), max_depth=max_depth - 1)}'.strip()
+        multi_line_result_below += ',\n' + ' ' * (indent + 2)
+    multi_line_result_below = multi_line_result_below[:-2] + '}'
+
+    if len(multi_line_result_right.splitlines()) < len(multi_line_result_below.splitlines()):
+        return multi_line_result_right
+    else:
+        return multi_line_result_below
+
+
+if __name__ == '__main__':
+    print(compact_dict_string({"body": {"game_state": {"crafting_machines": [{"name": "Basic Crafting Machine 16910", "duration": 10, "type": "CraftingMachine"}], "game_name": "test", "properties": [
+        {"tier": 1, "name": "Luxuriant", "type": "Property"},
+        {"tier": 1, "name": "Edible", "type": "Property"},
+        {"tier": 1, "name": "Appealable", "type": "Property"}], "resources": [
+        {"properties": [{"tier": 1, "name": "Luxuriant", "type": "Property"}], "name": "Wolverine", "type": "Resource"},
+        {"properties": [{"tier": 1, "name": "Edible", "type": "Property"}], "name": "Lm", "type": "Resource"},
+        {"properties": [{"tier": 1, "name": "Appealable", "type": "Property"}], "name": "Skittishness", "type": "Resource"}], "users": [
+        {"resource_inventory": {}, "session_id": "12843d4e-8cfb-4f30-acaa-d4d9de6cf33f", "username": "testUser", "type": "User"}], "resource_discoveries":
+                                                           {"testUser": [{"properties": [{"tier": 1, "name": "Luxuriant", "type": "Property"}], "name": "Wolverine", "type": "Resource"},
+                                                                         {"properties": [{"tier": 1, "name": "Edible", "type": "Property"}], "name": "Lm", "type": "Resource"},
+                                                                         {"properties": [{"tier": 1, "name": "Appealable", "type": "Property"}], "name": "Skittishness", "type": "Resource"}]},
+                                                       "type": "GameState"}}, "http_status_code": 200, "request_token": "774ba52b-31a2-481a-9f12-537495dae993"}, max_line_length=200))

+ 17 - 0
tool_lib/compute_cartesic_distance.py

@@ -0,0 +1,17 @@
+from typing import Tuple, List
+
+import numpy
+
+
+# This file uses list_of_coordinates in a 2dimensional cartesic space.
+# To calculate a symmetric matrix (nxn) with n as length of the list_of_coordinates
+
+def compute_symmetric_matrix_in_cartesic_space(coordinates: List[Tuple[float, float]]):
+    if len(coordinates) == 0:
+        return numpy.zeros((0, 0))
+    c = numpy.array(coordinates)
+    m = numpy.meshgrid([range(len(c))], [range(len(c))])
+    xy1 = c[m[0]]
+    xy2 = c[m[1]]
+    matrix = numpy.linalg.norm(xy1 - xy2, ord=2, axis=2)
+    return matrix

+ 100 - 0
tool_lib/compute_distance.py

@@ -0,0 +1,100 @@
+import math as mt
+
+import numpy as np
+
+
+# Efficient calculating of distance(2-dim) matrix, use of symmetric and 0 in diagonals
+# nested sums, first does not contain the last element, second does not contain the first
+# Implemented Geo_graphic Coordsystem
+# https://www.movable-type.co.uk/scripts/latlong.html formula haversine
+# TODO General dimension
+
+
+def get_angle_in_rad(coordinate):
+    return coordinate * mt.pi / 180
+
+
+def calc_haversine_angle(delta_lat, lat1, lat2, delta_long):
+    return (
+        mt.sin(delta_lat / 2) * mt.sin(delta_lat / 2)
+        + mt.cos(lat1) * mt.cos(lat2) * mt.sin(delta_long / 2) * mt.sin(delta_long / 2)
+    )
+
+
+def calc_haversine_distance_km(radius_earth_in_meter, haversine_angle):
+    radius_km = radius_earth_in_meter / 1000
+    return radius_km * 2 * mt.atan2(mt.sqrt(haversine_angle), mt.sqrt(1 - haversine_angle))
+
+
+def get_symmetric_distances_matrix(list_of_coordinates):
+    radius_earth_in_meter = 6371000  # radius*pi/180
+    matrix = np.zeros([len(list_of_coordinates), len(list_of_coordinates)])
+    for rows in range(0, len(list_of_coordinates) - 1):
+        for columns in range(rows + 1, len(list_of_coordinates)):
+            lat1 = get_angle_in_rad(list_of_coordinates[rows][0])
+            lat2 = get_angle_in_rad(list_of_coordinates[columns][0])
+            lon1 = get_angle_in_rad(list_of_coordinates[rows][1])
+            lon2 = get_angle_in_rad(list_of_coordinates[columns][1])
+
+            distance = haversine_distance(lat1, lon1, lat2, lon2, radius_earth_in_meter)
+            matrix[rows, columns] = distance
+            matrix[columns, rows] = matrix[rows, columns]
+    return matrix
+
+
+def haversine_distance(lat1, lon1, lat2, lon2, radius_earth_in_meter=6371000):
+    """input in radiant, output in km"""
+    for input_value in [lat1, lon1, lat2, lon2]:
+        if abs(input_value) > 2 * mt.pi:
+            raise ValueError(f"Input value is not in radiant. Values were {[lat1, lon1, lat2, lon2]}")
+    delta_lat = lat2 - lat1
+    delta_long = lon1 - lon2
+    haversine_angle = calc_haversine_angle(delta_lat, lat1, lat2, delta_long)
+    distance = calc_haversine_distance_km(radius_earth_in_meter, haversine_angle)
+    return distance
+
+
+def haversine_distance_from_degrees(lat1, lon1, lat2, lon2, radius_earth_in_meter=6371000):
+    return haversine_distance(
+        np.deg2rad(lat1),
+        np.deg2rad(lon1),
+        np.deg2rad(lat2),
+        np.deg2rad(lon2),
+        radius_earth_in_meter
+    )
+
+
+def get_row_of_distances(list_of_coordinates, start_coordinate):
+    '''
+    :param list_of_coordinates: list of coordinate tuple
+    :param start_coordinate: tuple of latitude and longitude of a specific node
+    :return:
+    '''
+    radius_earth_in_meter = 6371000  # radius*pi/180
+    array = np.zeros([len(list_of_coordinates), 1])
+    for rows in range(0, len(list_of_coordinates)):
+        lat1 = get_angle_in_rad(list_of_coordinates[rows][0])
+        lat2 = get_angle_in_rad(start_coordinate[0])
+        lon1 = get_angle_in_rad(list_of_coordinates[rows][1])
+        lon2 = get_angle_in_rad(start_coordinate[1])
+
+        delta_lat = lat2 - lat1
+        delta_long = lon1 - lon2
+
+        haversine_angle = calc_haversine_angle(delta_lat, lat1, lat2, delta_long)
+        distance = calc_haversine_distance_km(radius_earth_in_meter, haversine_angle)
+        array[rows] = distance
+    return array
+
+
+def geograpic_coord_to_cartesic(value):
+    '''
+    :param value: tuple latidude, longitude
+    :return: tuple, x,y
+    '''
+    lat, lon = np.deg2rad(value[0]), np.deg2rad(value[1])
+    radius_earth_in_km = 6371
+    x = radius_earth_in_km * mt.cos(lat) * mt.cos(lon)
+    y = radius_earth_in_km * mt.cos(lat) * mt.sin(lon)
+
+    return (x, y)

+ 62 - 0
tool_lib/custom_data_tables.py

@@ -0,0 +1,62 @@
+import sqlite3
+
+
+def create_custom_datatable(database_path, columns: list or dict, table_name=''):
+    if table_name == '':
+        return
+    # Create a String
+    query_str = ''#
+    if isinstance(columns, list):
+        for name_count in range(0, len(columns)):
+            query_str += columns[name_count] + ' VARCHAR, '
+    else:
+        for key,v in columns.items():
+            query_str += key + ' VARCHAR, '
+
+
+    query_str= 'CREATE TABLE IF NOT EXISTS ' + table_name + '(' \
+               + query_str[:-2] + ')'
+
+    # Connect to database
+    connection = sqlite3.connect(database_path)
+    cursor = connection.cursor()
+    cursor.execute(query_str)
+    connection.commit()
+    connection.close()
+
+def add_row(database_path, columns:dict, table_name =''):
+    if table_name == '':
+        return
+    # Create a String
+    column_names_str = ''
+    values_list = []
+
+    for key,value in columns.items():
+        column_names_str += key + ', '
+        values_list += [str(value)]
+
+    add_questionmarks = '?,' * len(values_list)
+    query_str = 'INSERT OR IGNORE INTO ' + table_name + '(' + column_names_str[:-2]+  ') VALUES(' + add_questionmarks[:-1] + ')'
+
+    # Connect to database
+    connection = sqlite3.connect(database_path)
+    cursor = connection.cursor()
+    cursor.execute(query_str, values_list)
+    connection.commit()
+    connection.close()
+
+def delete_rows_based_on_high_value(database_path,value, column:str, table_name =''):
+    if table_name == '':
+        return
+
+    # Create a String
+    query_str = 'DELETE FROM ' + table_name + ' WHERE ' + 'CAST('+ column+ ' as decimal)' + ' > ' + str(value)
+
+    # Connect to database
+    connection = sqlite3.connect(database_path)
+    cursor = connection.cursor()
+    cursor.execute(query_str)
+    connection.commit()
+    connection.close()
+
+

+ 237 - 0
tool_lib/date_time.py

@@ -0,0 +1,237 @@
+import cachetools
+import datetime
+import math
+from typing import List, Dict, Any, Type, Optional
+
+from lib.util import EBC
+
+
+def get_timestamp(start_day, input_format) -> int:
+    if isinstance(start_day, int):
+        return start_day
+    elif isinstance(start_day, str):
+        return int(datetime.datetime.timestamp(datetime.datetime.strptime(start_day, input_format)))
+    else:
+        return int(datetime.datetime.timestamp(start_day))
+
+
+class Day(EBC):
+    def __init__(self, dt: datetime.date, day_id: int):
+        self.day_id = day_id
+        self.dt = dt
+        self._ymd = self.dt.strftime('%Y-%m-%d')
+
+    @classmethod
+    def field_types(cls) -> Dict[str, Type]:
+        result = super().field_types()
+        result['dt'] = SerializableDateTime
+        return result
+
+    def timestamp(self):
+        dt = self.dt
+        if isinstance(dt, datetime.date):
+            dt = datetime.datetime(dt.year, dt.month, dt.day)
+        return dt.timestamp()
+
+    @staticmethod
+    def from_ts(ts: int, day_id: int):
+        return Day(
+            day_id=day_id,
+            dt=datetime.date.fromtimestamp(ts)
+        )
+
+    def day_idx(self):
+        return self.day_id - 1
+
+    def datetime_from_time_string(self, time_string: str):
+        return self.datetime_from_date_and_time_string(self.dt, time_string)
+
+    @staticmethod
+    def datetime_from_date_and_time_string(date: datetime.date, time_string: str):
+        if len(time_string) == 5:
+            time_string += ':00'
+        return datetime.datetime.strptime(f'{date.strftime("%Y-%m-%d")} {time_string}', '%Y-%m-%d %H:%M:%S')
+
+    def ymd(self) -> str:
+        return self._ymd
+
+    def weekday(self):
+        return self.dt.weekday()
+
+    def weekday_as_german_string(self):
+        return {
+            'Monday': 'Montag',
+            'Tuesday': 'Dienstag',
+            'Wednesday': 'Mittwoch',
+            'Thursday': 'Donnerstag',
+            'Friday': 'Freitag',
+            'Saturday': 'Samstag',
+            'Sunday': 'Sonntag',
+        }[self.dt.strftime('%A')]
+
+    def __eq__(self, other):
+        if not isinstance(other, type(self)):
+            raise ValueError
+        return self.day_id == other.day_id and self._ymd == other._ymd
+
+
+@cachetools.cached(cachetools.LRUCache(maxsize=128))
+def time_interval_as_days(start_day, day_count, inputformat='%Y-%m-%d') -> List[Day]:
+    '''
+    :param start_day: date
+    :param day_count:
+    :return: list of dates as string
+    '''
+    start_day = get_timestamp(start_day, inputformat)
+    results = [
+        Day.from_ts(start_day + 86400 * day_idx, day_id=day_idx + 1)
+        for day_idx in range(day_count)
+    ]
+    return results
+
+
+def weekday_name(index: int, lan='english'):
+    """
+    :param index: 0..6
+    :return: The weekday as name
+    """
+    if lan == 'english':
+        weekdays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
+    else:
+        weekdays = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
+    return weekdays[index]
+
+
+def get_calendarweek_from_datetime(datetime_obj):
+    return datetime_obj.strftime('%V')
+
+
+def timestamp_from_time_of_day_string(time_string):
+    ts = datetime.datetime.timestamp(datetime.datetime.strptime('1970-' + time_string + ' +0000', '%Y-%H:%M %z'))
+    assert 0 <= ts <= 24 * 60 * 60
+    return ts
+
+
+class SerializableDateTime(datetime.datetime, EBC):
+    @classmethod
+    def field_types(cls) -> Dict[str, Type]:
+        return {
+            'year': int,
+            'month': int,
+            'day': int,
+            'hour': int,
+            'minute': int,
+            'second': int,
+            'microsecond': int,
+            'tzinfo': type(None),
+        }
+
+    def to_json(self) -> Dict[str, Any]:
+        return {
+            'type': type(self).__name__,
+            'dt_str': self.strftime('%Y-%m-%d %H:%M:%S.%f'),
+        }
+
+    def __hash__(self):
+        return super().__hash__()
+
+    @staticmethod
+    def from_datetime(dt):
+        return SerializableDateTime(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.microsecond)
+
+    @staticmethod
+    def from_date(dt):
+        if isinstance(dt, datetime.datetime):
+            return SerializableDateTime.from_datetime(dt)
+        return SerializableDateTime(dt.year, dt.month, dt.day)
+
+    @staticmethod
+    def from_json(data: Dict[str, Any]):
+        cls: Type[SerializableDateTime] = EBC.SUBCLASSES_BY_NAME[data['type']]
+        if 'dt_str' in data:
+            dt = datetime.datetime.strptime(data['dt_str'], '%Y-%m-%d %H:%M:%S.%f')
+            data['year'] = dt.year
+            data['month'] = dt.month
+            data['day'] = dt.day
+            data['hour'] = dt.hour
+            data['minute'] = dt.minute
+            data['second'] = dt.second
+            data['microsecond'] = dt.microsecond
+        return cls(data['year'], data['month'], data['day'], data['hour'], data['minute'], data['second'], data['microsecond'], data.get('tzinfo', None))
+
+    def __eq__(self, other):
+        if isinstance(other, datetime.date) and not isinstance(other, datetime.datetime):
+            return self.date() == other
+        return (
+                self.year == other.year
+                and self.month == other.month
+                and self.day == other.day
+                and self.hour == other.hour
+                and self.minute == other.minute
+                and self.second == other.second
+                and self.microsecond == other.microsecond
+                and self.tzinfo == other.tzinfo
+        )
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+
+class SerializableTimeDelta(datetime.timedelta, EBC):
+    max: 'SerializableTimeDelta'
+
+    @classmethod
+    def field_types(cls) -> Dict[str, Type]:
+        return {
+            'seconds': float,
+            'days': Optional[int],
+            'microseconds': Optional[int],
+        }
+
+    def to_json(self) -> Dict[str, Any]:
+        return {
+            'type': type(self).__name__,
+            'seconds': self.seconds,
+            'days': self.days,
+            'microseconds': self.microseconds,
+        }
+
+    @classmethod
+    def from_total_seconds(cls, total_seconds) -> 'SerializableTimeDelta':
+        try:
+            return SerializableTimeDelta(seconds=total_seconds)
+        except OverflowError as e:
+            if 'must have magnitude <=' in str(e):
+                return cls.max
+
+    def positive_infinite(self):
+        return self.days == self.max.days and self.seconds == self.max.seconds and self.microseconds == self.max.microseconds
+
+    def total_seconds(self) -> float:
+        if self.positive_infinite():
+            return math.inf
+        return super().total_seconds()
+
+    @classmethod
+    def from_timedelta(cls, timedelta: datetime.timedelta):
+        return cls(timedelta.days, timedelta.seconds, timedelta.microseconds)
+
+    @staticmethod
+    def from_json(data: Dict[str, Any]):
+        cls: Type[SerializableTimeDelta] = EBC.SUBCLASSES_BY_NAME[data['type']]
+        return cls(data.get('days', 0.), data['seconds'], data.get('microseconds', 0.))
+
+    def __eq__(self, other):
+        return self.total_seconds() == other.total_seconds()
+
+
+SerializableTimeDelta.max = SerializableTimeDelta.from_timedelta(datetime.timedelta.max)
+
+
+def minutes_timestamp_from_time_of_day_string(time_of_day_string):
+    timestamp = timestamp_from_time_of_day_string(time_of_day_string)
+    return minutes_timestamp_from_seconds(timestamp)
+
+
+def minutes_timestamp_from_seconds(timestamp):
+    return timestamp / 60

+ 29 - 0
tool_lib/filter_classes.py

@@ -0,0 +1,29 @@
+'''
+This file is used for filter. Each filter contains different Options. And should be used for different format types.
+If you don't find a fitting filter then program it in this file to structure your coding.
+If there is a similar Filter which does not perfectly fit into your code, please extend this Filter instead of creating a new one
+
+Another Note: Try to keep up with this format: so with a lot of "coding Notes", the Class system and the variable_names
+If you have to use non-class filter please describe why and when to use this non-class filter
+'''
+from typing import Union
+
+import datetime
+import numpy as np
+
+class ManualTimeFilter:
+    '''
+    This filter, takes a Key(axe) which maps on Values on a Matrix
+                 takes an Matrix in which Values are filtered
+                 takes a timeconstraint
+
+    Example: Key= Johann, Matrix= Arbeitszeitmatrix , timeconstraint= Monday
+    Result: Filtered Version of this Matrix
+    '''
+    def __init__(self, key: str, matrix: Union[np.array, list], timeconstraint: Union[str, datetime]):
+        self.key= key
+        self.matrix = matrix
+        self.timeconstraint = timeconstraint
+
+    def __call__(self):
+        return self.matrix

+ 12 - 0
tool_lib/find_functions.py

@@ -0,0 +1,12 @@
+# This File Contains Functions which search for a key in an format and return the desired searched value.
+def find(key, value):
+    for k, v in value.items():
+        if k == key:
+            yield v
+        elif isinstance(v, dict):
+            for result in find(key, v):
+                yield result
+        elif isinstance(v, list):
+            for d in v:
+                for result in find(key, d):
+                    yield result

+ 21 - 0
tool_lib/format_transformations.py

@@ -0,0 +1,21 @@
+
+# This file is written to do type transformations. A transformation symbolizes a mapping.
+
+def asciify(s):
+    replaces = [
+        ('ö', 'oe'),
+        ('ä', 'ae'),
+        ('ü', 'ue'),
+        # (' ', ''),
+        # ('/', ''),
+        # ('(', ''),
+        # (')', ''),
+        ('ß', 'ss'),
+    ]
+    for rep in replaces:
+        s = s.replace(*rep)
+    return s
+
+def json_to_dict(data):
+    import json
+    return json.loads(data)

+ 36 - 0
tool_lib/geojson_map.py

@@ -0,0 +1,36 @@
+import json
+from typing import Optional
+
+import cachetools
+
+
+@cachetools.cached(cache=cachetools.LRUCache(maxsize=10000))
+def latitude_of_zip_code_from_geojson(zip_code: str) -> Optional[float]:
+    try:
+        int(zip_code)
+    except ValueError:
+        return None
+    for f in geojson_data['features']:
+        if int(f['properties']['plz']) == int(zip_code):
+            return f['geometry']['coordinates'][1]
+    return None
+
+
+@cachetools.cached(cache=cachetools.LRUCache(maxsize=10000))
+def zip_code_exists(zip_code: str) -> bool:
+    return latitude_of_zip_code_from_geojson(zip_code) is not None
+
+
+@cachetools.cached(cache=cachetools.LRUCache(maxsize=10000))
+def longitude_of_zip_code_from_geojson(zip_code: str) -> Optional[float]:
+    try:
+        int(zip_code)
+    except ValueError:
+        return None
+    for f in geojson_data['features']:
+        if int(f['properties']['plz']) == int(zip_code):
+            return f['geometry']['coordinates'][0]
+    return None
+
+
+geojson_data = json.loads(open('resources/plz-5stellig-centroid.geojson').read())

+ 44 - 0
tool_lib/infinite_timer.py

@@ -0,0 +1,44 @@
+from threading import Timer
+
+from lib.util import EBC
+
+
+class InfiniteTimer(EBC):
+    """
+    A Timer class that does not stop, unless you want it to.
+    https://stackoverflow.com/a/41450617
+    """
+
+    def __init__(self, seconds, target, daemon=True):
+        self._should_continue = False
+        self.is_running = False
+        self.seconds = seconds
+        self.target = target
+        self.thread = None
+        self.daemon = daemon
+
+    def _handle_target(self):
+        self.is_running = True
+        self.target()
+        self.is_running = False
+        self._start_timer()
+
+    def _start_timer(self):
+        if self._should_continue:  # Code could have been running when cancel was called.
+            self.thread = Timer(self.seconds, self._handle_target)
+            self.thread.daemon = self.daemon
+            self.thread.start()
+
+    def start(self):
+        if not self._should_continue and not self.is_running:
+            self._should_continue = True
+            self._start_timer()
+        else:
+            print("Timer already started or running, please wait if you're restarting.")
+
+    def cancel(self):
+        if self.thread is not None:
+            self._should_continue = False  # Just in case thread is running and cancel fails.
+            self.thread.cancel()
+        else:
+            print("Timer never started or failed to initialize.")

+ 140 - 0
tool_lib/main_wrapper.py

@@ -0,0 +1,140 @@
+import functools
+import inspect
+import os
+import time
+
+from lib.memory_control import MemoryLimiter
+from lib.my_logger import logging
+from lib.print_exc_plus import print_exc_plus
+
+try:
+    import winsound as win_sound
+
+
+    def beep(*args, **kwargs):
+        win_sound.Beep(*args, **kwargs)
+except ImportError:
+    win_sound = None
+    beep = lambda x, y: ...
+
+ENABLE_PROFILING = False
+
+
+def start_profiling():
+    try:
+        import yappi
+    except ModuleNotFoundError:
+        return
+    yappi.set_clock_type("wall")
+    print(f'Starting yappi profiler.')
+    yappi.start()
+
+
+def currently_profiling_yappi():
+    try:
+        import yappi
+    except ModuleNotFoundError:
+        return False
+    return len(yappi.get_func_stats()) > 0
+
+
+def profile_wall_time_instead_if_profiling():
+    try:
+        import yappi
+    except ModuleNotFoundError:
+        return
+    currently_profiling = len(yappi.get_func_stats())
+    if currently_profiling and yappi.get_clock_type() != 'wall':
+        yappi.stop()
+        print('Profiling wall time instead of cpu time.')
+        yappi.clear_stats()
+        yappi.set_clock_type("wall")
+        yappi.start()
+
+
+def dump_pstats_if_profiling(relating_to_object):
+    try:
+        import yappi
+    except ModuleNotFoundError:
+        return
+    currently_profiling = len(yappi.get_func_stats())
+    if currently_profiling:
+        try:
+            pstats_file = 'logs/profiling/' + os.path.normpath(inspect.getfile(relating_to_object)).replace(os.path.abspath('.'), '') + '.pstat'
+        except AttributeError:
+            print('WARNING: unable to set pstat file path for profiling.')
+            return
+        os.makedirs(os.path.dirname(pstats_file), exist_ok=True)
+        yappi.get_func_stats()._save_as_PSTAT(pstats_file)
+        print(f'Saved profiling log to {pstats_file}.')
+
+
+class YappiProfiler():
+    def __init__(self, relating_to_object):
+        self.relating_to_object = relating_to_object
+
+    def __enter__(self):
+        start_profiling()
+        profile_wall_time_instead_if_profiling()
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        dump_pstats_if_profiling(self.relating_to_object)
+
+
+def list_logger(base_logging_function, store_in_list: list):
+    def print_and_store(*args, **kwargs):
+        base_logging_function(*args, **kwargs)
+        store_in_list.extend(args)
+
+    return print_and_store
+
+
+EMAIL_CRASHES_TO = []
+
+
+def main_wrapper(f):
+    @functools.wraps(f)
+    def wrapper(*args, **kwargs):
+        if ENABLE_PROFILING:
+            start_profiling()
+        from lib.memory_control import MemoryLimiter
+        MemoryLimiter.limit_memory_usage()
+        start = time.perf_counter()
+        # import lib.stack_tracer
+        import __main__
+        # does not help much
+        # monitoring_thread = hanging_threads.start_monitoring(seconds_frozen=180, test_interval=1000)
+        os.makedirs('logs', exist_ok=True)
+        # stack_tracer.trace_start('logs/' + os.path.split(__main__.__file__)[-1] + '.html', interval=5)
+        # faulthandler.enable()
+        profile_wall_time_instead_if_profiling()
+
+        # noinspection PyBroadException
+        try:
+            return f(*args, **kwargs)
+        except KeyboardInterrupt:
+            error_messages = []
+            print_exc_plus(print=list_logger(logging.error, error_messages),
+                           serialize_to='logs/' + os.path.split(__main__.__file__)[-1] + '.dill')
+        except:
+            error_messages = []
+            print_exc_plus(print=list_logger(logging.error, error_messages),
+                           serialize_to='logs/' + os.path.split(__main__.__file__)[-1] + '.dill')
+            for recipient in EMAIL_CRASHES_TO:
+                from jobs.sending_emails import send_mail
+                send_mail.create_simple_mail_via_gmail(body='\n'.join(error_messages), filepath=None, excel_name=None, to_mail=recipient, subject='Crash report')
+        finally:
+            logging.info('Terminated.')
+            total_time = time.perf_counter() - start
+            # faulthandler.disable()
+            # stack_tracer.trace_stop()
+            frequency = 2000
+            duration = 500
+            beep(frequency, duration)
+            if ENABLE_PROFILING:
+                dump_pstats_if_profiling(f)
+            print('Total time', total_time)
+
+    wrapper.func = f
+
+    return wrapper

+ 311 - 0
tool_lib/memory_control.py

@@ -0,0 +1,311 @@
+import datetime
+import gc
+import logging
+import os
+import re
+import subprocess
+from collections import OrderedDict
+from typing import Union
+from time import sleep
+
+import psutil
+
+try:
+    from config import USE_MEMORY_CONTROL
+except ImportError:
+    USE_MEMORY_CONTROL = False
+try:
+    from config import GPU_MEMORY_USAGE
+except ImportError:
+    GPU_MEMORY_USAGE: Union[float, str] = 'growth'
+
+
+class NVLog(dict):
+    __indent_re__ = re.compile('^ *')
+    __version_re__ = re.compile(r'v([0-9.]+)$')
+
+    def __init__(self):
+        super().__init__()
+
+        lines = run_cmd(['nvidia-smi', '-q'])
+        lines = lines.splitlines()
+        while '' in lines:
+            lines.remove('')
+
+        path = [self]
+        self['version'] = self.__version__()
+        for line in lines[1:]:
+            indent = NVLog.__get_indent__(line)
+            line = NVLog.__parse_key_value_pair__(line)
+            while indent < len(path) * 4 - 4:
+                path.pop()
+            cursor = path[-1]
+            if len(line) == 1:
+                if line[0] == 'Processes':
+                    cursor[line[0]] = []
+                else:
+                    cursor[line[0]] = {}
+                cursor = cursor[line[0]]
+                path.append(cursor)
+            elif len(line) == 2:
+                if line[0] in ['GPU instance ID', 'Compute instance ID']:
+                    continue
+                if line[0] == 'Process ID':
+                    cursor.append({})
+                    cursor = cursor[-1]
+                    path.append(cursor)
+                cursor[line[0]] = line[1]
+
+        self['Attached GPUs'] = OrderedDict()
+        keys = list(self.keys())
+        for i in keys:
+            if i.startswith('GPU '):
+                self['Attached GPUs'][i] = self[i]
+                del self[i]
+
+    @staticmethod
+    def __get_indent__(line):
+        return len(NVLog.__indent_re__.match(line).group())
+
+    @staticmethod
+    def __parse_key_value_pair__(line):
+        result = line.split(' : ')
+        result[0] = result[0].strip()
+        if len(result) > 1:
+            try:
+                result[1] = int(result[1])
+            except:
+                pass
+            if result[1] in ['N/A', 'None']:
+                result[1] = None
+            if result[1] in ['Disabled', 'No']:
+                result[1] = False
+        return result
+
+    def __get_processes__(self):
+        processes = []
+        for i, gpu in enumerate(self['Attached GPUs']):
+            gpu = self['Attached GPUs'][gpu]
+            if gpu['Processes']:
+                for j in gpu['Processes']:
+                    processes.append((i, j))
+        return processes
+
+    @staticmethod
+    def __version__():
+        lines = run_cmd(['nvidia-smi', '-h'])
+        lines = lines.splitlines()
+        result = NVLog.__version_re__.search(lines[0]).group(1)
+        return result
+
+    def gpu_table(self):
+        output = []
+        output.append(self['Timestamp'])
+        output.append('+-----------------------------------------------------------------------------+')
+        values = []
+        values.append(self['version'])
+        values.append(self['Driver Version'])
+        if 'CUDA Version' in self:
+            values.append(self['CUDA Version'])
+        else:
+            values.append('N/A')
+        output.append('| NVIDIA-SMI %s       Driver Version: %s       CUDA Version: %-5s    |' % tuple(values))
+        output.append('|-------------------------------+----------------------+----------------------+')
+        output.append('| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |')
+        output.append('| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |')
+        output.append('|===============================+======================+======================|')
+        for i, gpu in enumerate(self['Attached GPUs']):
+            gpu = self['Attached GPUs'][gpu]
+            values = []
+            values.append(i)
+            values.append(gpu['Product Name'])
+            values.append('On' if gpu['Persistence Mode'] else 'Off')
+            values.append(gpu['PCI']['Bus Id'])
+            values.append('On' if gpu['Display Active'] else 'Off')
+            output.append('|   %d  %-19s %3s  | %s %3s |                  N/A |' % tuple(values))
+            values = []
+            values.append(gpu['Fan Speed'].replace(' ', ''))
+            values.append(gpu['Temperature']['GPU Current Temp'].replace(' ', ''))
+            values.append(gpu['Performance State'])
+            values.append(int(float(gpu['Power Readings']['Power Draw'][:-2])))
+            values.append(int(float(gpu['Power Readings']['Power Limit'][:-2])))
+            values.append(gpu['FB Memory Usage']['Used'].replace(' ', ''))
+            values.append(gpu['FB Memory Usage']['Total'].replace(' ', ''))
+            values.append(gpu['Utilization']['Gpu'].replace(' ', ''))
+            values.append(gpu['Compute Mode'])
+            output.append('| %3s   %3s    %s   %3dW / %3dW |  %8s / %8s |    %4s     %8s |' % tuple(values))
+            output.append('+-----------------------------------------------------------------------------+')
+        return '\n'.join(output)
+
+    def processes_table(self):
+        output = []
+        output.append('+-----------------------------------------------------------------------------+')
+        output.append('| Processes:                                                       GPU Memory |')
+        output.append('|  GPU       PID   Type   Process name                             Usage      |')
+        output.append('|=============================================================================|')
+        processes = self.__get_processes__()
+        if len(processes) == 0:
+            output.append('|  No running processes found                                                 |')
+        for i, process in processes:
+            values = []
+            values.append(i)
+            values.append(process['Process ID'])
+            values.append(process['Type'])
+            if len(process['Name']) > 42:
+                values.append(process['Name'][:39] + '...')
+            else:
+                values.append(process['Name'])
+            values.append(process['Used GPU Memory'].replace(' ', ''))
+            output.append('|   %2d     %5d %6s   %-42s %8s |' % tuple(values))
+        output.append('+-----------------------------------------------------------------------------+')
+        return '\n'.join(output)
+
+    def as_table(self):
+        output = []
+        output.append(self.gpu_table())
+        output.append('')
+        output.append(self.processes_table())
+        return '\n'.join(output)
+
+
+class NVLogPlus(NVLog):
+
+    def processes_table(self):
+        output = ['+-----------------------------------------------------------------------------+',
+                  '| Processes:                                                       GPU Memory |',
+                  '|  GPU       PID   User   Process name                             Usage      |',
+                  '|=============================================================================|']
+        processes = self.__get_processes__()
+        if len(processes) == 0:
+            output.append('|  No running processes found                                                 |')
+        for i, process in processes:
+            values = []
+            values.append(i)
+            values.append(process['Process ID'])
+            p = psutil.Process(process['Process ID'])
+            with p.oneshot():
+                values.append(p.username()[:8].center(8))
+                command = p.cmdline()
+                command[0] = os.path.basename(command[0])
+                command = ' '.join(command)
+                if len(command) > 42:
+                    values.append(command[:39] + '...')
+                else:
+                    values.append(command)
+            values.append(process['Used GPU Memory'].replace(' ', ''))
+            output.append('|   %2d     %5d %8s %-42s %8s |' % tuple(values))
+        output.append('+-----------------------------------------------------------------------------+')
+        return '\n'.join(output)
+
+
+def run_cmd(cmd):
+    return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout.decode('utf-8')
+
+
+class MemoryLimiter:
+    limited = False
+
+    @classmethod
+    def limit_memory_usage(cls, verbose=1):
+        if not USE_MEMORY_CONTROL:
+            print('memory_control module disabled.')
+            return
+        if cls.limited:
+            if verbose:
+                print('Already limited memory usage. Skipping...')
+            return
+        use_gpu_idx = None
+        printed_compute_mode = False
+        while True:  # until a GPU is free
+            try:
+                data = NVLog()
+            except FileNotFoundError:
+                print('WARNING: nvidia-smi is not available')
+                break
+            if 'use_specific_gpu' in os.environ:
+                print(f'use_specific_gpu = {os.environ["use_specific_gpu"]}')
+                use_gpu_idx = int(os.environ['use_specific_gpu'])
+            else:
+                for idx, gpu_data in reversed(list(enumerate(data['Attached GPUs'].values()))):
+                    any_processes = (
+                            gpu_data['Processes'] is not None
+                            or gpu_data['Utilization']['Memory'] != '0 %'
+                            or gpu_data['FB Memory Usage']['Used'] != '0 MiB'
+                    )
+                    compute_mode = gpu_data['Compute Mode']
+                    if not printed_compute_mode:
+                        print('GPU Compute Mode:', compute_mode)
+                        printed_compute_mode = True
+                    if compute_mode in ['Exclusive_Process', 'Exclusive_Thread'] or os.environ.get('use_empty_gpu'):
+                        if not any_processes:
+                            use_gpu_idx = idx
+                            break
+                    elif compute_mode == 'Default':
+                        if GPU_MEMORY_USAGE != 'growth':
+                            free_memory = int(re.search(r'(\d+) MiB', gpu_data['FB Memory Usage']['Free']).group(1))
+                            if free_memory > 2.5 * GPU_MEMORY_USAGE:
+                                use_gpu_idx = idx
+                                break
+                        else:
+                            use_gpu_idx = idx
+                            break
+                    elif compute_mode == 'Prohibited':
+                        continue
+                    else:
+                        raise NotImplementedError(f'Unknown compute mode: {compute_mode}.')
+                else:
+                    print(datetime.datetime.now().strftime("%H:%M") + ': All GPUs are currently in use.')
+                    sleep(300)
+                    continue
+            os.environ["CUDA_VISIBLE_DEVICES"] = str(use_gpu_idx)
+            print('Using GPU', f'{use_gpu_idx}:', list(data['Attached GPUs'].values())[use_gpu_idx]['Product Name'])
+            break
+        import tensorflow as tf
+
+        # limit GPU memory usage
+        # gpu_memory_limit = 3.5 * 1024
+        for gpu in tf.config.experimental.list_physical_devices('GPU'):
+            if GPU_MEMORY_USAGE == 'growth':
+                tf.config.experimental.set_memory_growth(gpu, True)
+            else:
+                tf.config.experimental.set_virtual_device_configuration(gpu, [tf.config.experimental.VirtualDeviceConfiguration(memory_limit=GPU_MEMORY_USAGE)])
+
+        # tf.config.experimental.set_virtual_device_configuration(gpus[use_gpu_idx], [tf.config.experimental.VirtualDeviceConfiguration(memory_limit=2)])
+
+        # limit RAM usage
+        try:
+            from lib.memory_limit_windows import create_job, limit_memory, assign_job
+            import psutil
+
+            ram_limit = psutil.virtual_memory().total * 2 // 3
+            print('Limiting RAM usage to {0:,} Bytes.'.format(ram_limit))
+
+            assign_job(create_job())
+            limit_memory(ram_limit)
+        except ModuleNotFoundError:
+            try:
+                from lib.memory_limit_linux import limit_memory, get_memory
+
+                ram_limit = get_memory() * 2 // 3
+                print('Limiting RAM usage to {0:,} Bytes.'.format(ram_limit))
+
+                limit_memory(ram_limit)
+            except ModuleNotFoundError:
+                print('WARNING: Setting memory limit failed. '
+                      'This can happen if you are not on Windows nor on Linux or if you have forgot to install some dependencies.')
+
+        cls.limited = True
+
+
+MemoryLimiter.limit_memory_usage()
+
+
+def tf_memory_leak_cleanup():
+    import tensorflow
+    for obj in gc.get_objects():
+        if isinstance(obj, tensorflow.Graph):
+            if hasattr(obj, '_py_funcs_used_in_graph'):
+                del obj._py_funcs_used_in_graph[:]
+        if isinstance(obj, tensorflow.keras.utils.GeneratorEnqueuer):
+            obj.stop()
+    gc.collect()

+ 58 - 0
tool_lib/my_logger.py

@@ -0,0 +1,58 @@
+import os
+import logging
+import sys
+
+import __main__
+from typing import List, Dict
+
+FORMAT = "%(asctime)-14s %(levelname)-8s %(module)s:%(lineno)s %(message)s"
+
+
+class ListLogger(logging.Handler):  # Inherit from logging.Handler
+    def __init__(self):
+        logging.Handler.__init__(self)
+        self.running = False
+        self.log_lists: Dict[str, List[str]] = {}
+
+    def start(self):
+        self.running = True
+        self.log_lists.clear()
+
+    def stop(self):
+        self.running = False
+        self.log_lists.clear()
+
+    def emit(self, record):
+        if self.running:
+            level_name = record.levelname
+            if hasattr(record, 'end_user') and record.end_user:
+                level_name = 'END_USER_' + level_name
+            self.log_lists.setdefault(level_name, []).append(record.msg)
+
+
+def end_user_warning(msg):
+    logging.warning(msg, extra={'end_user': True})
+
+
+def end_user_info(msg):
+    logging.info(msg, extra={'end_user': True})
+
+
+list_handler = ListLogger()
+
+logging = logging
+try:
+    logfile = 'logs/' + os.path.normpath(__main__.__file__).replace(os.path.abspath('.'), '') + '.log'
+except AttributeError:
+    print('WARNING: unable to set log file path.')
+else:
+    try:
+        os.makedirs(os.path.dirname(logfile), exist_ok=True)
+        logging.basicConfig(filename=logfile, filemode="a+", format=FORMAT)
+    except OSError:
+        print('WARNING: unable to set log file path.')
+stdout_logger = logging.StreamHandler(sys.stdout)
+stdout_logger.setFormatter(logging.Formatter(FORMAT))
+logging.getLogger().addHandler(stdout_logger)
+logging.getLogger().addHandler(list_handler)
+logging.getLogger().setLevel(logging.INFO)

+ 1545 - 0
tool_lib/parameter_search.py

@@ -0,0 +1,1545 @@
+"""
+made by Eren Yilmaz
+"""
+import functools
+import itertools
+from copy import deepcopy
+
+from goto import with_goto
+
+from lib import util
+from lib.tuned_cache import TunedMemory
+from lib.progress_bar import ProgressBar
+import pickle
+
+from matplotlib.axes import Axes
+import os
+import random
+import sqlite3
+from datetime import datetime
+from math import inf, nan, ceil, sqrt
+from timeit import default_timer
+from typing import Dict, Any, List, Callable, Optional, Tuple, Iterable, Union
+
+import numpy
+import pandas
+import scipy.stats
+
+import seaborn as sns
+import matplotlib.pyplot as plt
+import matplotlib.dates
+
+from lib.util import my_tabulate, round_to_digits, print_progress_bar, heatmap_from_points, LogicError, shorten_name
+
+score_cache = TunedMemory(location='.cache/scores', verbose=0)
+sns.set(style="whitegrid", font_scale=1.5)
+
+# set up db for results
+connection: sqlite3.Connection = sqlite3.connect('random_search_results.db')
+connection.cursor().execute('PRAGMA foreign_keys = 1')
+connection.cursor().execute('PRAGMA journal_mode = WAL')
+connection.cursor().execute('PRAGMA synchronous = NORMAL')
+
+Parameters = Dict[str, Any]
+MetricValue = float
+Metrics = Dict[str, MetricValue]
+History = List[MetricValue]
+
+suppress_intermediate_beeps = False
+
+label = goto = object()
+
+
+class Prediction:
+    def __init__(self, dataset: str, y_true, y_pred, name: str):
+        self.y_pred = y_pred
+        self.y_true = y_true
+        self.name = name
+        if not isinstance(name, str):
+            self.name = str(name)
+        else:
+            self.name = name
+        if not isinstance(dataset, str):
+            raise TypeError
+        self.dataset = dataset
+
+    def __str__(self):
+        return str(self.__dict__)
+
+    def __repr__(self):
+        return self.__class__.__name__ + repr({k: v for k, v in self.__dict__.items() if k != 'predictions'})
+
+
+class EvaluationResult:
+    def __init__(self,
+                 results: Dict[str, MetricValue],
+                 parameters: Parameters = None,
+                 predictions: Optional[List[Prediction]] = None):
+        self.predictions = predictions
+        if self.predictions is not None:
+            self.predictions = self.predictions.copy()
+        else:
+            self.predictions = []
+        if parameters is None:
+            self.parameters = parameters
+        else:
+            self.parameters = parameters.copy()
+        self.results = results.copy()
+
+    def __iter__(self):
+        yield self
+
+    def __eq__(self, other):
+        return (isinstance(other, EvaluationResult)
+                and self.parameters == other.parameters
+                and self.predictions == other.predictions
+                and self.results == other.results)
+
+    def __str__(self):
+        return '{0}{1}'.format(self.__class__.__name__,
+                               {k: getattr(self, k) for k in ['results', 'parameters', 'predictions']})
+
+    def __repr__(self):
+        return self.__class__.__name__ + repr(self.__dict__)
+
+
+assert list(EvaluationResult({}, {})) == list([EvaluationResult({}, {})])
+
+EvaluationFunction = Callable[[Parameters], Union[List[EvaluationResult], EvaluationResult, List[float], float]]
+
+
+class Parameter:
+    def __init__(self, name: str, initial_value, larger_value, smaller_value, first_try_increase=False):
+        self.name = name
+        self.initial_value = initial_value
+        self.larger_value = larger_value
+        self.smaller_value = smaller_value
+        self.first_try_increase = first_try_increase
+
+    def __repr__(self):
+        return self.__class__.__name__ + repr(self.__dict__)
+
+    def copy(self, new_name=None):
+        result: Parameter = deepcopy(self)
+        if new_name is not None:
+            result.name = new_name
+        return result
+
+
+class BoundedParameter(Parameter):
+    def __init__(self, name, initial_value, larger_value, smaller_value, minimum=-inf, maximum=inf,
+                 first_try_increase=False):
+        self.minimum = minimum
+        self.maximum = maximum
+
+        super().__init__(name,
+                         initial_value,
+                         lambda x: self._bounded(larger_value(x)),
+                         lambda x: self._bounded(smaller_value(x)),
+                         first_try_increase=first_try_increase)
+
+        if self.initial_value < self.minimum:
+            raise ValueError('Initial value is lower than minimum value.')
+        if self.initial_value > self.maximum:
+            raise ValueError('Initial value is larger than maximum value.')
+
+    def _bounded(self, y):
+        y = max(self.minimum, y)
+        y = min(self.maximum, y)
+        return y
+
+
+class ConstantParameter(Parameter):
+    def __init__(self, name, value):
+        super().__init__(name,
+                         value,
+                         lambda x: value,
+                         lambda x: value)
+
+
+class BinaryParameter(Parameter):
+    def __init__(self, name, value1, value2):
+        super().__init__(name,
+                         value1,
+                         lambda x: value2 if x == value1 else value1,
+                         lambda x: value2 if x == value1 else value1)
+
+
+class BooleanParameter(Parameter):
+    def __init__(self, name, initial_value: bool):
+        super().__init__(name,
+                         bool(initial_value),
+                         lambda x: not x,
+                         lambda x: not x)
+
+
+class TernaryParameter(Parameter):
+    def __init__(self, name, value1, value2, value3):
+        self.smaller = {value1: value3, value2: value1, value3: value2}
+        self.larger = {value1: value2, value2: value3, value3: value1}
+        super().__init__(name,
+                         value1,
+                         lambda x: self.smaller[x],
+                         lambda x: self.larger[x])
+
+
+class ListParameter(Parameter):
+    def __init__(self, name, initial_value, possible_values: List, first_try_increase=False, circle=False):
+        self.possible_values = possible_values.copy()
+        if initial_value not in self.possible_values:
+            raise ValueError()
+        if len(set(self.possible_values)) != len(self.possible_values):
+            print('WARNING: It seems that there are duplicates in the list of possible values for {0}'.format(name))
+        length = len(self.possible_values)
+        if circle:
+            smaller = lambda x: self.possible_values[(self.possible_values.index(x) + 1) % length]
+            larger = lambda x: self.possible_values[(self.possible_values.index(x) - 1) % length]
+        else:
+            smaller = lambda x: self.possible_values[min(self.possible_values.index(x) + 1, length - 1)]
+            larger = lambda x: self.possible_values[max(self.possible_values.index(x) - 1, 0)]
+
+        super().__init__(name,
+                         initial_value,
+                         smaller,
+                         larger,
+                         first_try_increase=first_try_increase)
+
+
+class ExponentialParameter(BoundedParameter):
+    def __init__(self, name, initial_value, base, minimum=-inf, maximum=inf, first_try_increase=False):
+        super().__init__(name,
+                         initial_value,
+                         lambda x: float(x * base),
+                         lambda x: float(x / base),
+                         minimum,
+                         maximum,
+                         first_try_increase=first_try_increase)
+        self.plot_scale = 'log'
+
+
+class ExponentialIntegerParameter(BoundedParameter):
+    def __init__(self, name, initial_value, base, minimum=-inf, maximum=inf, first_try_increase=False):
+        if minimum != -inf:
+            minimum = round(minimum)
+
+        if maximum != inf:
+            maximum = round(maximum)
+
+        super().__init__(name,
+                         round(initial_value),
+                         lambda x: round(x * base),
+                         lambda x: round(x / base),
+                         minimum,
+                         maximum,
+                         first_try_increase=first_try_increase)
+        self.plot_scale = 'log'
+
+
+class LinearParameter(BoundedParameter):
+    def __init__(self, name, initial_value, summand, minimum=-inf, maximum=inf, first_try_increase=False):
+        super().__init__(name,
+                         initial_value,
+                         lambda x: float(x + summand),
+                         lambda x: float(x - summand),
+                         minimum,
+                         maximum,
+                         first_try_increase=first_try_increase)
+
+
+class LinearIntegerParameter(BoundedParameter):
+    def __init__(self, name, initial_value, summand, minimum=-inf, maximum=inf, first_try_increase=False):
+        super().__init__(name,
+                         initial_value,
+                         lambda x: x + summand,
+                         lambda x: x - summand,
+                         minimum,
+                         maximum,
+                         first_try_increase=first_try_increase)
+
+
+class InvalidParametersError(Exception):
+    def __init__(self, parameters=None):
+        self.parameters = parameters
+
+
+class BadParametersError(InvalidParametersError):
+    pass
+
+
+class InvalidReturnError(Exception):
+    pass
+
+
+class EmptyTableError(Exception):
+    pass
+
+
+EXAMPLE_PARAMS = [
+    ExponentialParameter('learn_rate', 0.001, 10),
+    ExponentialIntegerParameter('hidden_layer_size', 512, 2, minimum=1),
+    LinearIntegerParameter('hidden_layer_count', 3, 1, minimum=0),
+    ExponentialIntegerParameter('epochs', 100, 5, minimum=1),
+    LinearParameter('dropout_rate', 0.5, 0.2, minimum=0, maximum=1),
+]
+
+
+def mean_confidence_interval_size(data, confidence=0.95, force_v: Optional[int] = None,
+                                  force_sem: Optional[float] = None):
+    if len(data) == 0:
+        return nan
+    if force_sem is None:
+        if len(data) == 1:
+            return inf
+        sem = scipy.stats.sem(data)
+    else:
+        sem = force_sem
+    if sem == 0:
+        return 0
+    if force_v is None:
+        v = len(data) - 1
+    else:
+        v = force_v
+    return numpy.mean(data) - scipy.stats.t.interval(confidence,
+                                                     df=v,
+                                                     loc=numpy.mean(data),
+                                                     scale=sem)[0]
+
+
+def try_parameters(experiment_name: str,
+                   evaluate: EvaluationFunction,
+                   params: Dict[str, any],
+                   optimize: Optional[str] = None,
+                   larger_result_is_better: bool = None, ):
+    print('running experiment...')
+    params = params.copy()
+
+    if larger_result_is_better is None and optimize is not None:
+        raise NotImplementedError(
+            'Don\'t know how to optimize {0}. Did you specify `larger_result_is_better`?'.format(optimize))
+    assert larger_result_is_better is not None or optimize is None
+
+    worst_score = -inf if larger_result_is_better else inf
+
+    cursor = connection.cursor()
+    start = default_timer()
+    try:
+        result = evaluate(params)
+        if not isinstance(result, Iterable):
+            result = [result]
+        evaluation_results: List[EvaluationResult] = list(result)
+    except InvalidParametersError as e:
+        if optimize is not None:
+            bad_results: Dict[str, float] = {
+                optimize: worst_score
+            }
+        else:
+            bad_results = {}
+        if e.parameters is None:
+            evaluation_results = [EvaluationResult(
+                parameters=params,
+                results=bad_results
+            )]
+        else:
+            evaluation_results = [EvaluationResult(
+                parameters=e.parameters,
+                results=bad_results
+            )]
+    finally:
+        duration = default_timer() - start
+
+    for idx in range(len(evaluation_results)):
+        if isinstance(evaluation_results[idx], float):
+            evaluation_results[idx] = EvaluationResult(parameters=params,
+                                                       results={optimize: evaluation_results[idx]})
+
+    p_count = 0
+    for evaluation_result in evaluation_results:
+        if evaluation_result.parameters is None:
+            evaluation_result.parameters = params
+        metric_names = sorted(evaluation_result.results.keys())
+        param_names = list(sorted(evaluation_result.parameters.keys()))
+        for metric_name in metric_names:
+            add_metric_column(experiment_name, metric_name, verbose=1)
+        for param_name in param_names:
+            add_parameter_column(experiment_name, param_name, evaluation_result.parameters[param_name], verbose=1)
+
+        if not set(param_names).isdisjoint(metric_names):
+            raise RuntimeError('Metrics and parameter names should be disjoint')
+
+        if optimize is not None and numpy.isnan(evaluation_result.results[optimize]):
+            evaluation_result.results[optimize] = worst_score
+
+        metric_values = [evaluation_result.results[metric_name] for metric_name in metric_names]
+        param_names_comma_separated = ','.join('"' + param_name + '"' for param_name in param_names)
+        metric_names_comma_separated = ','.join('"' + metric_name + '"' for metric_name in metric_names)
+        insert_question_marks = ','.join('?' for _ in range(len(param_names) + len(metric_names)))
+
+        cursor.execute('''
+                INSERT INTO {0} ({1}) VALUES ({2})
+                '''.format(experiment_name,
+                           param_names_comma_separated + ',' + metric_names_comma_separated,
+                           insert_question_marks), (*[evaluation_result.parameters[name] for name in param_names],
+                                                    *metric_values))
+        result_id = cursor.lastrowid
+        assert cursor.execute(f'SELECT COUNT(*) FROM {experiment_name}_predictions WHERE result_id = ? LIMIT 1',
+                              (result_id,)).fetchone()[0] == 0
+        p_count += len(evaluation_result.predictions)
+        dataset_names = [(prediction.dataset, prediction.name) for prediction in evaluation_result.predictions]
+        if len(set(dataset_names)) != len(dataset_names):
+            print('\n'.join(sorted(dsn
+                                   for idx, dsn in dataset_names
+                                   if dsn in dataset_names[idx:])))
+            raise InvalidReturnError(
+                'Every combination of name and dataset in a single evaluation result must be unique.'
+                'There should be a list of duplicates printed above where the number of occurrences'
+                'of an element in the list is the actual number of occurrences minus 1 '
+                '(so only duplicates are listed).')
+        # noinspection SqlResolve
+        cursor.executemany('''
+            INSERT INTO {0}_predictions (dataset, y_true, y_pred, result_id, name) 
+            VALUES (?, ?, ?, ?, ?)
+            '''.format(experiment_name), [(prediction.dataset,
+                                           pickle.dumps(prediction.y_true),
+                                           pickle.dumps(prediction.y_pred),
+                                           result_id,
+                                           prediction.name) for prediction in evaluation_result.predictions])
+
+    connection.commit()
+    print('saved', len(evaluation_results), 'results and', p_count, 'predictions to db')
+    if not suppress_intermediate_beeps:
+        util.beep(1000, 500)
+
+    if optimize is not None:
+        scores = [r.results[optimize] for r in evaluation_results]
+        if larger_result_is_better:
+            best_score = max(scores)
+        else:
+            best_score = min(scores)
+        print('  finished in', duration, 'seconds, best loss/score:', best_score)
+
+    for r in evaluation_results:
+        if list(sorted(r.results.keys())) != list(sorted(metric_names)):
+            raise InvalidReturnError("""
+            Wrong metric names were returned by `evaluate`:
+            Expected metric_names={0}
+            but was {1}.
+            The result was saved to database anyways, possibly with missing values.
+            """.format(list(sorted(metric_names)),
+                       list(sorted(r.results.keys()))))
+
+    return evaluation_results
+
+
+def config_dict_from_param_list(params: List[Parameter]):
+    return {
+        p.name: p.initial_value
+        for p in params
+    }
+
+
+def evaluate_with_initial_params(experiment_name: str,
+                                 params: List[Parameter],
+                                 evaluate: EvaluationFunction,
+                                 optimize: str,
+                                 larger_result_is_better: bool,
+                                 metric_names=None,
+                                 num_experiments=1, ):
+    random_parameter_search(experiment_name=experiment_name,
+                            params=params,
+                            evaluate=evaluate,
+                            optimize=optimize,
+                            larger_result_is_better=larger_result_is_better,
+                            mutation_probability=1.,
+                            no_mutations_probability=0.,
+                            max_num_experiments=num_experiments,
+                            metric_names=metric_names,
+                            initial_experiments=num_experiments,
+                            experiment_count='db_tries_initial', )
+
+
+def random_parameter_search(experiment_name: str,
+                            params: List[Parameter],
+                            evaluate: EvaluationFunction,
+                            optimize: str,
+                            larger_result_is_better: bool,
+                            mutation_probability: float = None,
+                            no_mutations_probability: float = None,
+                            allow_multiple_mutations=False,
+                            max_num_experiments=inf,
+                            metric_names=None,
+                            initial_experiments=1,
+                            runs_per_configuration=inf,
+                            initial_runs=1,
+                            ignore_configuration_condition='0',
+                            experiment_count='tries', ):
+    print('experiment name:', experiment_name)
+    if metric_names is None:
+        metric_names = [optimize]
+    if optimize not in metric_names:
+        raise ValueError('trying to optimize {0} but only metrics available are {1}'.format(optimize, metric_names))
+    params = sorted(params, key=lambda p: p.name)
+    validate_parameter_set(params)
+
+    param_names = [param.name for param in params]
+
+    cursor = connection.cursor()
+    create_experiment_tables_if_not_exists(experiment_name, params, metric_names)
+
+    def max_tries_reached(ps):
+        return len(result_ids_for_parameters(experiment_name, ps)) >= runs_per_configuration
+
+    def min_tries_reached(ps):
+        return len(result_ids_for_parameters(experiment_name, ps)) >= initial_runs
+
+    def try_(ps) -> bool:
+        tried = False
+        if not max_tries_reached(ps):
+            try_parameters(experiment_name=experiment_name,
+                           evaluate=evaluate,
+                           params=ps,
+                           optimize=optimize,
+                           larger_result_is_better=larger_result_is_better, )
+            tried = True
+        else:
+            print('Skipping because maximum number of tries is already reached.')
+        while not min_tries_reached(ps):
+            print('Repeating because minimum number of tries is not reached.')
+            try_parameters(experiment_name=experiment_name,
+                           evaluate=evaluate,
+                           params=ps,
+                           optimize=optimize,
+                           larger_result_is_better=larger_result_is_better, )
+            tried = True
+        return tried
+
+    if mutation_probability is None:
+        mutation_probability = 1 / (len(params) + 1)
+    if no_mutations_probability is None:
+        no_mutations_probability = (1 - 1 / len(params)) / 4
+    initial_params = {param.name: param.initial_value for param in params}
+    print('initial parameters:', initial_params)
+
+    def skip():
+        best_scores, best_mean, best_std, best_conf = get_results_for_params(optimize, experiment_name,
+                                                                             best_params, 0.99)
+        try_scores, try_mean, try_std, try_conf = get_results_for_params(optimize, experiment_name,
+                                                                         try_params, 0.99)
+        if larger_result_is_better:
+            if best_mean - best_conf > try_mean + try_conf:
+                return True
+        else:
+            if best_mean + best_conf < try_mean - try_conf:
+                return True
+        return False
+
+    # get best params
+    initial_params = {param.name: param.initial_value for param in params}
+    any_results = cursor.execute('SELECT EXISTS (SELECT * FROM {0} WHERE NOT ({1}))'.format(experiment_name,
+                                                                                            ignore_configuration_condition)).fetchone()[
+        0]
+    if any_results:
+        best_params = get_best_params(experiment_name,
+                                      larger_result_is_better,
+                                      optimize,
+                                      param_names,
+                                      additional_condition=f'NOT ({ignore_configuration_condition})')
+    else:
+        best_params = initial_params
+    try_params = best_params.copy()
+
+    def results_for_params(ps):
+        return get_results_for_params(
+            metric=optimize,
+            experiment_name=experiment_name,
+            parameters=ps
+        )
+
+    if experiment_count == 'tries':
+        num_experiments = 0
+    elif experiment_count == 'results':
+        num_experiments = 0
+    elif experiment_count == 'db_total':
+        num_experiments = cursor.execute('SELECT COUNT(*) FROM {0} WHERE NOT ({1})'.format(experiment_name,
+                                                                                           ignore_configuration_condition)).fetchone()[
+            0]
+    elif experiment_count == 'db_tries_best':
+        num_experiments = len(result_ids_for_parameters(experiment_name, best_params))
+    elif experiment_count == 'db_tries_initial':
+        num_experiments = len(result_ids_for_parameters(experiment_name, initial_params))
+    else:
+        raise ValueError('Invalid argument for experiment_count')
+    last_best_score = results_for_params(best_params)[1]
+    while num_experiments < max_num_experiments:
+
+        if num_experiments < initial_experiments:
+            try_params = initial_params.copy()
+        else:
+            any_results = \
+                cursor.execute('SELECT EXISTS (SELECT * FROM {0} WHERE NOT ({1}))'.format(experiment_name,
+                                                                                          ignore_configuration_condition)).fetchone()[
+                    0]
+            if any_results:
+                last_best_params = best_params
+                best_params = get_best_params(experiment_name,
+                                              larger_result_is_better,
+                                              optimize,
+                                              param_names,
+                                              additional_condition=f'NOT ({ignore_configuration_condition})')
+                best_scores, best_score, _, best_conf_size = results_for_params(best_params)
+                if last_best_score is not None and best_score is not None:
+                    if last_best_params != best_params:
+                        if last_best_score < best_score and larger_result_is_better or last_best_score > best_score and not larger_result_is_better:
+                            print(' --> Parameters were improved by this change!')
+                        if last_best_score > best_score and larger_result_is_better or last_best_score < best_score and not larger_result_is_better:
+                            print(' --> Actually other parameters are better...')
+                last_best_score = best_score
+
+                # print('currently best parameters:', best_params)
+                changed_params = {k: v for k, v in best_params.items() if best_params[k] != initial_params[k]}
+                print('currently best parameters (excluding unchanged parameters):', changed_params)
+                print('currently best score:', best_score, 'conf.', best_conf_size, 'num.', len(best_scores))
+            else:
+                best_params = {param.name: param.initial_value for param in params}
+                best_conf_size = inf
+            try_params = best_params.copy()
+            verbose = 1
+            if best_conf_size != inf:
+                if random.random() > no_mutations_probability:
+                    modify_params_randomly(mutation_probability, params, try_params, verbose,
+                                           allow_multiple_mutations=allow_multiple_mutations)
+
+        if num_experiments < initial_experiments:
+            try_params = initial_params.copy()
+        else:
+            # check if this already has a bad score
+            if skip():
+                print('skipping because this set of parameters is known to be worse with high probability.')
+                print()
+                continue
+
+        # print('trying parameters', {k: v for k, v in try_params.items() if try_params[k] != initial_params[k]})
+
+        results = try_(try_params)
+
+        if experiment_count == 'tries':
+            num_experiments += 1
+        elif experiment_count == 'results':
+            num_experiments += len(results)
+        elif experiment_count == 'db_total':
+            num_experiments = cursor.execute('SELECT COUNT(*) FROM {0}'.format(experiment_name)).fetchone()[0]
+        elif experiment_count == 'db_tries_best':
+            num_experiments = len(result_ids_for_parameters(experiment_name, best_params))
+        elif experiment_count == 'db_tries_initial':
+            num_experiments = len(result_ids_for_parameters(experiment_name, initial_params))
+        else:
+            raise LogicError('It is not possible that this is reached.')
+
+
+@with_goto
+def diamond_parameter_search(experiment_name: str,
+                             diamond_size: int,
+                             params: List[Parameter],
+                             evaluate: EvaluationFunction,
+                             optimize: str,
+                             larger_result_is_better: bool,
+                             runs_per_configuration=inf,
+                             initial_runs=1,
+                             metric_names=None,
+                             filter_results_condition='1'):
+    print('experiment name:', experiment_name)
+    if metric_names is None:
+        metric_names = [optimize]
+    if optimize not in metric_names:
+        raise ValueError('trying to optimize {0} but only metrics available are {1}'.format(optimize, metric_names))
+    print('Optimizing metric', optimize)
+    if runs_per_configuration > initial_runs:
+        print(
+            f'WARNING: You are using initial_runs={initial_runs} and runs_per_configuration={runs_per_configuration}. '
+            f'This may lead to unexpected results if you dont know what you are doing.')
+    params_in_original_order = params
+    params = sorted(params, key=lambda p: p.name)
+    validate_parameter_set(params)
+
+    create_experiment_tables_if_not_exists(experiment_name, params, metric_names)
+
+    initial_params = {param.name: param.initial_value for param in params}
+    print('initial parameters:', initial_params)
+
+    # get best params
+    initial_params = {param.name: param.initial_value for param in params}
+    try:
+        best_params = get_best_params_and_compare_with_initial(experiment_name, initial_params, larger_result_is_better,
+                                                               optimize,
+                                                               additional_condition=filter_results_condition)
+    except EmptyTableError:
+        best_params = initial_params
+
+    def max_tries_reached(ps):
+        return len(result_ids_for_parameters(experiment_name, ps)) >= runs_per_configuration
+
+    def min_tries_reached(ps):
+        return len(result_ids_for_parameters(experiment_name, ps)) >= initial_runs
+
+    def try_(ps) -> bool:
+        tried = False
+        if not max_tries_reached(ps):
+            try_parameters(experiment_name=experiment_name,
+                           evaluate=evaluate,
+                           params=ps,
+                           optimize=optimize,
+                           larger_result_is_better=larger_result_is_better, )
+            tried = True
+        else:
+            print('Skipping because maximum number of tries is already reached.')
+        while not min_tries_reached(ps):
+            print('Repeating because minimum number of tries is not reached.')
+            try_parameters(experiment_name=experiment_name,
+                           evaluate=evaluate,
+                           params=ps,
+                           optimize=optimize,
+                           larger_result_is_better=larger_result_is_better, )
+            tried = True
+        return tried
+
+    last_best_score = results_for_params(optimize, experiment_name, best_params)[1]
+    modifications_steps = [
+        {'param_name': param.name, 'direction': direction}
+        for param in params_in_original_order
+        for direction in ([param.larger_value, param.smaller_value] if param.first_try_increase
+                          else [param.smaller_value, param.larger_value])
+    ]
+    label.restart
+    restart_scheduled = False
+    while True:  # repeatedly iterate parameters
+        any_tries_done_this_iteration = False
+        for num_modifications in range(diamond_size + 1):  # first try small changes, later larger changes
+            modification_sets = itertools.product(*(modifications_steps for _ in range(num_modifications)))
+            for modifications in modification_sets:  # which modifications to try this time
+                while True:  # repeatedly modify parameters in this direction
+                    improvement_found_in_this_iteration = False
+                    try_params = best_params.copy()
+                    for modification in modifications:
+                        try_params[modification['param_name']] = modification['direction'](
+                            try_params[modification['param_name']])
+                    for param_name, param_value in try_params.items():
+                        if best_params[param_name] != param_value:
+                            print(f'Setting {param_name} = {param_value} for the next run.')
+                    if try_params == best_params:
+                        print('Repeating experiment with best found parameters.')
+
+                    if try_(try_params):  # if the experiment was actually conducted
+                        any_tries_done_this_iteration = True
+                        best_params = get_best_params_and_compare_with_initial(experiment_name, initial_params,
+                                                                               larger_result_is_better, optimize,
+                                                                               filter_results_condition)
+
+                        last_best_params = best_params
+                        best_scores, best_score, _, best_conf_size = results_for_params(optimize, experiment_name,
+                                                                                        best_params)
+
+                        changed_params = {k: v for k, v in best_params.items() if best_params[k] != initial_params[k]}
+                        print('currently best parameters (excluding unchanged parameters):', changed_params)
+                        print('currently best score:', best_score, 'conf.', best_conf_size, 'num.', len(best_scores))
+                    else:
+                        last_best_params = best_params
+                        _, best_score, _, best_conf_size = results_for_params(optimize, experiment_name, best_params)
+
+                    if last_best_score is not None and best_score is not None:
+                        if last_best_params != best_params:
+                            if last_best_score < best_score and larger_result_is_better or last_best_score > best_score and not larger_result_is_better:
+                                print(' --> Parameters were improved by this change!')
+                                improvement_found_in_this_iteration = True
+                                if num_modifications > 1:
+                                    # two or more parameters were modified and this improved the results -> first try to modify them again in the same direction,
+                                    # then restart the search from the best found configuration
+                                    restart_scheduled = True
+                            elif last_best_score > best_score and larger_result_is_better or last_best_score < best_score and not larger_result_is_better:
+                                print(' --> Actually other parameters are better...')
+                    if not improvement_found_in_this_iteration:
+                        break  # stop if no improvement was found in this direction
+                if restart_scheduled:
+                    break
+            if restart_scheduled:
+                break
+        if restart_scheduled:
+            goto.restart
+        if not any_tries_done_this_iteration:
+            break  # parameter search finished (converged in some sense)
+
+
+cross_parameter_search = functools.partial(diamond_parameter_search, diamond_size=1)
+cross_parameter_search.__name__ = 'cross_parameter_search'
+
+
+def get_best_params_and_compare_with_initial(experiment_name, initial_params, larger_result_is_better, optimize,
+                                             additional_condition='1'):
+    best_params = get_best_params(experiment_name, larger_result_is_better, optimize, list(initial_params),
+                                  additional_condition=additional_condition)
+    changed_params = {k: v for k, v in best_params.items() if best_params[k] != initial_params[k]}
+    best_scores, best_score, _, best_conf_size = results_for_params(optimize, experiment_name, best_params)
+    print('currently best parameters (excluding unchanged parameters):', changed_params)
+    print('currently best score:', best_score, 'conf.', best_conf_size, 'num.', len(best_scores))
+    return best_params
+
+
+def results_for_params(optimize, experiment_name, ps):
+    return get_results_for_params(
+        metric=optimize,
+        experiment_name=experiment_name,
+        parameters=ps
+    )
+
+
+def modify_params_randomly(mutation_probability, params, try_params, verbose, allow_multiple_mutations=False):
+    for param in params:
+        while random.random() < mutation_probability:
+            next_value = random.choice([param.smaller_value, param.larger_value])
+            old_value = try_params[param.name]
+            try:
+                try_params[param.name] = round_to_digits(next_value(try_params[param.name]), 4)
+            except TypeError:  # when the parameter is not a number
+                try_params[param.name] = next_value(try_params[param.name])
+            if verbose and try_params[param.name] != old_value:
+                print('setting', param.name, '=', try_params[param.name], 'for this run')
+            if not allow_multiple_mutations:
+                break
+
+
+def finish_experiments(experiment_name: str,
+                       params: List[Parameter],
+                       optimize: str,
+                       larger_result_is_better: bool,
+                       metric_names=None,
+                       filter_results_table='1',
+                       max_display_results=None,
+                       print_results_table=False,
+                       max_table_row_count=inf,
+                       plot_metrics_by_metrics=False,
+                       plot_metric_over_time=False,
+                       plot_metrics_by_parameters=False, ):
+    if max_display_results is inf:
+        max_display_results = None
+    if metric_names is None:
+        metric_names = [optimize]
+    # get the best parameters
+    cursor = connection.cursor()
+    params = sorted(params, key=lambda param: param.name)
+    param_names = sorted(set(param.name for param in params))
+    param_names_comma_separated = ','.join('"' + param_name + '"' for param_name in param_names)
+
+    best_params = get_best_params(experiment_name, larger_result_is_better, optimize, param_names,
+                                  additional_condition=filter_results_table, )
+    best_score = get_results_for_params(
+        metric=optimize,
+        experiment_name=experiment_name,
+        parameters=best_params
+    )
+    initial_params = {param.name: param.initial_value for param in params}
+
+    # get a list of all results with mean std and conf
+    if print_results_table or plot_metrics_by_parameters or plot_metrics_by_metrics:
+        concatenated_metric_names = ','.join('GROUP_CONCAT("' + metric_name + '", \'@\') AS ' + metric_name
+                                             for metric_name in metric_names)
+        worst_score = '-1e999999' if larger_result_is_better else '1e999999'
+        limit_string = f'LIMIT {max_table_row_count}' if max_table_row_count is not None and max_table_row_count < inf else ''
+        # noinspection SqlAggregates
+        cursor.execute('''
+        SELECT {1}, {4}
+        FROM {0} AS params
+        WHERE ({5})
+        GROUP BY {1}
+        ORDER BY AVG(CASE WHEN params.{3} IS NULL THEN {6} ELSE params.{3} END) {2}
+        {7}
+        '''.format(experiment_name,
+                   param_names_comma_separated,
+                   'DESC' if larger_result_is_better else 'ASC',
+                   optimize,
+                   concatenated_metric_names,
+                   filter_results_table,
+                   worst_score,
+                   limit_string))
+        all_results = cursor.fetchall()
+
+        column_description = list(cursor.description)
+
+        for idx, row in enumerate(all_results):
+            all_results[idx] = list(row)
+
+        # prepare results table
+        if print_results_table or plot_metrics_by_metrics or plot_metrics_by_parameters:
+            iterations = 0
+            print('Generating table of parameters')
+            for column_index, column in list(enumerate(column_description))[::-1]:  # reverse
+                print_progress_bar(iterations, len(metric_names))
+                column_name = column[0]
+                column_description[column_index] = column
+                if column_name in metric_names:
+                    if max_display_results > 0:
+                        column_description[column_index] = column_name + ' values'
+                    column_description.insert(column_index + 1, column_name + ' mean')
+                    column_description.insert(column_index + 2, column_name + ' std')
+                    column_description.insert(column_index + 3, column_name + ' conf')
+                    # noinspection PyUnusedLocal
+                    list_row: List
+                    for list_row in all_results:
+                        string_values: str = list_row[column_index]
+                        if string_values is None:
+                            metric_values: List[float] = [nan]
+                        else:
+                            metric_values = list(map(float, string_values.split('@')))
+                        list_row[column_index] = [round_to_digits(x, 3) for x in metric_values[:max_display_results]]
+                        list_row.insert(column_index + 1, numpy.mean(metric_values))
+                        list_row.insert(column_index + 2, numpy.std(metric_values))
+                        list_row.insert(column_index + 3, mean_confidence_interval_size(metric_values))
+                    if all(len(list_row[column_index]) == 0 for list_row in all_results):
+                        del column_description[column_index]
+                        for list_row in all_results:
+                            del list_row[column_index]
+                    iterations += 1
+                else:
+                    column_description[column_index] = column_name
+                print_progress_bar(iterations, len(metric_names))
+
+        if print_results_table:  # actually print the table
+            table = my_tabulate(all_results,
+                                headers=column_description,
+                                tablefmt='pipe')
+
+            print(table)
+            cursor.execute('''
+            SELECT COUNT(*)
+            FROM {0}
+            '''.format(experiment_name))
+            print('Total number of rows, experiments, cells in this table:',
+                  (len(all_results), cursor.fetchone()[0], len(all_results) * len(all_results[0])))
+            print('Best parameters:', best_params)
+            changed_params = {k: v for k, v in best_params.items() if best_params[k] != initial_params[k]}
+            print('Best parameters (excluding unchanged parameters):', changed_params)
+            print('loss/score for best parameters (mean, std, conf):', best_score[1:])
+
+        if plot_metrics_by_parameters or plot_metrics_by_metrics:
+            print('Loading data...')
+            df = pandas.DataFrame.from_records(all_results, columns=param_names + [x
+                                                                                   for name in metric_names
+                                                                                   for x in
+                                                                                   [
+                                                                                       name + '_values',
+                                                                                       name + '_mean',
+                                                                                       name + '_std',
+                                                                                       name + '_conf'
+                                                                                   ]])
+            if plot_metrics_by_parameters:
+                print('Plotting metrics by parameter...')
+                plots = [
+                    (param.name,
+                     getattr(param, 'plot_scale', None),
+                     param.smaller_value if isinstance(param, BoundedParameter) else None,
+                     param.larger_value if isinstance(param, BoundedParameter) else None)
+                    for param in params
+                ]
+
+                iterations = 0
+                for metric_name in metric_names:
+                    dirname = 'img/results/{0}/{1}/'.format(experiment_name, metric_name)
+                    os.makedirs(dirname, exist_ok=True)
+                    for plot, x_scale, min_mod, max_mod in plots:
+                        print_progress_bar(iterations, len(metric_names) * len(plots))
+                        if min_mod is None:
+                            min_mod = lambda x: x
+                        if max_mod is None:
+                            max_mod = lambda x: x
+                        if df[plot].nunique() <= 1:
+                            iterations += 1
+                            continue
+                        grid = sns.relplot(x=plot, y=metric_name + '_mean', data=df)
+                        if x_scale is not None:
+                            if x_scale == 'log' and min_mod(df.min(axis=0)[plot]) <= 0:
+                                x_min = None
+                            else:
+                                x_min = min_mod(df.min(axis=0)[plot])
+                            grid.set(xscale=x_scale,
+                                     xlim=(x_min,
+                                           max_mod(df.max(axis=0)[plot]),))
+                        plt.savefig(dirname + '{0}.png'.format(plot))
+                        plt.clf()
+                        plt.close()
+                        iterations += 1
+                        print_progress_bar(iterations, len(metric_names) * len(plots))
+
+            if plot_metrics_by_metrics:
+                print('Plotting metrics by metrics...')
+                dirname = 'img/results/{0}/'.format(experiment_name)
+                os.makedirs(dirname, exist_ok=True)
+
+                # Generate some plots, metric by metric
+                iterations = 0
+                print('Plotting metric by metric, grouped')
+                for metric_name in metric_names:
+                    for metric_2 in metric_names:
+                        if metric_name == metric_2:
+                            iterations += 1
+                            print_progress_bar(iterations, len(metric_names) ** 2)
+                            continue
+                        print_progress_bar(iterations, len(metric_names) ** 2)
+                        sns.relplot(x=metric_name + '_mean', y=metric_2 + '_mean', data=df)
+                        plt.savefig(dirname + '{0}_{1}_grouped.png'.format(metric_name, metric_2))
+                        plt.clf()
+                        plt.close()
+                        heatmap_from_points(x=df[metric_name + '_mean'], y=df[metric_2 + '_mean'])
+                        plt.xlabel(f'mean {metric_name}')
+                        plt.ylabel(f'mean {metric_2}')
+                        plt.savefig(dirname + '{0}_{1}_heatmap.png'.format(metric_name, metric_2))
+                        plt.clf()
+                        plt.close()
+                        iterations += 1
+                        print_progress_bar(iterations, len(metric_names) ** 2)
+
+                df = pandas.read_sql_query('SELECT * FROM {0}'.format(experiment_name),
+                                           connection)
+                df['dt_created'] = pandas.to_datetime(df['dt_created'])
+
+            if plot_metric_over_time:
+                # Generate some plots, metric over time
+                dirname = 'img/results/{0}/'.format(experiment_name)
+                os.makedirs(dirname, exist_ok=True)
+                print('Plotting metric over time')
+                iterations = 0
+                for metric_name in metric_names:
+                    if not df[metric_name].any():
+                        continue
+                    print_progress_bar(iterations, len(metric_names))
+                    ax = df.plot(x='dt_created', y=metric_name, style='.')
+                    ax.xaxis.set_major_formatter(matplotlib.dates.DateFormatter('%m-%d %H:00'))
+                    plt.savefig(dirname + 'dt_created_{0}.png'.format(metric_name))
+                    plt.clf()
+                    plt.close()
+                    iterations += 1
+                    print_progress_bar(iterations, len(metric_names))
+
+                # plot optimize grouped over time
+                assert df['dt_created'].is_monotonic  # sorting should not be a problem but we are lazy
+                y_means = []
+                df = df.drop_duplicates(subset='dt_created')
+                timestamps = pandas.datetimeIndex(df.dt_created).asi8 // 10 ** 9
+                iterations = 0
+                print('Preparing plot {0} over time'.format(optimize))
+                for x in timestamps:
+                    print_progress_bar(iterations, len(timestamps))
+                    not_after_x = 'CAST(strftime(\'%s\', dt_created) AS INT) <= {0}'.format(x)
+                    param = get_best_params(additional_condition=not_after_x,
+                                            param_names=param_names,
+                                            experiment_name=experiment_name,
+                                            larger_result_is_better=larger_result_is_better,
+                                            optimize=optimize)
+                    scores, mean, std, conf = get_results_for_params(optimize, experiment_name, param,
+                                                                     additional_condition=not_after_x)
+                    y_means.append(mean)
+                    iterations += 1
+                    print_progress_bar(iterations, len(timestamps))
+                df['score'] = y_means
+
+                ax = df.plot(x='dt_created', y='score')
+                ax.xaxis.set_major_formatter(matplotlib.dates.DateFormatter('%m-%d %H:00'))
+                plt.savefig(dirname + '{0}_over_time.png'.format(optimize))
+                plt.clf()
+                plt.close()
+
+    return best_params, best_score
+
+
+def predictions_for_parameters(experiment_name: str, parameters, show_progress=False):
+    result_ids = result_ids_for_parameters(experiment_name, parameters)
+    if not show_progress:
+        return [
+            predictions_for_result_id(experiment_name, result_id)
+            for result_id in result_ids
+        ]
+    else:
+        return [
+            predictions_for_result_id(experiment_name, result_id)
+            for result_id in ProgressBar(result_ids)
+        ]
+
+
+def result_ids_for_parameters(experiment_name, parameters: Dict[str, Any]):
+    condition, parameters = only_specific_parameters_condition(parameters)
+    cursor = connection.cursor()
+    cursor.execute('''
+    SELECT rowid FROM {0}
+    WHERE {1}
+    ORDER BY rowid
+    '''.format(experiment_name, condition), parameters)
+    result_ids = [row[0] for row in cursor.fetchall()]
+    return result_ids
+
+
+def creation_times_for_parameters(experiment_name, parameters):
+    condition, parameters = only_specific_parameters_condition(parameters)
+    cursor = connection.cursor()
+    cursor.execute('''
+    SELECT dt_created FROM {0}
+    WHERE {1}
+    ORDER BY rowid
+    '''.format(experiment_name, condition), parameters)
+    creation_times = [row[0] for row in cursor.fetchall()]
+    return creation_times
+
+
+def predictions_for_result_id(experiment_name: str, result_id):
+    cursor = connection.cursor()
+    cursor.execute('''
+    SELECT name, dataset, y_pred, y_true FROM {0}_predictions
+    WHERE result_id = ?
+    '''.format(experiment_name, ), (result_id,))
+    predictions = [{
+        'name': row[0],
+        'dataset': row[1],
+        'y_pred': row[2],
+        'y_true': row[3],
+    } for row in cursor.fetchall()]
+
+    return predictions
+
+
+def list_difficult_samples(experiment_name,
+                           loss_functions,
+                           dataset,
+                           max_losses_to_average=20,
+                           additional_condition='1',
+                           additional_parameters=(),
+                           also_print=False):
+    names = all_sample_names(dataset, experiment_name)
+    cursor = connection.cursor()
+    if 'epochs' in additional_condition:
+        try:
+            print('Creating index to fetch results faster (if not exists)...')
+            cursor.execute('''
+                CREATE INDEX IF NOT EXISTS {0}_by_name_epochs_dataset
+                ON {0} (name, epochs, dataset)'''.format(experiment_name))
+        except Exception as e:  # TODO check error type
+            print(e)
+            pass
+    cursor = connection.cursor()
+    table = []
+    print('Fetching results for names...')
+    for name in ProgressBar(names):
+        if additional_condition == '1':
+            additional_join = ''
+        else:
+            additional_join = 'JOIN {0} ON {0}.rowid = result_id'.format(experiment_name)
+        if isinstance(max_losses_to_average, int) is not None and max_losses_to_average != inf:
+            limit_string = f'LIMIT ?'
+            limit_args = [max_losses_to_average]
+        elif max_losses_to_average is None or max_losses_to_average == inf:
+            limit_string = ''
+            limit_args = []
+        else:
+            raise ValueError
+        cursor.execute('''
+            SELECT y_pred, y_true
+            FROM {0}
+             CROSS JOIN {0}_predictions ON {0}.rowid = result_id
+            WHERE name = ? AND dataset = ? AND ({1})
+            {3}'''.format(experiment_name, additional_condition, ..., limit_string), (name,
+                                                                                      dataset,
+                                                                                      *additional_parameters,
+                                                                                      *limit_args,))
+        data = cursor.fetchall()
+        if len(data) > 0:
+            def aggregate(xs):
+                if len(set(xs)) == 1:
+                    return xs[0]
+                else:
+                    return numpy.mean(xs)
+
+            table.append((*[aggregate([loss_function(y_pred=y_pred, y_true=y_true, name=name)
+                                       for y_pred, y_true in data])
+                            for loss_function in loss_functions],
+                          name, len(data)))
+    print('sorting table...')
+    table.sort(reverse=True)
+    if also_print:
+        print('stringifying table...')
+        print(my_tabulate(table,
+                          headers=[loss_function.__name__ for loss_function in loss_functions] + ['name', '#results'],
+                          tablefmt='pipe'))
+    return table
+
+
+def all_sample_names(dataset, experiment_name):
+    cursor = connection.cursor()
+    print('Creating index to have faster queries by name (if not exists)...')
+    cursor.execute('''
+        CREATE INDEX IF NOT EXISTS {0}_predictions_by_name_and_dataset
+        ON {0}_predictions (dataset, name)'''.format(experiment_name))
+    print('Fetching all names...')
+    names = []
+    last_found = ''  # smaller than all other strings
+    while True:
+        cursor.execute('SELECT name '
+                       'FROM {0}_predictions '
+                       'WHERE dataset = ? AND name > ?'
+                       'LIMIT 1'.format(experiment_name), (dataset, last_found))
+        row = cursor.fetchone()
+        if row is None:
+            break
+        names.append(row[0])
+        last_found = row[0]
+    return names
+
+
+def only_specific_parameters_condition(parameters: Dict[str, Any]) -> Tuple[str, Tuple]:
+    items = list(parameters.items())  # to have the correct ordering
+    return '(' + ' AND '.join(f'"{name}" IS ?' for name, _ in items) + ')', \
+           tuple(value for name, value in items)
+
+
+def only_best_parameters_condition(experiment_name: str,
+                                   larger_result_is_better: bool,
+                                   optimize: str,
+                                   param_names: List[str],
+                                   additional_condition: str = '1') -> Tuple[str, Tuple]:
+    parameters = get_best_params(experiment_name=experiment_name,
+                                 larger_result_is_better=larger_result_is_better,
+                                 optimize=optimize,
+                                 param_names=param_names,
+                                 additional_condition=additional_condition)
+    return only_specific_parameters_condition(parameters)
+
+
+def get_results_for_params(metric, experiment_name, parameters, confidence=0.95,
+                           additional_condition='1'):
+    param_names = list(parameters.keys())
+    cursor = connection.cursor()
+    params_equal = '\nAND '.join('"' + param_name + '" IS ?' for param_name in param_names)
+    cursor.execute(
+        '''
+        SELECT {0}
+        FROM {1}
+        WHERE {2} AND ({3})
+        '''.format(metric,
+                   experiment_name,
+                   params_equal,
+                   additional_condition),
+        tuple(parameters[name] for name in param_names)
+    )
+    # noinspection PyShadowingNames
+    scores = [row[0] if row[0] is not None else nan for row in cursor.fetchall()]
+    if len(scores) == 0:
+        return scores, nan, nan, nan
+    return scores, numpy.mean(scores), numpy.std(scores), mean_confidence_interval_size(scores, confidence)
+
+
+def num_results_for_params(param_names, experiment_name, parameters,
+                           additional_condition='1'):
+    cursor = connection.cursor()
+    params_equal = '\nAND '.join('"' + param_name + '" IS ?' for param_name in param_names)
+    cursor.execute(
+        '''
+        SELECT COUNT(*)
+        FROM {0}
+        WHERE {1} AND ({2})
+        '''.format(experiment_name,
+                   params_equal,
+                   additional_condition),
+        tuple(parameters[name] for name in param_names)
+    )
+    return cursor.fetchone()[0]
+
+
+def get_best_params(experiment_name: str,
+                    larger_result_is_better: bool,
+                    optimize: str,
+                    param_names: List[str],
+                    additional_condition='1') -> Optional[Parameters]:
+    cursor = connection.cursor()
+    param_names_comma_separated = ','.join('"' + param_name + '"' for param_name in param_names)
+    worst_score = '-1e999999' if larger_result_is_better else '1e999999'
+    # noinspection SqlAggregates
+    cursor.execute('''
+            SELECT * FROM {0} AS params
+            WHERE ({4})
+            GROUP BY {1}
+            ORDER BY AVG(CASE WHEN params.{3} IS NULL THEN {5} ELSE params.{3} END) {2}, MIN(rowid) ASC
+            LIMIT 1
+            '''.format(experiment_name,
+                       param_names_comma_separated,
+                       'DESC' if larger_result_is_better else 'ASC',
+                       optimize,
+                       additional_condition,
+                       worst_score, ))
+    row = cursor.fetchone()
+    if row is None:
+        raise EmptyTableError()
+    else:
+        return params_from_row(cursor.description, row, param_names=param_names)
+
+
+def params_from_row(description, row, param_names=None) -> Parameters:
+    best_params = {}
+    for idx, column_description in enumerate(description):
+        column_name = column_description[0]
+        if param_names is None or column_name in param_names:
+            best_params[column_name] = row[idx]
+    return best_params
+
+
+def create_experiment_tables_if_not_exists(experiment_name, params, metric_names):
+    cursor = connection.cursor()
+    param_names = set(param.name for param in params)
+    initial_params = {param.name: param.initial_value for param in params}
+    cursor.execute('''
+    CREATE TABLE IF NOT EXISTS {0}(
+        rowid INTEGER PRIMARY KEY,
+        dt_created datetime DEFAULT CURRENT_TIMESTAMP
+    )
+    '''.format(experiment_name))
+    cursor.execute('''
+    CREATE TABLE IF NOT EXISTS {0}_predictions(
+        rowid INTEGER PRIMARY KEY,
+        dataset TEXT NOT NULL,
+        y_true BLOB,
+        y_pred BLOB,
+        name TEXT NOT NULL, -- used to identify the samples
+        result_id INTEGER NOT NULL REFERENCES {0}(rowid),
+        UNIQUE(result_id, dataset, name) -- gives additional indices
+    )
+    '''.format(experiment_name))
+    connection.commit()
+
+    for param_name in param_names:
+        default_value = initial_params[param_name]
+        add_parameter_column(experiment_name, param_name, default_value)
+
+    for metric_name in metric_names:
+        add_metric_column(experiment_name, metric_name)
+
+
+def add_metric_column(experiment_name, metric_name, verbose=0):
+    cursor = connection.cursor()
+    try:
+        cursor.execute('ALTER TABLE {0} ADD COLUMN "{1}" NUMERIC DEFAULT NULL'.format(experiment_name,
+                                                                                      metric_name))
+    except sqlite3.OperationalError as e:
+        if 'duplicate column name' not in e.args[0]:
+            raise
+    else:
+        if verbose:
+            print(f'WARNING: created additional column {metric_name}. This may or may not be intentional')
+    connection.commit()
+
+
+def add_parameter_column(experiment_name, param_name, default_value, verbose=0):
+    cursor = connection.cursor()
+    try:
+        if isinstance(default_value, str):
+            default_value.replace("'", "\\'")
+            default_value = "'" + default_value + "'"
+        if default_value is None:
+            default_value = 'NULL'
+        cursor.execute('ALTER TABLE {0} ADD COLUMN "{1}" BLOB DEFAULT {2}'.format(experiment_name,
+                                                                                  param_name,
+                                                                                  default_value))
+    except sqlite3.OperationalError as e:
+        if 'duplicate column name' not in e.args[0]:
+            raise
+    else:
+        if verbose:
+            print(
+                f'WARNING: created additional column {param_name} with default value {default_value}. This may or may not be intentional')
+    connection.commit()
+
+
+def markdown_table(all_results, sort_by):
+    rows = [list(result['params'].values()) + [result['mean'], result['std'], result['conf'], result['all']] for result
+            in all_results]
+    rows.sort(key=sort_by)
+    table = my_tabulate(rows, headers=list(all_results[0]['params'].keys()) + ['mean', 'std', 'conf', 'results'],
+                        tablefmt='pipe')
+    return table
+
+
+def validate_parameter_set(params):
+    if len(params) == 0:
+        raise ValueError('Parameter set empty')
+    for i, param in enumerate(params):
+        # noinspection PyUnusedLocal
+        other_param: Parameter
+        for other_param in params[i + 1:]:
+            if param.name == other_param.name and param.initial_value != other_param.initial_value:
+                msg = '''
+                A single parameter cant have multiple initial values. 
+                Parameter "{0}" has initial values "{1}" and "{2}"
+                '''.format(param.name, param.initial_value, other_param.initial_value)
+                raise ValueError(msg)
+
+
+def run_name(parameters=None) -> str:
+    if parameters is None:
+        parameters = {}
+    shorter_parameters = {
+        shorten_name(k): shorten_name(v)
+        for k, v in parameters.items()
+    }
+    return ((str(datetime.now()) + str(shorter_parameters).replace(' ', ''))
+            .replace("'", '')
+            .replace('"', '')
+            .replace(":", '⦂')
+            .replace(",", '')
+            .replace("_", '')
+            .replace("<", '')
+            .replace(">", '')
+            .replace("{", '')
+            .replace("}", ''))
+
+
+def plot_experiment(metric_names,
+                    experiment_name: str,
+                    plot_name: str,
+                    param_names: List[str],
+                    params_list: List[Parameters],
+                    evaluate: EvaluationFunction,
+                    ignore: List[str] = None,
+                    plot_shape=None,
+                    metric_limits: Dict = None,
+                    titles=None,
+                    natural_metric_names: Dict[str, str] = None,
+                    min_runs_per_params=0,
+                    single_plot_width=6.4,
+                    single_plot_height=4.8, ):
+    if natural_metric_names is None:
+        natural_metric_names = {}
+    for parameters in params_list:
+        if 'epochs' not in parameters:
+            raise ValueError('`plot_experiment` needs the number of epochs to plot (`epochs`)')
+
+    if metric_limits is None:
+        metric_limits = {}
+    if ignore is None:
+        ignore = []
+    if titles is None:
+        titles = [None for _ in params_list]
+
+    if plot_shape is None:
+        width = ceil(sqrt(len(params_list)))
+        plot_shape = (ceil(len(params_list) / width), width,)
+    else:
+        width = plot_shape[1]
+    plot_shape_offset = 100 * plot_shape[0] + 10 * plot_shape[1]
+    axes: Dict[int, Axes] = {}
+    legend: List[str] = []
+    results_dir = 'img/results/{0}/over_time/'.format(experiment_name)
+    os.makedirs(results_dir, exist_ok=True)
+
+    metric_names = sorted(metric_names, key=lambda m: (metric_limits.get(m, ()), metric_names.index(m)))
+    print(metric_names)
+    plotted_metric_names = []
+    iterations = 0
+
+    for plot_std in [False, True]:
+        plt.figure(figsize=(single_plot_width * plot_shape[1], single_plot_height * plot_shape[0]))
+        for idx, metric in enumerate(metric_names):
+            print_progress_bar(iterations, 2 * (len(metric_names) - len(ignore)))
+            limits = metric_limits.get(metric, None)
+            try:
+                next_limits = metric_limits.get(metric_names[idx + 1], None)
+            except IndexError:
+                next_limits = None
+            if metric in ignore:
+                continue
+            sqlite_infinity = '1e999999'
+            metric_is_finite = '{0} IS NOT NULL AND {0} != {1} AND {0} != -{1}'.format(metric, sqlite_infinity)
+            for plot_idx, parameters in enumerate(params_list):
+                while num_results_for_params(param_names=param_names,
+                                             experiment_name=experiment_name,
+                                             parameters=parameters, ) < min_runs_per_params:
+                    print('Doing one of the missing experiments for the plot:')
+                    print(parameters)
+                    results = try_parameters(experiment_name=experiment_name,
+                                             evaluate=evaluate,
+                                             params=parameters, )
+                    assert any(result.parameters == parameters for result in results)
+
+                contains_avg_over = 'average_over_last_epochs' in parameters
+
+                total_epochs = parameters['epochs']
+                history = []
+                lower_conf_limits = []
+                upper_conf_limits = []
+                for epoch_end in range(total_epochs):
+                    current_parameters = parameters.copy()
+                    if contains_avg_over:
+                        current_parameters['average_over_last_epochs'] = None
+                    current_parameters['epochs'] = epoch_end + 1
+                    scores, mean, std, conf = get_results_for_params(
+                        metric=metric,
+                        experiment_name=experiment_name,
+                        parameters=current_parameters,
+                        additional_condition=metric_is_finite
+                    )
+                    history.append(mean)
+                    if plot_std:
+                        lower_conf_limits.append(mean - 1.959964 * std)
+                        upper_conf_limits.append(mean + 1.959964 * std)
+                    else:
+                        lower_conf_limits.append(mean - conf)
+                        upper_conf_limits.append(mean + conf)
+                x = list(range(len(history)))
+                if plot_shape_offset + plot_idx + 1 not in axes:
+                    # noinspection PyTypeChecker
+                    ax: Axes = plt.subplot(plot_shape_offset + plot_idx + 1)
+                    assert isinstance(ax, Axes)
+                    axes[plot_shape_offset + plot_idx + 1] = ax
+                ax = axes[plot_shape_offset + plot_idx + 1]
+                ax.plot(x, history)
+                ax.fill_between(x, lower_conf_limits, upper_conf_limits, alpha=0.4)
+                if titles[plot_idx] is not None:
+                    ax.set_title(titles[plot_idx])
+                if limits is not None:
+                    ax.set_ylim(limits)
+                ax.set_xlim(0, max(total_epochs, ax.get_xlim()[1]))
+                current_row = plot_idx // width
+                if current_row == plot_shape[0] - 1:
+                    ax.set_xlabel('Epoch')
+            natural_name = natural_metric_names.get(metric, metric)
+            if plot_std:
+                legend += ['mean ' + natural_name, '1.96σ of {0}'.format(natural_name)]
+            else:
+                legend += ['mean ' + natural_name, '95% conf. of mean {0}'.format(natural_name)]
+
+            plotted_metric_names.append(metric)
+            if limits is None or next_limits is None or limits != next_limits:
+                legend = legend[0::2] + legend[1::2]
+                for ax in axes.values():
+                    ax.legend(legend)
+                if plot_std:
+                    plt.savefig(results_dir + plot_name + '_' + ','.join(plotted_metric_names) + '_std' + '.png')
+                else:
+                    plt.savefig(results_dir + plot_name + '_' + ','.join(plotted_metric_names) + '.png')
+                plt.clf()
+                plt.close()
+                plt.figure(figsize=(single_plot_width * plot_shape[1], single_plot_height * plot_shape[0]))
+                axes = {}
+                plotted_metric_names = []
+                legend = []
+            iterations += 1
+            print_progress_bar(iterations, 2 * (len(metric_names) - len(ignore)))
+    plt.clf()
+    plt.close()
+
+
+if __name__ == '__main__':
+    def evaluate(params):
+        return (params['A'] - 30) ** 2 + 10 * ((params['B'] / (params['A'] + 1)) - 1) ** 2 + params['C']
+
+
+    diamond_parameter_search('test',
+                             diamond_size=2,
+                             params=[LinearParameter('A', 10, 10),
+                                     ExponentialIntegerParameter('B', 8, 2),
+                                     ConstantParameter('C', 5)],
+                             runs_per_configuration=1,
+                             initial_runs=1,
+                             evaluate=evaluate,
+                             optimize='loss',
+                             larger_result_is_better=False)

+ 17 - 0
tool_lib/path.py

@@ -0,0 +1,17 @@
+from pathlib import Path
+from os import getcwd
+class Path_Lib:
+    def __init__(self,name='',path=''):
+        self.name = name
+        self.path = path
+        pass
+    # Low Level
+    def get_cwd_path(self):
+        return getcwd()
+    # Mid Level
+    def get_path_of_file_in_cwd(self, name=''):
+        if name =='':
+            name = self.name
+        path= getcwd()+ '\\'+ name
+        return path
+

+ 221 - 0
tool_lib/plots.py

@@ -0,0 +1,221 @@
+import sqlite3
+import time
+from typing import Union
+import networkx as nx
+import matplotlib.pyplot as plt
+import numpy as np
+import sys
+import matplotlib.animation
+
+
+class Plots():
+    def __init__(self, x_axe, y_axe, z_axe=[], x_label='x', y_label='y', z_label='z', x_axe2=[], y_axe2=[], z_axe2=[], grid=True,
+                 figsize=(13, 10)):
+        self.x_axe = x_axe
+        self.y_axe = y_axe
+        self.z_axe = z_axe
+        self.x_label = x_label
+        self.y_label = y_label
+        self.z_label = z_label
+        self.x_axe2 = x_axe2
+        self.y_axe2 = y_axe2
+        self.z_axe2 = z_axe2
+        self.grid = grid
+        self.figure = plt.figure(figsize=figsize)
+
+    def get_a_exponential_func_as_plot(self, linrange=[-5, 5], expo=2):
+        x = np.linspace(linrange[0], linrange[1])
+        y = x ** expo
+        plt.plot(x, y, 'g')
+
+    def plot_2D_compare_bar_chart(self, legend=[], width=0.35, title=str, path=''):
+        x = np.arange(len(self.x_axe))
+
+        if legend:
+            plt.bar(x, self.y_axe, width=width, color='blue', label=legend[0])
+            plt.bar(x + width, self.y_axe2, width=width, color='red', label=legend[1])
+        else:
+            plt.bar(x, self.y_axe, width=width, color='blue')
+            plt.bar(x + width, self.y_axe2, width=width, color='red')
+
+        if self.grid:
+            plt.grid()
+
+        plt.xlabel(self.x_label)
+        plt.ylabel(self.y_label)
+
+        if title:
+            plt.title(title)
+
+        plt.xticks(x + width / 2, self.x_axe)
+        plt.legend(loc='best')
+
+        if path:
+            self.save_fig(name='plot_2D_compare_bar_chart', path=path)
+        else:
+            self.save_fig(name='plot_2D_compare_bar_chart')
+        plt.show()
+
+    def save_fig(self, name, path: Union[bool, str] = False, ):
+        if path:
+            plt.savefig(path + '{}_{}.png'.format(name, time.strftime("%Y-%m-%d_H%H-M%M")))
+        else:
+            plt.savefig('{}_{}.png'.format(name, time.strftime("%Y-%m-%d_H%H-M%M")))
+
+
+# Constant Layout
+NODE_SIZE = 200
+NODE_EDGE_COLOR = 'black'
+EDGE_WIDTH = 0.5
+FONT_SIZE = 8
+FONT_SIZE_EDGES = 1
+FONT_FAMILY = 'sans-serif'
+SAVE_FORMAT = 'svg'
+DPI = 1200
+CONNECTION_STYLE = 'arc3, rad=0.2'
+ARROW_SIZE = 12
+LABLE_POS = 0.35
+
+
+class NetworkxPlots():
+    def __init__(self, node_dict, pathing=[], color_map=[], legend=[], edges=[], edge_weightings=[], directed_graph=False):
+        if directed_graph:
+            self.graph = nx.DiGraph()
+        else:
+            self.graph = nx.Graph()
+
+        # Nodes
+        self.node_type_dict = {}
+        self.node_color_dict = {}
+        self.node_pos_dict = {}
+
+        for key, value in node_dict.items():
+            for ckey, cvalue in node_dict.items():
+                if node_dict[key]['node_type'] == node_dict[ckey]['node_type'] and node_dict[key][
+                    'node_type'] not in self.node_type_dict.keys():
+                    self.node_type_dict[node_dict[key]['node_type']] = []
+            self.node_type_dict[node_dict[key]['node_type']].append(key)
+            self.node_color_dict[node_dict[key]['node_type']] = node_dict[key]['node_color']
+            self.node_pos_dict[key] = node_dict[key]['node_name']
+
+        # Edges can be a nxn Matrix
+        self.edges = edges
+        self.pathing = pathing
+        self.color_map = color_map
+        self.edge_weightings = edge_weightings
+        self.legend = legend
+
+    def edges_for_complete_graph(self):
+        # without self_loops
+        for row in range(0, len(self.edges[:, 0])):
+            for column in range(0, len(self.edges[0, :])):
+                if round(self.edges[row, column], 1) == 0:
+                    pass
+                else:
+                    self.graph.add_edge(row + 1, column + 1, weight=1)  # round(self.edges[row, column], 1)
+
+    def directions_for_directed_graph(self, pathing=[]):
+        if pathing:
+            for order, path in enumerate(pathing):
+                self.graph.add_edge(path[0], path[1], weight=order + 1)
+        else:
+            for order, path in enumerate(self.pathing):
+                self.graph.add_edge(path[0], path[1], weight=order + 1)
+
+    def add_nodes_to_graph(self):
+        node_numberation = [node_number for node_number in range(1, len(self.node_pos_dict.keys()) + 1)]
+        for count in node_numberation:
+            self.graph.add_node(count)
+
+    def add_edges_to_graph(self):
+        self.graph.add_edges_from(self.edges, weight=self.edge_weightings)
+
+    def undirected_graph_plt_(self,
+                              name_path_tupl=''):  # TODO SIMPLYFY THESE FUNCTIONS BY EXTRA FUNCTIONS WHICH SEPERATES THEM INTO SMALLER ONES
+        '''
+        :param name_path_tupl: (name, path) to save the pic
+        :return: a showing of the undirected graph/ saves the picture
+        '''
+        # Setting
+        plt.axis("on")
+        ax = plt.gca()
+        self.create_edgeless_graph_plot(ax)
+        # Undirected labels
+        elarge = [(u, v) for (u, v, d) in self.graph.edges(data=True) if d["weight"]]
+        nx.draw_networkx_edges(self.graph, self.node_pos_dict, edgelist=elarge, width=EDGE_WIDTH, ax=ax)
+
+        # Create Figure
+        self.create_graph_settings_with_cartesic_coord(plt, ax)
+        # Save
+        if name_path_tupl:
+            self.save_fig(name=name_path_tupl[0], path=name_path_tupl[1], format=SAVE_FORMAT, dpi=DPI)
+        # plt.gca().set_aspect('equal', adjustable='box')
+        plt.show()
+
+    def directed_graph_with_path_plt_(self, name_path_tupl=''):
+        '''
+        :param name_path_tupl: (name, path) to save the pic
+        :return: a showing of the undirected graph/ saves the picture
+        '''
+        # Setting
+        plt.axis("on")
+        ax = plt.gca()
+        self.create_edgeless_graph_plot(ax)
+        # Directed labels
+        elarge = [(u, v) for (u, v, d) in self.graph.edges(data=True) if d["weight"]]
+        nx.draw_networkx_edges(self.graph, self.node_pos_dict, edgelist=elarge, width=EDGE_WIDTH, ax=ax,
+                               connectionstyle=CONNECTION_STYLE, arrowsize=ARROW_SIZE)
+        edge_labels = nx.get_edge_attributes(self.graph, 'weight')
+        nx.draw_networkx_edge_labels(self.graph, self.node_pos_dict, edge_labels=edge_labels, ax=ax, label_pos=LABLE_POS,
+                                     font_size=FONT_SIZE_EDGES)
+        # Create figure:
+        self.create_graph_settings_with_cartesic_coord(plt, ax)
+        # Save
+        if name_path_tupl:
+            self.save_fig(name=name_path_tupl[0], path=name_path_tupl[1], format=SAVE_FORMAT, dpi=DPI)
+        plt.gca().set_aspect('equal', adjustable='box')
+        plt.show()
+
+
+    def save_fig(self, name, path: Union[bool, str] = False, format='png', dpi=1200):
+        if path:
+            plt.savefig(path + r'\{}_{}.'.format(name, time.strftime("%Y-%m-%d_H%H-M%M")) + format, format=format, dpi=dpi)
+        else:
+            plt.savefig('{}_{}'.format(name, time.strftime("%Y-%m-%d_H%H-M%M")) + format, format=format, dpi=dpi)
+
+    def create_edgeless_graph_plot(self, ax):
+        for node_type in self.node_type_dict.keys():
+            nlist = self.node_type_dict[node_type]
+            ncolor = self.node_color_dict[node_type]
+
+            # draw the graph
+            nx.draw_networkx_nodes(self.graph,
+                                   pos=self.node_pos_dict,
+                                   nodelist=nlist,
+                                   ax=ax,
+                                   node_color=ncolor,
+                                   label=node_type,
+                                   edgecolors=NODE_EDGE_COLOR,
+                                   node_size=NODE_SIZE)
+
+        nx.draw_networkx_labels(self.graph, self.node_pos_dict, font_size=FONT_SIZE, font_family=FONT_FAMILY)
+
+    def create_graph_settings_with_cartesic_coord(self, plt, ax):
+        # getting the position of the legend so that the graph is not disrupted:
+        x_lim_max = max([x[0] for x in self.node_pos_dict.values()])
+        x_lim_max += x_lim_max * 0.01
+        y_lim_max = max([y[1] for y in self.node_pos_dict.values()])
+        y_lim_max += y_lim_max * 0.05
+        x_lim_min = min([x[0] for x in self.node_pos_dict.values()])
+        x_lim_min -= x_lim_min * 0.01
+        y_lim_min = min([y[1] for y in self.node_pos_dict.values()])
+        y_lim_min -= y_lim_min * 0.05
+
+        ax.tick_params(left=True, bottom=True, labelleft=True, labelbottom=True)
+        legend_size = 16
+        plt.legend(scatterpoints=1, prop={'size': legend_size})
+        plt.xlim(x_lim_min - 0.5, x_lim_max + legend_size / 6)
+        plt.ylim(y_lim_min - 0.5, y_lim_max + 0.5)
+        plt.xlabel('x')
+        plt.ylabel('y')
+        plt.tight_layout()

+ 321 - 0
tool_lib/print_exc_plus.py

@@ -0,0 +1,321 @@
+import inspect
+import os
+import re
+import sys
+import traceback
+from itertools import islice
+from pickle import PickleError
+from typing import Sized, Dict, Tuple, Type
+
+from types import FrameType
+
+from tool_lib.threading_timer_decorator import exit_after
+
+try:
+    import numpy
+except ImportError:
+    numpy = None
+
+FORMATTING_OPTIONS = {
+    'MAX_LINE_LENGTH': 1024,
+    'SHORT_LINE_THRESHOLD': 128,
+    'MAX_NEWLINES': 20,
+}
+ID = int
+
+
+# noinspection PyPep8Naming
+def name_or_str(X):
+    try:
+        return re.search(r"<class '?(.*?)'?>", str(X))[1]
+    except TypeError:  # if not found
+        return str(X)
+
+
+@exit_after(2)
+def type_string(x):
+    if numpy is not None and isinstance(x, numpy.ndarray):
+        return name_or_str(type(x)) + str(x.shape)
+    elif isinstance(x, Sized):
+        return name_or_str(type(x)) + f'({len(x)})'
+    else:
+        return name_or_str(type(x))
+
+
+@exit_after(2)
+def to_string_with_timeout(x):
+    return str(x)
+
+
+def nth_index(iterable, value, n):
+    matches = (idx for idx, val in enumerate(iterable) if val == value)
+    return next(islice(matches, n - 1, n), None)
+
+
+class DumpingException(Exception):
+    pass
+
+
+class SubclassNotFound(ImportError):
+    pass
+
+
+def subclass_by_name(name: str, base: Type):
+    candidates = [t for t in base.__subclasses__() if t.__name__ == name]
+    if len(candidates) != 1:
+        raise SubclassNotFound()
+    return candidates[0]
+
+
+def dont_import():
+    raise ImportError
+
+
+loaded_custom_picklers = False
+
+
+def load_custom_picklers():
+    global loaded_custom_picklers
+    if loaded_custom_picklers:
+        return
+    print('Loading custom picklers...')
+
+    typing_types = ['Dict', 'List', 'Set', 'Tuple', 'Callable', 'Optional']
+    for unpicklable_type in [
+        'from zmq import Socket as unpicklable',
+        'from zmq import Context as unpicklable',
+
+        'from sqlite3 import Connection as unpicklable',
+        'from sqlite3 import Cursor as unpicklable',
+
+        'from socket import socket as unpicklable',
+
+        'from tensorflow import Tensor as unpicklable',
+        'from tensorflow.python.types.core import Tensor as unpicklable',
+        # 'from tensorflow.keras import Model as unpicklable',
+        'from tensorflow.python.eager.def_function import Function as unpicklable',
+        'from tensorflow.python.keras.utils.object_identity import _ObjectIdentityWrapper as unpicklable',
+        # Next line: pybind11_builtins.pybind11_object
+        'from tensorflow.python._pywrap_tfe import TFE_MonitoringBoolGauge0;unpicklable=TFE_MonitoringBoolGauge0.__base__',
+
+        'unpicklable = subclass_by_name(\\"PyCapsule\\", object)',
+        'unpicklable = subclass_by_name(\\"_CData\\", object)',
+
+        'from h5py import HLObject as unpicklable',
+
+        'from builtins import memoryview as unpicklable',
+
+        # can't pickle type annotations from typing in python <= 3.6 (Next line: typing._TypingBase)
+        'import sys;dont_import() if sys.version >=\\"3.7\\" else None;from typing import Optional;unpicklable = type(Optional).__base__.__base__',
+        *[f'import sys;dont_import() if sys.version >=\\"3.7\\" else None;from typing import {t};unpicklable = type({t})' for t in typing_types],
+        'import sys;dont_import() if sys.version >=\\"3.7\\" else None;from typing import Dict;unpicklable = type(Dict).__base__',
+
+        'import inspect;unpicklable = type(inspect.stack()[0].frame)',
+
+        # can't pickle thread-local data
+        'from threading import local as unpicklable',
+
+        # can't pickle generator objects
+        'unpicklable = type(_ for _ in [])',
+    ]:
+        try:
+            unpicklable = eval(f'exec("{unpicklable_type}") or unpicklable')
+        except ImportError:
+            pass
+        except TypeError as e :
+            if 'Descriptors cannot not be created directly' in str(e):
+                pass
+            else:
+                raise
+        else:
+            register_unpicklable(unpicklable, also_subclasses=True)
+        finally:
+            unpicklable = None
+    loaded_custom_picklers = True
+
+
+def register_unpicklable(unpicklable: Type, also_subclasses=False):
+    import dill
+    @dill.register(unpicklable)
+    def save_unpicklable(pickler, obj):
+        def recreate_unpicklable():
+            return f'This was something that could not be pickled and instead was replaced with this string'
+
+        recreate_unpicklable.__name__ = f'unpicklable_{unpicklable.__name__}'
+        pickler.save_reduce(recreate_unpicklable, (), obj=obj)
+
+    if also_subclasses:
+        if unpicklable.__subclasses__ is type.__subclasses__:
+            subclasses = []
+        else:
+            subclasses = unpicklable.__subclasses__()
+        for subclass in subclasses:
+            register_unpicklable(subclass, also_subclasses=True)
+
+
+def dump_stack_to_file(serialize_to, print=print, stack=None):
+    if stack is None:
+        stack = inspect.stack()[1:]
+    try:
+        import dill
+    except ModuleNotFoundError:
+        print('Dill not available. Not dumping stack.')
+    else:
+        print('Dumping stack...')
+        load_custom_picklers()
+
+        serializable_stack = []
+        for frame in stack:
+            if isinstance(frame, inspect.FrameInfo):
+                frame = frame.frame
+            serializable_stack.append({
+                k: frame.__getattribute__(k)
+                for k in ['f_globals', 'f_locals', 'f_code', 'f_lineno', 'f_lasti']
+            })
+
+        with open(serialize_to, 'wb') as serialize_to_file:
+            try:
+                print(f'Dumping stack...')
+                dill.dump(serializable_stack, serialize_to_file)
+            except (PickleError, RecursionError) as e:
+                print(f'Was not able to dump the stack. Error {type(e)}: {e}')
+                unpicklable_frames = []
+                for frame_idx in range(len(serializable_stack)):
+                    try:
+                        dill.dumps(serializable_stack[frame_idx])
+                    except (PickleError, RecursionError):
+                        unpicklable_frames.append(frame_idx)
+                print(f'Unpicklable frames (top=0, bottom={len(serializable_stack)}):', unpicklable_frames)
+
+                if 'typing.' in str(e):
+                    print('This might be fixed by upgrading to python 3.7 or above.')
+            else:
+                print(f'Dumped stack. Can be loaded with:')
+                print(f'with open(r"{os.path.abspath(serialize_to)}", "rb") as f: import dill; dill._dill._reverse_typemap["ClassType"] = type; stack = dill.load(f)')
+        if os.path.isfile(serialize_to):
+            from lib.util import backup_file
+            backup_file(serialize_to)
+
+
+def print_exc_plus(print=print, serialize_to=None, print_trace=True):
+    """
+    Print the usual traceback information, followed by a listing of all the
+    local variables in each frame.
+    """
+    limit = FORMATTING_OPTIONS['MAX_LINE_LENGTH']
+    max_newlines = FORMATTING_OPTIONS['MAX_NEWLINES']
+    tb = sys.exc_info()[2]
+    if numpy is not None:
+        options = numpy.get_printoptions()
+        numpy.set_printoptions(precision=3, edgeitems=2, floatmode='maxprec', threshold=20, linewidth=120)
+    else:
+        options = {}
+    stack = []
+    long_printed_objs: Dict[ID, Tuple[str, FrameType]] = {}
+
+    while tb:
+        stack.append(tb.tb_frame)
+        tb = tb.tb_next
+    if print_trace:
+        for frame in stack:
+            if frame is not stack[0]:
+                print('-' * 40)
+            try:
+                print("Frame %s in %s at line %s" % (frame.f_code.co_name,
+                                                     os.path.relpath(frame.f_code.co_filename),
+                                                     frame.f_lineno))
+            except ValueError:  # if path is not relative
+                print("Frame %s in %s at line %s" % (frame.f_code.co_name,
+                                                     frame.f_code.co_filename,
+                                                     frame.f_lineno))
+            for key, value in frame.f_locals.items():
+                # We have to be careful not to cause a new error in our error
+                # printer! Calling str() on an unknown object could cause an
+                # error we don't want.
+
+                # noinspection PyBroadException
+                try:
+                    key_string = to_string_with_timeout(key)
+                except KeyboardInterrupt:
+                    key_string = "<TIMEOUT WHILE PRINTING KEY>"
+                except Exception:
+                    key_string = "<ERROR WHILE PRINTING KEY>"
+
+                # noinspection PyBroadException
+                try:
+                    type_as_string = type_string(value)
+                except KeyboardInterrupt:
+                    type_as_string = "<TIMEOUT WHILE PRINTING TYPE>"
+                except Exception as e:
+                    # noinspection PyBroadException
+                    try:
+                        type_as_string = f"<{type(e).__name__} WHILE PRINTING TYPE>"
+                    except Exception:
+                        type_as_string = "<ERROR WHILE PRINTING TYPE>"
+
+                if id(value) in long_printed_objs:
+                    prev_key_string, prev_frame = long_printed_objs[id(value)]
+                    if prev_frame is frame:
+                        print("\t%s is the same as '%s'" %
+                              (key_string + ' : ' + type_as_string,
+                               prev_key_string))
+                    else:
+                        print("\t%s is the same as '%s' in frame %s in %s at line %s." %
+                              (key_string + ' : ' + type_as_string,
+                               prev_key_string,
+                               prev_frame.f_code.co_name,
+                               os.path.relpath(prev_frame.f_code.co_filename),
+                               prev_frame.f_lineno))
+                    continue
+
+                # noinspection PyBroadException
+                try:
+                    value_string = to_string_with_timeout(value)
+                except KeyboardInterrupt:
+                    value_string = "<TIMEOUT WHILE PRINTING VALUE>"
+                except Exception:
+                    value_string = "<ERROR WHILE PRINTING VALUE>"
+                line: str = '\t' + key_string + ' : ' + type_as_string + ' = ' + value_string
+                if limit is not None and len(line) > limit:
+                    line = line[:limit - 1] + '...'
+                if max_newlines is not None and line.count('\n') > max_newlines:
+                    line = line[:nth_index(line, '\n', max_newlines)].strip() + '... (' + str(
+                        line[nth_index(line, '\n', max_newlines):].count('\n')) + ' more lines)'
+                if len(line) > FORMATTING_OPTIONS['SHORT_LINE_THRESHOLD']:
+                    long_printed_objs[id(value)] = key_string, frame
+                print(line)
+
+        traceback.print_exc()
+
+        etype, value, tb = sys.exc_info()
+        for line in traceback.TracebackException(type(value), value, tb, limit=limit).format(chain=True):
+            print(line)
+
+    if serialize_to is not None:
+        dump_stack_to_file(stack=stack, serialize_to=serialize_to, print=print)
+
+    if numpy is not None:
+        numpy.set_printoptions(**options)
+
+
+def main():
+    def fun1(c, d, e):
+        return fun2(c, d + e)
+
+    def fun2(g, h):
+        raise RuntimeError
+
+    def fun3(z):
+        return numpy.zeros(shape=z)
+
+    try:
+        import numpy as np
+        fun1(numpy.random.normal(size=(3, 4, 5, 6)), '12321', '123')
+        data = '???' * 100
+        fun3(data)
+    except:
+        print_exc_plus()
+
+
+if __name__ == '__main__':
+    main()

+ 194 - 0
tool_lib/progress_bar.py

@@ -0,0 +1,194 @@
+import functools
+import math
+import time
+from math import floor
+from typing import Iterable, Sized, Iterator
+
+
+class ProgressBar(Sized, Iterable):
+    def __iter__(self) -> Iterator:
+        self.check_if_num_steps_defined()
+        self.current_iteration = -1  # start counting at the end of the first epoch
+        self.current_iterator = iter(self._backend)
+        self.start_time = time.perf_counter()
+        return self
+
+    def __init__(self,
+                 num_steps=None,
+                 prefix='',
+                 suffix='',
+                 line_length=75,
+                 empty_char='-',
+                 fill_char='#',
+                 print_eta=True,
+                 decimals=1):
+        self.decimals = decimals
+        self.line_length = line_length
+        self.suffix = suffix
+        self.empty_char = empty_char
+        self.prefix = prefix
+        self.fill_char = fill_char
+        self.print_eta = print_eta
+        self.current_iteration = 0
+        self.last_printed_value = None
+        self.current_iterator = None
+        self.start_time = time.perf_counter()
+
+        try:
+            self._backend = range(num_steps)
+        except TypeError:
+            if isinstance(num_steps, Sized):
+                if isinstance(num_steps, Iterable):
+                    self._backend = num_steps
+                else:
+                    self._backend = range(len(num_steps))
+            elif num_steps is None:
+                self._backend = None
+            else:
+                raise
+
+        assert num_steps is None or isinstance(self._backend, (Iterable, Sized))
+
+    def set_num_steps(self, num_steps):
+        try:
+            self._backend = range(num_steps)
+        except TypeError:
+            if isinstance(num_steps, Sized):
+                if isinstance(num_steps, Iterable):
+                    self._backend = num_steps
+                else:
+                    self._backend = range(len(num_steps))
+            elif num_steps is None:
+                self._backend = None
+            else:
+                raise
+
+        assert num_steps is None or isinstance(self._backend, (Iterable, Sized))
+
+    def __len__(self):
+        return len(self._backend)
+
+    def __next__(self):
+        self.print_progress()
+        try:
+            result = next(self.current_iterator)
+            self.increment_iteration()
+            self.print_progress()
+            return result
+        except StopIteration:
+            self.increment_iteration()
+            self.print_progress()
+            raise
+
+    def step(self, num_iterations=1):
+        self.current_iteration += num_iterations
+        self.print_progress()
+
+    def print_progress(self, iteration=None):
+        """
+        Call in a loop to create terminal progress bar
+        @params:
+            iteration   - Optional  : current iteration (Int)
+        """
+        if iteration is not None:
+            self.current_iteration = iteration
+        try:
+            progress = self.current_iteration / len(self)
+        except ZeroDivisionError:
+            progress = 1
+        if self.current_iteration == 0:
+            self.start_time = time.perf_counter()
+        if self.print_eta and progress > 0:
+            time_spent = (time.perf_counter() - self.start_time)
+            eta = time_spent / progress * (1 - progress)
+            if progress == 1:
+                eta = f' T = {int(time_spent / 60):02d}:{round(time_spent % 60):02d}'
+            else:
+                eta = f' ETA {int(eta / 60):02d}:{round(eta % 60):02d}'
+        else:
+            eta = ''
+        percent = ("{0:" + str(4 + self.decimals) + "." + str(self.decimals) + "f}").format(100 * progress)
+        bar_length = self.line_length - len(self.prefix) - len(self.suffix) - len(eta) - 4 - 6
+        try:
+            filled_length = int(bar_length * self.current_iteration // len(self))
+        except ZeroDivisionError:
+            filled_length = bar_length
+        if math.isclose(bar_length * progress, filled_length):
+            overflow = 0
+        else:
+            overflow = bar_length * progress - filled_length
+            overflow *= 10
+            overflow = floor(overflow)
+        assert overflow in range(10), overflow
+        if overflow > 0:
+            bar = self.fill_char * filled_length + str(overflow) + self.empty_char * (bar_length - filled_length - 1)
+        else:
+            bar = self.fill_char * filled_length + self.empty_char * (bar_length - filled_length)
+
+        print_value = '\r{0} |{1}| {2}% {4}{3}'.format(self.prefix, bar, percent, eta, self.suffix)
+        if self.current_iteration == len(self):
+            print_value += '\n'  # Print New Line on Complete
+        if self.last_printed_value == print_value:
+            return
+        self.last_printed_value = print_value
+        print(print_value, end='')
+
+    def increment_iteration(self):
+        self.current_iteration += 1
+        if self.current_iteration > len(self):  # catches the special case at the end of the bar
+            self.current_iteration %= len(self)
+
+    def monitor(self, func=None):
+        """ Decorates the given function func to print a progress bar before and after each call. """
+        if func is None:
+            # Partial application, to be able to specify extra keyword
+            # arguments in decorators
+            return functools.partial(self.monitor)
+
+        @functools.wraps(func)
+        def wrapper(*args, **kwargs):
+            self.check_if_num_steps_defined()
+            self.print_progress()
+            result = func(*args, **kwargs)
+            self.increment_iteration()
+            self.print_progress()
+            return result
+
+        return wrapper
+
+    def check_if_num_steps_defined(self):
+        if self._backend is None:
+            raise RuntimeError('You need to specify the number of iterations before starting to iterate. '
+                               'You can either pass it to the constructor or use the method `set_num_steps`.')
+
+
+if __name__ == '__main__':
+    # Einfach beim iterieren verwenden
+    for x in ProgressBar([0.5, 2, 0.5]):
+        time.sleep(x)
+
+    # manuell aufrufen
+    data = [1, 5, 5, 6, 12, 3, 4, 5]
+    y = 0
+    p = ProgressBar(len(data))
+    for x in data:
+        p.print_progress()
+        time.sleep(0.2)
+        y += x
+        p.current_iteration += 1
+        p.print_progress()
+
+    print(y)
+
+    # oder einfach bei jedem funktionsaufruf den balken printen
+    p = ProgressBar()
+
+
+    @p.monitor
+    def heavy_computation(t=0.25):
+        time.sleep(t)
+
+
+    p.set_num_steps(10)  # 10 steps pro balken
+    for _ in range(20):  # zeichnet 2 balken
+        heavy_computation(0.25)

+ 134 - 0
tool_lib/python_gui.py

@@ -0,0 +1,134 @@
+import PySimpleGUI as sg
+import os.path
+
+
+# First the window layout in 2 columns
+
+
+
+def optiwae_gui(obj):
+    file_list_column = [
+
+        [
+
+            sg.Text("Type Section"),
+
+            sg.In(size=(25, 1), enable_events=True, key="-FOLDER-"),
+
+            sg.FolderBrowse(),
+
+        ],
+
+        [
+
+            sg.Listbox(
+
+                values=[], enable_events=True, size=(40, 20), key="-FILE LIST-"
+
+            )
+
+        ],
+
+    ]
+    variables_viewer_column = [
+        [sg.Text('x')],
+
+
+    ]
+
+    # ----- Full layout -----
+    lines_of_variables_0 = []
+    lines_of_variables_1 = []
+    lines_of_variables_2 = []
+    for count,(key, value) in enumerate(obj.items()):
+        if count<= 10:
+            lines_of_variables_0 += [[sg.Text(str(key) + '\n')]]
+        if count >10:
+            lines_of_variables_1+= [[sg.Text(str(key) +'\n')]]
+        if count >20:
+            lines_of_variables_2+= [[sg.Text(str(key) +'\n')]]
+
+
+    layout = [
+
+            # sg.Column(file_list_column),
+            [sg.Text('Enter Changes', size=(15, 1)), sg.InputText()],
+            [sg.Submit(), sg.Cancel()],
+
+
+            sg.Frame(layout= lines_of_variables_0, title=''),
+            sg.VSeperator(),
+            sg.Frame(layout=lines_of_variables_1, title=''),
+            sg.VSeperator(),
+            sg.Frame(layout=lines_of_variables_2, title=''),
+
+
+        ]
+
+
+    layout = [layout]
+    window = sg.Window("Variables Viewer", layout)
+    list_of_commands = []
+    while True:
+
+        event, values = window.read()
+        list_of_commands += [values[0]]
+
+        if event == "Exit" or event == sg.WIN_CLOSED or event=='Cancel':
+            window.close()
+            break
+
+
+    return list(set(list_of_commands))
+
+
+
+    # if event == "-FOLDER-":
+    #
+    #     folder = values["-FOLDER-"]
+    #
+    #     try:
+    #
+    #         # Get list of files in folder
+    #
+    #         file_list = os.listdir(folder)
+    #
+    #     except:
+    #
+    #         file_list = []
+    #
+    #     fnames = [
+    #
+    #         f
+    #
+    #         for f in file_list
+    #
+    #         if os.path.isfile(os.path.join(folder, f))
+    #
+    #            and f.lower().endswith((".png", ".gif"))
+    #
+    #     ]
+    #
+    #     window["-FILE LIST-"].update(fnames)
+    # elif event == "-FILE LIST-":  # A file was chosen from the listbox
+    #
+    #     try:
+    #
+    #         filename = os.path.join(
+    #
+    #             values["-FOLDER-"], values["-FILE LIST-"][0]
+    #
+    #         )
+    #
+    #         window["-TOUT-"].update(filename)
+    #
+    #         window["-IMAGE-"].update(filename=filename)
+    #
+    #     except:
+    #
+    #         pass
+
+
+if __name__ == '__main__':
+    k= {'x' :5, 'y':6}
+    optiwae_gui(k)

+ 38 - 0
tool_lib/python_to_latex.py

@@ -0,0 +1,38 @@
+'''This file is created to be an Interface between Python and Latex.
+   Goals are: Creating Latexfigures out of Plots '''
+from pylatex import Document,Section,Subsection,Tabular,Math, TikZ, Axis, Plot, Figure, Matrix, Alignat
+import numpy as np
+import os
+
+class PythonLatex:
+
+    def __init__(self, geometrie_options, file_path, data=[]):
+        self.geometrie_options = geometrie_options
+        self.file_path = file_path
+        self.data = data
+        self.doc = Document(geometry_options= self.geometrie_options)
+
+    def get_table(self):
+        pass
+
+    def get_equation(self):
+        pass
+
+    def get_matrix(self):
+        pass
+
+
+class LatexPlots(PythonLatex):
+
+    def __init__(self, plot_options, image_file_path=''):
+        super().__init__(geometrie_options=geometrie_options)
+        self.plot_options = plot_options
+        if image_file_path:
+            self.image_filename = os.path.join(os.path.dirname(__file__), image_file_path)
+        else:
+            self.image_filename = ''
+
+
+if __name__ == '__main__':
+    geometrie_options = {'tmargin': '1cm', 'lmargin': '10cm'}
+    PythonLatex(geometrie_options=geometrie_options)

+ 19 - 0
tool_lib/random_generator.py

@@ -0,0 +1,19 @@
+import random
+
+# !!!!!!!!!!!!!!!!! NOT UPDATED !!!!!!!!!!!!!!!!!!!!!
+# def get_random_dict_elements(dict_, count):
+#     random_dict = {}
+#     for i in range(0, count):
+#         key, value = random.choice(list(dict_.items()))
+#         random_dict[key] = value
+#     return random_dict
+
+
+def get_random_list_elements(list_, count):
+    list_2 = list_.copy()
+    random_list = []
+    for i in range(0, min(count,len(list_2))):
+        elem = random.choice(list_2)
+        random_list += [elem]
+        list_2.remove(elem)
+    return random_list

+ 32 - 0
tool_lib/run_multiple_jobs.py

@@ -0,0 +1,32 @@
+#################################################################################
+#Import (here could be project specific imports, clean after use))
+import inspect
+#################################################################################
+
+def run_multiple_jobs_with_constants(ordered_list_of_functions,dict_of_functions,dict_constants=[]):
+    '''
+    This function is made to run through multiple functions in different files,
+    these functions can contain constants which can be given as additional Input
+    :param ordered_list_of_functions: a list in which the function handles are in
+    :param dict_of_functions: a dictionary which maps the
+    :param dict_constants: constants which are used for the functions
+    '''
+
+    pass
+
+def run_multiple_jobs_with_alternating_constants(ordered_list_of_functions,dict_constants):
+    pass
+
+def run_single_job_multiple_times(ordered_list_of_functions,dict_constants):
+    pass
+
+def run_single_job_multiple_times_alternation():
+    pass
+
+#################################################################################
+#this function can be used if u want to run this file
+def main():
+    pass
+
+if __name__ == '__main__':  # only if we execute this file but not if we import it
+    main()

+ 105 - 0
tool_lib/stack_tracer.py

@@ -0,0 +1,105 @@
+"""Stack tracer for multi-threaded applications.
+
+
+Usage:
+
+import stacktracer
+stacktracer.start_trace("trace.html",interval=5,auto=True) # Set auto flag to always update file!
+....
+stacktracer.stop_trace()
+"""
+
+import sys
+import traceback
+from pygments import highlight
+from pygments.lexers import PythonLexer
+from pygments.formatters import HtmlFormatter
+
+
+# Taken from http://bzimmer.ziclix.com/2008/12/17/python-thread-dumps/
+
+def stacktraces():
+    code = []
+    for threadId, stack in sys._current_frames().items():
+        code.append("\n# ThreadID: %s" % threadId)
+        for filename, lineno, name, line in traceback.extract_stack(stack):
+            code.append('File: "%s", line %d, in %s' % (filename, lineno, name))
+            if line:
+                code.append("  %s" % (line.strip()))
+
+    return highlight("\n".join(code), PythonLexer(), HtmlFormatter(
+        full=False,
+        # style="native",
+        noclasses=True,
+    ))
+
+
+# This part was made by nagylzs
+import os
+import time
+import threading
+
+
+class TraceDumper(threading.Thread):
+    """Dump stack traces into a given file periodically."""
+
+    def __init__(self, fpath, interval, auto):
+        """
+        @param fpath: File path to output HTML (stack trace file)
+        @param auto: Set flag (True) to update trace continuously.
+            Clear flag (False) to update only if file not exists.
+            (Then delete the file to force update.)
+        @param interval: In seconds: how often to update the trace file.
+        """
+        assert (interval > 0.1)
+        self.auto = auto
+        self.interval = interval
+        self.fpath = os.path.abspath(fpath)
+        self.stop_requested = threading.Event()
+        threading.Thread.__init__(self)
+
+    def run(self):
+        while not self.stop_requested.isSet():
+            time.sleep(self.interval)
+            if self.auto or not os.path.isfile(self.fpath):
+                self.stacktraces()
+
+    def stop(self):
+        self.stop_requested.set()
+        self.join()
+        try:
+            if os.path.isfile(self.fpath):
+                os.unlink(self.fpath)
+        except:
+            pass
+
+    def stacktraces(self):
+        fout = open(self.fpath, "w+")
+        try:
+            fout.write(stacktraces())
+        finally:
+            fout.close()
+
+
+_tracer = None
+
+
+def trace_start(fpath, interval=5, auto=True):
+    """Start tracing into the given file."""
+    global _tracer
+    if _tracer is None:
+        _tracer = TraceDumper(fpath, interval, auto)
+        _tracer.setDaemon(True)
+        _tracer.start()
+    else:
+        raise Exception("Already tracing to %s" % _tracer.fpath)
+
+
+def trace_stop():
+    """Stop tracing."""
+    global _tracer
+    if _tracer is None:
+        raise Exception("Not tracing, cannot stop.")
+    else:
+        _tracer.stop()
+        _tracer = None

+ 116 - 0
tool_lib/threading_timer_decorator.py

@@ -0,0 +1,116 @@
+from __future__ import print_function
+
+import sys
+import threading
+from functools import partial
+from time import sleep
+
+try:
+    import thread
+except ImportError:
+    import _thread as thread
+
+try:  # use code that works the same in Python 2 and 3
+    range, _print = xrange, print
+
+
+    def print(*args, **kwargs):
+        flush = kwargs.pop('flush', False)
+        _print(*args, **kwargs)
+        if flush:
+            kwargs.get('file', sys.stdout).flush()
+except NameError:
+    pass
+
+
+class Cancellation:
+    def __init__(self, canceled=False):
+        self.canceled = canceled
+
+
+def cdquit(fn_name, cancellation):
+    if cancellation.canceled:
+        return
+    # print to stderr, unbuffered in Python 2.
+    print('{0} took too long'.format(fn_name), file=sys.stderr)
+    sys.stderr.flush()  # Python 3 stderr is likely buffered.
+    thread.interrupt_main()  # raises KeyboardInterrupt
+
+
+def exit_after(s):
+    '''
+    use as decorator to exit process if
+    function takes longer than s seconds
+    '''
+
+    def outer(fn):
+        def inner(*args, **kwargs):
+            c = Cancellation()
+            timer = threading.Timer(s, partial(cdquit, cancellation=c), args=[fn.__name__])
+            timer.start()
+            try:
+                result = fn(*args, **kwargs)
+            finally:
+                c.canceled = True
+                timer.cancel()
+            return result
+
+        return inner
+
+    return outer
+
+
+def call_method_with_timeout(method, timeout, *args, **kwargs):
+    return exit_after(timeout)(method)(*args, **kwargs)
+
+
+@exit_after(1)
+def a():
+    print('a')
+
+
+@exit_after(2)
+def b():
+    print('b')
+    sleep(1)
+
+
+@exit_after(3)
+def c():
+    print('c')
+    sleep(2)
+
+
+@exit_after(4)
+def d():
+    print('d started')
+    for i in range(10):
+        sleep(1)
+        print(i)
+
+
+@exit_after(5)
+def countdown(n):
+    print('countdown started', flush=True)
+    for i in range(n, -1, -1):
+        print(i, end=', ', flush=True)
+        sleep(1)
+    print('countdown finished')
+
+
+def main():
+    a()
+    b()
+    c()
+    try:
+        d()
+    except KeyboardInterrupt as error:
+        print('d should not have finished, printing error as expected:')
+        print(error)
+    countdown(3)
+    countdown(10)
+    print('This should not print!!!')
+
+
+if __name__ == '__main__':
+    main()

+ 130 - 0
tool_lib/tuned_cache.py

@@ -0,0 +1,130 @@
+import functools
+import sys
+from copy import deepcopy
+
+assert 'joblib' not in sys.modules, 'Import tuned cache before joblib'
+
+# noinspection PyProtectedMember,PyPep8
+import joblib
+# noinspection PyProtectedMember,PyPep8
+from joblib.func_inspect import _clean_win_chars
+# noinspection PyProtectedMember,PyPep8
+from joblib.memory import MemorizedFunc, _FUNCTION_HASHES, NotMemorizedFunc, Memory
+
+_FUNC_NAMES = {}
+
+
+# noinspection SpellCheckingInspection
+class TunedMemory(Memory):
+    def cache(self, func=None, ignore=None, verbose=None, mmap_mode=False):
+        """ Decorates the given function func to only compute its return
+            value for input arguments not cached on disk.
+
+            Parameters
+            ----------
+            func: callable, optional
+                The function to be decorated
+            ignore: list of strings
+                A list of arguments name to ignore in the hashing
+            verbose: integer, optional
+                The verbosity mode of the function. By default that
+                of the memory object is used.
+            mmap_mode: {None, 'r+', 'r', 'w+', 'c'}, optional
+                The memmapping mode used when loading from cache
+                numpy arrays. See numpy.load for the meaning of the
+                arguments. By default that of the memory object is used.
+
+            Returns
+            -------
+            decorated_func: MemorizedFunc object
+                The returned object is a MemorizedFunc object, that is
+                callable (behaves like a function), but offers extra
+                methods for cache lookup and management. See the
+                documentation for :class:`joblib.memory.MemorizedFunc`.
+        """
+        if func is None:
+            # Partial application, to be able to specify extra keyword
+            # arguments in decorators
+            return functools.partial(self.cache, ignore=ignore,
+                                     verbose=verbose, mmap_mode=mmap_mode)
+        if self.store_backend is None:
+            return NotMemorizedFunc(func)
+        if verbose is None:
+            verbose = self._verbose
+        if mmap_mode is False:
+            mmap_mode = self.mmap_mode
+        if isinstance(func, TunedMemorizedFunc):
+            func = func.func
+        return TunedMemorizedFunc(func, location=self.store_backend,
+                                  backend=self.backend,
+                                  ignore=ignore, mmap_mode=mmap_mode,
+                                  compress=self.compress,
+                                  verbose=verbose, timestamp=self.timestamp)
+
+
+class TunedMemorizedFunc(MemorizedFunc):
+    def __call__(self, *args, **kwargs):
+        # Also store in the in-memory store of function hashes
+        if self.func not in _FUNCTION_HASHES:
+            is_named_callable = (hasattr(self.func, '__name__') and
+                                 self.func.__name__ != '<lambda>')
+            if is_named_callable:
+                # Don't do this for lambda functions or strange callable
+                # objects, as it ends up being too fragile
+                func_hash = self._hash_func()
+                try:
+                    _FUNCTION_HASHES[self.func] = func_hash
+                except TypeError:
+                    # Some callable are not hashable
+                    pass
+
+        # return same result as before
+        return MemorizedFunc.__call__(self, *args, **kwargs)
+
+
+old_get_func_name = joblib.func_inspect.get_func_name
+
+
+def tuned_get_func_name(func, resolv_alias=True, win_characters=True):
+    if (func, resolv_alias, win_characters) not in _FUNC_NAMES:
+        _FUNC_NAMES[(func, resolv_alias, win_characters)] = old_get_func_name(func, resolv_alias, win_characters)
+
+        if len(_FUNC_NAMES) > 1000:
+            # keep cache small and fast
+            for idx, k in enumerate(_FUNC_NAMES.keys()):
+                if idx % 2:
+                    del _FUNC_NAMES[k]
+        # print('cache size ', len(_FUNC_NAMES))
+
+    return deepcopy(_FUNC_NAMES[(func, resolv_alias, win_characters)])
+
+
+joblib.func_inspect.get_func_name = tuned_get_func_name
+joblib.memory.get_func_name = tuned_get_func_name
+
+
+def main():
+    class A:
+        test_cache = TunedMemory('.cache/test_cache', verbose=1)
+
+        def __init__(self, a):
+            self.a = a
+            self.compute = self.test_cache.cache(self.compute)
+
+        def compute(self):
+            return self.a + 1
+
+    a1, a2 = A(2), A(2)
+    print(a1.compute())
+    print('---')
+    print(a2.compute())
+    print('---')
+    a1.a = 3
+    print(a1.compute())
+    print('---')
+    print(a2.compute())
+    print('---')
+
+
+if __name__ == '__main__':
+    main()

+ 35 - 0
tool_lib/unlock.py

@@ -0,0 +1,35 @@
+import logging
+import threading
+from typing import Union
+
+import gevent.threading
+
+
+class UnLock(object):
+    """
+    The idea is to use this class within a context manager to release a lock temporarily but then acquire it again.
+    Example:
+        lock = gevent.threading.Lock()
+        with lock:
+            logging.info('Websocket request 10 received')
+            with UnLock(lock):
+                sleep(10.)  # this part does not need the resource to be locked
+            logging.info('Websocket request 10 finished')
+    """
+
+    def __init__(self, lock: Union[gevent.threading.Lock, threading.Lock], require_taken: bool = True, verbose=False):
+        self.lock = lock
+        self.require_taken = require_taken
+        self.verbose = verbose
+
+    def __enter__(self):
+        if self.require_taken and not self.lock.locked():
+            raise RuntimeError('Lock not taken')
+        self.lock.release()
+        if self.verbose:
+            logging.info('Unlocked')
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self.lock.acquire()
+        if self.verbose:
+            logging.info('Locked again.')

+ 299 - 175
tool_lib/util.py

@@ -12,15 +12,17 @@ import sqlite3
 import sys
 import sys
 import threading
 import threading
 import time
 import time
+import typing
 from bisect import bisect_left
 from bisect import bisect_left
 from enum import Enum
 from enum import Enum
 from itertools import chain, combinations
 from itertools import chain, combinations
 from math import log, isnan, nan, floor, log10, gcd
 from math import log, isnan, nan, floor, log10, gcd
 from numbers import Number
 from numbers import Number
 from shutil import copyfile
 from shutil import copyfile
+from subprocess import check_output, CalledProcessError, PIPE
 from threading import RLock
 from threading import RLock
 from types import FunctionType
 from types import FunctionType
-from typing import Union, Tuple, List, Optional, Dict, Any, Type
+from typing import Union, Tuple, List, Optional, Dict, Any, Type, Callable, Literal
 # noinspection PyUnresolvedReferences
 # noinspection PyUnresolvedReferences
 from unittest import TestCase, mock
 from unittest import TestCase, mock
 
 
@@ -36,6 +38,9 @@ import scipy.stats
 import tabulate
 import tabulate
 from scipy.ndimage import zoom
 from scipy.ndimage import zoom
 
 
+from tool_lib import stack_tracer, print_exc_plus
+from tool_lib.my_logger import logging
+
 X = Y = Z = float
 X = Y = Z = float
 
 
 
 
@@ -46,10 +51,13 @@ class KnownIssue(Exception):
     pass
     pass
 
 
 
 
-def powerset(iterable):
+def powerset(iterable, largest_first=False):
     """powerset([1,2,3]) --> () (1,) (2,) (3,) (1,2) (1,3) (2,3) (1,2,3)"""
     """powerset([1,2,3]) --> () (1,) (2,) (3,) (1,2) (1,3) (2,3) (1,2,3)"""
     s = list(iterable)
     s = list(iterable)
-    return chain.from_iterable(combinations(s, r) for r in range(len(s) + 1))
+    sizes = range(len(s) + 1)
+    if largest_first:
+        sizes = reversed(sizes)
+    return chain.from_iterable(combinations(s, r) for r in sizes)
 
 
 
 
 def plot_with_conf(x, y_mean, y_conf, alpha=0.5, **kwargs):
 def plot_with_conf(x, y_mean, y_conf, alpha=0.5, **kwargs):
@@ -74,6 +82,10 @@ def choice(sequence, probabilities):
     raise AssertionError('Probabilities must sum to 1')
     raise AssertionError('Probabilities must sum to 1')
 
 
 
 
+def local_timezone():
+    return datetime.datetime.now(datetime.timezone(datetime.timedelta(0))).astimezone().tzinfo
+
+
 def print_attributes(obj, include_methods=False, ignore=None):
 def print_attributes(obj, include_methods=False, ignore=None):
     if ignore is None:
     if ignore is None:
         ignore = []
         ignore = []
@@ -93,7 +105,7 @@ def attr_dir(obj, include_methods=False, ignore=None):
     return {attr: obj.__getattr__(attr)
     return {attr: obj.__getattr__(attr)
             for attr in dir(obj)
             for attr in dir(obj)
             if not attr.startswith('_') and (
             if not attr.startswith('_') and (
-                include_methods or not callable(obj.__getattr__(attr))) and attr not in ignore}
+                    include_methods or not callable(obj.__getattr__(attr))) and attr not in ignore}
 
 
 
 
 def zoom_to_shape(a: np.ndarray, shape: Tuple, mode: str = 'smooth', verbose=1):
 def zoom_to_shape(a: np.ndarray, shape: Tuple, mode: str = 'smooth', verbose=1):
@@ -166,20 +178,6 @@ def zoom_to_shape(a: np.ndarray, shape: Tuple, mode: str = 'smooth', verbose=1):
         return NotImplementedError('Mode not available.')
         return NotImplementedError('Mode not available.')
 
 
 
 
-def profile_wall_time_instead_if_profiling():
-    try:
-        import yappi
-    except ModuleNotFoundError:
-        return
-    currently_profiling = len(yappi.get_func_stats())
-    if currently_profiling and yappi.get_clock_type() != 'wall':
-        yappi.stop()
-        print('Profiling wall time instead of cpu time.')
-        yappi.clear_stats()
-        yappi.set_clock_type("wall")
-        yappi.start()
-
-
 def dummy_computation(*_args, **_kwargs):
 def dummy_computation(*_args, **_kwargs):
     pass
     pass
 
 
@@ -619,78 +617,6 @@ def lru_cache_by_id(maxsize):
     return cachetools.cached(cachetools.LRUCache(maxsize=maxsize), key=id)
     return cachetools.cached(cachetools.LRUCache(maxsize=maxsize), key=id)
 
 
 
 
-class EquivalenceRelation:
-    def equivalent(self, a, b) -> bool:
-        raise NotImplementedError('Abstract method')
-
-    def equivalence_classes(self, xs: list):
-        classes = []
-        for x in xs:
-            for c in classes:
-                if self.equivalent(x, c[0]):
-                    c.append(x)
-                    break
-            else:
-                classes.append([x])
-        return classes
-
-    def check_reflexivity_on_dataset(self, xs):
-        for x in xs:
-            if not self.equivalent(x, x):
-                return False
-        return True
-
-    def check_symmetry_on_dataset(self, xs):
-        for x in xs:
-            for y in xs:
-                if x is y:
-                    continue
-                if self.equivalent(x, y) and not self.equivalent(y, x):
-                    return False
-        return True
-
-    def check_axioms_on_dataset(self, xs):
-        return (
-            self.check_reflexivity_on_dataset(xs)
-            and self.check_symmetry_on_dataset(xs)
-            and self.check_transitivity_on_dataset(xs, assume_symmetry=True, assume_reflexivity=True)
-        )
-
-    def check_transitivity_on_dataset(self, xs, assume_symmetry=False, assume_reflexivity=False):
-        for x_idx, x in enumerate(xs):
-            for y_idx, y in enumerate(xs):
-                if x is y:
-                    continue
-                if self.equivalent(x, y):
-                    for z_idx, z in enumerate(xs):
-                        if y is z:
-                            continue
-                        if assume_symmetry and x_idx > z_idx:
-                            continue
-                        if assume_reflexivity and x is z:
-                            continue
-                        if self.equivalent(y, z):
-                            if not self.equivalent(x, z):
-                                return False
-        return True
-
-    def match_lists(self, xs, ys, filter_minimum_size=0, filter_maximum_size=math.inf):
-        xs = list(xs)
-        ys = list(ys)
-        if any(x is y for x in xs for y in ys):
-            raise ValueError('Lists contain the same element. This is currently not supported.')
-        classes = self.equivalence_classes([*xs, *ys])
-
-        return [
-            [
-                (0 if any(x2 is x for x2 in xs) else 1, x)
-                for x in c
-            ]
-            for c in classes[::-1]
-            if filter_minimum_size <= len(c) <= filter_maximum_size
-        ]
-
-
 def iff_patch(patch: mock._patch):
 def iff_patch(patch: mock._patch):
     def decorator(f):
     def decorator(f):
         def wrapped(*args, **kwargs):
         def wrapped(*args, **kwargs):
@@ -726,7 +652,6 @@ def iff_not_patch(patch: mock._patch):
 
 
 
 
 EMAIL_CRASHES_TO = []
 EMAIL_CRASHES_TO = []
-VOICE_CALL_ON_CRASH: List[Tuple[str, str]] = []
 
 
 
 
 def list_logger(base_logging_function, store_in_list: list):
 def list_logger(base_logging_function, store_in_list: list):
@@ -763,11 +688,7 @@ def main_wrapper(f):
                                           serialize_to='logs/' + os.path.split(__main__.__file__)[-1] + '.dill')
                                           serialize_to='logs/' + os.path.split(__main__.__file__)[-1] + '.dill')
             for recipient in EMAIL_CRASHES_TO:
             for recipient in EMAIL_CRASHES_TO:
                 from jobs.sending_emails import send_mail
                 from jobs.sending_emails import send_mail
-                send_mail.create_simple_mail_via_gmail(body='\n'.join(error_messages), filepath=None, excel_name=None, to_mail=recipient, subject='[python] Crash report')
-            for to_number, from_number in VOICE_CALL_ON_CRASH:
-                logging.info(f'Calling {from_number} to notify about the crash.')
-                voice_call('This is a notification message that one of your python scripts has crashed. If you are unsure about the origin of this call, please contact Eren Yilmaz.',
-                           to_number, from_number)
+                send_mail.create_simple_mail_via_gmail(body='\n'.join(error_messages), filepath=None, excel_name=None, to_mail=recipient, subject='Crash report')
         finally:
         finally:
             logging.info('Terminated.')
             logging.info('Terminated.')
             total_time = time.perf_counter() - start
             total_time = time.perf_counter() - start
@@ -786,22 +707,6 @@ def main_wrapper(f):
     return wrapper
     return wrapper
 
 
 
 
-def voice_call(msg, to_number, from_number):
-    from twilio.rest import Client
-    account_sid = 'AC63c459168c3e4fe34e462acb4f44f748'
-    auth_token = 'b633bc0e945fe7cb737fdac395cc71d6'
-    client = Client(account_sid, auth_token)
-
-    call = client.calls.create(
-                            twiml=f'<Response><Say>{msg}</Say></Response>',
-                            from_=from_number,
-                            to=to_number,
-                        )
-
-    print(call.sid)
-
-
-
 def required_size_for_safe_rotation(base: Tuple[X, Y, Z], rotate_range_deg) -> Tuple[X, Y, Z]:
 def required_size_for_safe_rotation(base: Tuple[X, Y, Z], rotate_range_deg) -> Tuple[X, Y, Z]:
     if abs(rotate_range_deg) > 45:
     if abs(rotate_range_deg) > 45:
         raise NotImplementedError
         raise NotImplementedError
@@ -887,23 +792,14 @@ def get_all_subclasses(klass):
     return all_subclasses
     return all_subclasses
 
 
 
 
-def my_mac_address():
-    """
-    https://stackoverflow.com/a/160821
-    """
-    import uuid
-    mac = uuid.getnode()
-    if (mac >> 40) % 2:
-        return None
-    mac = uuid.UUID(int=mac).hex[-12:]
-    return mac
-
-
-
 def latin1_json(data):
 def latin1_json(data):
     return json.dumps(data, ensure_ascii=False).encode('latin-1')
     return json.dumps(data, ensure_ascii=False).encode('latin-1')
 
 
 
 
+def utf8_json(data):
+    return json.dumps(data, ensure_ascii=False).encode('utf-8')
+
+
 def l2_norm(v1, v2):
 def l2_norm(v1, v2):
     if len(v1) != len(v2):
     if len(v1) != len(v2):
         raise ValueError('Both vectors must be of the same size')
         raise ValueError('Both vectors must be of the same size')
@@ -920,6 +816,29 @@ def allow_additional_unused_keyword_arguments(func):
     return wrapper
     return wrapper
 
 
 
 
+class BiDict(dict):
+    """
+    https://stackoverflow.com/a/21894086
+    """
+    def __init__(self, *args, **kwargs):
+        super(BiDict, self).__init__(*args, **kwargs)
+        self.inverse = {}
+        for key, value in self.items():
+            self.inverse.setdefault(value, []).append(key)
+
+    def __setitem__(self, key, value):
+        if key in self:
+            self.inverse[self[key]].remove(key)
+        super(BiDict, self).__setitem__(key, value)
+        self.inverse.setdefault(value, []).append(key)
+
+    def __delitem__(self, key):
+        self.inverse.setdefault(self[key], []).remove(key)
+        if self[key] in self.inverse and not self.inverse[self[key]]:
+            del self.inverse[self[key]]
+        super(BiDict, self).__delitem__(key)
+
+
 def copy_and_rename_method(func, new_name):
 def copy_and_rename_method(func, new_name):
     funcdetails = [
     funcdetails = [
         func.__code__,
         func.__code__,
@@ -1001,36 +920,106 @@ def running_workers(executor):
                if t == 1)
                if t == 1)
 
 
 
 
+class Bunch(dict):
+    def __init__(self, **kwargs):
+        dict.__init__(self, kwargs)
+        self.__dict__.update(kwargs)
+
+    def add_method(self, m):
+        setattr(self, m.__name__, functools.partial(m, self))
+
+
 def queued_calls(executor):
 def queued_calls(executor):
     return len(executor._work_queue.queue)
     return len(executor._work_queue.queue)
 
 
 
 
-def retry_on_error(max_tries=3, delay=0.5, backoff=2, only_error_classes=Exception):
-    def decorator(func):
-        @functools.wraps(func)
-        def wrapper(*args, **kwargs):
-            for i in range(max_tries):
-                try:
-                    return func(*args, **kwargs)
-                except only_error_classes as e:
-                    if i == max_tries - 1:
-                        raise
-                    logging.error(f'Re-try after error in {func.__name__}: {type(e).__name__}, {e}')
-                    time.sleep(delay * (backoff ** i))
-        return wrapper
-    return decorator
+def single_use_function(f: Callable):
+    @functools.wraps(f)
+    def wrapper(*args, **kwargs):
+        if not wrapper.already_called:
+            wrapper.already_called = True
+            return f(*args, **kwargs)
+        else:
+            raise RuntimeError(f'{f} is supposed to be called only once.')
 
 
+    wrapper.already_called = False
+    return wrapper
+
+
+def single_use_method(f: Callable):
+    @functools.wraps(f)
+    def wrapper(self, *args, **kwargs):
+        if not hasattr(self, f'_already_called_{id(wrapper)}') or not getattr(self, f'_already_called_{id(wrapper)}'):
+            setattr(self, f'_already_called_{id(wrapper)}', True)
+            return f(self, *args, **kwargs)
+        else:
+            raise RuntimeError(f'{f} is supposed to be called only once per object.')
+
+    return wrapper
 
 
 
 
-class EBC:
-    SUBCLASSES_BY_NAME: Dict[str, Type['EBC']] = {}
+class JSONConvertible:
+    SUBCLASSES_BY_NAME: Dict[str, Type[typing.Self]] = {}
 
 
     def __init_subclass__(cls, **kwargs):
     def __init_subclass__(cls, **kwargs):
         super().__init_subclass__(**kwargs)
         super().__init_subclass__(**kwargs)
-        EBC.SUBCLASSES_BY_NAME[cls.__name__] = cls
+        JSONConvertible.SUBCLASSES_BY_NAME[cls.__name__] = cls
 
 
+    @classmethod
+    def subclasses(cls, strict=False):
+        return [t for t in cls.SUBCLASSES_BY_NAME.values() if issubclass(t, cls) and (not strict or t != cls)]
+
+    def to_json(self) -> Dict[str, Any]:
+        raise NotImplementedError('Abstract method')
+
+    @staticmethod
+    def from_json(data: Dict[str, Any]):
+        cls = JSONConvertible.SUBCLASSES_BY_NAME[data['type']]
+        if cls.from_json is JSONConvertible.from_json:
+            raise NotImplementedError(f'Abstract method from_json of class {cls.__name__}')
+        return cls.from_json(data)
+
+    @classmethod
+    def schema(cls) -> Dict[str, Any]:
+        fields = cls.field_types()
+        result: Dict[str, Any] = {
+            "type": "object",
+            "properties": {'type': {'type': 'string', 'enum': [cls.__name__]}},
+            'required': ['type'],
+        }
+        for field, field_type in fields.items():
+            optional = hasattr(field_type, '__origin__') and field_type.__origin__ is typing.Union and type(None) in field_type.__args__
+            if optional:
+                field_type = Union[tuple(filter(lambda t: t is not type(None), field_type.__args__))]
+            result['properties'][field] = schema_by_python_type(field_type)
+            if not optional:
+                result['required'].append(field)
+
+        subclasses = cls.subclasses(strict=True)
+        if len(subclasses) > 0:
+            result = {'oneOf': [result] + [{'$ref': f'#/components/schemas/{s.__name__}'} for s in subclasses]}
+        return result
+
+    @classmethod
+    def schema_for_referencing(cls):
+        return {'$ref': f'#/components/schemas/{cls.__name__}'}
+
+    @classmethod
+    def schema_python_types(cls) -> Dict[str, Type]:
+        fields = typing.get_type_hints(cls.__init__)
+        result: Dict[str, Type] = {}
+        for field, field_type in fields.items():
+            result[field] = field_type
+        return result
+
+    @classmethod
+    def field_types(cls) -> Dict[str, Type]:
+        return typing.get_type_hints(cls.__init__)
+
+
+class EBC(JSONConvertible):
     def __eq__(self, other):
     def __eq__(self, other):
-        return isinstance(other, type(self)) and self.__dict__ == other.__dict__
+        return type(other) == type(self) and self.__dict__ == other.__dict__
 
 
     def __str__(self):
     def __str__(self):
         return str(self.__dict__)
         return str(self.__dict__)
@@ -1039,18 +1028,33 @@ class EBC:
         return f'{type(self).__name__}(**' + str(self.__dict__) + ')'
         return f'{type(self).__name__}(**' + str(self.__dict__) + ')'
 
 
     def to_json(self) -> Dict[str, Any]:
     def to_json(self) -> Dict[str, Any]:
+        def obj_to_json(o):
+            if isinstance(o, JSONConvertible):
+                return o.to_json()
+            elif isinstance(o, numpy.ndarray):
+                return array_to_json(o)
+            elif isinstance(o, list):
+                return list_to_json(o)
+            elif isinstance(o, dict):
+                return dict_to_json(o)
+            else:
+                return o  # probably a primitive type
+
+        def dict_to_json(d: Dict[str, Any]):
+            return {k: obj_to_json(v) for k, v in d.items()}
+
+        def list_to_json(l: list):
+            return [obj_to_json(v) for v in l]
+
+        def array_to_json(o: numpy.ndarray):
+            return o.tolist()
+
         result: Dict[str, Any] = {
         result: Dict[str, Any] = {
             'type': type(self).__name__,
             'type': type(self).__name__,
             **self.__dict__,
             **self.__dict__,
         }
         }
         for k in result:
         for k in result:
-            if isinstance(result[k], EBC):
-                result[k] = result[k].to_json()
-            elif isinstance(result[k], numpy.ndarray):
-                result[k] = result[k].tolist()
-            elif isinstance(result[k], list):
-                result[k] = [r.to_json() if isinstance(r, EBC) else r
-                             for r in result[k]]
+            result[k] = obj_to_json(result[k])
         return result
         return result
 
 
     @staticmethod
     @staticmethod
@@ -1059,6 +1063,85 @@ class EBC:
         return class_from_json(cls, data)
         return class_from_json(cls, data)
 
 
 
 
+class _Container(EBC):
+    def __init__(self, item):
+        self.item = item
+
+
+def container_to_json(obj: Union[list, dict, numpy.ndarray]) -> Dict[str, Any]:
+    # Useful when converting list or dicts of json-convertible objects to json (recursively)
+    e = _Container(obj)
+    return e.to_json()['item']
+
+
+class OpenAPISchema:
+    def __init__(self, schema: dict):
+        self.schema = schema
+
+
+def schema_by_python_type(t: Union[Type, dict, OpenAPISchema]):
+    if t == 'TODO':
+        return t
+    if isinstance(t, dict):
+        return {
+            "type": "object",
+            "properties": {n: schema_by_python_type(t2) for n, t2 in t.items()},
+            'required': [n for n in t],
+        }
+    elif isinstance(t, OpenAPISchema):
+        return t.schema
+    elif t is int:
+        return {'type': 'integer'}
+    elif t is Any:
+        return {}
+    elif t is float:
+        return {'type': 'number'}
+    elif t is str:
+        return {'type': 'string'}
+    elif t is bool:
+        return {'type': 'boolean'}
+    elif t is type(None) or t is None:
+        return {'type': 'string', 'nullable': True}
+    elif hasattr(t, '__origin__'):  # probably a generic typing-type
+        if t.__origin__ is list:
+            return {'type': 'array', 'items': schema_by_python_type(t.__args__[0])}
+        elif t.__origin__ is dict:
+            return {'type': 'object', 'additionalProperties': schema_by_python_type(t.__args__[1])}
+        elif t.__origin__ is tuple and len(set(t.__args__)) == 1:
+            return {'type': 'array', 'items': schema_by_python_type(t.__args__[0]), 'minItems': len(t.__args__), 'maxItems': len(t.__args__)}
+        elif t.__origin__ is Union:
+            return {'oneOf': [schema_by_python_type(t2) for t2 in t.__args__]}
+        elif t.__origin__ is Literal:
+            return {'enum': list(t.__args__)}
+        else:
+            raise ValueError(f'Unknown type {t}')
+    elif issubclass(t, list):
+        return {'type': 'array'}
+    elif issubclass(t, type) and len(set(t.__args__)) == 1:
+        return {'type': 'array', 'items': schema_by_python_type(t.__args__[0]), 'minItems': len(t.__args__), 'maxItems': len(t.__args__)}
+    elif t is datetime.datetime:
+        from lib.date_time import SerializableDateTime
+        return schema_by_python_type(SerializableDateTime)
+    elif hasattr(t, 'schema_for_referencing'):
+        return t.schema_for_referencing()
+    else:
+        raise ValueError(f'Unknown type {t}')
+
+
+class Synchronizable:
+    def __init__(self):
+        self._lock = threading.RLock()
+
+    @staticmethod
+    def synchronized(f):
+        @functools.wraps(f)
+        def wrapper(self, *args, **kwargs):
+            with self._lock:
+                return f(self, *args, **kwargs)
+
+        return wrapper
+
+
 def class_from_json(cls, data: Dict[str, Any]):
 def class_from_json(cls, data: Dict[str, Any]):
     if isinstance(data, str):
     if isinstance(data, str):
         data = json.loads(data)
         data = json.loads(data)
@@ -1066,30 +1149,39 @@ def class_from_json(cls, data: Dict[str, Any]):
     try:
     try:
         return cls(**data)
         return cls(**data)
     except TypeError as e:
     except TypeError as e:
-        if "__init__() got an unexpected keyword argument 'type'" in str(e) or 'takes no arguments' in str(e):
+        if "__init__() got an unexpected keyword argument 'type'" in str(e):
             # probably this was from a to_json method
             # probably this was from a to_json method
             if data['type'] != cls.__name__:
             if data['type'] != cls.__name__:
                 t = data['type']
                 t = data['type']
                 logging.warning(f'Reconstructing a {cls.__name__} from a dict with type={t}')
                 logging.warning(f'Reconstructing a {cls.__name__} from a dict with type={t}')
             data = data.copy()
             data = data.copy()
             del data['type']
             del data['type']
-            for k,v  in data.items():
-                if probably_serialized_from_ebc(v):
-                    data[k] = EBC.SUBCLASSES_BY_NAME[v['type']].from_json(v)
+            for k, v in data.items():
+                if probably_serialized_from_json_convertible(v):
+                    data[k] = JSONConvertible.SUBCLASSES_BY_NAME[v['type']].from_json(v)
                 elif isinstance(v, list):
                 elif isinstance(v, list):
-                    data[k] = [EBC.SUBCLASSES_BY_NAME[x['type']].from_json(x)
-                         if probably_serialized_from_ebc(x)
-                         else x
-                         for x in v]
-            return allow_additional_unused_keyword_arguments(cls)(**data)
-        else:
-            raise
+                    data[k] = [JSONConvertible.SUBCLASSES_BY_NAME[x['type']].from_json(x)
+                               if probably_serialized_from_json_convertible(x)
+                               else x
+                               for x in v]
+                elif isinstance(v, dict):
+                    data[k] = {
+                        k2: JSONConvertible.SUBCLASSES_BY_NAME[x['type']].from_json(x)
+                        if probably_serialized_from_json_convertible(x)
+                        else x
+                        for k2, x in v.items()
+                    }
+            try:
+                return cls(**data)
+            except TypeError:
+                return allow_additional_unused_keyword_arguments(cls)(**data)
 
 
-def probably_serialized_from_ebc(data):
-    return isinstance(data, dict) and 'type' in data and data['type'] in EBC.SUBCLASSES_BY_NAME
 
 
+def probably_serialized_from_json_convertible(data):
+    return isinstance(data, dict) and 'type' in data and data['type'] in JSONConvertible.SUBCLASSES_BY_NAME
 
 
-class EBE(Enum):
+
+class EBE(JSONConvertible, Enum):
     def __int__(self):
     def __int__(self):
         return self.value
         return self.value
 
 
@@ -1099,29 +1191,61 @@ class EBE(Enum):
     def __repr__(self):
     def __repr__(self):
         return self.name
         return self.name
 
 
+    def __lt__(self, other):
+        return list(type(self)).index(self) < list(type(self)).index(other)
+
+    def __ge__(self, other):
+        return list(type(self)).index(self) >= list(type(self)).index(other)
+
     @classmethod
     @classmethod
-    def from_name(cls, variable_name):
+    def from_name(cls, variable_name) -> typing.Self:
         return cls.__dict__[variable_name]
         return cls.__dict__[variable_name]
 
 
+    def to_json(self) -> Dict[str, Any]:
+        return {
+            'type': type(self).__name__,
+            'name': self.name,
+        }
 
 
-class Bunch(dict, EBC):
-    def __init__(self, **kwargs):
-        dict.__init__(self, kwargs)
-        self.__dict__.update(kwargs)
+    @classmethod
+    def schema(cls) -> Dict[str, Any]:
+        return {
+            "type": "object",
+            "properties": {'type': schema_by_python_type(str), 'name': schema_by_python_type(str)}
+        }
 
 
-    def add_method(self, m):
-        setattr(self, m.__name__, functools.partial(m, self))
+    @staticmethod
+    def from_json(data: Dict[str, Any]) -> 'JSONConvertible':
+        cls = EBE.SUBCLASSES_BY_NAME[data['type']]
+        return cls.from_name(data['name'])
 
 
 
 
 def floor_to_multiple_of(x, multiple_of):
 def floor_to_multiple_of(x, multiple_of):
     return math.floor(x / multiple_of) * multiple_of
     return math.floor(x / multiple_of) * multiple_of
 
 
 
 
-def round_to_multiple_of(x, multiple_of):
-    return round(x / multiple_of) * multiple_of
-
-
 def ceil_to_multiple_of(x, multiple_of):
 def ceil_to_multiple_of(x, multiple_of):
     return math.ceil(x / multiple_of) * multiple_of
     return math.ceil(x / multiple_of) * multiple_of
 
 
 
 
+def call_tool(command, cwd=None, shell=False):
+    try:
+        print(f'Calling `{" ".join(command)}`...')
+        sub_env = os.environ.copy()
+        output: bytes = check_output(command, stderr=PIPE, env=sub_env, cwd=cwd, shell=shell)
+        output: str = output.decode('utf-8', errors='ignore')
+        return output
+    except CalledProcessError as e:
+        stdout = e.stdout.decode('utf-8', errors='ignore')
+        stderr = e.stderr.decode('utf-8', errors='ignore')
+        if len(stdout) == 0:
+            print('stdout was empty.')
+        else:
+            print('stdout was: ')
+            print(stdout)
+        if len(stderr) == 0:
+            print('stderr was empty.')
+        else:
+            print('stderr was: ')
+            print(stderr)
+        raise