Skip to content

Commit 2a4bdcf

Browse files
akima
1 parent 2f3b56e commit 2a4bdcf

File tree

12 files changed

+346
-48
lines changed

12 files changed

+346
-48
lines changed

core/pioreactor/calibrations/protocols/od_fusion_offset.py

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -102,25 +102,28 @@ def _load_estimator_for_worker(worker: str, estimator_name: str) -> structs.ODFu
102102
return json_decode(json_encode(payload), type=structs.ODFusionEstimator)
103103

104104

105-
def _affine_transform_spline_fit_data(
106-
spline_data: structs.SplineFitData,
105+
def _affine_transform_cubic_fit_data(
106+
curve_data: structs.AkimaFitData | structs.SplineFitData,
107107
scale_logc: float,
108108
offset_logc: float,
109-
) -> structs.SplineFitData:
109+
) -> structs.AkimaFitData | structs.SplineFitData:
110110
if scale_logc <= 0:
111-
raise ValueError("Scale must be positive to transform spline data.")
112-
return structs.SplineFitData(
113-
knots=[float(scale_logc * knot + offset_logc) for knot in spline_data.knots],
114-
coefficients=[
115-
[
116-
float(coeffs[0]),
117-
float(coeffs[1] / scale_logc),
118-
float(coeffs[2] / scale_logc**2),
119-
float(coeffs[3] / scale_logc**3),
120-
]
121-
for coeffs in spline_data.coefficients
122-
],
123-
)
111+
raise ValueError("Scale must be positive to transform curve data.")
112+
113+
knots = [float(scale_logc * knot + offset_logc) for knot in curve_data.knots]
114+
coefficients = [
115+
[
116+
float(coeffs[0]),
117+
float(coeffs[1] / scale_logc),
118+
float(coeffs[2] / scale_logc**2),
119+
float(coeffs[3] / scale_logc**3),
120+
]
121+
for coeffs in curve_data.coefficients
122+
]
123+
124+
if isinstance(curve_data, structs.AkimaFitData):
125+
return structs.AkimaFitData(knots=knots, coefficients=coefficients)
126+
return structs.SplineFitData(knots=knots, coefficients=coefficients)
124127

125128

