import datetime
from abc import ABC, abstractmethod
from copy import copy
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List
from pydantic import Field, PastDatetime
from evolver.base import (
BaseConfig,
BaseInterface,
ConfigDescriptor,
CreatedTimestampField,
ExpireField,
TimeStamp,
_BaseConfig,
)
from evolver.settings import settings
if TYPE_CHECKING:
pass
class Status(TimeStamp):
delta: datetime.timedelta | None = Field(None, description="The time delta between now and `timestamp`.")
ok: bool | None = Field(None, description="The associated calibration config is stale when False and ok when True.")
def model_post_init(self, __context: Any) -> None:
# Compute status age.
if self.delta is None:
self.delta = datetime.datetime.now() - self.created
# Is status OK?
if self.expire is None:
self.ok = True
elif self.ok is None:
self.ok = self.delta <= self.expire
def root_calibrator_file_storage_path():
"""Defer this such that it can be monkeypatched during tests."""
return settings.ROOT_CALIBRATOR_FILE_STORAGE_PATH
class CalibrationStateModel(Transformer.Config):
"""
Model to represent the state of a calibration procedure. All procedures record their completed actions, and history of actions in this model.
The data collected by the calibration procedure (i.e. the data the actions have gathered, that's used as input to the Hardware.input/outputTransformer methods) is also stored here.
Attributes:
started (bool): A flag to indicate if the calibration procedure has been initialized, used by the front end to determine procedure controls to display.
completed_actions (List[str]): A list of actions that have been completed during the calibration procedure.
history: A list of previous states of the calibration procedure. Used to undo actions.
measured (Dict[Any, Any]): A dictionary of data collected by the calibration procedure.
This data is used by the Transformer class to fit a model to the data.
For example, a temperature calibrator might collect raw and reference temperature data for each vial.
"""
class Config:
extra = "allow"
completed_actions: List[str] = Field(default_factory=list)
history: List["CalibrationStateModel"] = Field(default_factory=list)
started: bool = False
measured: Dict[Any, Any] = {}
fitted_calibrator: ConfigDescriptor | None = None
[docs]
class Calibrator(BaseInterface):
"""Base Interface class for all calibration implementations.
A modular layer for encapsulating the calibration procedure and data transformations.
"""
class Config(Transformer.Config):
input_transformer: Transformer | None = None
output_transformer: Transformer | None = None
no_refit: bool = Field(False, description="If True, use only cached fitted transfomers from calibration_file")
calibration_file: str | None = Field(
None,
description="Completed calibration file (created when a procedure_file is complete, using the 'apply' button). Contents of this file is used as input values for transformation",
)
procedure_file: str | None = Field(
None, description="Working calibration file for currently active (or next) procedure"
)
class Status(_BaseConfig):
input_transformer: Status | None = None
output_transformer: Status | None = None
ok: bool | None = None
def model_post_init(self, __context: Any) -> None:
if self.ok is None:
# The following logic accounts for when transformers are None.
if self.input_transformer and self.output_transformer:
self.ok = self.input_transformer.ok and self.output_transformer.ok
elif self.input_transformer:
self.ok = self.input_transformer.ok
elif self.output_transformer:
self.ok = self.output_transformer.ok
[docs]
def __init__(self, *args, calibration_file=None, procedure_file=None, **kwargs):
super().__init__(*args, calibration_file=calibration_file, procedure_file=procedure_file, **kwargs)
if procedure_file is not None and procedure_file == calibration_file:
raise ValueError("to prevent corruption procedure_file must not be set to the same as calibration_file")
if calibration_file:
self.calibration_file = calibration_file
self.load_calibration_file(calibration_file)
else:
self.calibration_data = CalibrationStateModel()
@property
def status(self) -> Status:
return self.Status(
input_transformer=self.input_transformer.status if self.input_transformer else None,
output_transformer=self.output_transformer.status if self.output_transformer else None,
)
def load_calibration_file(self, calibration_file: str | None = None):
if calibration_file is not None:
self.load_calibration(CalibrationStateModel.load(calibration_file))
else:
raise ValueError("no calibration file provided")
def load_calibration(self, calibration_data: CalibrationStateModel):
self.calibration_data = calibration_data
# If calibration data is provided, there are two choices that make
# sense: either we want to refit from measured data using the configured
# transformer classes, or we use the cached fitted ones. No measurements
# means we must use the cached fitted ones.
if self.no_refit or not self.calibration_data.measured:
if self.calibration_data.fitted_calibrator is None:
raise ValueError("Calibration data must have a fitted calibrator in case of no_refit or no measured")
fitted_calibrator = self.calibration_data.fitted_calibrator.create()
self.input_transformer = fitted_calibrator.input_transformer
self.output_transformer = fitted_calibrator.output_transformer
else:
self.init_transformers(calibration_data)
[docs]
@abstractmethod
def create_calibration_procedure(self, selected_hardware, resume, *args, **kwargs):
"""This creates the calibration procedure, which is composed of a sequence of actions."""
pass
def dispatch(self, action):
"""Delegate to the calibration procedure"""
if self.calibration_procedure is None:
raise ValueError("Calibration procedure is not initialized.")
return self.calibration_procedure.dispatch(action)
class IndependentVialBasedCalibrator(Calibrator, ABC):
class Config(Calibrator.Config):
"""Specify transformers for each vial independently. Whilst they may all use the same transformer class, each
vial will mostly likely have different transformer config parameters and thus require their own transformer
instance.
"""
vials: list = Field(
list(range(settings.DEFAULT_NUMBER_OF_VIALS_PER_BOX)),
description="The vials that this calibrator is for.",
)
default_input_transformer: Transformer | None = None
default_output_transformer: Transformer | None = None
input_transformer: dict[int, Transformer | None] | None = None
output_transformer: dict[int, Transformer | None] | None = None
def get_input_transformer(self, vial):
"""Get the input transformer for a given vial."""
self.input_transformer = {} if self.input_transformer is None else self.input_transformer
return self.input_transformer.setdefault(int(vial), copy(self.default_input_transformer))
def get_output_transformer(self, vial):
"""Get the output transformer for a given vial."""
self.output_transformer = {} if self.output_transformer is None else self.output_transformer
return self.output_transformer.setdefault(int(vial), copy(self.default_output_transformer))