Where's the right place to define a calibration type?

I’ve added a pH probe calibration, but when I want to call “load calibration” I get an error because the YAML decoder only recognises the subclasses hard-coded in AnyCalibration in structs. Is there a reason that calibration types outside of these might fail, and this error is correct? Or does this need changing to something like AnyCalibration = subclass_union(CalibrationBase) ?

>>> ph_cal = load_active_calibration("ph_probe")
Traceback (most recent call last):
  File "/usr/local/lib/python3.11/dist-packages/pioreactor/calibrations/__init__.py", line 133, in load_calibration
    data = yaml_decode(target_file.read_bytes(), type=structs.subclass_union(structs.CalibrationBase))
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/dist-packages/msgspec/yaml.py", line 186, in decode
    return _convert(
           ^^^^^^^^^
msgspec.ValidationError: Invalid value 'ph' - at `$.calibration_type`
print(structs.subclass_union(CalibrationBase))
typing.Union[pioreactor.structs.SimpleStirringCalibration, pioreactor.structs.SimplePeristalticPumpCalibration, pioreactor.structs.ODCalibration]

Hm, I don’t think this should happen. When you define your pH calibration, it should be a subclass of CalibrationBase, and hence subclass_union(CalibrationBase) should include it.

  1. Can you confirm your ph calibration is a direct subclass of CalibrationBase?
  2. I can see you’re in a python shell, has your ph calibration been “hydrated” - that is, it’s been defined in that shell either via a direct or indirect import, or defined in the shell?

Sorry the shell was just to illustrate. This is where I define the calibration

from pioreactor.calibrations import CalibrationProtocol
from pioreactor.structs import CalibrationBase
from pioreactor.utils.timing import current_utc_datetime
from pioreactor.whoami import get_unit_name
import typing as t

class PHCalibration(CalibrationBase, kw_only=True, tag="ph"):

    electrode_type: str
    x: str = "pH"
    y: str = "Voltage"

    def voltage_to_ph(self, voltage: float) -> float:
        return self.y_to_x(voltage)
    def ph_to_voltage(self, ph: float) -> float:
        return self.x_to_y(ph)

class BufferBasedPHProtocol(CalibrationProtocol):
    target_device = "ph_probe"
    protocol_name = "buffer_based"
    description = "Calibrate the pH sensor using buffer solutions"

    def run(self, target_device: str) -> PHCalibration:
        return run_ph_calibration()

def calculate_poly_curve_of_best_fit(x: list[float], y: list[float], degree: int) -> list[float]:
    import numpy as np

    try:
        coefs = np.polyfit(x, y, deg=degree)
    except Exception:
        print("Unable to fit.")
        coefs = np.zeros(degree)

    return coefs.tolist()

def run_ph_calibration() -> PHCalibration:


    unit_name = get_unit_name()

    # run the calibration

   recorded_xs = [4,7,10,4,7,10]
    recorded_ys = [1.51, 1.12, 0.79, 1.49, 1.12, 0.79]

    curve_data = calculate_poly_curve_of_best_fit(recorded_xs, recorded_ys,  1)

    return PHCalibration(
        calibration_name=f"pH-{current_utc_datetime().strftime('%Y-%m-%d')}",
        created_at=current_utc_datetime(),
        curve_data_ = curve_data, # ax1, intercept
        curve_type="poly",
        x="pH",
        y="Voltage",
        recorded_data={"x":recorded_xs, "y": recorded_ys},
        calibrated_on_pioreactor_unit = unit_name,
        electrode_type="Vernier-FPH"
    )

Then this is how I’ve managed to get my script to load the calibration value (vernier-FPH-BTA/vernier_fph_bta/vernier_fph_bta.py at main · Change-Bio/vernier-FPH-BTA · GitHub)

Significant code for this example is:

from pioreactor.plugin_management import get_plugins
from pioreactor.calibrations import load_active_calibration

ph_calibration = get_plugins()["ph_calibration"].module.PHCalibration # we need to create this subclass here in order for load_active_calibration to work
self.ph_cal = load_active_calibration("ph_probe")

It’s all still pretty hacky but it’s working for now, I just hardcode the calibration values at the moment but will integrate a CLI borrowing from the OD calibration script at some point

So the PHCalibration isn’t seen until the plugin is loaded, which occurs when pioreactor.plugin_management.get_plugins runs. This is why your code only works after you run that (I don’t think you need the module bits after).

Now, get_plugins implicity runs when you invoke pioreactor with pio - are you doing that? OTOH, get_plugins doesn’t run in the shell unless you explicitly run it.

Are you running ReadPh via pio run vernier_fph_bta?

Oh! Also, plugins run in lexographic order of script name, so make sure PHCalibration is defined in a python file prior to ReadPh. You can use p01_x.py, p02_y.py file names to achieve this (as an example)

Thanks for the help! The reactor is running now so will leave it as it is but I can explore further next week. The Vernier plugin with ReadPh is installed through pip and run through the UI, guess this runs pio run in the background? Then PH calibration I run using pio calibrations run --device ph_probe.

The goal is to run pio calibrations run --device ph_probe before a run and then use the UI to start reading pH (it just reads the voltage from MQTT, converts using the calibration, and then republishes again)