Skip to content

Commit e9e6a7f

Browse files
introducing splines to calibrations
1 parent 348716a commit e9e6a7f

24 files changed

+1031
-155
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
- Self-test results now surface per-check pass/fail status in the Inventory page and support retrying failed checks.
2020
- Removed redundant `from __future__ import annotations` usage now that we run on Python 3.13.
2121
- When a Pioreactor model is changed, a (non-blocking) hardware check is performed.
22+
- You can now restart the web server (lighttpd), and the background task queue, Huey, from the UI. Go to Leader -> "Long-running jobs", and see the "web server and queue" line.
23+
- Added spline curve support for calibrations, including OD standards sessions and calibration charts.
24+
- Added polynomial/spline curve utilities and JS curve helpers to keep UI plots in sync with backend curve types.
25+
- `pio calibrations analyze` now supports `--fit poly|spline` (default poly).
2226

2327
#### Breaking changes
2428
- Moved Self-test to the Inventory page. Pioreactors no longer need to be assigned to an experiment to run self-test.
@@ -36,6 +40,7 @@
3640
- Fix floating point error at the boundary of OD calibrations.
3741
- Fix runtime forward-reference errors in type annotations after dropping `__future__` imports.
3842
- Fix timeouts being too short on some UI export operations
43+
- Re-save calibration files on `pio calibrations analyze` confirmation even when the curve is unchanged.
3944

4045
### 25.12.10
4146

