Skip to content

Commit d7aeea4

Browse files
turbidostat now dynamically chooses the biomass signal; some UI updates
1 parent e030d4c commit d7aeea4

File tree

16 files changed

+292
-79
lines changed

16 files changed

+292
-79
lines changed

core/pioreactor/automations/dosing/turbidostat.py

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66
from pioreactor.automations import events
77
from pioreactor.automations.dosing.base import DosingAutomationJob
88
from pioreactor.background_jobs.od_reading import REF_keyword
9+
from pioreactor.calibrations import load_active_calibration
910
from pioreactor.config import config
11+
from pioreactor.estimators import load_active_estimator
1012
from pioreactor.exc import CalibrationError
1113
from pioreactor.utils import local_persistent_storage
12-
from pioreactor.utils.streaming_calculations import ExponentialMovingAverage
1314

1415

1516
class Turbidostat(DosingAutomationJob):
@@ -23,6 +24,7 @@ class Turbidostat(DosingAutomationJob):
2324
"exchange_volume_ml": {"datatype": "float", "settable": True, "unit": "mL"},
2425
"target_biomass": {"datatype": "float", "settable": True},
2526
"biomass_signal": {"datatype": "string", "settable": True},
27+
"resolved_biomass_signal": {"datatype": "string", "settable": False},
2628
"duration": {"datatype": "float", "settable": False, "unit": "min"},
2729
}
2830
target_biomass = None
@@ -32,7 +34,7 @@ def __init__(
3234
self,
3335
exchange_volume_ml: float | str,
3436
target_biomass: Optional[float | str] = None,
35-
biomass_signal: str = "normalized_od",
37+
biomass_signal: str = "auto",
3638
**kwargs,
3739
) -> None:
3840
super().__init__(**kwargs)
@@ -50,9 +52,6 @@ def __init__(
5052
self.target_biomass = float(target_biomass)
5153

5254
self.exchange_volume_ml = float(exchange_volume_ml)
53-
self.ema_od = ExponentialMovingAverage(
54-
config.getfloat("turbidostat.config", "od_smoothing_ema", fallback=0.5)
55-
)
5655

5756
def set_duration(self, value: float | None):
5857
# force duration to always be 0.25 - we want to check often.
@@ -85,14 +84,41 @@ def _resolve_od_channel(self) -> pt.PdChannel:
8584

8685
raise ValueError("No OD signal channels found in [od_config.photodiode_channel].")
8786

87+
@property
88+
def _od_angle(self) -> pt.PdAngle:
89+
angle = config["od_config.photodiode_channel"][self._od_channel]
90+
if angle in (None, "", REF_keyword):
91+
raise ValueError(f"No OD signal angle configured for channel {self._od_channel}.")
92+
return cast(pt.PdAngle, str(angle))
93+
94+
def _has_active_od_fused_estimator(self) -> bool:
95+
try:
96+
return load_active_estimator(pt.OD_FUSED_DEVICE) is not None
97+
except Exception as e:
98+
self.logger.warning(
99+
"Unable to load active od_fused estimator for auto biomass signal selection: %s",
100+
e,
101+
)
102+
return False
103+
104+
def _has_active_od_calibration_for_resolved_angle(self) -> bool:
105+
od_device = cast(pt.ODCalibrationDevices, f"od{self._od_angle}")
106+
try:
107+
return load_active_calibration(od_device) is not None
108+
except Exception as e:
109+
self.logger.warning(
110+
"Unable to load active OD calibration for device %s for auto biomass signal selection: %s",
111+
od_device,
112+
e,
113+
)
114+
return False
115+
88116
def execute(self) -> Optional[events.DilutionEvent]:
89117
assert self.target_biomass is not None
90-
latest_biomass = self.latest_biomass_value(self.biomass_signal, od_channel=self._od_channel)
91-
if self.biomass_signal in {"od", "od_fused"}:
92-
latest_biomass = self.ema_od.update(latest_biomass)
118+
resolved_biomass_signal = self.resolved_biomass_signal
119+
latest_biomass = self.latest_biomass_value(resolved_biomass_signal, od_channel=self._od_channel)
93120

94121
if latest_biomass >= self.target_biomass:
95-
self.ema_od.clear() # clear the ema so that we don't cause a second dosing to occur right after.
96122
latest_biomass_before_dosing = latest_biomass
97123
target_biomass_before_dosing = self.target_biomass
98124

@@ -103,13 +129,13 @@ def execute(self) -> Optional[events.DilutionEvent]:
103129
data = {
104130
"latest_biomass": latest_biomass_before_dosing,
105131
"target_biomass": target_biomass_before_dosing,
106-
"biomass_signal": self.biomass_signal,
132+
"resolved_biomass_signal": resolved_biomass_signal,
107133
"exchange_volume_ml": self.exchange_volume_ml,
108134
"volume_actually_moved_ml": results["media_ml"],
109135
}
110136

111137
return events.DilutionEvent(
112-
f"Latest biomass ({self.biomass_signal}) = {latest_biomass_before_dosing:.2f} ≥ Target biomass = {target_biomass_before_dosing:.2f}; cycled {results['media_ml']:.2f} mL",
138+
f"Latest biomass ({resolved_biomass_signal}) = {latest_biomass_before_dosing:.2f} ≥ Target biomass = {target_biomass_before_dosing:.2f}; cycled {results['media_ml']:.2f} mL",
113139
data,
114140
)
115141
else:
@@ -118,11 +144,24 @@ def execute(self) -> Optional[events.DilutionEvent]:
118144
def set_target_biomass(self, new_target: float) -> None:
119145
self.target_biomass = float(new_target)
120146

147+
@property
148+
def resolved_biomass_signal(self) -> str:
149+
if self.biomass_signal != "auto":
150+
return str(self.biomass_signal)
151+
152+
if self._has_active_od_fused_estimator():
153+
return "od_fused"
154+
155+
if self._has_active_od_calibration_for_resolved_angle():
156+
return "od"
157+
158+
return "normalized_od"
159+
121160
def set_biomass_signal(self, new_signal: str) -> None:
122161
self._set_biomass_signal(new_signal)
123162

124163
def _set_biomass_signal(self, biomass_signal: str) -> None:
125-
allowed = ("normalized_od", "od_fused", "od")
164+
allowed = ("auto", "normalized_od", "od_fused", "od")
126165
if biomass_signal not in allowed:
127166
raise ValueError(
128167
f"Unsupported biomass_signal={biomass_signal}. Use one of: {', '.join(allowed)}."

core/pioreactor/web/app.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@
4747

4848
try:
4949
load_plugins()
50-
except Exception as e:
50+
except BaseException as e:
51+
# BaseExpection since some plugins might use SystemExit, which isn't an Exception.
5152
logger.debug(f"Web-server encountered error {e} when loading plugins.")
5253

5354

core/pioreactor/web/static/asset-manifest.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"files": {
33
"main.css": "/static/static/css/main.9c7a48b7.css",
4-
"main.js": "/static/static/js/main.e4e759f0.js",
4+
"main.js": "/static/static/js/main.b47da0a4.js",
55
"static/media/roboto-all-500-normal.woff": "/static/static/media/roboto-all-500-normal.0ab669b7a0d19b178f57.woff",
66
"static/media/roboto-all-700-normal.woff": "/static/static/media/roboto-all-700-normal.a457fde362a540fcadff.woff",
77
"static/media/roboto-all-400-normal.woff": "/static/static/media/roboto-all-400-normal.c5d001fa922fa66a147f.woff",
@@ -28,10 +28,10 @@
2828
"static/media/roboto-greek-ext-700-normal.woff2": "/static/static/media/roboto-greek-ext-700-normal.bd9854c751441ccc1a70.woff2",
2929
"index.html": "/static/index.html",
3030
"main.9c7a48b7.css.map": "/static/static/css/main.9c7a48b7.css.map",
31-
"main.e4e759f0.js.map": "/static/static/js/main.e4e759f0.js.map"
31+
"main.b47da0a4.js.map": "/static/static/js/main.b47da0a4.js.map"
3232
},
3333
"entrypoints": [
3434
"static/css/main.9c7a48b7.css",
35-
"static/js/main.e4e759f0.js"
35+
"static/js/main.b47da0a4.js"
3636
]
3737
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/static/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Pioreactor"/><link rel="apple-touch-icon" href="/static/logo192.png"/><link rel="manifest" href="/static/manifest.json"/><script defer="defer" src="/static/static/js/main.e4e759f0.js"></script><link href="/static/static/css/main.9c7a48b7.css" rel="stylesheet"></head><body><div id="root"></div></body></html>
1+
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/static/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Pioreactor"/><link rel="apple-touch-icon" href="/static/logo192.png"/><link rel="manifest" href="/static/manifest.json"/><script defer="defer" src="/static/static/js/main.b47da0a4.js"></script><link href="/static/static/css/main.9c7a48b7.css" rel="stylesheet"></head><body><div id="root"></div></body></html>

core/pioreactor/web/static/static/js/main.e4e759f0.js renamed to core/pioreactor/web/static/static/js/main.b47da0a4.js

Lines changed: 14 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/pioreactor/web/static/static/js/main.e4e759f0.js.LICENSE.txt renamed to core/pioreactor/web/static/static/js/main.b47da0a4.js.LICENSE.txt

File renamed without changes.

core/pioreactor/web/static/static/js/main.e4e759f0.js.map renamed to core/pioreactor/web/static/static/js/main.b47da0a4.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/tests/test_dosing_automation.py

Lines changed: 97 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,10 @@ def test_silent_automation() -> None:
177177
@pytest.mark.slow
178178
def test_turbidostat_automation() -> None:
179179
experiment = "test_turbidostat_automation"
180-
target_od = 1.0
180+
target_biomass = 1.0
181181
with Turbidostat(
182-
target_normalized_od=target_od,
182+
target_biomass=target_biomass,
183+
biomass_signal="normalized_od",
183184
duration=60,
184185
exchange_volume_ml=0.25,
185186
unit=unit,
@@ -298,13 +299,13 @@ def test_turbidostat_automation() -> None:
298299
assert algo.run() is None
299300

300301

301-
def test_cant_target_both_in_turbidostat() -> None:
302-
experiment = "test_cant_target_both_in_turbidostat"
302+
def test_rejects_invalid_biomass_signal_in_turbidostat() -> None:
303+
experiment = "test_rejects_invalid_biomass_signal_in_turbidostat"
303304

304305
with pytest.raises(ValueError):
305306
with Turbidostat(
306-
target_od=0.5,
307-
target_normalized_od=2.0,
307+
target_biomass=0.5,
308+
biomass_signal="garbage",
308309
duration=60,
309310
exchange_volume_ml=0.25,
310311
unit=unit,
@@ -314,42 +315,108 @@ def test_cant_target_both_in_turbidostat() -> None:
314315
pass
315316

316317

318+
def test_turbidostat_auto_prefers_active_od_fused_estimator(monkeypatch) -> None:
319+
experiment = "test_turbidostat_auto_prefers_active_od_fused_estimator"
320+
321+
monkeypatch.setattr(
322+
"pioreactor.automations.dosing.turbidostat.load_active_estimator",
323+
lambda device: object(),
324+
)
325+
monkeypatch.setattr(
326+
"pioreactor.automations.dosing.turbidostat.load_active_calibration",
327+
lambda device: object(),
328+
)
329+
330+
with Turbidostat(
331+
target_biomass=0.5,
332+
duration=60,
333+
exchange_volume_ml=0.25,
334+
unit=unit,
335+
experiment=experiment,
336+
skip_first_run=True,
337+
) as algo:
338+
assert algo.resolved_biomass_signal == "od_fused"
339+
340+
341+
def test_turbidostat_auto_uses_active_od_calibration_without_estimator(monkeypatch) -> None:
342+
experiment = "test_turbidostat_auto_uses_active_od_calibration_without_estimator"
343+
344+
monkeypatch.setattr(
345+
"pioreactor.automations.dosing.turbidostat.load_active_estimator",
346+
lambda device: None,
347+
)
348+
monkeypatch.setattr(
349+
"pioreactor.automations.dosing.turbidostat.load_active_calibration",
350+
lambda device: object(),
351+
)
352+
353+
with Turbidostat(
354+
target_biomass=0.5,
355+
duration=60,
356+
exchange_volume_ml=0.25,
357+
unit=unit,
358+
experiment=experiment,
359+
skip_first_run=True,
360+
) as algo:
361+
assert algo.resolved_biomass_signal == "od"
362+
363+
364+
def test_turbidostat_auto_falls_back_to_normalized_od_without_active_models(monkeypatch) -> None:
365+
experiment = "test_turbidostat_auto_falls_back_to_normalized_od_without_active_models"
366+
367+
monkeypatch.setattr(
368+
"pioreactor.automations.dosing.turbidostat.load_active_estimator",
369+
lambda device: None,
370+
)
371+
monkeypatch.setattr(
372+
"pioreactor.automations.dosing.turbidostat.load_active_calibration",
373+
lambda device: None,
374+
)
375+
376+
with Turbidostat(
377+
target_biomass=0.5,
378+
duration=60,
379+
exchange_volume_ml=0.25,
380+
unit=unit,
381+
experiment=experiment,
382+
skip_first_run=True,
383+
) as algo:
384+
assert algo.resolved_biomass_signal == "normalized_od"
385+
386+
317387
def test_cant_change_target_in_turbidostat() -> None:
318388
experiment = "test_cant_change_target_in_turbidostat"
319389

320390
with Turbidostat(
321-
target_od=0.5,
391+
target_biomass=0.5,
322392
duration=60,
323393
exchange_volume_ml=0.25,
324394
unit=unit,
325395
experiment=experiment,
326396
skip_first_run=True,
327397
) as algo:
328-
assert not algo.is_targeting_nOD
329-
assert algo.target_od == 0.5
330-
assert algo.target_normalized_od is None
398+
assert algo.target_biomass == 0.5
331399

332-
algo.set_target_normalized_od(2.0)
400+
algo.set_target_biomass(2.0)
333401

334-
assert not algo.is_targeting_nOD
335-
assert algo.target_od == 0.5
336-
assert algo.target_normalized_od is None
402+
assert algo.target_biomass == 2.0
337403

338404

339405
@pytest.mark.slow
340406
def test_turbidostat_targeting_od() -> None:
341407
experiment = "test_turbidostat_targeting_od"
342408

343-
target_od = 0.2
409+
target_biomass = 0.2
344410
with Turbidostat(
345-
target_od=target_od,
411+
target_biomass=target_biomass,
412+
biomass_signal="od",
346413
duration=60,
347414
exchange_volume_ml=0.25,
348415
unit=unit,
349416
experiment=experiment,
350417
skip_first_run=True,
351418
) as algo:
352-
assert algo.target_od == target_od
419+
assert algo.target_biomass == target_biomass
353420
pubsub.publish(
354421
f"pioreactor/{unit}/{experiment}/od_reading/ods",
355422
encode(
@@ -512,10 +579,11 @@ def test_changing_morbidostat_parameters_over_mqtt() -> None:
512579
def test_changing_turbidostat_params_over_mqtt() -> None:
513580
experiment = "test_changing_turbidostat_params_over_mqtt"
514581
og_volume = 0.5
515-
og_target_od = 1.0
582+
og_target_biomass = 1.0
516583
algo = Turbidostat(
517584
exchange_volume_ml=og_volume,
518-
target_normalized_od=og_target_od,
585+
target_biomass=og_target_biomass,
586+
biomass_signal="normalized_od",
519587
duration=60,
520588
unit=unit,
521589
experiment=experiment,
@@ -549,10 +617,13 @@ def test_changing_turbidostat_params_over_mqtt() -> None:
549617

550618
assert algo.exchange_volume_ml == 1.0
551619

552-
new_od = 1.5
553-
pubsub.publish(f"pioreactor/{unit}/{experiment}/dosing_automation/target_normalized_od/set", new_od)
620+
new_target_biomass = 1.5
621+
pubsub.publish(
622+
f"pioreactor/{unit}/{experiment}/dosing_automation/target_biomass/set",
623+
new_target_biomass,
624+
)
554625
pause()
555-
assert algo.target_normalized_od == new_od
626+
assert algo.target_biomass == new_target_biomass
556627
algo.clean_up()
557628

558629

@@ -672,7 +743,7 @@ def test_throughput_calculator_restart() -> None:
672743
with Turbidostat(
673744
unit=unit,
674745
experiment=experiment,
675-
target_normalized_od=1.0,
746+
target_biomass=1.0,
676747
duration=5 / 60,
677748
exchange_volume_ml=1.0,
678749
) as automation_job:
@@ -693,7 +764,7 @@ def test_throughput_calculator_manual_set() -> None:
693764
with Turbidostat(
694765
unit=unit,
695766
experiment=experiment,
696-
target_normalized_od=1.0,
767+
target_biomass=1.0,
697768
duration=5 / 60,
698769
exchange_volume_ml=1.0,
699770
) as automation_job:
@@ -960,7 +1031,7 @@ def test_disconnect_cleanly(fast_dosing_timers) -> None:
9601031
algo = Turbidostat(
9611032
unit=unit,
9621033
experiment=experiment,
963-
target_normalized_od=1.0,
1034+
target_biomass=1.0,
9641035
duration=50,
9651036
exchange_volume_ml=1.0,
9661037
)
@@ -1027,7 +1098,7 @@ def test_what_happens_when_no_od_data_is_coming_in() -> None:
10271098
)
10281099

10291100
with Turbidostat(
1030-
target_normalized_od=0.1, duration=40 / 60, exchange_volume_ml=0.25, unit=unit, experiment=experiment
1101+
target_biomass=0.1, duration=40 / 60, exchange_volume_ml=0.25, unit=unit, experiment=experiment
10311102
) as algo:
10321103
pause()
10331104
event = algo.run()

core/tests/test_experiment_profile_structs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ def test_complex2() -> None:
251251
options:
252252
automation_name: turbidostat
253253
volume: 1.0
254-
target_normalized_od: 5.0
254+
target_biomass: 5.0
255255
- type: stop
256256
hours_elapsed: 3.0
257257
"""

0 commit comments

Comments
 (0)