126129
def _apply_logc_affine_to_estimator(
@@ -135,11 +138,11 @@ def _apply_logc_affine_to_estimator(
135138
source_estimator_name: str,
136139
) -> structs.ODFusionEstimator:
137140
mu_splines = {
138-
angle: _affine_transform_spline_fit_data(estimator.mu_splines[angle], scale_logc, offset_logc)
141+
angle: _affine_transform_cubic_fit_data(estimator.mu_splines[angle], scale_logc, offset_logc)
139142
for angle in estimator.angles
140143
}
141144
sigma_splines_log = {
142-
angle: _affine_transform_spline_fit_data(estimator.sigma_splines_log[angle], scale_logc, offset_logc)
145+
angle: _affine_transform_cubic_fit_data(estimator.sigma_splines_log[angle], scale_logc, offset_logc)
143146
for angle in estimator.angles
144147
}
145148

core/pioreactor/calibrations/protocols/od_fusion_standards.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -425,8 +425,10 @@ def advance(self, ctx: SessionContext) -> SessionStep | None:
425425
recorded_data=fit.recorded_data,
426426
ir_led_intensity=float(config["od_reading.config"]["ir_led_intensity"]),
427427
angles=list(FUSION_ANGLES),
428-
mu_splines=fit.mu_splines,
429-
sigma_splines_log=fit.sigma_splines_log,
428+
mu_splines=cast(dict[pt.PdAngle, structs.AkimaFitData | structs.SplineFitData], fit.mu_splines),
429+
sigma_splines_log=cast(
430+
dict[pt.PdAngle, structs.AkimaFitData | structs.SplineFitData], fit.sigma_splines_log
431+
),
430432
min_logc=fit.min_logc,
431433
max_logc=fit.max_logc,
432434
sigma_floor=fit.sigma_floor,

core/pioreactor/calibrations/protocols/od_standards.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -171,11 +171,9 @@ def _calculate_curve_data(
171171
od600_values: list[float],
172172
voltages: list[float],
173173
) -> structs.CalibrationCurveData:
174-
weights = [1.0] * len(voltages)
175-
weights[0] = len(voltages) / 2
176-
from pioreactor.utils.splines import spline_fit
174+
from pioreactor.utils.akimas import akima_fit
177175

178-
return spline_fit(od600_values, voltages, knots="auto", weights=weights)
176+
return akima_fit(od600_values, voltages)
179177

180178

181179
def _build_standards_chart_metadata(

core/pioreactor/calibrations/protocols/stirring_dc_based.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,15 +113,15 @@ def _build_stirring_calibration_from_measurements(
113113
logger = create_logger("stirring_calibration", experiment="$experiment")
114114
logger.debug(f"rpm = {alpha:.2f} * dc% + {beta:.2f}")
115115

116-
from pioreactor.utils.splines import spline_fit
116+
from pioreactor.utils.akimas import akima_fit
117117

118118
return SimpleStirringCalibration(
119119
pwm_hz=config.getfloat("stirring.config", "pwm_hz"),
120120
voltage=voltage,
121121
calibration_name=f"stirring-calibration-{current_utc_datetime().strftime('%Y-%m-%d_%H-%M')}",
122122
calibrated_on_pioreactor_unit=unit,
123123
created_at=current_utc_datetime(),
124-
curve_data_=spline_fit(dcs, rpms, knots="auto"),
124+
curve_data_=akima_fit(dcs, rpms),
125125
recorded_data={"x": dcs, "y": rpms},
126126
)
127127

core/pioreactor/calibrations/utils.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ def curve_to_functional_form(curve_data: structs.CalibrationCurveData) -> str:
4444
elif curve_type == "spline":
4545
spline_data = cast(structs.SplineFitData, curve_data)
4646
return f"natural cubic spline with {len(spline_data.knots)} knots"
47+
elif curve_type == "akima":
48+
akima_data = cast(structs.AkimaFitData, curve_data)
49+
return f"Akima interpolator with {len(akima_data.knots)} knots"
4750
else:
4851
raise NotImplementedError()
4952

@@ -68,6 +71,16 @@ def curve_callable(x: float):
6871

6972
return curve_callable
7073

74+
elif curve_type == "akima":
75+
from pioreactor.utils.akimas import akima_eval
76+
77+
akima_data = cast(structs.AkimaFitData, curve_data)
78+
79+
def curve_callable(x: float):
80+
return akima_eval(akima_data, x)
81+
82+
return curve_callable
83+
7184
else:
7285
raise NotImplementedError()
7386

@@ -144,6 +157,8 @@ def crunch_data_and_confirm_with_user(
144157
candidate_curve = structs.PolyFitCoefficients(coefficients=[])
145158
elif fit == "spline":
146159
candidate_curve = structs.SplineFitData(knots=[], coefficients=[])
160+
elif fit == "akima":
161+
candidate_curve = structs.AkimaFitData(knots=[], coefficients=[])
147162
else:
148163
raise ValueError()
149164

@@ -169,8 +184,12 @@ def crunch_data_and_confirm_with_user(
169184
initial_knots = knots
170185

171186
candidate_curve = spline_fit(x, y, knots=knots, weights=weights)
187+
elif fit == "akima":
188+
from pioreactor.utils.akimas import akima_fit
189+
190+
candidate_curve = akima_fit(x, y)
172191
else:
173-
raise ValueError("only `poly` or `spline` supported")
192+
raise ValueError("only `poly`, `spline`, or `akima` supported")
174193

175194
curve_callable = curve_to_callable(candidate_curve)
176195
plot_data(
@@ -229,6 +248,8 @@ def _fit_prompt_choices(fit: str) -> list[str]:
229248
return ["y", "q", "d"]
230249
if fit == "spline":
231250
return ["y", "q", "k"]
251+
if fit == "akima":
252+
return ["y", "q"]
232253
return ["y", "q"]
233254

234255

@@ -239,6 +260,8 @@ def _fit_prompt_hint(fit: str, candidate_curve: structs.CalibrationCurveData) ->
239260
if fit == "spline":
240261
spline_data = cast(structs.SplineFitData, candidate_curve)
241262
return f"k: choose a new knot count (currently {len(spline_data.knots)})"
263+
if fit == "akima":
264+
return ""
242265
return ""
243266

244267

@@ -249,4 +272,7 @@ def _curve_data_is_empty(fit: str, curve_data: structs.CalibrationCurveData) ->
249272
if fit == "spline":
250273
spline_data = cast(structs.SplineFitData, curve_data)
251274
return len(spline_data.knots) == 0 or len(spline_data.coefficients) == 0
275+
if fit == "akima":
276+
akima_data = cast(structs.AkimaFitData, curve_data)
277+
return len(akima_data.knots) == 0 or len(akima_data.coefficients) == 0
252278
return True

core/pioreactor/cli/calibrations.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ def delete_calibration(device: str, calibration_name: str) -> None:
285285
"--fit",
286286
"fit",
287287
default="poly",
288-
type=click.Choice(["poly", "spline"]),
288+
type=click.Choice(["poly", "spline", "akima"]),
289289
show_default=True,
290290
help="Curve fit type to use when analyzing.",
291291
)

core/pioreactor/structs.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,16 @@ def type(self):
5959
return self.__struct_config__.tag
6060

6161

62-
type CalibrationCurveData = PolyFitCoefficients | SplineFitData
62+
class AkimaFitData(Struct, tag="akima"):
63+
knots: list[float]
64+
coefficients: list[list[float]]
65+
66+
@property
67+
def type(self):
68+
return self.__struct_config__.tag
69+
70+
71+
type CalibrationCurveData = PolyFitCoefficients | SplineFitData | AkimaFitData
6372

6473

6574
class AutomationSettings(JSONPrintedStruct):
@@ -272,6 +281,13 @@ def x_to_y(self, x: X) -> Y:
272281
if len(spline_data.knots) == 0 or len(spline_data.coefficients) == 0:
273282
raise exc.NoSolutionsFoundError(f"calibration {self}'s curve_data_ is empty")
274283
return round(spline_eval(spline_data, x), 10)
284+
if self.curve_data_.type == "akima":
285+
from pioreactor.utils.akimas import akima_eval
286+
287+
akima_data = t.cast(AkimaFitData, self.curve_data_)
288+
if len(akima_data.knots) == 0 or len(akima_data.coefficients) == 0:
289+
raise exc.NoSolutionsFoundError(f"calibration {self}'s curve_data_ is empty")
290+
return round(akima_eval(akima_data, x), 10)
275291

276292
raise NotImplementedError(f"Unsupported curve_type: {self.curve_data_.type}")
277293

@@ -295,6 +311,13 @@ def y_to_x(self, y: Y, enforce_bounds=False) -> X:
295311
if len(spline_data.knots) == 0 or len(spline_data.coefficients) == 0:
296312
raise exc.NoSolutionsFoundError(f"calibration {self}'s curve_data_ is empty")
297313
plausible_sols_ = spline_solve(spline_data, y)
314+
elif self.curve_data_.type == "akima":
315+
from pioreactor.utils.akimas import akima_solve
316+
317+
akima_data = t.cast(AkimaFitData, self.curve_data_)
318+
if len(akima_data.knots) == 0 or len(akima_data.coefficients) == 0:
319+
raise exc.NoSolutionsFoundError(f"calibration {self}'s curve_data_ is empty")
320+
plausible_sols_ = akima_solve(akima_data, y)
298321
else:
299322
raise NotImplementedError(f"Unsupported curve_type: {self.curve_data_.type}")
300323

@@ -428,8 +451,8 @@ class OD600Calibration(ODCalibration, kw_only=True, tag="od600"):
428451
class ODFusionEstimator(EstimatorBase, kw_only=True, tag="od_fused_estimator"):
429452
ir_led_intensity: float
430453
angles: list[pt.PdAngle]
431-
mu_splines: dict[pt.PdAngle, SplineFitData]
432-
sigma_splines_log: dict[pt.PdAngle, SplineFitData]
454+
mu_splines: dict[pt.PdAngle, AkimaFitData | SplineFitData]
455+
sigma_splines_log: dict[pt.PdAngle, AkimaFitData | SplineFitData]
433456
min_logc: float
434457
max_logc: float
435458
sigma_floor: float

0 commit comments

Comments
 (0)