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
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
734 lines
24 KiB
Python
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
|