Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
amitschang committed Feb 26, 2024
1 parent 300342d commit 3a9ff5f
Show file tree
Hide file tree
Showing 6 changed files with 287 additions and 3 deletions.
34 changes: 31 additions & 3 deletions evolver/app/main.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,44 @@
import asyncio
from fastapi import FastAPI
from fastapi.responses import RedirectResponse

from evolver.device import Evolver, EvolverConfig
from .. import __project__, __version__


app = FastAPI()
evolver = Evolver()


@app.get("/")
async def root():
return RedirectResponse(url='/docs')
async def describe_evolver():
return {
'config': evolver.config,
'state': evolver.get_state(),
'last_read': evolver.last_read,
}


@app.post("/")
async def update_evolver(config: EvolverConfig):
evolver.update_config(config)


@app.get("/healthz")
async def healthz():
return {"message": f"Running '{__project__}' ver: '{__version__}'"}


async def evolver_async_loop():
while True:
evolver.loop_once()
await asyncio.sleep(evolver.config.interval)


@app.on_event('startup')
async def start_evolver_loop():
asyncio.create_task(evolver_async_loop())


if __name__ == '__main__':
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8000, log_level="info")
102 changes: 102 additions & 0 deletions evolver/device.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import time
from pydantic import BaseModel
from evolver.util import load_class_fqcn
from evolver.hardware import SensorDriver, EffectorDriver


class AdapterDescriptor(BaseModel):
driver: str
config: dict = {}


class HardwareDriverDescriptor(AdapterDescriptor):
calibrator: AdapterDescriptor = None


class EvolverConfig(BaseModel):
vials: list = list(range(16))
hardware: dict[str, HardwareDriverDescriptor] = {}
adapters: list[AdapterDescriptor] = []
serial: AdapterDescriptor = None
enable_react: bool = True
enable_commit: bool = True
interval: int = 20


class Evolver:
hardware = {}
adapters = []
last_read = {}

def __init__(self, config: EvolverConfig = EvolverConfig()):
self.update_config(config)

def update_config(self, config: EvolverConfig):
self.config = config
for name, driver in config.hardware.items():
self.setup_driver(name, driver)
for name in list(self.hardware.keys()):
if name not in config.hardware.keys():
del(self.hardware[name])
del(self.last_read[name])
self.adapters = []
for adapter in config.adapters:
self.setup_adapter(adapter)
self.setup_serial(config.serial)

def setup_driver(self, name, driver_config: HardwareDriverDescriptor):
driver_class = load_class_fqcn(driver_config.driver)
config = driver_class.Config.model_validate(driver_config.config)
calibrator = None
if driver_config.calibrator is not None:
calibrator_class = load_class_fqcn(driver_config.calibrator.driver)
calibrator_config = calibrator_class.Config.model_validate(driver_config.calibrator.config)
calibrator = calibrator_class(calibrator_config)
self.hardware[name] = driver_class(self, config, calibrator)

def setup_adapter(self, adapter):
cls = load_class_fqcn(adapter.driver)
conf = cls.Config.model_validate(adapter.config)
self.adapters.append(cls(self, conf))

def setup_serial(self, serial):
pass

def get_hardware(self, name):
return self.hardware[name]

@property
def sensors(self):
return {k: v for k,v in self.hardware.items() if isinstance(v, SensorDriver)}

@property
def effectors(self):
return {k: v for k,v in self.hardware.items() if isinstance(v, EffectorDriver)}

@property
def calibration_status(self):
return {name: device.calibrator.status for name,device in self.hardware.items()}

def read_state(self):
for name, device in self.sensors.items():
device.read()
self.last_read[name] = time.time()

def get_state(self):
return {name: device.get() for name,device in self.sensors.items()}

def evaluate_adapters(self):
for adapter in self.adapters:
adapter.react()

def commit_proposals(self):
for device in self.effectors.values():
device.commit()

def loop_once(self):
self.read_state()
# for any hardware awaiting calibration, call calibration update method here
if self.config.enable_react:
self.evaluate_adapters()
if self.config.enable_commit:
self.commit_proposals()
107 changes: 107 additions & 0 deletions evolver/hardware/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
from abc import ABC, abstractmethod
from copy import copy
from pydantic import BaseModel


