Files
AareCommon/src/aarecommon/models/models.py
T
GotthardGandClaude Fable 5 064a1d5cab
CI / lint (pull_request) Successful in 23s
CI / test (3.12) (pull_request) Successful in 22s
CI / test (3.13) (pull_request) Successful in 23s
CI / test (3.11) (pull_request) Successful in 54s
CI / lint (push) Canceled after 11s
CI / test (3.11) (push) Canceled after 0s
CI / test (3.12) (push) Canceled after 0s
CI / test (3.13) (push) Canceled after 0s
Build and Publish / release (push) Successful in 12s
feat(models): fluorescence ingest contract shared with AareDB
FluorescenceScanIngestModel wraps the existing
FluorescenceSpectrumOutputModel with the sample it belongs to — the
payload for AareDB's POST /dispatcher/protected_router/fluorescence/
ingest. FluorescenceScanIngestResponseModel returns the stored scan id
plus the elements AareDB identified from the peaks, so the beamline GUI
can display what was detected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3xoBJBmqJJPLg475iK6cE
2026-08-17 15:36:53 +02:00

734 lines
24 KiB
Python

import re
from dataclasses import dataclass
from enum import Enum
from typing import Annotated, Literal
from jfjoch_client.models.scan_result import ScanResult
from pydantic import AfterValidator, AliasChoices, BaseModel, ConfigDict, Field, field_validator
from aarecommon.math.coordinate import Coordinate, positive_coords
from aarecommon.math.diffraction_geometry import DiffractionGeometry
from aarecommon.math.sample_geometry import SampleGeometryModel
from aarecommon.models.beamline import MXBeamline
from aarecommon.models.tell import TellStateModel
class StagePositionEnum(Enum):
MEASURE = 0
PARK = 1
DOWN = 2
UNKNOWN = 3
class TokenData(BaseModel):
sub: str # Username
pgroups: list[str]
session: int
staff: bool = False
class DewarAddress(BaseModel):
segment: Literal["A", "B", "C", "D", "E", "F", "X", "R"]
pos: Annotated[int, Field(ge=1, le=5)]
class SampleDewarAddress(BaseModel):
puck: DewarAddress
pin: Annotated[int, Field(ge=1, le=16)]
# From the database for puck loading
class PuckInfo(BaseModel):
db_id: int
puck_name: str
dewar_name: str
user: str = ""
location: DewarAddress | None = None
# From TELL to database after loading
class PuckLoadedInfo(BaseModel):
puck_name: str
location: DewarAddress
class DataCollectionParameters(BaseModel):
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
directory: str | None = None
oscillation: float | None = None # Only accept positive float
exposure: float | None = None # Only accept positive floats between 0 and 1
totalangle: int | None = Field( # was totalrange
default=None, validation_alias=AliasChoices("totalangle", "totalrange")
) # Only accept positive integers between 0 and 360
transmission: int | None = None # Only accept positive integers between 0 and 100
targetresolution: float | None = None # Only accept positive float
beamsize: str | None = None
aperture: int | None = None # Optional string field
datacollectiontype: str | None = (
None # Only accept "standard", other types might be added later
)
processingpipeline: str | None = "" # Only accept "gopy", "autoproc", "xia2dials"
spacegroupnumber: int | None = None # Only accept positive integers between 1 and 230
unitcell: str | None = Field( # was cellparameters
default=None, validation_alias=AliasChoices("unitcell", "cellparameters")
) # Must be a set of six positive floats or integers
rescutkey: str | None = None # Only accept "is" or "cchalf"
rescutvalue: float | None = None # Must be a positive float if rescutkey is provided
processingresolution: float | None = Field( # was userresolution
default=None, validation_alias=AliasChoices("processingresolution", "userresolution")
)
pdbid: str | None = "" # Accepts either the format of the protein data bank code or {provided}
autoprocfull: bool | None = None
procfull: bool | None = None
adpenabled: bool | None = None
noano: bool | None = None
ffcscampaign: bool | None = None
trustedhigh: float | None = None # Should be a float between 0 and 2.0
autoprocextraparams: str | None = None # Optional string field
chiphiangles: float | None = None # Optional float field between 0 and 30
dose: float | None = None # Optional float field
cloud: bool = True
pdbmodel: str | None = None
@field_validator("directory", mode="after")
@classmethod
def directory_characters(cls, v):
# Default directory value if empty
if not v: # Handles None or empty cases
default_value = "{date}/{prefix}"
return default_value
v = str(v).strip("/") # Ensure it's a string and no trailing slashes
v = v.replace(" ", "_")
# Validate directory pattern with macros and allowed characters
valid_macros = [
# Current macros
"{puck}",
"{position}",
"{prefix}",
"{date}",
"{run}",
"{beamline}",
# Legacy macros — accepted for back-compat, no longer documented
"{sgPuck}",
"{sgPosition}",
"{sgPrefix}",
"{sgPriority}",
"{protein}",
"{method}",
]
valid_macro_pattern = re.compile("|".join(re.escape(macro) for macro in valid_macros))
# Check if the value contains valid macros
allowed_chars_pattern = "[a-z0-9_.+-/]"
v_without_macros = valid_macro_pattern.sub("macro", v)
allowed_path_pattern = re.compile(
f"^(({allowed_chars_pattern}+|macro)*/*)*$", re.IGNORECASE
)
if not allowed_path_pattern.match(v_without_macros):
raise ValueError(f"'{v}' is not valid. Value must be a valid path or macro.")
return v
@field_validator("unitcell", mode="before")
@classmethod
def unitcell_format(cls, v):
if v:
tokens = v.replace(",", " ").split()
try:
values = [float(i) for i in tokens]
except ValueError:
raise ValueError(
f"'{v}' is not valid. "
"Value must be six positive floats or integers (space or comma separated)."
)
if len(values) != 6 or any(val <= 0 for val in values):
raise ValueError(
f"'{v}' is not valid. "
"Value must be six positive floats or integers (space or comma separated)."
)
return v
@field_validator("cloud", mode="before")
@classmethod
def coerce_cloud_default(cls, v):
if v in ("", None):
return True
if isinstance(v, bool):
return v
v_str = str(v).strip().lower()
if v_str in {"true", "yes", "1"}:
return True
if v_str in {"false", "no", "0"}:
return False
raise ValueError("cloud must be blank for default, or True/False")
# From database to TELL after loading
class SampleShortInfo(BaseModel):
db_id: int
puck_name: str
dewar_name: str
sample_name: str
run_number: int
aaredb_params: DataCollectionParameters | None = None
user: str = ""
pin: Annotated[int, Field(ge=1, le=16)]
location: DewarAddress | None = None
priority: float | None = 1.0
comment: str | None = None
mount_count: int = 0
rotation_count: int = 0
raster_count: int = 0
screening_count: int = 0
def tell_address(self) -> SampleDewarAddress:
return SampleDewarAddress(puck=self.location, pin=self.pin)
def loc_str(self) -> str:
if self.location is None:
return "-"
else:
return f"{self.location.segment}{self.location.pos}-{self.pin}"
def loc_str_sort(self) -> str:
if self.location is None:
return ""
else:
return f"{self.location.segment}{self.location.pos}-{self.pin:02d}"
@classmethod
def from_dict(cls, data: dict):
return cls(**data)
class SampleShortInfoList(BaseModel):
s: list[SampleShortInfo]
class BeamMarkCoeffModel(BaseModel):
"""
Model to calculate beam center for a given zoom level based on a quadratic approximation
For both x and y it contains tuple of coefficients a, b, c (a*zoom^2 + b*zoom + c)
Default is beam center in 1000,1000 at any zoom level
"""
coeff_x: tuple[float, float, float] = (0, 0, 1000.0)
coeff_y: tuple[float, float, float] = (0, 0, 1000.0)
def apply(self, zoom: float):
return Coordinate(
x=self.coeff_x[0] * zoom**2 + self.coeff_x[1] * zoom + self.coeff_x[2],
y=self.coeff_y[0] * zoom**2 + self.coeff_y[1] * zoom + self.coeff_y[2],
)
class FluorescenceSpectrumParameterModel(BaseModel):
erase: bool = True
acq_time_s: float
transmission: Annotated[float, Field(ge=0, le=1)] | None = None
class FluorescenceSpectrumOutputModel(BaseModel):
bkg: list[float] | None = None
spectrum: list[float]
energy_eV: list[float]
average_dead_time: Annotated[float, Field(ge=0, le=1)]
class FluorescenceElementModel(BaseModel):
"""One emission line AareDB identified in a stored fluorescence spectrum."""
symbol: str
line: str # "Ka" or "La"
energy_ev: float # tabulated line energy
peak_energy_ev: float # where the matching peak actually sits
counts: float # peak height
confidence: float # 1.0 alpha+beta matched, 0.5 alpha only
class FluorescenceScanIngestModel(BaseModel):
"""AareDB fluorescence ingest payload: the measured spectrum plus the
sample it belongs to (POST /dispatcher/protected_router/fluorescence/ingest)."""
sample_id: int
scan: FluorescenceSpectrumOutputModel
comment: str | None = None
class FluorescenceScanIngestResponseModel(BaseModel):
"""AareDB's ingest response — the beamline GUI shows the detected elements."""
id: int
sample_event_id: int
elements: list[FluorescenceElementModel] = []
class MLBoxType(Enum):
LOOP_ALL = 0
PIN = 1
CRYSTAL = 2
LOOP_FACE = 3
ICE = 4
NEEDLE = 5
class BoundingBoxModel(BaseModel):
top_x: float
top_y: float
bottom_x: float
bottom_y: float
class MLBoxModel(BaseModel):
cls: MLBoxType
box: BoundingBoxModel
conf: float
@staticmethod
def from_tuple(
klass: MLBoxType, box_tuple: tuple[float, float, float, float], conf: float
) -> "MLBoxModel":
x1, y1, x2, y2 = box_tuple
return MLBoxModel(
cls=klass,
box=BoundingBoxModel(
top_x=float(x1), top_y=float(y1), bottom_x=float(x2), bottom_y=float(y2)
),
conf=float(conf),
)
class MLOutputModel(BaseModel):
boxes: dict[str, MLBoxModel] = {}
@staticmethod
def get_class_str(klass: MLBoxType) -> str:
if klass == MLBoxType.LOOP_ALL:
return "Loop_all"
if klass == MLBoxType.PIN:
return "Pin"
if klass == MLBoxType.CRYSTAL:
return "Crystal"
if klass == MLBoxType.LOOP_FACE:
return "Loop_face"
if klass == MLBoxType.ICE:
return "Ice"
if klass == MLBoxType.NEEDLE:
return "Needle"
return "Unknown"
def _next_unique_key(self, base: str) -> str:
if base not in self.boxes:
return base
i = 2
while f"{base}_{i}" in self.boxes:
i += 1
return f"{base}_{i}"
def add_box(
self,
cls: MLBoxType,
box_tuple: tuple[float, float, float, float],
conf: float | None = None,
) -> str:
key_base = self.get_class_str(cls)
key = self._next_unique_key(key_base)
self.boxes[key] = MLBoxModel.from_tuple(cls, box_tuple, conf)
return key
def get_box_model(self, key: str) -> MLBoxModel | None:
return self.boxes.get(key)
def get_box_tuple(self, key: str) -> tuple[float, float, float, float] | None:
m = self.get_box_model(key)
if not m or not m.box:
return None
return (m.box.top_x, m.box.top_y, m.box.bottom_x, m.box.bottom_y)
def get_box_tuple_with_conf(self, key: str) -> tuple[float, float, float, float, float] | None:
m = self.get_box_model(key)
if not m or not m.box:
return None
conf = float(m.conf) if m.conf is not None else 0.0
return (m.box.top_x, m.box.top_y, m.box.bottom_x, m.box.bottom_y, conf)
def get_keys_for_class(self, cls: MLBoxType) -> list[str]:
base = self.get_class_str(cls)
return [
k
for k, v in self.boxes.items()
if v.cls == cls and (k == base or k.startswith(base + "_"))
]
def get_models_for_class(self, cls: MLBoxType) -> list[MLBoxModel]:
keys = self.get_keys_for_class(cls)
return [self.boxes[k] for k in keys]
def get_tuples_for_class(self, cls: MLBoxType) -> list[tuple[float, float, float, float]]:
out: list[tuple[float, float, float, float]] = []
for m in self.get_models_for_class(cls):
if m.box:
out.append((m.box.top_x, m.box.top_y, m.box.bottom_x, m.box.bottom_y))
return out
def get_tuples_with_conf_for_class(
self, cls: MLBoxType
) -> list[tuple[float, float, float, float, float]]:
out: list[tuple[float, float, float, float, float]] = []
for m in self.get_models_for_class(cls):
if m.box:
out.append(
(m.box.top_x, m.box.top_y, m.box.bottom_x, m.box.bottom_y, float(m.conf))
)
return out
def get_best_for_class(self, cls: MLBoxType) -> MLBoxModel | None:
models = self.get_models_for_class(cls)
if not models:
return None
return max(models, key=lambda m: m.conf if m.conf is not None else 0.0)
class BeamlineStateEnum(Enum):
Maintenance = 1
SampleExchange = 2
SampleAlignment = 3
DataCollection = 4
DewarTransfer = 5
XrayFluorescence = 6
BeamLocation = 7
Moving = 8
RobotSampleExchange = 9
XtalSnapshot = 10
BeamstopAlignment = 11
FluxMeasurement = 12
def display_name(self) -> str:
return {
BeamlineStateEnum.Maintenance: "Maintenance",
BeamlineStateEnum.SampleExchange: "Sample exchange",
BeamlineStateEnum.SampleAlignment: "Sample alignment",
BeamlineStateEnum.DataCollection: "Data collection",
BeamlineStateEnum.DewarTransfer: "Dewar transfer",
BeamlineStateEnum.XrayFluorescence: "X-ray fluorescence",
BeamlineStateEnum.BeamLocation: "Beam location",
BeamlineStateEnum.Moving: "Moving",
BeamlineStateEnum.RobotSampleExchange: "Robot sample exchange",
BeamlineStateEnum.XtalSnapshot: "Xtal snapshot",
BeamlineStateEnum.BeamstopAlignment: "Beamstop alignment",
BeamlineStateEnum.FluxMeasurement: "Flux measurement",
}.get(self, "-")
class DAQOperation(str, Enum):
AUTOMATION = "automation"
MOUNT = "mount"
UNMOUNT = "unmount"
LOOP_CENTERING = "loop_centering"
FACE_CENTERING = "face_centering"
RASTER = "raster"
ROTATION = "rotation"
MEASURE = "measure"
class SessionsStateEnum(Enum):
Vacant = 0
OwnedByYou = 1
OwnedByElse = 2
PendingYouToElse = 3
PendingElseToYou = 4
class SampleCameraSettings(BaseModel):
gain: float
exposure: float
class ZoomModeEnum(Enum):
User = 1
LoopCenter = 2
BeamLocation = 3
class ZoomModel(BaseModel):
z: dict[float, SampleCameraSettings]
def get_camera_settings(self, zoom_value: float) -> SampleCameraSettings:
if not self.z:
raise ValueError("No zoom data available")
if zoom_value in self.z:
elem = self.z[zoom_value]
return SampleCameraSettings(gain=elem.gain, exposure=elem.exposure)
sorted_zooms = sorted(self.z.keys())
if zoom_value <= sorted_zooms[0]:
elem = self.z[sorted_zooms[0]]
return SampleCameraSettings(gain=elem.gain, exposure=elem.exposure)
if zoom_value >= sorted_zooms[-1]:
elem = self.z[sorted_zooms[-1]]
return SampleCameraSettings(gain=elem.gain, exposure=elem.exposure)
print("interpolating zoom")
for i in range(len(sorted_zooms) - 1):
if sorted_zooms[i] <= zoom_value <= sorted_zooms[i + 1]:
lower_zoom = sorted_zooms[i]
upper_zoom = sorted_zooms[i + 1]
lower_elem = self.z[lower_zoom]
upper_elem = self.z[upper_zoom]
t = (zoom_value - lower_zoom) / (upper_zoom - lower_zoom)
interpolated_gain = lower_elem.gain + t * (upper_elem.gain - lower_elem.gain)
interpolated_exp = lower_elem.exposure + t * (
upper_elem.exposure - lower_elem.exposure
)
return SampleCameraSettings(gain=interpolated_gain, exposure=interpolated_exp)
closest = min(self.z.keys(), key=lambda x: abs(x - zoom_value))
elem = self.z[closest]
return SampleCameraSettings(gain=elem.gain, exposure=elem.exposure)
def def_zoom(beamline) -> ZoomModel:
print(f"user zoom for {beamline}")
if beamline == MXBeamline.X06DA:
return ZoomModel(
z={
1: SampleCameraSettings(gain=0, exposure=0.05),
280: SampleCameraSettings(gain=0, exposure=0.05),
500: SampleCameraSettings(gain=0, exposure=0.1),
700: SampleCameraSettings(gain=0, exposure=0.15),
800: SampleCameraSettings(gain=0, exposure=0.2),
1000: SampleCameraSettings(gain=0, exposure=0.25),
}
)
elif (
beamline == MXBeamline.X10SA
or beamline == MXBeamline.X06SA
or beamline == MXBeamline.SIMULATED
):
return ZoomModel(z={1: SampleCameraSettings(gain=0, exposure=0.002)})
else:
raise ValueError(f"Invalid beamline: {beamline}")
def def_bl_zoom(beamline) -> ZoomModel:
if beamline == MXBeamline.X06DA:
# Sensible starting presets so the beam is visible at every zoom; these
# are tuned live and persisted to Redis from the GUI (config.zoom_settings).
return ZoomModel(
z={
1: SampleCameraSettings(gain=0, exposure=0.05),
280: SampleCameraSettings(gain=0, exposure=0.05),
500: SampleCameraSettings(gain=0, exposure=0.1),
700: SampleCameraSettings(gain=0, exposure=0.15),
800: SampleCameraSettings(gain=0, exposure=0.2),
1000: SampleCameraSettings(gain=0, exposure=0.25),
}
)
elif (
beamline == MXBeamline.X10SA
or beamline == MXBeamline.X06SA
or beamline == MXBeamline.SIMULATED
):
return ZoomModel(z={1: SampleCameraSettings(gain=0, exposure=0.002)})
else:
raise ValueError(f"Invalid beamline: {beamline}")
def def_loop_centering_zoom(beamline) -> ZoomModel:
if beamline == MXBeamline.X06DA:
return ZoomModel(z={1: SampleCameraSettings(gain=0, exposure=0.05)})
elif beamline == MXBeamline.X10SA:
return ZoomModel(
z={
1: SampleCameraSettings(gain=0, exposure=0.002),
280: SampleCameraSettings(gain=0, exposure=0.002),
}
)
elif beamline == MXBeamline.X06SA or beamline == MXBeamline.SIMULATED:
return ZoomModel(z={1: SampleCameraSettings(gain=0, exposure=0.05)})
else:
raise ValueError(f"Invalid beamline: {beamline}")
def zoom_manager(
mode: ZoomModeEnum = ZoomModeEnum.User, beamline: MXBeamline = None
) -> ZoomModel | None:
if beamline is None or beamline == MXBeamline.SIMULATED:
print("SIMULATED")
return def_zoom(beamline)
if mode == ZoomModeEnum.BeamLocation:
return def_bl_zoom(beamline)
elif mode == ZoomModeEnum.LoopCenter:
return def_loop_centering_zoom(beamline)
elif mode == ZoomModeEnum.User:
return def_zoom(beamline)
else:
raise ValueError(f"Invalid zoom mode: {mode}")
class AutofocusSettings(BaseModel):
center_x_pxl: float | None # Use beam center
center_y_pxl: float | None # Use beam center
radius_pxl: float
z_range_um: float
z_steps: int
class BeamlineStatus(BaseModel):
name: str
ring_current_mA: float
front_light: Annotated[float, Field(ge=0.0, le=100.0)]
back_light: Annotated[float, Field(ge=0.0, le=100.0)]
cryojet_K: float
shutter_open: bool
exp_shutter_open: bool | None
flux_ph_s: float
sample_camera: SampleCameraSettings
transmission: Annotated[float, Field(ge=0.0, le=1.0)] | None
zoom: float
commissioning_mode: bool
dtz_min: float
dtz_max: float
# Hutch personnel-safety system state. ``pss_prohibited`` is True when the
# hutch is interlocked so the robot may move (PROHIBITED-STATE); the GUI
# blocks a mount when it is False. ``pss_alarm`` is True when ALARM-STATE
# != 0 (warning). Defaults keep older payloads/constructors valid and avoid
# the GUI false-blocking when an old server omits the field.
pss_prohibited: bool = True
pss_alarm: bool = False
class SessionStatus(BaseModel):
session: SessionsStateEnum = SessionsStateEnum.Vacant
current_pgroup: str | None = None
staff: bool = False
class OpenGuiSessionInfo(BaseModel):
session: int
username: str
staff: bool = False
last_seen_ts: float
last_interaction_ts: float | None = None
close_requested: bool = False
close_requested_by: str | None = None
close_requested_at: float | None = None
close_grace_seconds: int | None = None
holds_baton: bool = False
class CrystalSize(BaseModel):
x: float = 0.0
y: float = 0.0
z: float = 0.0
class DAQStatusModel(BaseModel):
geom: SampleGeometryModel
diffraction: DiffractionGeometry
bl: BeamlineStatus
state: BeamlineStateEnum
busy: bool
sample: SampleShortInfo | None = None
session: SessionStatus
open_guis: list[OpenGuiSessionInfo] = []
box: BoundingBoxModel | None = None
last_best_res: float | None = None
last_best_b_factor: float | None = None
crystal_size: CrystalSize = CrystalSize(x=0, y=0, z=0)
tell_connected: bool = True
tell_error: str | None = None
tell_state: TellStateModel | None = None
smargon_connected: bool = True
smargon_error: str | None = None
aerotech_connected: bool = True
aerotech_error: str | None = None
class BeamlineSettingsModel(BaseModel):
dtz_max: float | None = 1600.0
dtz_min: float | None = 120.0
dtz_collection: float | None = 130.0
dtz_park: float | None = 150.0
dtz_wash_sample_distance: float | None = 150.0
dtz_bsz_safety_margin: float | None = 50.0
bsz: float | None = 25.0
camera_max_magnification: float | None = 1.0
camera_min_magnification: float | None = 500.0
camera_translation_factor_a: float | None = 0.00253
camera_translation_factor_b: float | None = 512.0
class CryojetSettingsModel(BaseModel):
cryojet_park_position: float | None = 12.0
cryojet_measurement_position: float | None = 5.0
cryojet_in_use: bool | None = True
class SimpleStrategyInputModel(BaseModel):
last_best_res: float | None = None
last_best_b_factor: float | None = None
crystal_size: CrystalSize = CrystalSize(x=0, y=0, z=0)
angular_range: int = 360
start_angle: float = 0
incr_omega_deg: float = 0.2
d_vis: float = 1.5
current_temp_k: float = 100
filename: str | None = None
class SimpleScanParameters(BaseModel):
filename: str | None = None
dtz: float = 150
exp_time_s: float = 0.02
start_omega_deg: float = 0
incr_omega_deg: float = 0.2
steps: int = 1800
transmission: Annotated[float, Field(ge=0.0, le=1.0)] = 1.0
last_best_res: float | None = None
last_best_b_factor: float | None = None
crystal_size: CrystalSize = CrystalSize(x=0, y=0, z=0)
flux_ph_s: float | None = None
calculated_dose_Mgy: float | None = None
calculated_dose_rate_MGy_s: float | None = None
xtal_size_dose_rate_MGy_s: float | None = None
target_dose_MGy: float | None = None
beam_size_x_um: float | None = None
beam_size_y_um: float | None = None
dose_rate_MGy_s: float | None = None
d_vis: float | None = None
d_tar: float | None = None
class ScanResultPayloadModel(BaseModel):
result: ScanResult
sample_id: int
attach_image: bool = True
beam_mark_pxl: tuple[float, float]
beam_size_mm: Annotated[Coordinate, AfterValidator(positive_coords)]
class RecoveryActionRequest(BaseModel):
confirmation_code: str
@dataclass
class LoopCenteringResult:
success: bool
comment: str | None = None
error: Exception | None = None