Skip to content

Commit 2377618

Browse files
generalize to allow multiple od cals
1 parent eacec95 commit 2377618

File tree

12 files changed

+407
-130
lines changed

12 files changed

+407
-130
lines changed

AGENTS.md

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,6 @@ make frontend-dev # Run React dev server on 127.0.0.1:3000
7171
pytest core/tests/test_cli.py
7272
```
7373

74-
* To run just the web backend tests, use
75-
76-
```bash
77-
pytest core/tests/web/
78-
```
79-
8074
---
8175

8276
## Logging

core/pioreactor/background_jobs/od_reading.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,10 @@ def hydate_models(self, calibration_data: structs.ODCalibration | None) -> None:
706706
return
707707

708708
channel = calibration_data.pd_channel
709+
710+
if channel in self.models:
711+
raise ValueError(f"Calibration model for channel {channel} already hydrated.")
712+
709713
cal_type = calibration_data.calibration_type
710714
calibration_name = calibration_data.calibration_name
711715

@@ -1337,7 +1341,7 @@ def start_od_reading(
13371341
fake_data: bool = False,
13381342
unit: pt.Unit | None = None,
13391343
experiment: pt.Experiment | None = None,
1340-
calibration: bool | structs.ODCalibration | None = True,
1344+
calibration: bool | structs.ODCalibration | list[structs.ODCalibration] | None = True,
13411345
ir_led_intensity: float | None = None,
13421346
) -> ODReader:
13431347
"""
@@ -1386,7 +1390,14 @@ def start_od_reading(
13861390
calibration_transformer: CalibrationTransformerProtocol
13871391
if calibration is True:
13881392
calibration_transformer = CachedCalibrationTransformer()
1389-
calibration_transformer.hydate_models(load_active_calibration("od"))
1393+
for channel, angle in channel_angle_map.items():
1394+
active_calibration = load_active_calibration(f"od{angle}") # type: ignore
1395+
if active_calibration is not None:
1396+
calibration_transformer.hydate_models(active_calibration)
1397+
elif isinstance(calibration, list):
1398+
calibration_transformer = CachedCalibrationTransformer()
1399+
for calibration_item in calibration:
1400+
calibration_transformer.hydate_models(calibration_item)
13901401
elif isinstance(calibration, structs.CalibrationBase):
13911402
calibration_transformer = CachedCalibrationTransformer()
13921403
calibration_transformer.hydate_models(calibration)

core/pioreactor/calibrations/__init__.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
calibration_protocols: dict[Device, dict[ProtocolName, Type[CalibrationProtocol]]] = defaultdict(dict)
3030

3131

32+
OD_DEVICES = ["od", "od45", "od90", "od135"]
33+
34+
3235
class CalibrationProtocol:
3336
protocol_name: ProtocolName
3437
target_device: Device | list[Device]
@@ -44,30 +47,34 @@ def __init_subclass__(cls, **kwargs):
4447
else:
4548
raise ValueError("target_device must be a string or a list of strings")
4649

47-
def run(self, *args, **kwargs) -> structs.CalibrationBase:
50+
def run(
51+
self, target_device: str, *args, **kwargs
52+
) -> structs.CalibrationBase | list[structs.CalibrationBase]:
4853
raise NotImplementedError("Subclasses must implement this method.")
4954

5055

5156
class SingleVialODProtocol(CalibrationProtocol):
52-
target_device = "od"
57+
target_device = OD_DEVICES
5358
protocol_name = "single_vial"
5459
description = "Calibrate OD using a single vial"
5560

56-
def run(self, *args, **kwargs) -> structs.OD600Calibration:
61+
def run(self, target_device: str, **kwargs) -> structs.OD600Calibration:
5762
from pioreactor.calibrations.od_calibration_single_vial import run_od_calibration
5863

59-
return run_od_calibration()
64+
return run_od_calibration(target_device)
6065

6166

6267
class StandardsODProtocol(CalibrationProtocol):
63-
target_device = "od"
68+
target_device = OD_DEVICES
6469
protocol_name = "standards"
6570
description = "Calibrate OD using standards. Requires multiple vials"
6671

67-
def run(self, *args, **kwargs) -> structs.OD600Calibration:
72+
def run( # type: ignore
73+
self, target_device: str, *args, **kwargs
74+
) -> structs.OD600Calibration | list[structs.OD600Calibration]:
6875
from pioreactor.calibrations.od_calibration_using_standards import run_od_calibration
6976

70-
return run_od_calibration()
77+
return run_od_calibration(target_device)
7178

7279

7380
class DurationBasedPumpProtocol(CalibrationProtocol):
@@ -95,7 +102,7 @@ def run(
95102

96103

97104
@overload
98-
def load_active_calibration(device: Literal["od"]) -> structs.ODCalibration | None:
105+
def load_active_calibration(device: Literal["od", "od45", "od90", "od135"]) -> structs.ODCalibration | None:
99106
pass
100107

101108

core/pioreactor/calibrations/od_calibration_single_vial.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ def get_name_from_user() -> str:
8181
return name
8282

8383

84-
def get_metadata_from_user() -> tuple[pt.CalibratedOD, pt.CalibratedOD, pt.mL, pt.PdAngle, pt.PdChannel]:
84+
def get_metadata_from_user(
85+
target_device: str,
86+
) -> tuple[pt.CalibratedOD, pt.CalibratedOD, pt.mL, pt.PdAngle, pt.PdChannel]:
8587
if config.get("od_reading.config", "ir_led_intensity") == "auto":
8688
echo(
8789
red(
@@ -131,9 +133,9 @@ def get_metadata_from_user() -> tuple[pt.CalibratedOD, pt.CalibratedOD, pt.mL, p
131133
confirm(green("Continue?"), abort=True, default=True, prompt_suffix=": ")
132134

133135
pd_channels = config["od_config.photodiode_channel"]
134-
ref_channel = next((k for k, v in pd_channels.items() if v == REF_keyword), None)
136+
ref_channels = [k for k, v in pd_channels.items() if v == REF_keyword]
135137

136-
if ref_channel is None:
138+
if not ref_channels:
137139
echo(
138140
red(
139141
"REF required for OD calibration. Set an input to REF in [od_config.photodiode_channel] in your config."
@@ -142,7 +144,41 @@ def get_metadata_from_user() -> tuple[pt.CalibratedOD, pt.CalibratedOD, pt.mL, p
142144
raise click.Abort()
143145
# technically it's not required? we just need a specific PD channel to calibrate from.
144146

145-
pd_channel = cast(pt.PdChannel, "1" if ref_channel == "2" else "2") # TODO: fix for dr
147+
channel_angle_map: dict[pt.PdChannel, pt.PdAngle] = {}
148+
for channel, angle in pd_channels.items():
149+
if angle in (None, "", REF_keyword):
150+
continue
151+
channel_angle_map[cast(pt.PdChannel, channel)] = cast(pt.PdAngle, angle)
152+
153+
if not channel_angle_map:
154+
echo(red("Need at least one non-REF PD channel for this calibration."))
155+
raise click.Abort()
156+
157+
channel_choices = sorted(channel_angle_map.keys(), key=int)
158+
if target_device != "od":
159+
target_angle = target_device.removeprefix("od")
160+
channel_choices = [
161+
channel for channel in channel_choices if channel_angle_map[channel] == target_angle
162+
]
163+
if not channel_choices:
164+
echo(
165+
red(
166+
f"No channels configured for angle {target_angle}°. Check [od_config.photodiode_channel]."
167+
)
168+
)
169+
raise click.Abort()
170+
171+
if len(channel_choices) == 1:
172+
pd_channel = channel_choices[0]
173+
else:
174+
pd_channel = cast(
175+
pt.PdChannel,
176+
prompt(
177+
green("Select the PD channel to calibrate"),
178+
type=click.Choice(channel_choices),
179+
prompt_suffix=": ",
180+
),
181+
)
146182

147183
confirm(
148184
green(
@@ -385,7 +421,7 @@ def to_struct(
385421
return data_blob
386422

387423

388-
def run_od_calibration() -> structs.OD600Calibration:
424+
def run_od_calibration(target_device: str) -> structs.OD600Calibration:
389425
unit = get_unit_name()
390426
experiment = get_testing_experiment_name()
391427
curve_data_: list[float] = []
@@ -405,7 +441,7 @@ def run_od_calibration() -> structs.OD600Calibration:
405441
dilution_amount,
406442
angle,
407443
pd_channel,
408-
) = get_metadata_from_user()
444+
) = get_metadata_from_user(target_device)
409445
setup_HDC_instructions()
410446

411447
with start_stirring() as st:

0 commit comments

Comments
 (0)