diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c88834 --- /dev/null +++ b/.gitignore @@ -0,0 +1,170 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +logs/ + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.env.local +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.vscode/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +cache.csv +log/ +calibrate/ +experiment.py +nohup.out \ No newline at end of file diff --git a/pyduino/CITATION.cff b/CITATION.cff similarity index 100% rename from pyduino/CITATION.cff rename to CITATION.cff diff --git a/pyduino/LICENSE b/LICENSE similarity index 100% rename from pyduino/LICENSE rename to LICENSE diff --git a/pyduino/__init__.py b/pyduino/__init__.py index 0807791..e59edfd 100644 --- a/pyduino/__init__.py +++ b/pyduino/__init__.py @@ -1,9 +1,18 @@ import logging +import os +from dotenv import load_dotenv + +load_dotenv() +load_dotenv(dotenv_path=".env.local") + +log_file_path = os.environ.get('PYDUINO_SLAVE_LOG','/var/log/pyduino/slave.log') +os.makedirs(os.path.dirname(log_file_path), exist_ok=True) + logging.basicConfig( level=logging.DEBUG, format='%(asctime)s %(levelname)s %(message)s', handlers=[ - logging.FileHandler('/var/log/pyduino/slave.log'), + logging.FileHandler(log_file_path), logging.StreamHandler() ] ) \ No newline at end of file diff --git a/pyduino/config.yaml b/pyduino/config.yaml index d6bfb4b..e8c065f 100644 --- a/pyduino/config.yaml +++ b/pyduino/config.yaml @@ -1,67 +1,25 @@ hyperparameters: reset_density: true #Whether or not to use gotod - log_name: "ga_dcte2400_branco_15" #Log folder name (delete to use the default) - f_param: efficiency #Parameter to read as fitness from the reactors + log_name: "free_growth_test_2024_04_07" #Log folder name (delete to use the default) density_param: DensidadeAtual #Parameter name for density counts - mutation_probability: 0.01 #0.15 - maximize: true - resolution: 4 #Number of bits used for each parameter - elitism: true #Whether or not to use elitism - do_crossover: false #Whether or not to perform crossover when the genetic algorithm is enabled - rng_seed: 2 #Random seed for genetic algorithm + maximize: false + rng_seed: 2 #Random seed parameter initialization ranges: # brilho: # - 0 # - 100 branco: - 1 - #- 100 - - 19 - full: + - 100 + others: - 0 - #- 100 - - 17 - "440": - - 0 - #- 100 - - 26 - "470": - - 0 - #- 100 - - 24 - "495": - - 0 - #- 100 - - 24 - "530": - - 0 - #- 100 - - 24 - "595": - - 0 - #- 100 - - 92 - "634": - - 0 - #- 100 - - 58 - "660": - - 0 - #- 100 - - 44 - "684": - - 0 - #- 100 - - 35 - #others: - # - 0 - # - 100 + - 100 slave: port: "5000" #Must be a string network: "192.168.1.1/24" - exclude: "192.168.1.112" + exclude: #"192.168.1.112" system: - initial_state: "preset_state.d/no4.csv" + initial_state: "preset_state.d/all.csv" reboot_wait_time: 5 #Time in seconds to wait after a reboot. relevant_parameters: # - brilho @@ -116,7 +74,7 @@ system: co2: int dtco2: int dash: - glob: "./log/pre_free1212_branco_3/*.csv" + glob: "./log/nelder_mead_power_test/*.csv" plot: - name: Temperature @@ -132,18 +90,18 @@ dash: name: Density cols: DensidadeAtual: - - - name: Efficiency - cols: - efficiency: - growing_only: true - #mode: 'lines+markers' - - - name: GrowthRate - cols: - growth_rate: - growing_only: true - #mode: 'lines+markers' + # - + # name: Efficiency + # cols: + # efficiency: + # growing_only: true + # #mode: 'lines+markers' + # - + # name: GrowthRate + # cols: + # growth_rate: + # growing_only: true + # #mode: 'lines+markers' - name: power cols: diff --git a/pyduino/dashboard.py b/pyduino/dashboard.py index 31f1504..0103a7e 100644 --- a/pyduino/dashboard.py +++ b/pyduino/dashboard.py @@ -40,7 +40,7 @@ NROWSDF = y["tail"] if len(LOG_PATH)!=0: with open(LOG_PATH[0]) as file: - HEAD = pd.read_csv(io.StringIO(''.join([file.readline() for _ in range(2)])),sep=SEP) + HEAD = pd.read_csv(io.StringIO(''.join([file.readline() for _ in range(1)])),sep=SEP) print(HEAD) HEAD = HEAD.columns else: diff --git a/pyduino/log.py b/pyduino/log.py index 3322f3a..9a509d5 100644 --- a/pyduino/log.py +++ b/pyduino/log.py @@ -5,6 +5,9 @@ import io from glob import glob from uuid import uuid1 +from tabulate import tabulate +from collections import OrderedDict +from datetime import datetime __location__ = os.path.realpath(os.path.join(os.getcwd(), os.path.dirname(__file__))) config_file = os.path.join(__location__,"config.yaml") @@ -15,6 +18,25 @@ def datetime_from_str(x): def datetime_to_str(x): return x.strftime("%Y%m%d%H%M%S") +def to_markdown_table(data: OrderedDict) -> str: + """ + Converts the given data into a markdown table format. + + Args: + data (OrderedDict[OrderedDict]): The data to be converted into a markdown table. + + Returns: + str: The markdown table representation of the data. + """ + rows = [] + for rid, rdata in data.items(): + rdata = OrderedDict({"ID": rid, **rdata}) + rows.append(rdata) + return tabulate(rows, headers="keys", tablefmt="pipe") + +def y_to_table(y): + return tabulate(list(y.items()), tablefmt="pipe") + class log: @property def timestamp(self): @@ -32,7 +54,7 @@ def __init__(self,subdir,path="./log",name=None): Example: log_obj = log(['reactor_0','reactor_1'],path='./log',name='experiment_0') - log/ + log/YEAR/MONTH/ ├─ experiment_0/ │ ├─ reactor_0.csv │ ├─ reactor_1.csv @@ -42,7 +64,8 @@ def __init__(self,subdir,path="./log",name=None): path (str): Save path for the logs. name (str): Name given for this particular instance. If none will name it with the current timestamp. """ - self.path = path + self.today = datetime.now() + self.path = os.path.join(path, self.today.strftime("%Y"), self.today.strftime("%m")) self.start_timestamp = datetime_to_str(self.timestamp) if name is None else name self.log_name = name Path(os.path.join(self.path,self.start_timestamp)).mkdir(parents=True,exist_ok=True) @@ -60,6 +83,22 @@ def __init__(self,subdir,path="./log",name=None): with open(config_file) as cfile, open(os.path.join(self.path,self.start_timestamp,f"{self.start_timestamp.replace('/','-')}-{str(uuid1())}.yaml"),'w') as wfile: wfile.write(cfile.read()) + self.log_name = name + Path(os.path.join(self.path,self.start_timestamp)).mkdir(parents=True,exist_ok=True) + if isinstance(subdir,str): + self.subdir = list(map(os.path.basename,glob(os.path.join(self.prefix,subdir)))) + elif isinstance(subdir,list): + self.subdir = subdir + else: + raise ValueError("Invalid type for subdir. Must be either a list of strings or a glob string.") + self.subdir = list(map(lambda x: str(x)+".csv" if len(os.path.splitext(str(x))[1])==0 else str(x),self.subdir)) + self.first_timestamp = None + self.data_frames = {} + + self.paths = list(map(lambda x: os.path.join(self.prefix,x),self.subdir)) + + with open(config_file) as cfile, open(os.path.join(self.path,self.start_timestamp,f"{self.start_timestamp.replace('/','-')}-{str(uuid1())}.yaml"),'w') as wfile: + wfile.write(cfile.read()) def log_rows(self,rows,subdir,add_timestamp=True,tags=None,**kwargs): @@ -124,18 +163,23 @@ def log_optimal(self,column,maximum=True,**kwargs): """ Logs optima of all rows into a single file. """ - i=self.data_frames.loc[:,column].argmax() if maximum else self.data_frames.loc[:,column].argmin() + i=self.data_frames.loc[:,column].astype(float).argmax() if maximum else self.data_frames.loc[:,column].astype(float).argmin() self.df_opt = self.data_frames.iloc[i,:] self.log_rows(rows=[self.df_opt.to_dict()],subdir='opt',sep='\t',**kwargs) - def log_average(self,**kwargs): + def log_average(self, cols: list, **kwargs): """ - Logs average of all rows into a single file. + Calculate the average values of specified columns in the data frames and log the results. + + Parameters: + - cols (list): A list of column names to calculate the average for. + - **kwargs: Additional keyword arguments to customize the logging process. """ df = self.data_frames.copy() + df.loc[:, cols] = df.loc[:, cols].astype(float) df.elapsed_time_hours = df.elapsed_time_hours.round(decimals=2) - self.df_avg = df.groupby("elapsed_time_hours").mean().reset_index() - self.log_rows(rows=self.df_avg,subdir='avg',sep='\t',**kwargs) + self.df_avg = df.loc[:, cols + ['elapsed_time_hours']].groupby("elapsed_time_hours").mean().reset_index() + self.log_rows(rows=self.df_avg, subdir='avg', sep='\t', **kwargs) def cache_data(self,rows,path="./cache.csv",**kwargs): """ diff --git a/pyduino/optimization/__init__.py b/pyduino/optimization/__init__.py new file mode 100644 index 0000000..06d21d5 --- /dev/null +++ b/pyduino/optimization/__init__.py @@ -0,0 +1,58 @@ +from typing import Callable, List, Tuple +from abc import ABC, abstractmethod +import warnings +import numpy as np + +def linmap(domain: List[Tuple[float, float]], codomain: List[Tuple[float, float]]) -> Callable[[np.ndarray], np.ndarray]: + """ + Linear mapping from domain to codomain. + domain list of pairs: + Maxima and minima that each parameter can cover. + Example: [(0, 10), (0, 10)] for two parameters ranging from 0 to 10. + codomain list of pairs: + Maxima and minima that each parameter can cover. + Example: [(0, 10), (0, 10)] for two parameters ranging from 0 to 10. + """ + domain = np.array(domain) + codomain = np.array(codomain) + + M = (codomain[:, 1] - codomain[:, 0])/ (domain[:, 1] - domain[:, 0]) + + def f(x): + assert x.shape[1] == domain.shape[0], f"x must have the same number of features as the domain. Expected {domain.shape[0]}, got {x.shape[1]}." + return codomain[:, 0] + M * (x - domain[:, 0]) + + return f + +class Optimizer(ABC): + """ + Abstract class for optimization algorithms + """ + @abstractmethod + def __init__(self, growth_rate: float, population_size, ranges, iterations=None, rng_seed=0): + pass + + @abstractmethod + def view(self, x, linmap): + pass + + @abstractmethod + def view_g(self): + pass + + @abstractmethod + def inverse_view(self, x, linmap=None): + pass + + @abstractmethod + def ask_oracle(self) -> np.ndarray: + raise NotImplementedError("The oracle must be implemented.") + + @abstractmethod + def init_oracle(self): + warnings.warn("The oracle initialization is not implemented.") + pass + + @abstractmethod + def step(self): + pass \ No newline at end of file diff --git a/pyduino/optimization/gradient_descent.py b/pyduino/optimization/gradient_descent.py new file mode 100644 index 0000000..8bbe0e1 --- /dev/null +++ b/pyduino/optimization/gradient_descent.py @@ -0,0 +1,71 @@ +import numpy as np +from . import Optimizer, linmap + +class GradientDescent(Optimizer): + def __init__(self, population_size: int, ranges: list[float], damping: float = 0.01, rng_seed: int = 0): + """ + This gradient descent algorithm assumes that the optimization function 'f' is to be minimized, differentiable, and time independent. + + $$\frac{\mathrm{d} f}{\mathrm{d} t} = \frac{\partial f}{\partial \vec{x}}\frac{\mathrm{d} \vec{x}}{\mathrm{d} t}$$ + + Where $\frac{\partial f}{\partial t}$ is assumed to be zero. + + ranges list of pairs: + Maxima and minima that each parameter can cover. + Example: [(0, 10), (0, 10)] for two parameters ranging from 0 to 10. + population_size int: + Number of individuals in the population. + damping float: + Damping factor to avoid oscillations. Default is 0.01. + rng_seed int: + Seed for the random number generator. Default is 0. + """ + self.population_size = population_size + self.ranges = np.array(ranges) + self.damping = damping + self.rng_seed = np.random.default_rng(rng_seed) + + # Derived attributes + self.invlinmap = linmap(self.ranges, np.array([[0, 1]] * len(self.ranges))) + self.linmap = linmap(np.array([[0, 1]] * len(self.ranges)), self.ranges) + + # Initialize the population (random position and initial momentum) + self.population = self.rng_seed.random((self.population_size, len(self.ranges))) + self.momenta = self.rng_seed.random((self.population_size, len(self.ranges))) + self.oracle_past = self.rng_seed.random((self.population_size, 1)) + + def view(self, x): + """ + Maps the input from the domain to the codomain. + """ + return self.linmap(x) + + def view_g(self): + """ + Maps the input from the domain to the codomain. + """ + return self.linmap(self.population) + + def inverse_view(self, x): + """ + Maps the input from the codomain to the domain. + """ + return self.invlinmap(x) + + def ask_oracle(self) -> np.ndarray: + return super().ask_oracle() + + def set_oracle(self, X: np.ndarray): + return super().set_oracle(X) + + def init_oracle(self): + return self.set_oracle(self.view(self.population)) + + def step(self, dt: float): + """ + Moves the population in the direction of the gradient. + + dt float: + Time taken from the last observation. + """ + pass diff --git a/pyduino/optimization/nelder_mead.py b/pyduino/optimization/nelder_mead.py new file mode 100644 index 0000000..81324ee --- /dev/null +++ b/pyduino/optimization/nelder_mead.py @@ -0,0 +1,114 @@ +import numpy as np +from typing import Callable, List +from . import Optimizer, linmap + +def sigmoid(x): + return 1 / (1 + np.exp(-x)) +def logit(x, inf=100): + return np.where(x==0, -inf, np.where(x==1, inf, np.log(x) - np.log(1-x))) + +class NelderMead(Optimizer): + def __init__(self, population_size: int, ranges: List[float], rng_seed: int = 0, hypercube_radius = 100): + """ + This Nelder-Mead algorithm assumes that the optimization function 'f' is to be minimized and time independent. + + ranges list of pairs: + Maxima and minima that each parameter can cover. + Example: [(0, 10), (0, 10)] for two parameters ranging from 0 to 10. + population_size int: + Number of individuals in the population. + rng_seed int: + Seed for the random number generator. Default is 0. + """ + self.population_size = population_size + self.ranges = np.array(ranges) + self.rng_seed = np.random.default_rng(rng_seed) + + # Derived attributes + self.a = 1/hypercube_radius + self.backward = lambda x: logit(linmap(self.ranges, np.array([[0, 1]] * len(self.ranges)))(x))/self.a + self.forward = lambda x: linmap(np.array([[0, 1]] * len(self.ranges)), self.ranges)(sigmoid(self.a * x)) + + # Initialize the population (random position and initial momentum) + self.population = self.rng_seed.random((self.population_size, len(self.ranges))) + + # Initialize y as vector of nans + self.y = np.full(self.population_size, np.nan) + + def view(self, x): + """ + Maps the input from the domain to the codomain. + """ + return self.forward(x) + + def view_g(self): + """ + Maps the input from the domain to the codomain. + """ + return self.forward(self.population) + + def inverse_view(self, x): + """ + Maps the input from the codomain to the domain. + """ + return self.backward(x) + + def ask_oracle(self, X: np.ndarray) -> np.ndarray: + return super().ask_oracle() + + def init_oracle(self): + return self.ask_oracle(self.view_g()) + + def step(self): + """ + This function performs a single step of the Nelder-Mead algorithm. + """ + + # Sort the population by the value of the oracle + y = self.ask_oracle(self.view_g()) + idx = np.argsort(y) + self.population = self.population[idx] + + # Compute the centroid of the population + centroid = self.population[:-1].mean(axis=0) + + # Reflect the worst point through the centroid + reflected = centroid + (centroid - self.population[-1]) + + # Evaluate the reflected point + + y_reflected = self.ask_oracle(self.view(reflected.reshape(1,-1))) + + # If the reflected point is better than the second worst, but not better than the best, then expand + + if y_reflected < y[-2] and y_reflected > y[0]: + expanded = centroid + (reflected - centroid) + y_expanded = self.ask_oracle(self.view(expanded.reshape(1,-1))) + if y_expanded < y_reflected: + self.population[-1] = expanded + else: + self.population[-1] = reflected + # If the reflected point is better than the best, then expand + elif y_reflected < y[0]: + expanded = centroid + 2 * (reflected - centroid) + y_expanded = self.ask_oracle(self.view(expanded.reshape(1,-1))) + if y_expanded < y_reflected: + self.population[-1] = expanded + else: + self.population[-1] = reflected + # If the reflected point is worse than the second worst, then contract + elif y_reflected > y[-2]: + contracted = centroid + 0.5 * (self.population[-1] - centroid) + y_contracted = self.ask_oracle(self.view(contracted.reshape(1,-1))) + if y_contracted < y[-1]: + self.population[-1] = contracted + else: + for i in range(1, len(self.population)): + self.population[i] = 0.5 * (self.population[i] + self.population[0]) + # If the reflected point is worse than the worst, then shrink + elif y_reflected > y[-1]: + for i in range(1, len(self.population)): + self.population[i] = 0.5 * (self.population[i] + self.population[0]) + + self.y = y.copy() + return self.view_g() \ No newline at end of file diff --git a/pyduino/preset_state.d/all.csv b/pyduino/preset_state.d/all.csv new file mode 100644 index 0000000..873fd7d --- /dev/null +++ b/pyduino/preset_state.d/all.csv @@ -0,0 +1,8 @@ +ID brilho branco full 440 470 495 530 595 634 660 684 cor modopainel bomdia boanoite tau step modotemp temp vent pelt densidade mododil dil dildt r b ir ar ima ganho ti modoco2 co2 co2ref dtco2 dil dilDt in out +1 100 50 0 0 0 0 0 0 0 0 0 1 6 18 0 27 15000000 " " " " " " 50 25 0 1000 +2 100 50 0 0 0 0 0 0 0 0 0 1 6 18 0 27 12500000 " " " " " " 50 25 0 1000 +3 100 50 0 0 0 0 0 0 0 0 0 1 6 18 0 27 10000000 " " " " " " 50 25 0 1000 +8 100 50 0 0 0 0 0 0 0 0 0 1 6 18 0 27 10000000 " " " " " " 50 25 0 1000 +5 100 50 0 0 0 0 0 0 0 0 0 1 6 18 0 27 7500000 " " " " " " 50 25 0 1000 +6 100 50 0 0 0 0 0 0 0 0 0 1 6 18 0 27 5000000 " " " " " " 50 25 0 1000 +7 100 50 0 0 0 0 0 0 0 0 0 1 6 18 0 27 2500000 " " " " " " 50 25 0 1000 \ No newline at end of file diff --git a/pyduino/pyduino2.py b/pyduino/pyduino2.py index 3163321..2321683 100644 --- a/pyduino/pyduino2.py +++ b/pyduino/pyduino2.py @@ -71,11 +71,10 @@ def __init__(self, url): def http_get(self,route): return requests.get(urljoin(self.url,route)) - def http_post(self,route,command,await_response,delay): + def http_post(self,route,command,await_response): return requests.post(urljoin(self.url,route),json={ "command": command, - "await_response": await_response, - "delay": delay + "await_response": await_response }) def connect(self): @@ -113,18 +112,18 @@ def close(self): """ self.http_post("send","fim",False,0) - def send(self, msg, delay=5): + def send(self, msg): """ Sends command and awaits for a response """ - resp = self.http_post("send",msg,True,delay) + resp = self.http_post("send",msg,True) return resp.json()["response"] def _send(self, msg): """ Sends command and doesn't await for a response """ - resp = self.http_post("send",msg,False,0) + resp = self.http_post("send",msg,False) return resp.ok def set_in_chunks(self,params,chunksize=4): @@ -161,10 +160,10 @@ def horacerta(self): self._send("horacerta") -def send_wrapper(reactor,command,delay,await_response): +def send_wrapper(reactor,command,await_response): id, reactor = reactor if await_response: - return (id,reactor.send(command,delay)) + return (id,reactor.send(command)) else: return (id,reactor._send(command)) @@ -215,7 +214,7 @@ def send(self,command,await_response=True,**kwargs): def send_parallel(self,command,delay,await_response=True): out = [] with Pool(7) as p: - out = p.map(partial(send_wrapper,command=command,delay=delay,await_response=await_response),list(self.reactors.items())) + out = p.map(partial(send_wrapper,command=command,await_response=await_response),list(self.reactors.items())) return out def set(self, data=None, **kwargs): diff --git a/pyduino/slave.py b/pyduino/slave.py index d578364..e77a39a 100644 --- a/pyduino/slave.py +++ b/pyduino/slave.py @@ -12,7 +12,7 @@ import socket from typing import Any, Optional, Dict -STEP = 1/8.0 +TIMEOUT = 60 HEADER_DELAY = 5 class ReactorServer(Flask): @@ -82,7 +82,7 @@ def http_send(): content = request.json logging.info(f"Received request: {content['command']}") if content['await_response']: - response = self.send(content["command"], delay=content["delay"]) + response = self.send(content["command"]) else: response = self._send(content["command"]) return jsonify({"response": response}), 200 @@ -111,10 +111,10 @@ def serial_connect(self): self.available_ports = list_ports.comports() self.available_ports = list(filter(lambda x: (x.vid,x.pid) in {(1027,24577),(9025,16),(6790,29987)},self.available_ports)) self.port = self.available_ports[0].device - self.serial = Serial(self.port, baudrate=self.baudrate, timeout=STEP) + self.serial = Serial(self.port, baudrate=self.baudrate, timeout=TIMEOUT) logging.info(f"Connected to serial port {self.serial}.") - def connect(self, delay: float = STEP): + def connect(self): """ Begins the connection to the reactor. @@ -123,6 +123,7 @@ def connect(self, delay: float = STEP): """ sleep(HEADER_DELAY) self.serial.flush() + self.serial.reset_input_buffer() self._send("quiet_connect") self.connected = True @@ -140,22 +141,23 @@ def close(self): Interrupts the connection with the reactor. """ if self.serial.is_open: + self.serial.reset_input_buffer() self.send("fim") self.serial.close() - def send(self, msg: str, delay: float = 0) -> str: + def send(self, msg: str) -> str: """ Sends a command to the reactor and receives the response. Args: msg (str): The command to send to the reactor. - delay (float, optional): Delay in seconds before sending the command. Returns: str: The response received from the reactor. """ if not self.connected: self.connect() + self.serial.reset_input_buffer() self._send(msg) return self._recv() @@ -170,7 +172,7 @@ def _send(self, msg: str): def _recv(self) -> str: """ - Reads from the serial port until it finds an EOT ASCII token. + Reads from the serial port until it finds an End-of-Text ASCII token. Returns: str: The response received from the reactor. diff --git a/pyduino/spectra.py b/pyduino/spectra.py index 010aa80..94466f8 100644 --- a/pyduino/spectra.py +++ b/pyduino/spectra.py @@ -1,22 +1,19 @@ -from gapy.gapy2 import GA +from pyduino.optimization.nelder_mead import NelderMead from pyduino.pyduino2 import ReactorManager, chunks, PATHS import numpy as np -from functools import partial import json import os import pandas as pd import numpy as np -from typing import Union import time +from tensorboardX import SummaryWriter from datetime import date, datetime -from pyduino.data_parser import yaml_genetic_algorithm, RangeParser, get_datetimes +from pyduino.data_parser import RangeParser from collections import OrderedDict -from scipy.special import softmax -from pyduino.utils import yaml_get, bcolors, TriangleWave, ReLUP -from pyduino.log import datetime_to_str +from pyduino.utils import yaml_get, bcolors, TriangleWave, get_param +from pyduino.log import datetime_to_str, y_to_table, to_markdown_table import traceback - -# asd +import warnings __location__ = os.path.realpath(os.path.join(os.getcwd(), os.path.dirname(__file__))) @@ -78,11 +75,10 @@ def parse_dados(X,param): """ return np.array(list(map(seval,map(lambda x: x[1].get(param,0),sorted(X.items(),key=lambda x: x[0]))))) -class Spectra(RangeParser,ReactorManager,GA): - def __init__(self,elitism,f_param,ranges,density_param,maximize=True,log_name=None,reset_density=False,**kwargs): +class Spectra(RangeParser,ReactorManager,NelderMead): + def __init__(self,ranges,density_param,maximize=True,log_name=None,reset_density=False,**kwargs): """ Args: - f_param (str): Parameter name to be extracted from `ReactorManager.log_dados`. ranges (:obj:dict of :obj:list): Dictionary of parameters with a two element list containing the its minimum and maximum attainable values respectively. reset_density (bool): Whether or not to reset density values on the reactors at each iteration. @@ -100,63 +96,58 @@ def __init__(self,elitism,f_param,ranges,density_param,maximize=True,log_name=No RangeParser.__init__(self,ranges,self.parameters) - #assert os.path.exists(IRRADIANCE_PATH) - self.irradiance = PATHS.SYSTEM_PARAMETERS['irradiance']#yaml_get(IRRADIANCE_PATH) - #self.irradiance = np.array([self.irradiance[u] for u in self.keyed_ranges.keys()]) - self.irradiance = pd.Series(self.irradiance) + self.irradiance = PATHS.SYSTEM_PARAMETERS['irradiance'] ReactorManager.__init__(self) - GA.__init__( + NelderMead.__init__( self, - population_size=len(self.reactors), + population_size=len(self.parameters), ranges=self.ranges_as_list(), - generations=0, - **kwargs - ) + rng_seed=kwargs.get('rng_seed',0) + ) self.ids = list(self.reactors.keys()) self.sorted_ids = sorted(self.ids) - self.log_init(name=log_name) - self.payload = self.G_as_keyed() if self.payload is None else self.payload + self.log_init(name=log_name) + self.writer = SummaryWriter(self.log.prefix) + print(bcolors.OKGREEN,"[INFO]", "Created tensorboard log at", self.log.prefix, bcolors.ENDC) + self.payload = self.population_as_dict if self.payload is None else self.payload self.data = None self.do_gotod = reset_density - self.fparam = f_param self.density_param = density_param - self.fitness = np.nan * np.ones(len(self.reactors)) self.maximize = maximize self.dt = np.nan - self.elitism = elitism - def G_as_keyed(self): + def assign_to_reactors(self, x): """ - Converts genome matrix into an appropriate format to send to the reactors. + Assigns a list of parameters to the reactors. + + Parameters: + x (list): The input list to be converted. + + Returns: + OrderedDict: An ordered dictionary where the keys are the IDs and the values are the ranges. + """ + ids = self.ids[:len(x)] return OrderedDict( zip( - self.ids, + ids, map( lambda u: self.ranges_as_keyed(u), - list(np.round(self.view(self.G,self.linmap),2)) + list(np.round(self.view(x),2)) ) ) ) - def f_map(self,x_1,x_0): + @property + def population_as_dict(self): """ - Computation for the fitness function. + Converts genome matrix into an appropriate format to send to the reactors. """ - f_1 = x_1.loc[self.density_param].astype(float) - self.power = ((pd.DataFrame(self.G_as_keyed()).T*self.irradiance).sum(axis=1))/100 - if (self.dt is not np.nan) and (self.iteration_counter>0): - f_0 = x_0.loc[self.density_param].astype(float) - #self.growth_rate = (f_1-f_0)/self.dt - self.growth_rate = (f_1/f_0-1)/self.dt - #self.efficiency = self.growth_rate/(self.power+1) - #self.efficiency = 1000000*self.growth_rate*np.exp(-self.power/5) - self.efficiency = 1000000*self.growth_rate*np.exp(-(self.power-7)*(self.power-7)/(2*1.5*1.5)) - else: - self.growth_rate = self.power*np.nan - self.efficiency = self.power*np.nan - x_1.loc['power',:] = self.power.copy() - x_1.loc['efficiency',:] = self.efficiency.copy() - x_1.loc['growth_rate',:] = self.growth_rate.copy() + return self.assign_to_reactors(self.population) + + @property + def power(self): + return {reactor_ids: sum(vals[key]*self.irradiance[key] for key in self.irradiance.keys()) for reactor_ids, vals in self.payload.items()} + def payload_to_matrix(self): return np.nan_to_num( np.array( @@ -173,8 +164,7 @@ def pretty_print_dict(self,D): df = pd.DataFrame(D) df.index = df.index.str.lower() df = df.loc[self.parameters,:] - df.loc['fitness'] = self.fitness - df.loc['probs'] = 100*self.p + df.loc['fitness'] = self.y return df.round(decimals=2) def F_get(self): """ @@ -188,10 +178,17 @@ def F_set(self,x): x (:obj:`dict` of :obj:`dict`): Dictionary having reactor id as keys and a dictionary of parameters and their values as values. """ + for _id,params in x.items(): for chk in chunks(list(params.items()),3): self.reactors[_id].set(dict(chk)) time.sleep(1) + def init(self): + """ + Sets payload to the reactors. + """ + self.F_set(self.payload) + def set_spectrum(self,preset): """ Sets all reactors with a preset spectrum contained in `SPECTRUM_PATH`. @@ -201,12 +198,7 @@ def set_spectrum(self,preset): def set_preset_state_spectra(self,*args,**kwargs): self.set_preset_state(*args,**kwargs) - self.G = self.inverse_view(self.payload_to_matrix()).astype(int) - - def update_fitness(self,X): - #Get and return parameter chosen for fitness - self.fitness = ((-1)**(1+self.maximize))*X.loc[self.fparam].astype(float).to_numpy() - return self.fitness + self.population = self.inverse_view(self.payload_to_matrix()).astype(int) def GET(self,tag): """ @@ -214,164 +206,137 @@ def GET(self,tag): """ print("[INFO]","GET",datetime.now().strftime("%c")) self.past_data = self.data.copy() if self.data is not None else pd.DataFrame(self.payload) - self.data = pd.DataFrame(self.F_get()) - self.f_map(self.data,self.past_data) - self.log.log_many_rows(self.data,tags={'growth_state':tag}) - self.log.log_optimal(column=self.fparam,maximum=self.maximize,tags={'growth_state':tag}) - self.log.log_average(tags={'growth_state':tag}) + self.data = self.F_get() + self.log.log_many_rows(pd.DataFrame(self.data),tags={'growth_state':tag}) - def gotod(self,deltaTgotod): + def log_data(self, i, tags={}): + """ + Logs the tensor values and fitness scores. + + This method iterates over the tensor values and fitness scores and logs them using the writer object. + """ + print(bcolors.BOLD,"[INFO]","LOGGING",datetime.now().strftime("%c"), bcolors.ENDC) + P = self.view_g() + for k,(rid, ry) in enumerate(self.y.items()): + self.writer.add_scalar(f'reactor_{rid}/y', float(ry), i) + for r_param_id, rparam in enumerate(self.parameters): + self.writer.add_scalar(f'reactor_{rid}/{rparam}', float(P[k][r_param_id]), i) + if self.maximize: + self.writer.add_scalar('optima', max(self.y), i) + else: + self.writer.add_scalar('optima', min(self.y), i) + + data = self.F_get() + + # Log the DataFrame as a table in text format + self.writer.add_text("reactor_state", text_string=to_markdown_table(data), global_step=i) + + self.log.log_many_rows(data,tags=tags) + + def gotod(self): self.t_gotod_1 = datetime.now() self.send("gotod",await_response=False) print("[INFO] gotod sent") - time.sleep(deltaTgotod) + time.sleep(self.deltaTgotod) self.dt = (datetime.now()-self.t_gotod_1).total_seconds() print("[INFO] gotod DT", self.dt) - self.GET("gotod") - def run( - self, - deltaT: int, - run_ga: bool = True, - deltaTgotod: int = None - ): + # === Optimizer methods === + def ask_oracle(self, X) -> np.ndarray: """ - Runs reading and wiriting operations in an infinite loop on intervals given by `deltaT`. + Asks the oracle for the fitness of the given input. - Args: - deltaT (int): Amount of time in seconds to wait in each iteration. - run_ga (bool): Whether or not execute a step in the genetic algorithm. - deltaTgotod (int, optional): Time to wait after sending `gotod` command. + Parameters: + X (np.ndarray): The input for which the fitness is to be calculated. Must be already mapped to codomain. + + Returns: + np.ndarray: The fitness value calculated by the oracle. """ + y = np.array([]) - #Checking if gotod time is at least five minutes - if run_ga and deltaTgotod is None: raise ValueError("deltaTgotod must be at least 5 minutes.") - if run_ga and deltaTgotod <= 5*60: raise ValueError("deltaTgotod must be at least 5 minutes.") + assert X.shape[1] == len(self.parameters) + assert len(X.shape) == 2, "X must be a 2D array." + n_partitions = len(X) // len(self.reactors) + (len(X) % len(self.reactors) > 0) + partitions = np.array_split(X, n_partitions) - self.iteration_counter = 1 + for partition in partitions: + self.payload = self.assign_to_reactors(partition) + reactors = self.payload.keys() - self.GET("growing") + self.gotod() + data0 = self.F_get() + f0 = get_param(data0, self.density_param, reactors) - with open("error_traceback.log","w") as log_file: - log_file.write(datetime_to_str(self.log.timestamp)+'\n') - try: - self.deltaT = deltaT - print("START") - while True: - #growing - self.t_grow_1 = datetime.now() - time.sleep(max(2,deltaT)) - self.dt = (datetime.now()-self.t_grow_1).total_seconds() - print("[INFO]","DT",self.dt) - self.GET("growing") - self.update_fitness(self.data) - #GA - if run_ga: - #self.p = softmax(self.fitness/100) - #self.p = ReLUP(self.fitness*self.fitness*self.fitness) - self.p = ReLUP(self.fitness*self.fitness) - #Hotfix for elitism - print(f"{bcolors.OKCYAN}self.data{bcolors.ENDC}") - self.data.loc['p',:] = self.p.copy() - print(f"{bcolors.BOLD}{self.data.T.loc[:,self.titled_parameters+['power','efficiency','growth_rate','p']]}{bcolors.ENDC}") - if self.elitism: - self.elite_ix = self.ids[self.p.argmax()] - self.anti_elite_ix = self.ids[self.p.argmin()] - self.elite = self.G[self.p.argmax()].copy() - self.crossover() - self.mutation() - if self.elitism: - self.G[self.p.argmin()] = self.elite.copy() - self.payload = self.G_as_keyed() - else: - df = self.data.T - df.columns = df.columns.str.lower() - self.payload = df[self.parameters].T.to_dict() - self.G = self.inverse_view(self.payload_to_matrix()).astype(int) - print("[INFO]","SET",datetime.now().strftime("%c")) - self.F_set(self.payload) if run_ga else None - #gotod - if self.do_gotod: - self.gotod(deltaTgotod) - self.iteration_counter += 1 - except Exception as e: - traceback.print_exc(file=log_file) - raise(e) + self.F_set(self.payload) + time.sleep(self.deltaT) + data = self.F_get() + f = get_param(data, self.density_param, reactors) - def run_incremental( - self, - deltaT: int, - parameter: str, - deltaTgotod: int = None, - N: int = 1, - M: int = 1, - bounds:list = [100,0] - ): + #yield_rate = np.array([(float(f[id])/float(f[id]) - 1)/self.deltaT/self.power[id] for id in reactors]).astype(float) + + fitness = np.array([self.power[id] for id in reactors]).astype(float) + + y = np.append(y,((-1)**(self.maximize))*(fitness)) + + self.y = y + return y + # === * === + + def run( + self, + deltaT: float, + mode: str = 'optimize', + deltaTgotod: int = None + ): """ - Runs reading and wiriting operations in an infinite loop on intervals given by `deltaT` and increments parameters - periodically on an interval given by `deltaClockHours`. + Run the bioreactor simulation. Args: - deltaT (int): Amount of time in seconds to wait in each iteration. - parameter (str): Name of the parameters to be updated. - deltaTgotod (int, optional): Time to wait after sending `gotod` command. - N (int): Number of iteration groups to wait to trigger a parameter update. - M (int): Number of iterations to wait to increment `N`. - bounds: Starts on `bounds[0]` and goes towards `bounds[1]`. Then, the other way around. + deltaT (float): The time step for the simulation. + mode (str, optional): The mode of operation. Defaults to 'optimize'. + deltaTgotod (int, optional): The time interval for performing optimization. Defaults to None. + + Notes: + - If mode is 'optimize' and deltaTgotod is less than or equal to 300, a warning will be raised. + - If mode is 'free', the number of rows in X must be equal to the number of reactors. + """ + # Checking if gotod time is at least five minutes + if mode == "optimize" and deltaTgotod <= 300: + warnings.warn("deltaTgotod should be at least 5 minutes.") - #Initialize stepping - df = pd.DataFrame(self.payload).T - self.triangles = list(map(lambda x: TriangleWave(x,bounds[0],bounds[1],N),df.loc[:,parameter].to_list())) - self.triangle_wave_state = 1 if bounds[1] >= bounds[0] else -1 - c = 0 - m = 0 + if mode == "free": + assert self.population.shape[0] == len(self.reactors), "X must have the same number of rows as reactors in free mode." - #Checking if gotod time is at least five minutes - if deltaTgotod is not None and deltaTgotod < 5*60: print(bcolors.WARNING,"[WARNING]","deltaTgotod should be at least 5 minutes.",bcolors.ENDC) + self.deltaT = deltaT + self.deltaTgotod = deltaTgotod + self.iteration_counter = 1 - with open("error_traceback.log","w") as log_file: - log_file.write(datetime_to_str(self.log.timestamp)+'\n') + with open("error_traceback.log", "w") as log_file: + log_file.write(datetime_to_str(self.log.timestamp) + '\n') try: - self.deltaT = deltaT + print("START") while True: - self.t1 = datetime.now() - self.GET("growing") - self.update_fitness(self.data) - #gotod - if self.do_gotod: - self.send("gotod",await_response=False) - print("[INFO] gotod sent") - time.sleep(deltaTgotod) - self.dt = (datetime.now()-self.t1).total_seconds() - print("[INFO] gotod DT", self.dt) - self.GET("gotod") - self.t1 = datetime.now() - - # Pick up original parameters from preset state and increment them with `parameter_increment`. - df = pd.DataFrame(self.data).T - df.loc[:,parameter] = list(map(lambda T: T.y(c),self.triangles)) - print("[INFO]","WAVE","UP" if self.triangle_wave_state > 0 else "DOWN", "COUNTER", str(m), "LEVEL", str(c)) - if c%N==0: - self.triangle_wave_state *= -1 - # --------------------------- - - df.columns = df.columns.str.lower() - self.payload = df[self.parameters].T.to_dict() - self.G = self.inverse_view(self.payload_to_matrix()).astype(int) - print("[INFO]","SET",self.t1.strftime("%c")) - self.F_set(self.payload) - time.sleep(max(2,deltaT)) - m+=1 - if (m%M)==0: - c += 1 - self.t2 = datetime.now() - self.dt = (self.t2-self.t1).total_seconds() - print("[INFO]","DT",self.dt) + # growing + self.t_grow_1 = datetime.now() + time.sleep(max(2, deltaT)) + self.dt = (datetime.now() - self.t_grow_1).total_seconds() + print("[INFO]", "DT", self.dt) + # Optimizer + if mode == "optimize": + self.step() + if isinstance(self.deltaTgotod, int): + self.gotod() + elif mode == "free": + data = self.F_get() + self.y = get_param(data, self.density_param, self.reactors) + print("[INFO]", "SET", datetime.now().strftime("%c")) + print("[DEBUG]", "Y-VALUES", y_to_table(self.y)) + self.log_data(self.iteration_counter) + self.iteration_counter += 1 except Exception as e: traceback.print_exc(file=log_file) raise(e) - if __name__ == "__main__": g = Spectra(**hyperparameters) diff --git a/pyduino/utils.py b/pyduino/utils.py index 44cfa51..331705d 100644 --- a/pyduino/utils.py +++ b/pyduino/utils.py @@ -2,6 +2,7 @@ from nmap import PortScanner import requests import numpy as np +from collections import OrderedDict class bcolors: HEADER = '\033[95m' @@ -14,6 +15,26 @@ class bcolors: BOLD = '\033[1m' UNDERLINE = '\033[4m' +def get_param(data, key: str, ids: set = False) -> OrderedDict: + + """ + Retrieve a specific parameter from a dictionary of data. + + Parameters: + - data: The dictionary containing the data. + - key: The key of the parameter to retrieve. + - ids: (optional) A set of IDs to filter the data. If not provided, all data will be returned. + + Returns: + - An ordered dictionary containing the filtered data. + + """ + filtered = OrderedDict(list(map(lambda x: (x[0], x[1][key]), data.items()))) + if not ids: + return filtered + else: + return OrderedDict(filter(lambda x: x[0] in ids,filtered.items())) + def yaml_get(filename): """ Loads hyperparameters from a YAML file. diff --git a/requirements.txt b/requirements.txt index 9c16729..2f82394 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,7 @@ pyserial PyYAML requests scipy -tailer \ No newline at end of file +tailer +python-dotenv +tensorboardX==2.6.2.2 +tabulate \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..afb9462 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +import dotenv +dotenv.load_dotenv() +dotenv.load_dotenv(dotenv_path=".env.local") \ No newline at end of file diff --git a/tests/test_linmap.py b/tests/test_linmap.py new file mode 100644 index 0000000..e1ad299 --- /dev/null +++ b/tests/test_linmap.py @@ -0,0 +1,16 @@ +# Tests the linear map. +import sys +import os +import numpy as np +import dotenv +dotenv.load_dotenv() +dotenv.load_dotenv(dotenv_path=".env.local") + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from pyduino.optimization import linmap + +def test_linmap(): + domain = [(0,1), (0,1), (0,1)] + codomain = [(-1,1), (-10,10), (0,8)] + f = linmap(domain, codomain) + assert f(np.random.random((10,3))).shape == (10,3) \ No newline at end of file diff --git a/tests/test_optim.py b/tests/test_optim.py new file mode 100644 index 0000000..d752b50 --- /dev/null +++ b/tests/test_optim.py @@ -0,0 +1,61 @@ +import sys +import os +import numpy as np +import dotenv +import dotenv +import pytest +dotenv.load_dotenv() +dotenv.load_dotenv(dotenv_path=".env.local") + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from pyduino.optimization.nelder_mead import NelderMead + +class TestOptim: + optimizer = NelderMead(population_size=10, ranges=[(-10, 10)] * 4) + + def test_logic(self): + x = (2*np.random.random((10, 4))-1)*100 + + print("X") + print(x) + print("Forward -> Backward") + print(self.optimizer.backward(self.optimizer.forward(x))) + + assert np.allclose(x, self.optimizer.backward(self.optimizer.forward(x))) + assert np.allclose(x, self.optimizer.inverse_view(self.optimizer.view(x))) + + assert np.all(self.optimizer.backward(np.zeros((10,4))) < 1) + + def test_nelder_mead_parabola(self): + class Oracle: + def ask(self, X): + return (X**2).sum(axis=1) + oracle = Oracle() + self.optimizer.ask_oracle = oracle.ask + + # Set initial population to oracle + self.optimizer.init_oracle() + + for i in range(100): + self.optimizer.step() + assert self.optimizer.view(self.optimizer.population).shape == (10, 4) + assert self.optimizer.view_g().shape == (10, 4) + assert self.optimizer.inverse_view(self.optimizer.view(self.optimizer.population)).shape == (10, 4) + assert np.isclose(self.optimizer.y.min(), 0, atol=1e-4), f"Oracle: {self.optimizer.y.min()}" + + def test_nelder_mead_rosenbrock(self): + class Oracle: + def ask(self, X): + return ((1 - X[:, :-1])**2).sum(axis=1) + 100 * ((X[:, 1:] - X[:, :-1]**2)**2).sum(axis=1) + oracle = Oracle() + self.optimizer.ask_oracle = oracle.ask + + # Set initial population to oracle + self.optimizer.init_oracle() + + for i in range(1000): + self.optimizer.step() + assert self.optimizer.view(self.optimizer.population).shape == (10, 4) + assert self.optimizer.view_g().shape == (10, 4) + assert self.optimizer.inverse_view(self.optimizer.view(self.optimizer.population)).shape == (10, 4) + assert np.isclose(self.optimizer.y.min(), 0, atol=1e-4), f"Oracle: {self.optimizer.y.min()}" \ No newline at end of file diff --git a/tests/test_spectra.py b/tests/test_spectra.py new file mode 100644 index 0000000..163ee89 --- /dev/null +++ b/tests/test_spectra.py @@ -0,0 +1,34 @@ +import sys +import os +import numpy as np +import pandas as pd +import dotenv +dotenv.load_dotenv() +dotenv.load_dotenv(dotenv_path=".env.local") + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from pyduino.spectra import Spectra, PATHS + +class TestSpectra: + g = Spectra(**PATHS.HYPERPARAMETERS) + g.deltaTgotod = 1 + g.deltaT = 1 + g.init() + + def test_functions(self): + assert len(self.g.reactors) != 0 + assert len(self.g.ids) == self.g.population_size + + def test_array_assignment(self): + X = np.random.random((len(self.g.ids),len(self.g.parameters))) + assigned = self.g.assign_to_reactors(X) + keys = list(assigned.keys()) + assert len(keys)==len(self.g.ids) + assert len(assigned[keys[0]]) == len(self.g.parameters) + def test_oracle(self): + data = self.g.F_get() + df = self.g.pretty_print_dict(data) + assert isinstance(df, pd.DataFrame) + y = self.g.ask_oracle(self.g.population) + def test_logger(self): + self.g.GET("test") \ No newline at end of file diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..31c47fe --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,28 @@ +import sys +import os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from pyduino.utils import get_param +import dotenv +dotenv.load_dotenv() +dotenv.load_dotenv(dotenv_path=".env.local") + +def test_get_param(): + # Test case 1: No IDs provided + data = {'A': {'param1': 1, 'param2': 2}, 'B': {'param1': 3, 'param2': 4}} + key = 'param1' + expected_output = {'A': 1, 'B': 3} + assert get_param(data, key) == expected_output + + # Test case 2: IDs provided + data = {'A': {'param1': 1, 'param2': 2}, 'B': {'param1': 3, 'param2': 4}} + key = 'param1' + ids = {'A'} + expected_output = {'A': 1} + assert get_param(data, key, ids) == expected_output + + # Test case 5: Check order of keys with IDs provided + data = {'A': {'param1': 1, 'param2': 2}, 'B': {'param1': 3, 'param2': 4}, 'C': {'param1': 5, 'param2': 6}} + key = 'param1' + ids = {'C', 'A'} + expected_output = {'C': 5, 'A': 1} + assert get_param(data, key, ids) == expected_output \ No newline at end of file