Skip to content

Commit

Permalink
Merge branch 'main' into iainland/calibration-file-and-resume
Browse files Browse the repository at this point in the history
  • Loading branch information
imaitland authored Feb 4, 2025
2 parents 89104e3 + 76c467a commit 10772b8
Show file tree
Hide file tree
Showing 12 changed files with 224 additions and 64 deletions.
7 changes: 4 additions & 3 deletions evolver/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from evolver.app.exceptions import CalibratorNotFoundError, HardwareNotFoundError, OperationNotSupportedError
from evolver.app.html_routes import html_app
from evolver.app.models import EventInfo, SchemaResponse
from evolver.app.routers import hardware
from evolver.app.routers import experiment, hardware
from evolver.base import require_all_fields
from evolver.device import Evolver
from evolver.history.interface import HistoryResult
Expand Down Expand Up @@ -59,6 +59,7 @@ async def validation_error_handler(_, exc):


app.include_router(hardware.router)
app.include_router(experiment.router)


@app.get("/", operation_id="describe")
Expand Down Expand Up @@ -91,7 +92,7 @@ async def get_schema(classinfo: ImportString | None = evolver.util.fully_qualifi

@app.post("/history/", operation_id="history")
async def get_history(
name: str = None,
names: list[str] = None,
kinds: list[str] | None = ["sensor"],
t_start: float = None,
t_stop: float = None,
Expand All @@ -100,7 +101,7 @@ async def get_history(
n_max: int = None,
) -> HistoryResult:
return app.state.evolver.history.get(
name=name, kinds=kinds, t_start=t_start, t_stop=t_stop, vials=vials, properties=properties, n_max=n_max
names=names, kinds=kinds, t_start=t_start, t_stop=t_stop, vials=vials, properties=properties, n_max=n_max
)


Expand Down
26 changes: 26 additions & 0 deletions evolver/app/routers/experiment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from fastapi import APIRouter, Request

from evolver.device import Experiment

router = APIRouter(prefix="/experiment", tags=["experiment"])


@router.get("/")
def get_experiments(request: Request) -> dict[str, Experiment]:
return request.app.state.evolver.experiments


@router.get("/{experiment_name}/logs")
def get_experiment_logs(request: Request, experiment_name: str):
evolver = request.app.state.evolver
controllers = evolver.experiments[experiment_name].controllers
names = [c.name for c in controllers if c.name]
return evolver.history.get(names=names, kinds=["log", "event"])


@router.get("/{experiment_name}")
def get_experiment_overview(request: Request, experiment_name: str):
return {
"config": request.app.state.evolver.experiments[experiment_name],
"logs": get_experiment_logs(request, experiment_name),
}
3 changes: 2 additions & 1 deletion evolver/app/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@

from evolver.app.main import app
from evolver.device import Evolver
from evolver.settings import app_settings
from evolver.settings import app_settings, settings


@pytest.fixture
def app_client(tmp_path, monkeypatch):
monkeypatch.setattr(app_settings, "CONFIG_FILE", tmp_path / app_settings.CONFIG_FILE)
monkeypatch.setattr(settings, "EXPERIMENT_FILE_STORAGE_PATH", tmp_path / settings.EXPERIMENT_FILE_STORAGE_PATH)

# Create and save a default config file to be read upon app startup.
Evolver.Config().save(app_settings.CONFIG_FILE)
Expand Down
90 changes: 77 additions & 13 deletions evolver/app/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from evolver.calibration.demo import NoOpCalibrator
from evolver.calibration.interface import Status
from evolver.calibration.standard.polyfit import LinearCalibrator, LinearTransformer
from evolver.device import Evolver
from evolver.device import Evolver, Experiment
from evolver.hardware.demo import NoOpEffectorDriver, NoOpSensorDriver
from evolver.hardware.interface import EffectorDriver, SensorDriver
from evolver.history.demo import InMemoryHistoryServer
Expand Down Expand Up @@ -182,26 +182,26 @@ def test_calibrator_not_found_exceptions(self, app_client, func, kwargs):
assert contents["detail"] == "Hardware has no calibrator"

@pytest.mark.parametrize(
"query_params",
("query_params", "body"),
[
{},
{"n_max": 1},
{"name": "nonexistent"},
{"name": "test", "t_start": 0, "t_stop": 1, "n_max": 1},
{"name": "test", "vials": [0, 1]},
{"name": "test", "properties": ["value"]},
({}, {}),
({"n_max": 1}, {}),
({}, {"names": ["nonexistent"]}),
({"t_start": 0, "t_stop": 1, "n_max": 1}, {"names": ["test"]}),
({}, {"names": ["test"], "vials": [0, 1]}),
({}, {"names": ["test"], "properties": ["value"]}),
],
)
def test_history(self, app_client, query_params):
def test_history(self, app_client, query_params, body):
app.state.evolver = Evolver(history=InMemoryHistoryServer(), hardware={"test": NoOpSensorDriver()})
response = app_client.post("/history/", params=query_params)
response = app_client.post("/history/", params=query_params, json=body)
assert response.status_code == 200
assert response.json() == {"data": {}}
app.state.evolver.loop_once()
app.state.evolver.loop_once()
response = app_client.post("/history/", params=query_params)
response = app_client.post("/history/", params=query_params, json=body)
assert response.status_code == 200
if query_params.get("name", "test") == "test":
if body.get("names", ["test"]) == ["test"]:
assert response.json()["data"]["test"][0]["timestamp"] > 0
assert isinstance(response.json()["data"]["test"][0]["data"], dict)
if n_max := query_params.get("n_max", None):
Expand Down Expand Up @@ -235,7 +235,7 @@ def test_event_endpoint(self, app_client):
"/event", json={"name": "test", "message": "test_event_api", "vial": 99, "data": {"key": "value"}}
)
assert response.status_code == 200
recorded_event = app.state.evolver.history.get("test", kinds=["event"]).data["test"][0]
recorded_event = app.state.evolver.history.get(names=["test"], kinds=["event"]).data["test"][0]
assert recorded_event.vial == 99
assert recorded_event.data == {"message": "test_event_api", "key": "value", "vial": 99, "level": "EVENT"}

Expand All @@ -256,6 +256,70 @@ def read(self):
assert response.status_code == 200
assert response.json()["state"]["test"]["0"]["x"] is None

def test_experiments_list(self, app_client):
response = app_client.get("/experiment/")
assert response.status_code == 200
assert response.json() == {}
app.state.evolver = Evolver(
experiments={
"test": Experiment(controllers=[ConfigDescriptor(classinfo="evolver.controller.demo.NoOpController")])
}
)
response = app_client.get("/experiment/")
assert response.status_code == 200
assert response.json() == {
"test": {
"controllers": [
{"classinfo": "evolver.controller.demo.NoOpController", "config": {"name": "NoOpController"}}
],
"enabled": True,
"name": None,
}
}

def test_experiment_details_endpoint(self, app_client):
app.state.evolver = Evolver(
history=InMemoryHistoryServer(),
experiments={
"test": Experiment(controllers=[ConfigDescriptor(classinfo="evolver.controller.demo.NoOpController")])
},
)
app.state.evolver.loop_once()
response = app_client.get("/experiment/test")
assert response.status_code == 200
assert response.json()["config"] == {
"controllers": [
{"classinfo": "evolver.controller.demo.NoOpController", "config": {"name": "NoOpController"}}
],
"enabled": True,
"name": None,
}
assert len(response.json()["logs"]["data"]["NoOpController"]) == 1

def test_experiment_log_endpoint(self, app_client):
app.state.evolver = Evolver(
history=InMemoryHistoryServer(),
experiments={
"test": Experiment(
controllers=[
ConfigDescriptor(
classinfo="evolver.controller.demo.NoOpController", config={"name": "NoOpController1"}
),
ConfigDescriptor(
classinfo="evolver.controller.demo.NoOpController", config={"name": "NoOpController2"}
),
]
)
},
)
response = app_client.get("/experiment/test/logs")
assert response.status_code == 200
assert response.json() == {"data": {}}
app.state.evolver.loop_once()
response = app_client.get("/experiment/test/logs")
assert len(response.json()["data"]["NoOpController1"]) == 1
assert len(response.json()["data"]["NoOpController2"]) == 1


def test_app_load_file(app_client):
config = Evolver.Config(
Expand Down
2 changes: 2 additions & 0 deletions evolver/controller/demo.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from evolver.controller.interface import Controller
from evolver.logutils import EVENT


class NoOpController(Controller):
ncalls = 0

def control(self, *args, **kwargs):
self.logger.log(EVENT, "NoOpController control called")
self.ncalls += 1
14 changes: 9 additions & 5 deletions evolver/controller/standard/tests/test_chemostat.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,16 @@ def test_chemostat_standard_operation(mock_hardware, window, min_od, stir_rate):

def test_evolver_based_setup(): # test to ensure evolver pluggability via config/loop
config = {
"controllers": [
{
"classinfo": "evolver.controller.standard.Chemostat",
"config": {"od_sensor": "od", "pump": "pump", "stirrer": "stirrer", "vials": [0, 1]},
"experiments": {
"test": {
"controllers": [
{
"classinfo": "evolver.controller.standard.Chemostat",
"config": {"od_sensor": "od", "pump": "pump", "stirrer": "stirrer", "vials": [0, 1]},
}
],
}
],
},
"raise_loop_exceptions": True,
}
evolver = Evolver.create(config)
Expand Down
33 changes: 29 additions & 4 deletions evolver/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from collections import defaultdict
from math import prod

from pydantic import Field, ValidationInfo, field_validator
from pydantic import Field, ValidationInfo, field_serializer, field_validator

from evolver.base import BaseInterface, ConfigDescriptor
from evolver.connection.interface import Connection
Expand All @@ -19,10 +19,23 @@
DEFAULT_HISTORY = HistoryServer


class Experiment(BaseInterface.Config):
enabled: bool = True
controllers: list[ConfigDescriptor | Controller] = []

# this seemed to have been required for the tests of config symmetry.
# Without it there is pydantic error about unkown type (the underlying
# Controller class) - so seemed to be kind of bypassing the evolver
# BaseModel hookups?
@field_serializer("controllers")
def serialize_controllers(self, data):
return [ConfigDescriptor.model_validate(c) for c in data]


class Evolver(BaseInterface):
class Config(BaseInterface.Config):
name: str = "Evolver"
experiment: str = "unspecified"
namespace: str = "unspecified"
vial_layout: list[int] = Field(
default=settings.DEFAULT_VIAL_LAYOUT,
description="The layout of the vials in 2 or 3 dimensions. Always left-to-right bottom-top-top order.",
Expand All @@ -31,7 +44,7 @@ class Config(BaseInterface.Config):
)
vials: list = list(range(settings.DEFAULT_NUMBER_OF_VIALS_PER_BOX))
hardware: dict[str, ConfigDescriptor | HardwareDriver] = {}
controllers: list[ConfigDescriptor | Controller] = []
experiments: dict[str, Experiment] = {}
serial: ConfigDescriptor | Connection = ConfigDescriptor.model_validate(DEFAULT_SERIAL)
history: ConfigDescriptor | History = ConfigDescriptor.model_validate(DEFAULT_HISTORY)
enable_control: bool = True
Expand All @@ -53,6 +66,14 @@ def validate_vials(cls, value: list[int], info: ValidationInfo):
def __init__(self, *args, **kwargs):
self.last_read = defaultdict(lambda: int(-1))
super().__init__(*args, evolver=self, **kwargs)
# We have to turn experiment controllers passed in as configdescriptors
# to objects while passing in self so controllers can consume hardware
# and read from history, etc.
for experiment in self.experiments.values():
for i in range(len(experiment.controllers)):
elem = experiment.controllers[i]
if isinstance(elem, ConfigDescriptor):
experiment.controllers[i] = elem.create(non_config_kwargs={"evolver": self})
self._setup_log_capture()

def _setup_log_capture(self):
Expand All @@ -76,6 +97,10 @@ def sensors(self):
def effectors(self):
return {k: v for k, v in self.hardware.items() if isinstance(v, EffectorDriver)}

@property
def enabled_controllers(self):
return [c for exp in self.experiments.values() for c in exp.controllers if exp.enabled]

@property
def calibration_status(self):
"""Return the calibration Status for each device's calibrator, or None if hardware has no calibrator. This is explicit and returns all available
Expand Down Expand Up @@ -129,7 +154,7 @@ def read_state(self):
return read_errors

def evaluate_controllers(self):
return [self._loop_exception_wrapper(c.run, f"updating controller {c}") for c in self.controllers]
return [self._loop_exception_wrapper(c.run, f"updating controller {c}") for c in self.enabled_controllers]

def commit_proposals(self):
return [
Expand Down
10 changes: 5 additions & 5 deletions evolver/history/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,19 @@ def put(self, name, kind, data, vial=None):

def get(
self,
name: str = None,
names: list[str] = None,
kinds: list[str] = None,
t_start: float = None,
t_stop: float = None,
vials: list[int] | None = None,
properties: list[str] | None = None,
n_max: int = None,
):
names = self.history.keys()
if name is not None:
names = [n for n in names if n == name]
query_names = self.history.keys()
if names is not None:
query_names = [n for n in query_names if n in names]
data = {}
for n in names:
for n in query_names:
history = list(self.history[n])[:n_max]
try:
history = [filter_data_properties(d, properties) for d in history]
Expand Down
6 changes: 4 additions & 2 deletions evolver/history/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def put(self, name: str, kind: str, data: Any, vial: int = None):
def get(
self,
name: str = None,
names: list[str] = None,
kinds: list[str] = None,
t_start: float = None,
t_stop: float = None,
Expand All @@ -49,8 +50,9 @@ def get(
according to the input parameters, as listed below.
Args:
name: The name of hardware component to retrieve data for. If None,
return all data.
names: A list of names of hardware components to retrieve data for.
Should be Mutually exclusive with the name parameter, where the
name parameter would take precedence if supplied.
t_start: The start time of the data to retrieve in floating point unix
epoch seconds. If not specified, either n_max or an
implementation-defined number of data points will be returned,
Expand Down
Loading

0 comments on commit 10772b8

Please sign in to comment.