core/pioreactor/calibrations/protocols/od_reference_standard.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,8 +256,25 @@ def get_reference_standard_step(
256256
return get_session_step(_REFERENCE_STANDARD_STEPS, session, executor)
257257

258258

259+
def get_valid_od_devices_for_this_unit() -> list[str]:
260+
261+
pd_channels = config["od_config.photodiode_channel"]
262+
valid_devices: list[pt.ODCalibrationDevices] = []
263+
264+
for _, angle in pd_channels.items():
265+
if angle in (None, "", REF_keyword):
266+
continue
267+
device = f"od{angle}"
268+
if device in pt.OD_DEVICES and device not in valid_devices:
269+
valid_devices.append(cast(pt.ODCalibrationDevices, device))
270+
271+
if "od90" in valid_devices and "od45" in valid_devices and "od135" in valid_devices:
272+
valid_devices.append("od")
273+
return valid_devices
274+
275+
259276
class ODReferenceStandardProtocol(CalibrationProtocol[pt.ODCalibrationDevices]):
260-
target_device = pt.OD_DEVICES
277+
target_device = get_valid_od_devices_for_this_unit()
261278
protocol_name = "od_reference_standard"
262279
title = "Optics calibration jig"
263280
description = (

core/pioreactor/calibrations/protocols/od_single_vial.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from math import log2
66
from time import sleep
77
from typing import cast
8+
from typing import TYPE_CHECKING
89

910
import click
1011
from click import clear
@@ -18,8 +19,6 @@
1819
from pioreactor.background_jobs.od_reading import average_over_od_readings
1920
from pioreactor.background_jobs.od_reading import REF_keyword
2021
from pioreactor.background_jobs.od_reading import start_od_reading
21-
from pioreactor.background_jobs.stirring import start_stirring as stirring
22-
from pioreactor.background_jobs.stirring import Stirrer
2322
from pioreactor.calibrations import utils
2423
from pioreactor.calibrations.cli_helpers import action_block
2524
from pioreactor.calibrations.cli_helpers import green
@@ -37,6 +36,9 @@
3736
from pioreactor.whoami import get_unit_name
3837
from pioreactor.whoami import is_testing_env
3938

39+
if TYPE_CHECKING:
40+
from pioreactor.background_jobs.stirring import Stirrer
41+
4042

4143
def introduction() -> None:
4244
import logging
@@ -204,6 +206,8 @@ def setup_HDC_instructions() -> None:
204206

205207

206208
def start_stirring():
209+
from pioreactor.background_jobs.stirring import start_stirring as stirring
210+
207211
while not confirm(green("Ready to start stirring?"), default=True, abort=True, prompt_suffix=": "):
208212
pass
209213

@@ -219,7 +223,7 @@ def start_stirring():
219223

220224

221225
def start_recording_and_diluting(
222-
st: Stirrer,
226+
st: "Stirrer",
223227
initial_od600: pt.OD,
224228
minimum_od600: pt.OD,
225229
dilution_amount: float,

core/pioreactor/calibrations/protocols/od_standards.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
from pioreactor.background_jobs.od_reading import average_over_od_readings
2121
from pioreactor.background_jobs.od_reading import REF_keyword
2222
from pioreactor.background_jobs.od_reading import start_od_reading
23-
from pioreactor.background_jobs.stirring import start_stirring as stirring
2423
from pioreactor.calibrations import list_of_calibrations_by_device
2524
from pioreactor.calibrations import utils
2625
from pioreactor.calibrations.registry import CalibrationProtocol
@@ -137,6 +136,8 @@ def _measure_standard(
137136
rpm: float,
138137
channel_angle_map: dict[pt.PdChannel, pt.PdAngle],
139138
) -> dict[pt.PdChannel, pt.Voltage]:
139+
from pioreactor.background_jobs.stirring import start_stirring as stirring
140+
140141
with stirring(
141142
target_rpm=rpm,
142143
unit=get_unit_name(),
@@ -180,11 +181,17 @@ def _devices_for_angles(channel_angle_map: dict[pt.PdChannel, pt.PdAngle]) -> li
180181
def _calculate_curve_data(
181182
od600_values: list[float],
182183
voltages: list[float],
183-
) -> list[float]:
184-
degree = min(3, max(1, len(od600_values) - 1))
184+
) -> tuple[str, list]:
185185
weights = [1.0] * len(voltages)
186186
weights[0] = len(voltages) / 2
187-
return utils.calculate_poly_curve_of_best_fit(od600_values, voltages, degree, weights)
187+
if len(od600_values) >= 3:
188+
from pioreactor.utils.splines import spline_fit
189+
190+
knots = min(4, len(od600_values))
191+
return "spline", spline_fit(od600_values, voltages, knots=knots, weights=weights)
192+
193+
degree = min(3, max(1, len(od600_values) - 1))
194+
return "poly", utils.calculate_poly_curve_of_best_fit(od600_values, voltages, degree, weights)
188195

189196

190197
def _build_standards_chart_metadata(
@@ -204,9 +211,10 @@ def _build_standards_chart_metadata(
204211
points = [{"x": float(od600_values[i]), "y": float(voltages[i])} for i in range(count)]
205212
curve = None
206213
if count > 1:
214+
curve_type, curve_data = _calculate_curve_data(od600_values[:count], voltages[:count])
207215
curve = {
208-
"type": "poly",
209-
"coefficients": _calculate_curve_data(od600_values[:count], voltages[:count]),
216+
"type": curve_type,
217+
"coefficients": curve_data,
210218
}
211219
series.append(
212220
{
@@ -445,7 +453,7 @@ class AnotherStandard(SessionStep):
445453
def render(self, ctx: SessionContext) -> CalibrationStep:
446454
step = steps.form(
447455
"Next standard",
448-
"Record another standard or redo the last measurement?",
456+
"Record another standard, move on to the blank, or redo the last measurement?",
449457
[
450458
fields.choice(
451459
"next_action",
@@ -544,10 +552,10 @@ def advance(self, ctx: SessionContext) -> SessionStep | None:
544552
for pd_channel, angle in sorted(channel_angle_map.items(), key=lambda item: int(item[0])):
545553
voltages_list = ctx.data["voltages_by_channel"][pd_channel]
546554
od600_values = ctx.data["od600_values"]
547-
curve_data_ = _calculate_curve_data(od600_values, voltages_list)
555+
curve_type, curve_data_ = _calculate_curve_data(od600_values, voltages_list)
548556
cal = to_struct(
549557
curve_data_,
550-
"poly",
558+
curve_type,
551559
voltages_list,
552560
od600_values,
553561
angle,
@@ -590,8 +598,22 @@ def get_standards_step(
590598
return get_session_step(_OD_STANDARDS_STEPS, session, executor)
591599

592600

601+
def get_valid_od_devices_for_this_unit() -> list[str]:
602+
pd_channels = config["od_config.photodiode_channel"]
603+
valid_devices: list[pt.ODCalibrationDevices] = []
604+
605+
for _, angle in pd_channels.items():
606+
if angle in (None, "", REF_keyword):
607+
continue
608+
device = f"od{angle}"
609+
if device in pt.OD_DEVICES and device not in valid_devices:
610+
valid_devices.append(cast(pt.ODCalibrationDevices, device))
611+
612+
return valid_devices
613+
614+
593615
class StandardsODProtocol(CalibrationProtocol[pt.ODCalibrationDevices]):
594-
target_device = pt.OD_DEVICES
616+
target_device = get_valid_od_devices_for_this_unit()
595617
protocol_name = "standards"
596618
title = "OD standards calibration"
597619
description = "Calibrate OD channels using a series of OD600 standards and a blank."

core/pioreactor/calibrations/protocols/stirring_dc_based.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,14 +138,17 @@ def _build_stirring_calibration_from_measurements(
138138
logger.warning("Something went wrong - detected negative correlation between RPM and stirring.")
139139
raise ValueError("Negative correlation between RPM and stirring.")
140140

141+
from pioreactor.utils.splines import spline_fit
142+
143+
knots = min(3, len(dcs))
141144
return SimpleStirringCalibration(
142145
pwm_hz=config.getfloat("stirring.config", "pwm_hz"),
143146
voltage=voltage,
144147
calibration_name=f"stirring-calibration-{current_utc_datetime().strftime('%Y-%m-%d_%H-%M')}",
145148
calibrated_on_pioreactor_unit=unit,
146149
created_at=current_utc_datetime(),
147-
curve_data_=[alpha, beta],
148-
curve_type="poly",
150+
curve_data_=spline_fit(dcs, rpms, knots=knots),
151+
curve_type="spline",
149152
recorded_data={"x": dcs, "y": rpms},
150153
)
151154

core/pioreactor/calibrations/session_flow.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,7 @@ def _render_chart_for_cli(chart: dict[str, object]) -> None:
449449
if isinstance(curve, dict):
450450
curve_type = curve.get("type")
451451
coeffs = curve.get("coefficients")
452-
if curve_type == "poly" and isinstance(coeffs, list):
452+
if curve_type in {"poly", "spline"} and isinstance(coeffs, list):
453453
curve_callable = curve_to_callable(curve_type, coeffs)
454454
plot_data(
455455
x_vals,

0 commit comments

Comments
 (0)