class VialConfigBaseModel(BaseModel):
vials: list[int] | None = None


class VialBaseModel(BaseModel):
vial: int


class BaseCalibrator(ABC):
class Config(BaseModel):
calibfile: str = None

def __init__(self, config: Config = Config()):
self.config = config

@property
@abstractmethod
def status(self):
pass


class NoOpCalibrator(BaseCalibrator):
@property
def status(self):
return True


class HardwareDriver(ABC):
class Config(BaseModel):
pass
calibrator = NoOpCalibrator()

def __init__(self, evolver, config = None, calibrator = None):
self.evolver = evolver
self.reconfigure(config or self.Config())
if calibrator:
self.calibrator = calibrator

def reconfigure(self, config):
self.config = config


class VialHardwareDriver(HardwareDriver):
def reconfigure(self, config):
super().reconfigure(config)
if config.vials is None:
config.vials = self.evolver.config.vials
else:
if not set(config.vials).issubset(self.evolver.all_vials):
raise ValueError('invalid vials found in config')


class SensorDriver(VialHardwareDriver):
class Config(VialConfigBaseModel):
pass
class Output(VialBaseModel):
raw: int
value: float
outputs: dict[int, Output] = {}

def get(self) -> list[Output]:
return self.outputs

@abstractmethod
def read(self):
pass


class EffectorDriver(VialHardwareDriver):
class Config(VialConfigBaseModel):
pass
class Input(VialBaseModel):
value: float
proposal: dict[int, Input] = {}
committed: dict[int, Input] = {}

def set(self, input: Input):
self.proposal[input.vial] = input

@abstractmethod
def commit(self):
pass


class NoOpSensorDriver(SensorDriver):
class Config(VialConfigBaseModel):
echo_raw: int = 1
echo_val: int = 2

def read(self):
self.outputs = {
i: self.Output(vial=i, raw=self.config.echo_raw, value=self.config.echo_val)
for i in self.config.vials
}

def get(self):
return self.outputs


class NoOpEffectorDriver(EffectorDriver):
def commit(self):
self.comitted = copy(self.proposal)
40 changes: 40 additions & 0 deletions evolver/test/test_device.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import pytest
from evolver.device import Evolver, EvolverConfig
from evolver.hardware import NoOpSensorDriver


@pytest.fixture
def conf_with_driver():
return {
'vials': [0,1,2,3],
'hardware': {
'testsensor': {'driver': 'evolver.hardware.NoOpSensorDriver', 'config': {}},
'testeffector': {'driver': 'evolver.hardware.NoOpEffectorDriver', 'config': {}},
}
}


@pytest.fixture
def demo_evolver(conf_with_driver):
return Evolver(EvolverConfig.model_validate(conf_with_driver))


def test_evolver_instantiate_with_default_config():
conf = EvolverConfig()
evolver = Evolver()
evolver.update_config(conf)
Evolver(conf)


def test_evolver_with_driver(demo_evolver):
assert isinstance(demo_evolver.hardware['testsensor'], NoOpSensorDriver)


@pytest.mark.parametrize('method', ['read_state', 'loop_once'])
def test_evolver_read_and_get_state(demo_evolver, method):
state = demo_evolver.get_state()
assert state['testsensor'] == {}
getattr(demo_evolver, method)()
state = demo_evolver.get_state()
for vial in demo_evolver.config.vials:
assert state['testsensor'][vial] == NoOpSensorDriver.Output(vial=vial, raw=1, value=2)
6 changes: 6 additions & 0 deletions evolver/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,9 @@ def find_package_location(package=__project__):

def find_repo_location(package=__project__):
return Path(find_package_location(package) / os.pardir)


def load_class_fqcn(fqcn):
mod, cls = fqcn.rsplit('.', 1)
module = importlib.import_module(mod)
return getattr(module, cls)
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ license = {file = "LICENSE"}
requires-python = ">=3.11"
dependencies = [
"fastapi[all]",
"uvicorn",
]

[project.optional-dependencies]
Expand Down

0 comments on commit 3a9ff5f

Please sign in to comment.