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 :py:class:`Transformer` s 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 :py:class:`CalibrationProcedure` interface contains the framework for defining and executing a procedure within the applicaiton, as a developer of a calibrator, you will implement the :py:meth:`Calibrator.create_calibration_procedure` method which is responsible for constructing a :py:class:`CalibrationProcedure` object and storing it within the procedure. A procedure is a sequence of :py:class:`CalibrationAction`, which themselves contain any required input data to get from the user, and contain an :py:meth:`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 :py:class:`CalibrationStateModel` object, 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 :py:meth:`CalibrationProcedure.get_actions` #. Dispatches a given action - calling :py:meth:`CalibrationProcedure.dispatch_action` #. Periodically saves the state (to disk), calling :py:meth:`CalibrationProcedure.save` #. optionally undoes the last action, calling :py:meth:`CalibrationProcedure.undo` #. Applies the procedure - calling :py:meth:`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 :py:class:`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 (:py:class:`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: .. code-block:: yaml <> 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: .. code-block:: yaml <> 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: .. code-block:: yaml config: output_transformers: '0': classinfo: TransformerClass config: param1: 0.5 param2: 0.1 '1': classinfo: TransformerClass config: param1: 1.0 param2: 2.0