Developing Calibrators
In this framework, a calibrator is attached to a hardware driver and server a dual purpose:
It provides a mechanism to perform a calibration procedure on the hardware, guiding the user through a step-by-step process, taking real-world and digital measurements as needed. The output of a procedure is a file, that is then read in by the calibrator to provide transformations.
It contains the
Transformers that a hardware within its read logic will use to transform the raw voltage readings to physical units (based on the data collected during the calibration procedure).
The reason for this dual purpose is that the data generated by the calibration procedure and the data required by the transformer for a fit are closely linked. The calibrator is singly responsible for how to call transformers with necessary data and parameters, and how to gather those data.
Procedures definition
A calibration procedure is the interface to the user for performing the
necessary steps and gathering the data needed to calibrate a hardware. The
CalibrationProcedure interface contains the framework for defining
and executing a procedure within the applicaiton, as a developer of a
calibrator, you will implement the
Calibrator.create_calibration_procedure() method which is responsible
for constructing a CalibrationProcedure object and storing it within
the procedure.
A procedure is a sequence of CalibrationAction, which themselves
contain any required input data to get from the user, and contain an
CalibrationAction.execute() method which is called during the procedure
upon user input. An example action (abbreviated):
class ReferenceValueAction(CalibrationAction):
# define this to require input for a given action
class FormModel(BaseModel):
temperature: float = Field(..., title="Temperature", description="Temperature in degrees Celsius")
def execute(self, state: CalibrationStateModel, payload: Optional[FormModel] = None):
# store measurements, read sensor data, do nothing etc.
state.measured[self.vial_idx]["reference"].append(payload.temperature)
return state
In the above, note:
A FormModel class is defined, this is what the framework uses to request data from the user (in the form of an HTML form, for example). A Post request to the endpoint to perform this action must contain the data in the form as per its definition, and this will be passed to the execute method as the payload.
The execute method is called when the user completes the aciton (e.g. by issuing a POST request to the endpoint). In this case the method stores the reference value within the state. In some cases, the action may not require anything to be done, for example when the action is instructing the user to prepare something in the physical environment. For any action, they will have to call it, which indicates their completion of the action.
The state parameter is a
CalibrationStateModelobject, which contains some information about the current state of the calibration procedure, in addition to the measured data and optionally any other state data necessary for the specific procedure (e.g. to be communicated to subsequent actions).
Given actions such as the above, a procedure could then be added to a calibrator in the following manner:
class MyCalibrator(Calibrator):
def create_calibration_procedure(self, selected_hardware: Hardware, resume: bool = False):
# enable resume
procedure_state = CalibrationStateModel.load(self.procedure_file) if resume else None
procedure = CalibrationProcedure(
state=procedure_state.model_dump() if procedure_state else None, hardware=selected_hardware
)
# add our specific actions
procedure.add_action(ReferenceValueAction())
procedure.add_action(AnotherAction())
# set the procedure on the calibrator
self.calibration_procedure = procedure
The above contains the necessary logic to resume a previously saved procedure from an existing procedure file (set on the calibrator) using the resume flag.
Procedure flow
After the procedure is created, a client (for example, the web UI) runs the procedure via the following steps:
Gets actions - calling
CalibrationProcedure.get_actions()Dispatches a given action - calling
CalibrationProcedure.dispatch_action()Periodically saves the state (to disk), calling
CalibrationProcedure.save()optionally undoes the last action, calling
CalibrationProcedure.undo()Applies the procedure - calling
CalibrationProcedure.apply(). This persists the completed procedure data to the calibrator in configuration for subsequent use.
As a developer of a procedure, all of the above will be handled by the framework and clients, but it is useful to understand the role of the procedure, actions and state in the overall flow.
Transformers
Transformers handle the work of converting a raw reading on a sensor or a physical input on an effector into the physical or raw counterparts. As with other parts of the framework, transformers are a configurable component on the calibrators to which they are attached, implementing the following interface:
class Transformer:
def convert_to(self, raw: Any) -> Any
def convert_from(self, real: Any) -> Any
The convert_from and convert_to methods are inverses of each other. The meaning of each depends on whether the transformer is an input or an output transformer, according to the following guidelines:
Sensors should use an output transformer and call its convert_to method to convert from the raw reading to the physical value to return to user.
Effectors should use an input transformer and call its convert_from method to convert from the physical value to the raw value to send to hardware.
The HardwareDriver class
contains a convenience method to call transformers from the configured
calibrator falling back as needed:
# transform sensor value for this vial to physical units
# (self is derivative HardwareDriver)
self._transform("output_transformer", "convert_to", raw, vial)
By default the transform function will fallback to a None value, but the caller of _transform can specify a fallback arg. In most cases a None usefully transmits information to the user that the hardware is not calibrated properly for the present conditions. A log error message will be emitted in such a case.
Transformer fitting
Transformers must implement a fit method, which is used to calculate parameters for the transformation function from input to output, following whatever method makes sense for the given transformer. It should return a Self.Config object which can be used to create a fitted transformer.
By default, the calibrator will attempt to initialize the transformers and fit them based on data collected in the procedure. To do so, a calibrator must implement the init_transformers method which uses calibrator-specific knowledge to formulate the fit call:
def init_transformers(self, calibration_data: CalibrationStateModel):
for vial, data in calibration_data.measured.items():
self.get_output_transformer(vial).refit(data["reference"], data["raw"])
in the above case, the specific knowledge encoded is that the procedure writes out two items to the state for each vial, “reference” and “raw”. Note that we call refit above, which under the hood calls fit on the transformer and creates a new transformer from the resultant fit configuration object.
The interface for calibrator also affords that the procedure or an external
utility can cache fitted transformers in the calibration data
(CalibrationStateModel) structure, which can be optionally consulted
to avoid a re-fit at initialization time. Setting the refit flag to False on
the calibrator configuration will instruct the calibrator to load from the
fitted_calibrator found in the calibration file:
<<on hardware>>
config:
calibrator:
classinfo: CalibratorClass
config:
refit: false
calibration_file: file_with_fitted_transformers.yaml
Setting transformers on calibrators
Transformer classes can be set on the config of a Calibrator in order to override the default. In most cases we want to do transformation on all vials with the same type of transformer and we want to fit based on data collected in a procedure. In this case, simply setting the transformer class as:
<<on hardware>>
config:
calibrator:
classinfo: CalibratorClass
config:
default_output_transformer:
classinfo: TransformerClass
config: {}
Note
There are no guarantees made that a configured transformer class is appropriate for the given calibrator/hardware. For example, if the hardware needs to convert from two raw values into its physical unit, both the calibrator and transformer configured on it should be those that handle such a situation. In cases where these are incompatible, the hardware will generally fallback to a null value (see above) and log an error message.
In some rare cases, we may want to set pre-fit transformers on the calibrator and bypass the procedure entirely. In this case, we can set the appropriate configuration on transformers, which will be initialized into objects upon startup:
config:
output_transformers:
'0':
classinfo: TransformerClass
config:
param1: 0.5
param2: 0.1
'1':
classinfo: TransformerClass
config:
param1: 1.0
param2: 2.0