diff --git a/docs/proposed_aare_config.py b/docs/proposed_aare_config.py new file mode 100644 index 00000000..ddeeba9c --- /dev/null +++ b/docs/proposed_aare_config.py @@ -0,0 +1,1022 @@ +"""Proposed single configuration model for aaredaq + aarecommon. + +One nested pydantic model holding every user-configurable value from +``docs/configurable_values_inventory.md``. Defaults are the values the code uses +*today*, so ``AareConfig(network=...)`` reproduces current behaviour (one +deliberate exception, noted on ``gui.filenames.directory_template``); +the point of the model is that each of them becomes visible, validated and +overridable from one place (YAML per beamline, Redis for operator-tunable +groups, or both). + +Intended home once adopted: ``aarecommon/models/aare_config.py``, so the GUI +and the server share it. + +Conventions +----------- +* The comment above each field gives where the value is used today, as + `` :`` relative to ``aaredaq/src/aare/`` or + ``aarecommon/src/aarecommon/``. Lines are from the working tree on 2026-10-14. +* ``yaml:`` names the existing per-beamline YAML key where one exists, so the + loader can map old files onto this model. +* Network endpoints that genuinely differ per beamline are **required**; every + single default the code has today is wrong for one of the two beamlines, and + the silent fall-through is what produced inventory §2. + +Deliberately excluded +--------------------- +* ``DEFAULT_CRITICALITY`` on exception classes (``aarecommon errors/exception_handler.py``). + Whether an error is 503 or 422 is an API contract, not an operator setting. +* Pydantic request/response model defaults (``RotationScanRequest`` etc.). +* Physics constants (hc = 12.398 keV·Å). +""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import BaseModel, Field, model_validator + +LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] +Seconds = Annotated[float, Field(gt=0.0)] +Millis = Annotated[int, Field(gt=0)] +Fraction = Annotated[float, Field(ge=0.0, le=1.0)] +Positive = Annotated[float, Field(gt=0.0)] + + +# --------------------------------------------------------------------------- +# Network, endpoints, paths +# --------------------------------------------------------------------------- + + +class RedisConfig(BaseModel): + """Connection to the per-beamline Redis that holds all shared state.""" + + # aaredaq daq/config.py:104 yaml: daq.hardware.redis_url + host: str = Field(description="Redis hostname, e.g. x10sa-redis.psi.ch.") + # aaredaq daq/config.py:105 + port: int = Field(6379, ge=1, le=65535, description="Redis TCP port.") + # aaredaq daq/config.py:105 + db: int = Field(0, ge=0, le=15, description="Redis logical database index.") + + +class CameraConfig(BaseModel): + """Camera endpoints consumed by the GUI.""" + + # aaredaq gui/gui.py:53-97 yaml: gui.cameras.sample_camera_zmq_url + sample_camera_zmq_url: str = Field(description="ZMQ PUB endpoint of the sample camera stream.") + # aaredaq gui/gui.py:56-97 yaml: gui.cameras.prediction_zmq_url + prediction_zmq_url: str = Field(description="ZMQ endpoint of the ML prediction stream.") + # aaredaq gui/gui.py:59-96 yaml: gui.cameras.beamline_camera_url + beamline_camera_url: str = Field(description="Hostname of the Axis hutch camera.") + # aaredaq gui/main_window.py:520 (reader commented out) yaml: gui.cameras.secondary_beamline_camera_url + secondary_beamline_camera_url: str | None = Field( + None, description="Optional second hutch camera. Currently unread by the GUI." + ) + # aaredaq gui/gui.py:62-96 yaml: gui.cameras.gonio_camera_url + gonio_camera_url: str = Field(description="Hostname of the goniometer Axis camera.") + # aaredaq gui/gui.py:65-97 yaml: gui.cameras.gonio_camera_id + gonio_camera_id: int = Field( + 1, ge=1, description="Axis camera channel id on the gonio camera server." + ) + # aaredaq gui/threads/axis_video_thread.py:34 + axis_mjpeg_resolution: str = Field( + "1280x720", description="Resolution requested from Axis MJPEG streams." + ) + # aaredaq gui/threads/axis_video_thread.py:34 + axis_mjpeg_fps: int = Field( + 20, ge=1, le=60, description="Frame rate requested from Axis MJPEG streams." + ) + + +class NetworkConfig(BaseModel): + """Every service endpoint. Required fields differ per beamline.""" + + redis: RedisConfig + cameras: CameraConfig + # aaredaq devices/bec_worker.py:97 yaml: daq.hardware.bec_url + bec_host: str = Field(description="BEC Redis/server host, e.g. x10sa-bec-001.psi.ch.") + # aaredaq devices/smargon.py:25,28 yaml: daq.hardware.smargon_url + smargon_url: str = Field(description="Smargopolo REST base URL.") + # aaredaq daq/config.py:1174 yaml: daq.hardware.smargon_frontend_url + smargon_frontend_url: str = Field( + description="Smargopolo web frontend, linked from the local-contact panel." + ) + # aaredaq devices/aerotech.py:20-26, daq/config.py:1178 yaml: daq.hardware.aerotech_url + aerotech_url: str = Field(description="AareScan/Aerotech REST base URL.") + # aaredaq devices/tell_backend.py:90,92 yaml: daq.hardware.tell_url + tell_url: str = Field( + description="TELL PShell server URL. x10sa currently points at a PC hostname." + ) + # aarecommon config/beamline.py:45-49,77 yaml: shared.jfjoch.jfjoch_url (code also reads daq.hardware.jfjoch_url) + jfjoch_url: str = Field(description="JFJoch detector broker URL.") + # aaredaq daq/config.py:1184-1186 + jfjoch_frontend_url: str = Field( + description="JFJoch web frontend, linked from the local-contact panel." + ) + # aarecommon lc_infer_wrapper.py:17 yaml: daq.hardware.aarelc_url + aarelc_url: str = Field(description="aarelc ML inference service URL.") + # aaredaq daq/aaredb.py:44, spreadsheetupdater.py:144,168, tellupdater.py:382 yaml: daq.db.aaredb_url (currently unread) + aaredb_url: str = Field( + "https://mx-aaredb-dmz-01.psi.ch/dispatcher", + description="aaredb dispatcher base URL. Also the host of the TELL and spreadsheet websockets.", + ) + # aaredaq gui/gui.py:51-91 yaml: gui.daq.daq_url + daq_url: str = Field(description="DAQ server URL the GUI talks to.") + # aaredaq gui/gui.py:52-92 yaml: gui.daq.cert_path + daq_cert_path: str = Field(description="TLS certificate the GUI uses to verify daq_url.") + + +class PathsConfig(BaseModel): + """Filesystem roots.""" + + # aaredaq daq/server.py:2366, gui/panels/file_path_panel.py:121, gui/threads/jfjoch_viewer.py:69, operations/screenshot/service.py:41 + data_root: str = Field( + "/sls/mx/data", description="Root under which /raw/... datasets are written." + ) + # aaredaq operations/screenshot/service.py:40, daq/daq.py:301, gui/panels/smargon_trace_panel.py:520 + logs_dir: str = Field( + "/sls/mx/applications/logs", + description="Directory for screenshots and the smargon trace CSV.", + ) + # aaredaq daq/daq.py:301 + smargon_trace_filename: str = Field( + "smargon_trace.csv", description="Smargon trace CSV name inside logs_dir." + ) + # aaredaq devices/bec_worker.py:99 + bec_log_dir: str = Field( + "/tmp/logs", description="log_writer.base_path handed to the BEC client." + ) + # aaredaq devices/bec_worker.py:346 + bec_device_config_template: str = Field( + "/sls/{beamline}/config/bec/production/{bl}_bec/{bl}_bec/device_configs/{bl}-devices.yaml", + description="Path template of the BEC device YAML; {beamline} is lowercase id, {bl} the BEC package name.", + ) + + +# --------------------------------------------------------------------------- +# Server, auth, sessions +# --------------------------------------------------------------------------- + + +class ServerConfig(BaseModel): + """uvicorn and SSE behaviour of the DAQ server.""" + + # aaredaq daq/server.py:2627 + host: str = Field("127.0.0.1", description="Bind address. Proxied, so loopback by default.") + # aaredaq daq/server.py:2628 + port: int = Field(5210, ge=1, le=65535, description="Bind port.") + # aaredaq daq/server.py:2629 + workers: int = Field( + 4, + ge=1, + le=32, + description="uvicorn worker processes. Each runs lifespan() and owns its own BEC/EPICS/TELL connections.", + ) + # aaredaq daq/server.py:76 + all_pgroups_cache_ttl_s: Seconds = Field( + 60.0, description="Cache lifetime of the all-pgroups lookup." + ) + # aaredaq daq/server.py:283,303 + progress_sse_poll_s: Seconds = Field( + 0.15, description="Poll interval of the face-detection and automation-progress SSE streams." + ) + # aaredaq daq/server.py:1295,1313 + spreadsheet_sse_poll_s: Seconds = Field( + 10.0, description="Poll interval of the spreadsheet and reference-tools SSE streams." + ) + # aaredaq daq/server.py:2262 + baton_sse_poll_s: Seconds = Field(0.5, description="Poll interval of the baton SSE stream.") + # aaredaq daq/server.py:2534,2537 + fluorimeter_sse_poll_s: Seconds = Field( + 0.2, description="Poll interval of the fluorimeter SSE stream." + ) + + +class AuthConfig(BaseModel): + """Token, session and baton lifetimes, plus who counts as staff.""" + + # aaredaq daq/auth.py:29 + jwt_algorithm: Literal["HS256", "HS384", "HS512"] = Field( + "HS256", description="JWT signing algorithm." + ) + # aaredaq daq/auth.py:30 + access_token_expire_minutes: int = Field( + 24 * 60 * 7, ge=1, description="JWT lifetime. Currently one week." + ) + # aaredaq daq/auth.py:31, daq/config.py:96 (duplicated as GUI_SESSION_EXPIRE_SECONDS) + session_expire_s: int = Field( + 600, + ge=10, + description="Active session / GUI session expiry, refreshed on each authenticated call.", + ) + # aaredaq daq/auth.py:32, daq/config.py:431 + baton_request_timeout_s: int = Field( + 30, ge=1, description="How long a baton request waits for the holder to answer." + ) + # aaredaq daq/config.py:439 + baton_request_redis_grace_s: int = Field( + 5, ge=0, description="Extra TTL on the pending-request key beyond the request timeout." + ) + # aaredaq daq/auth.py:423 + baton_refusal_rearm_s: int = Field( + 10, ge=1, description="Timeout applied when a refused request is re-armed." + ) + # aaredaq daq/config.py:208 + session_release_grace_s: int = Field( + 60, ge=0, description="Grace period before a queued baton transfer is executed." + ) + # aaredaq daq/auth.py:34 + staff_group: str = Field( + "unx-sls_mx_bs", description="Unix group whose members are treated as beamline staff." + ) + # aaredaq daq/auth.py:35 + super_users: list[str] = Field( + default_factory=lambda: ["e10019", "e11206", "e18747"], + description="Accounts that bypass baton ownership checks.", + ) + + +# --------------------------------------------------------------------------- +# Hardware ranges and calibration +# --------------------------------------------------------------------------- + + +class LampRange(BaseModel): + """Raw PV values for one lamp. The GUI maps 0-100 % linearly onto off..max. + + The code currently encodes these three different ways that disagree + (inventory §4.1); this is the single place to reconcile them. + """ + + off: float = Field(ge=0.0, description="Raw value for 0 %.") + half: float = Field(ge=0.0, description="Raw value for the 'half' preset.") + max: float = Field(gt=0.0, description="Raw value for 100 %.") + + @model_validator(mode="after") + def _monotonic(self) -> LampRange: + if not (self.off <= self.half <= self.max): + raise ValueError( + f"lamp range must satisfy off <= half <= max, got {self.off}, {self.half}, {self.max}" + ) + return self + + +class LampsConfig(BaseModel): + # aaredaq daq/devices.py:59 (predefs) and daq/daq.py:1767-1776 (percentage map, 1.0..2.5) + front: LampRange = Field( + default_factory=lambda: LampRange(off=1.49, half=2.0, max=3.0), + description="Front light. devices.py predefs say max 3.0; daq.py maps 100 % to 2.5.", + ) + # aaredaq daq/devices.py:66 (predefs) and daq/daq.py:1781-1792 (percentage map, 0..3.0) + back: LampRange = Field( + default_factory=lambda: LampRange(off=0.0, half=0.98, max=1.2), + description="Back light. devices.py predefs say max 1.2; daq.py maps 100 % to 3.0; BEC yaml says on=1.3.", + ) + + +class DetectorStageConfig(BaseModel): + """Detector distance (dtz) limits and safe positions, in mm.""" + + # aaredaq daq/daq.py:3028 yaml: daq.hardware.default_detector_distance_minimum + dtz_min_mm: Positive = Field( + 100.0, description="Fallback lower dtz limit when the detector has not reported one." + ) + # aaredaq daq/daq.py:3029 yaml: daq.hardware.default_detector_distance_maximum + dtz_max_mm: Positive = Field(1000.0, description="Fallback upper dtz limit.") + # aaredaq daq/config.py:111,1246 yaml: daq.hardware.dtz_safe_position + dtz_safe_position_mm: Positive | None = Field( + 300.0, + description="dtz the detector retreats to before sample exchange. x06da yaml sets null.", + ) + # aaredaq daq/workflows.py:13 + workflow_safe_position_mm: Positive = Field( + 600.0, description="dtz used by the standalone workflows script." + ) + # aaredaq daq/devices.py:51 yaml: daq.detector_distance_limit_modifier (x06da misspells it detector_limit_modifier) + limit_modifier: Positive = Field( + 1.0, description="Multiplier applied to detector-reported dtz limits." + ) + # aaredaq gui/panels/scan_settings_panel.py:122 + gui_min_dtz_mm: Positive = Field( + 108.0, + description="Lower bound of the GUI dtz spinbox. Its own comment says 'beamline dependent'.", + ) + # aaredaq daq/config.py:53,569,590 yaml: daq.hardware.lens_magnification + lens_magnification: Positive = Field( + 10.0, description="Sample-camera lens magnification used for pixel-to-mm." + ) + + @model_validator(mode="after") + def _ordered(self) -> DetectorStageConfig: + if self.dtz_min_mm >= self.dtz_max_mm: + raise ValueError("dtz_min_mm must be < dtz_max_mm") + return self + + +class GoniometerConfig(BaseModel): + # aaredaq daq/daq.py:1702 + omega_abs_limit_deg: Positive = Field( + 2000.0, description="Omega set-points outside ±this are rejected." + ) + # aaredaq daq/devices.py:31 + chi_zero_tolerance_deg: Positive = Field( + 1e-3, description="|chi| below this is treated as zero." + ) + # aaredaq gui/panels/rotation_data_collection.py:19 + max_omega_speed_deg_s: Positive = Field( + 500.0, description="Upper omega speed the rotation panel will request." + ) + # aaredaq gui/panels/rotation_data_collection.py:21 (per beamline: X06DA 1/900, X10SA 1/120) + min_exp_time_s: Positive = Field( + 1 / 120, description="Detector frame-time floor for the rotation panel." + ) + # aaredaq gui/panels/smart_rotation_panel.py:42,48 (X06DA 0.002) + strategy_min_exp_time_s: Positive = Field( + 0.0005, description="Frame-time floor for the smart-rotation strategy." + ) + # aaredaq gui/panels/smart_rotation_panel.py:41 + fixed_frame_rate_hz: Positive = Field( + 100.0, description="Frame rate the smart-rotation dose model assumes." + ) + # aaredaq daq/daq.py:1446 + rotation_min_exp_time_s: Positive = Field( + 0.0011, description="Server-side clamp: shorter rotation exposures are raised to this." + ) + + +class Coordinate3(BaseModel): + x: float = Field(0.0, description="X in mm.") + y: float = Field(0.0, description="Y in mm.") + z: float = Field(0.0, description="Z in mm.") + + +class HomePositionsConfig(BaseModel): + # aaredaq devices/smargon.py:19, gui/panels/smargon_trace_panel.py:33-35 + smargon_home_mm: Coordinate3 = Field( + default_factory=lambda: Coordinate3(z=18.0), description="Smargon home (sh_mm)." + ) + # aaredaq devices/smargon.py:20, devices/aerotech.py:10 + aerotech_home_mm: Coordinate3 = Field( + default_factory=Coordinate3, description="Aerotech home (at_mm)." + ) + # aaredaq daq/config.py:47-52 + abr_mount_position_mm: Coordinate3 = Field( + default_factory=Coordinate3, description="Aerotech position for mounting." + ) + # aaredaq daq/config.py:52 + abr_mount_omega_deg: float = Field(0.0, description="Omega for mounting.") + + +class DetectorTiltConfig(BaseModel): + # aaredaq beamline_dispatch/x06da/beamline_dispatch.py:109, aarecommon math/diffraction_geometry.py:83 + poni_rot1_rad: float = Field( + 0.0, ge=-0.1, le=0.1, description="pyFAI rot1. x06da: -0.001396263." + ) + # aaredaq beamline_dispatch/x06da/beamline_dispatch.py:110, aarecommon math/diffraction_geometry.py:84 + poni_rot2_rad: float = Field( + 0.0, ge=-0.1, le=0.1, description="pyFAI rot2. x06da: -0.003839724." + ) + + +class FluxTableConfig(BaseModel): + """Flux versus energy lookup, used for dose estimates.""" + + # aaredaq beamline_dispatch/x06da/beamline_dispatch.py:10 + energy_kev: list[Positive] = Field(default_factory=list, description="Energy grid, ascending.") + # aaredaq beamline_dispatch/x06da/beamline_dispatch.py:25 + flux_ph_s: list[Positive] = Field(default_factory=list, description="Flux at each energy.") + # yaml: daq.maximum_flux (currently unread) + maximum_flux_ph_s: Positive | None = Field( + None, description="Nominal maximum flux of the beamline." + ) + + @model_validator(mode="after") + def _paired(self) -> FluxTableConfig: + if len(self.energy_kev) != len(self.flux_ph_s): + raise ValueError("energy_kev and flux_ph_s must have the same length") + if self.energy_kev != sorted(self.energy_kev): + raise ValueError("energy_kev must be ascending") + return self + + +class AreaDetectorConfig(BaseModel): + # aaredaq devices/area_detector.py:113 + gain: float = Field(15.0, ge=0.0, description="Default camera gain.") + # aaredaq devices/area_detector.py:113 + exposure_s: Positive = Field(0.025, description="Default camera exposure.") + + +class HardwareConfig(BaseModel): + lamps: LampsConfig = Field(default_factory=LampsConfig) + detector_stage: DetectorStageConfig = Field(default_factory=DetectorStageConfig) + goniometer: GoniometerConfig = Field(default_factory=GoniometerConfig) + homes: HomePositionsConfig = Field(default_factory=HomePositionsConfig) + detector_tilt: DetectorTiltConfig = Field(default_factory=DetectorTiltConfig) + flux: FluxTableConfig = Field(default_factory=FluxTableConfig) + area_detector: AreaDetectorConfig = Field(default_factory=AreaDetectorConfig) + + +# --------------------------------------------------------------------------- +# Timeouts and polling, grouped by device +# --------------------------------------------------------------------------- + + +class TellTimeouts(BaseModel): + # aaredaq devices/tell_client.py:82,87,202,367; operations/mounting/service.py:83; daq/daq.py:1869 + command_s: Seconds = Field(360.0, description="Mount/unmount/dry/park command completion.") + # aaredaq devices/tell_client.py:386 + ready_s: Seconds = Field(30.0, description="Wait for the Ready state before a command.") + # aaredaq operations/mounting/service.py:65,154,173,186 + unmount_s: Seconds = Field(60.0, description="Unmount used inside the mounting service.") + # aaredaq operations/mounting/service.py:146 (default 1.0 at :33) + magnet_sensor_check_s: Seconds = Field( + 10.0, description="Wait for the magnet position sensor after mount." + ) + # aaredaq devices/tell_backend.py:65 + probe_s: Seconds = Field(1.0, description="HTTP timeout of the connectivity probe.") + # aaredaq devices/tell_backend.py:172 + poll_s: Seconds = Field(0.01, description="Busy-wait poll interval.") + # aaredaq devices/tell_backend.py:336, tell_client.py:561 + lazy_retry_s: Seconds = Field(2.0, description="Reconnect interval of the lazy backend.") + # aaredaq daq/tellupdater.py:64 + sse_reconnect_s: Seconds = Field(5.0, description="TELL event-stream reconnect delay.") + # aaredaq operations/mounting/service.py:41 + post_command_sleep_s: float = Field( + 0.1, ge=0.0, description="Settle after sending a TELL command." + ) + + +class SmargonTimeouts(BaseModel): + # aaredaq devices/smargon.py:43,77 + http_s: Seconds = Field(2.0, description="REST request timeout.") + # aaredaq devices/smargon.py:111 + initialize_s: Seconds = Field(360.0, description="Homing/initialisation.") + # aaredaq devices/smargon.py:214,222 + move_s: Seconds = Field(60.0, description="Wait for a move to finish.") + # aaredaq devices/smargon.py:214,222 + tolerance_mm: Positive = Field(0.01, description="In-position tolerance.") + # aaredaq devices/smargon.py:214,222; daq/devices.py:364,371 + poll_s: Seconds = Field(0.01, description="Poll interval while waiting.") + # aaredaq daq/daq.py:2016, operations/raster/service.py:534 + long_move_s: Seconds = Field(180.0, description="Wait used around raster and automation moves.") + # aaredaq daq/devices.py:364 + short_wait_s: Seconds = Field(10.0, description="Default smargon_wait in the device layer.") + + +class AerotechTimeouts(BaseModel): + # aaredaq devices/aerotech.py:114, operations/raster/service.py:438 + move_s: Seconds = Field(60.0, description="wait_till_done.") + # aaredaq daq/devices.py:305 + det_z_move_s: Seconds = Field(60.0, description="BEC det_z move.") + # aaredaq daq/workflows.py:163,175 + workflow_wait_s: Seconds = Field(60.0, description="Status waits in the workflows script.") + + +class JFJochTimeouts(BaseModel): + # aaredaq devices/jfjoch.py:293; operations/raster/service.py:506; operations/rotation/service.py:35 + wait_running_s: Seconds = Field(60.0, description="Wait for the detector to report running.") + # aaredaq operations/raster/service.py:428 + raster_margin_fraction: Fraction = Field( + 0.1, description="Raster completion timeout = expected × (1 + this) + margin_s." + ) + # aaredaq operations/raster/service.py:428 + raster_margin_s: Seconds = Field( + 60.0, description="Additive margin on the raster completion timeout." + ) + + +class MiscTimeouts(BaseModel): + # aaredaq daq/devices.py:60,67,77,90 + epics_pv_s: Seconds = Field( + 10.0, description="PV connect/put timeout for lamps, cryojet, positions." + ) + # aaredaq devices/my_motor.py:9 + motor_connect_s: Seconds = Field(5.0, description="EPICS motor connect.") + # aaredaq devices/my_motor.py:63,84,94 + motor_move_s: Seconds = Field(300.0, description="EPICS motor move.") + # aaredaq devices/my_motor.py:84,94 + motor_poll_s: Seconds = Field(0.01, description="EPICS motor poll.") + # aaredaq devices/workflow_tools.py:6 + wait_position_s: Seconds = Field(60.0, description="Generic wait_position helper.") + # aaredaq devices/filter_transmission.py:8 + filter_transmission_s: Seconds = Field(60.0, description="Transmission filter move.") + # aaredaq devices/filter_transmission.py:8 + filter_fail_on_timeout: bool = Field( + True, description="Raise rather than warn when the filter times out." + ) + # aaredaq devices/fluorimeter.py:65,91,98,114 + fluorimeter_s: Seconds = Field(30.0, description="Fluorimeter acquisition waits.") + # aaredaq devices/zmq_client.py:27 + zmq_ms: Millis = Field(2000, description="ZMQ receive timeout.") + # aaredaq daq/aaredb.py:338,414 + aaredb_http_s: Seconds = Field(30.0, description="aaredb HTTP request timeout.") + # aaredaq daq/spreadsheetupdater.py:187 + spreadsheet_updater_period_s: Seconds = Field( + 5.0, description="Spreadsheet updater loop period." + ) + # aaredaq devices/bec_worker.py:566 + bec_settle_s: float = Field(1.0, ge=0.0, description="Sleep after BEC environment init.") + # aaredaq daq/workflows.py:289 + workflow_settle_s: float = Field(10.0, ge=0.0, description="Sleep in the workflows script.") + # aaredaq daq/config.py:349,361,383,393,479,640,645 + redis_lock_s: Seconds = Field(10.0, description="TTL of the session and settings Redis locks.") + # aaredaq daq/config.py:106 (currently no timeout) + hardware_lock_s: Seconds | None = Field( + None, + description="TTL of the hardware busy lock. None = never expires; a leaked lock then wedges the beamline.", + ) + + +class TimeoutsConfig(BaseModel): + tell: TellTimeouts = Field(default_factory=TellTimeouts) + smargon: SmargonTimeouts = Field(default_factory=SmargonTimeouts) + aerotech: AerotechTimeouts = Field(default_factory=AerotechTimeouts) + jfjoch: JFJochTimeouts = Field(default_factory=JFJochTimeouts) + misc: MiscTimeouts = Field(default_factory=MiscTimeouts) + + +# --------------------------------------------------------------------------- +# Automation policy +# --------------------------------------------------------------------------- + + +class ScanDefaults(BaseModel): + """Default scan parameters when neither spreadsheet nor smart params apply.""" + + # aaredaq daq/daq.py:2324, beamline_dispatch/default:60 yaml: ...default_rotation_settings.exp_time_s + rotation_exp_time_s: Positive = Field(0.04, description="Rotation exposure per image.") + # aaredaq daq/daq.py:2330, beamline_dispatch/default:60 yaml: ...default_rotation_settings.dtz (250 vs 110 vs 150 today) + rotation_dtz_mm: Positive = Field(110.0, description="Rotation detector distance.") + # aaredaq daq/daq.py:2334 (reads incr_omega_deg; yaml key is increment_omega_deg), beamline_dispatch/default:60 + rotation_incr_omega_deg: Positive = Field(0.2, description="Rotation oscillation per image.") + # aaredaq daq/daq.py:2337 yaml: ...default_rotation_settings.steps + rotation_steps: int = Field(1800, ge=1, description="Number of rotation images.") + # aaredaq daq/daq.py:2331 yaml: ...default_rotation_settings.start_omega_deg + rotation_start_omega_deg: float = Field(0.0, description="Rotation start angle.") + # aaredaq beamline_dispatch/x10sa:129 (0.1), x06da:126 (1.0) yaml: ...default_rotation_settings.transmission + rotation_transmission: Fraction = Field(1.0, description="Rotation transmission as a fraction.") + # aaredaq daq/daq.py:2312, beamline_dispatch/default:65 yaml: ...default_raster_scan_settings.exp_time_s + raster_exp_time_s: Positive = Field(0.04, description="Auto-raster exposure per cell.") + # aaredaq daq/daq.py:2315, beamline_dispatch/default:65 yaml: ...default_raster_scan_settings.transmission + raster_transmission: Fraction = Field(1.0, description="Auto-raster transmission.") + # aaredaq daq/daq.py:2093 (200), :2318 (250), gui/scan_logic/raster_grid_manager.py:149 (200.0) yaml: ...default_raster_scan_settings.dtz + raster_dtz_mm: Positive = Field( + 200.0, description="Raster detector distance. Three different fallbacks exist today." + ) + # aaredaq gui/panels/rotation_data_collection.py:116 yaml: ...default_screening_settings.exp_time_s (defined nowhere) + screening_exp_time_s: Positive = Field(0.1, description="Screening exposure per image.") + + +class MountingConfig(BaseModel): + # aaredaq operations/mounting/service.py:14 + dry_after_fail_count: int = Field( + 3, ge=1, description="Consecutive mount failures before the gripper is dried." + ) + # aaredaq operations/mounting/service.py:15 + stop_after_fail_count: int = Field( + 5, ge=1, description="Consecutive mount failures before automation stops." + ) + # aaredaq daq/config_model.py (LocalContactConfigModel.mount_to_center_sleep_s), used daq/daq.py:2459 + mount_to_center_sleep_s: float = Field( + 0.0, ge=0.0, le=120.0, description="Delay after mount before loop centering." + ) + # aaredaq daq/daq.py:1980,2574 + post_mount_settle_s: float = Field( + 2.0, ge=0.0, description="Fixed sleeps around mount/unmount in the automation sequence." + ) + + @model_validator(mode="after") + def _ordered(self) -> MountingConfig: + if self.dry_after_fail_count > self.stop_after_fail_count: + raise ValueError("dry_after_fail_count must be <= stop_after_fail_count") + return self + + +class AutoRasterConfig(BaseModel): + # aaredaq daq/daq.py:283 + max_images: int = Field( + 4500, ge=1, description="Grid is scaled down to fit under this many cells." + ) + # aaredaq daq/daq.py:284 + min_cell_size_mm: Positive = Field(0.005, description="Smallest allowed raster cell.") + # aaredaq daq/daq.py:285 + skip_sample_if_too_large: bool = Field( + True, description="Skip the sample instead of clamping when the grid exceeds max_images." + ) + # aaredaq daq/daq.py:2534 + seed_grid_beam_fraction: Positive = Field( + 0.5, description="Initial 1×1 grid size as a fraction of the beam." + ) + # aaredaq operations/common/ml_bounding_box.py:258 + line_scan_cell_beam_fraction: Positive = Field( + 0.8, description="Line-scan cell size as a fraction of the beam." + ) + # aaredaq operations/raster/service.py:730 + line_scan_cell_height_beam_fraction: Positive = Field( + 0.25, description="Second line-scan cell height as a fraction of beam height." + ) + # aaredaq operations/common/ml_bounding_box.py:378 + line_scan_exp_time_s: Positive = Field( + 0.01, description="Exposure used for line scans (ignores ScanDefaults today)." + ) + # aaredaq operations/common/ml_bounding_box.py:379 + line_scan_transmission: Fraction = Field(1.0, description="Transmission used for line scans.") + # aaredaq operations/common/ml_bounding_box.py:277 yaml: daq.auto_raster.grid_padding_fraction_x (x06da only) + grid_padding_fraction_x: float = Field( + 0.15, ge=0.0, le=5.0, description="Horizontal padding around the ML box." + ) + # aaredaq operations/common/ml_bounding_box.py:278 yaml: daq.auto_raster.grid_padding_fraction_y (x06da only) + grid_padding_fraction_y_top: float = Field( + 0.15, ge=0.0, le=5.0, description="Padding above the ML box." + ) + # aaredaq operations/common/ml_bounding_box.py:279 yaml: daq.auto_raster.grid_padding_fraction_y_bottom (x06da 0.3) + grid_padding_fraction_y_bottom: float | None = Field( + None, ge=0.0, le=5.0, description="Padding below the ML box; None = same as top." + ) + # aaredaq daq/config_model.py (LocalContactConfigModel) + line_scan_loop_face_y_padding_fraction_each_side: float = Field( + 0.5, + ge=0.0, + le=5.0, + description="Second line-scan Y padding when the prediction is loop_face.", + ) + # aaredaq daq/config_model.py (LocalContactConfigModel) + line_scan_loop_all_y_padding_fraction_each_side: float = Field( + 0.5, + ge=0.0, + le=5.0, + description="Second line-scan Y padding when the prediction is loop_all.", + ) + # aaredaq operations/common/ml_bounding_box.py:455 yaml: daq.auto_raster.include_crystal (x06da only) + include_crystal: bool = Field( + False, description="Grow the raster box to include detected crystal boxes." + ) + # aaredaq operations/common/ml_bounding_box.py:475 (no yaml key) + exclude_pin_x_overlap: bool = Field( + True, description="Trim the raster box where it overlaps the pin horizontally." + ) + # aaredaq operations/common/ml_bounding_box.py:480 (no yaml key) + exclude_pin_max_overlap_fraction: Fraction = Field( + 0.75, description="Only trim if the pin overlap is below this fraction." + ) + # aaredaq operations/raster/service.py:302 + y_retarget_threshold_beam_multiple: Positive = Field( + 2.0, description="Re-target in Y when the ML shift exceeds this many beam heights." + ) + # aaredaq operations/raster/service.py:736 + y_retarget_threshold_cell_multiple: Positive = Field( + 4.0, description="...or this many cell heights, whichever is larger." + ) + # aaredaq operations/raster/service.py:441 + row_overhead_s: float = Field( + 0.3, ge=0.0, description="Per-row overhead in the raster time estimate." + ) + # aaredaq operations/raster/service.py:446 + max_total_time_s: Seconds = Field( + 1200.0, description="Rasters estimated longer than this are refused." + ) + # aaredaq operations/raster/models.py:26 + wait_for_screenshot_s: float = Field( + 0.3, ge=0.0, description="Settle before the raster screenshot." + ) + # aaredaq operations/raster/service.py:644; operations/common/ml_bounding_box.py:163,414 + settle_s: float = Field(0.2, ge=0.0, description="Short settles between raster steps.") + + +class LoopCenteringConfig(BaseModel): + # aaredaq operations/loop_centering/models.py:36 + overlap_with_pin: Fraction = Field( + 0.5, description="Max loop/pin overlap before the detection is rejected." + ) + # aaredaq operations/loop_centering/models.py:38 + zoom_value: Positive = Field(200.0, description="Camera zoom used for centering.") + # aaredaq operations/loop_centering/models.py:39 + max_attempts: int = Field(2, ge=1, description="Centering iterations.") + # aaredaq operations/loop_centering/models.py:40 + target_tolerance_um: Positive = Field( + 500.0, description="Accept when the target moves less than this." + ) + # aaredaq operations/loop_centering/service.py:100 + settle_s: float = Field(0.2, ge=0.0, description="Settle after each move.") + + +class FaceDetectionConfig(BaseModel): + # aaredaq daq/daq.py:2501 (automation uses 7/30); operations/face_detection/models.py:14-15 (model default 14/15) + steps: int = Field(7, ge=1, description="Number of omega positions sampled.") + # aaredaq daq/daq.py:2501; operations/face_detection/models.py:15 + step_size_deg: Positive = Field(30.0, description="Omega increment between samples.") + # aaredaq daq/daq.py:2501; operations/face_detection/models.py:16 + face_min_ratio: Fraction = Field( + 0.3, description="Minimum width/height ratio to count as face-on." + ) + # aaredaq operations/face_detection/service.py:91 + angle_wrap_deg: Positive = Field( + 720.0, description="Restart the sweep from 0 if it would exceed this." + ) + # aaredaq operations/face_detection/service.py:129 + centre_correction_tolerance: Fraction = Field( + 0.2, + description="Re-centre if the loop is further than this fraction of beam_y from the beam.", + ) + # aaredaq operations/face_detection/utils.py:39,69 + mad_outlier_k: Positive = Field(3.5, description="MAD multiplier for outlier rejection.") + # aaredaq daq/daq.py:1901 + autofocus_settle_s: float = Field( + 1.0, ge=0.0, description="Settle before each autofocus frame." + ) + # aaredaq daq/daq.py:2262,2272; operations/screenshot/service.py:61,67 + screenshot_settle_s: float = Field(0.2, ge=0.0, description="Settle before a screenshot.") + + +class AnalysisConfig(BaseModel): + """Thresholds in the raster/crystal analysis and ML plumbing.""" + + # aaredaq daq/daq.py:280; aarecommon math/find_xtal.py:344 + min_spots_low_res: float = Field( + 10.0, ge=0.0, description="Low-resolution spot count that counts as 'crystal present'." + ) + # aarecommon math/find_xtal.py:147 + ice_veto_fraction: Fraction = Field( + 0.8, description="Reject cells whose ice spots exceed this fraction of all spots." + ) + # aarecommon math/find_xtal.py:178 + min_efficiency: float = Field(1.0, ge=0.0, description="Minimum indexing efficiency.") + # aarecommon math/find_xtal.py:507,587,615 + mask_n_sigma: float = Field(0.5, ge=0.0, description="Crystal mask: mean + n·σ.") + # aarecommon math/find_xtal.py:568 + kmeans_clusters: int = Field(3, ge=2, description="Crystal mask k-means clusters.") + # aarecommon math/find_xtal.py:590,616 + signal_percentile: float = Field( + 60.0, ge=0.0, le=100.0, description="Crystal mask: keep cells above this percentile." + ) + # aarecommon math/autofocus.py:20 + autofocus_threshold_multiplier: Positive = Field( + 3.0, description="Autofocus threshold = median × this." + ) + # aarecommon lc_infer_wrapper.py:60 + ml_confidence_threshold: Fraction = Field(0.4, description="Minimum ML detection confidence.") + # aaredaq daq/mlbox.py:46 + ml_retry_count: int = Field(3, ge=0, description="Retries for ML inference calls.") + # aaredaq daq/mlbox.py:47 + ml_retry_sleep_s: float = Field(0.1, ge=0.0, description="Sleep between ML retries.") + # aaredaq daq/beamcenterfit.py:44 + beam_fit_enlargement_factor: Positive = Field( + 2.0, description="Bounding-box growth for the beam-centre fit." + ) + # aaredaq daq/daq.py:1666 + resolution_correction_inv_angstrom: float = Field( + 0.01, + ge=0.0, + description="Spreadsheet target resolution is tightened by 1/d += this before computing dtz.", + ) + # aarecommon math/coordinate.py:91 + coordinate_tolerance_coarse_mm: Positive = Field(0.1, description="Coarse coordinate equality.") + # aarecommon math/coordinate.py:115 + coordinate_tolerance_fine_mm: Positive = Field(0.01, description="Fine coordinate equality.") + + +class AutomationConfig(BaseModel): + # aaredaq daq/daq.py:281 + busy_timeout_s: Seconds = Field(600.0, description="Automation busy-state expiry.") + # aaredaq daq/daq.py:282 + manual_raster_busy_timeout_s: Seconds = Field( + 600.0, description="Manual-raster busy-state expiry." + ) + # aaredaq daq/daq.py:305 + sample_sync_min_interval_s: Seconds = Field(2.0, description="Rate limit on TELL sample sync.") + # aaredaq daq/daq.py:2518 + run_token_hex_bytes: int = Field( + 3, ge=1, le=16, description="Random bytes in the per-run filename token (3 → 6 hex chars)." + ) + scan_defaults: ScanDefaults = Field(default_factory=ScanDefaults) + mounting: MountingConfig = Field(default_factory=MountingConfig) + auto_raster: AutoRasterConfig = Field(default_factory=AutoRasterConfig) + loop_centering: LoopCenteringConfig = Field(default_factory=LoopCenteringConfig) + face_detection: FaceDetectionConfig = Field(default_factory=FaceDetectionConfig) + analysis: AnalysisConfig = Field(default_factory=AnalysisConfig) + + +# --------------------------------------------------------------------------- +# Detector dataset settings +# --------------------------------------------------------------------------- + + +class DetectorDatasetConfig(BaseModel): + """Fixed values written into every JFJoch DatasetSettings.""" + + # aaredaq devices/jfjoch.py:142 + fallback_pgroup: str = Field( + "p16371", + description="pgroup used when no sample is loaded. Writes into a real pgroup today.", + ) + # aaredaq devices/jfjoch.py:143 + fallback_sample_name: str = Field( + "unknown_sample", description="Sample name used when no sample is loaded." + ) + # aaredaq devices/jfjoch.py:151 + raster_subfolder: str = Field( + "raw/raster", description="Dataset folder for rasters, under /." + ) + # aaredaq devices/jfjoch.py:160 + screening_subfolder: str = Field( + "raw/screening", + description="Dataset folder for screening. The GUI also prepends 'screening/' — see rotation_filename_path.md §6.1.", + ) + # aaredaq devices/jfjoch.py:165 + rotation_subfolder: str = Field("raw", description="Dataset folder for rotations.") + # aaredaq devices/jfjoch.py:198 + max_spot_count: int = Field(1000, ge=1, description="Spot-finder cap per image.") + # aaredaq devices/jfjoch.py:199 + detect_ice_rings: bool = Field(True, description="Enable ice-ring detection.") + # aaredaq devices/jfjoch.py:204 + polarization_factor: float = Field( + 1.0, ge=-1.0, le=1.0, description="Beam polarization factor." + ) + # aaredaq devices/jfjoch.py:211 + grid_snake: bool = Field(True, description="Serpentine raster order.") + # aaredaq devices/jfjoch.py:214 + grid_vertical: bool = Field(False, description="Raster fast axis vertical.") + # aaredaq devices/jfjoch.py:226 + omega_axis_vector: tuple[int, int, int] = Field( + (-1, 0, 0), description="Goniometer axis direction." + ) + + +# --------------------------------------------------------------------------- +# GUI behaviour +# --------------------------------------------------------------------------- + + +class GuiAutomationConfig(BaseModel): + # aaredaq gui/widgets/message_box.py:11, gui/panels/sample_queue_panel.py:226 + low_current_threshold_ma: float = Field( + 360.0, ge=0.0, description="Ring current below which automation is paused / refused." + ) + # aaredaq gui/widgets/message_box.py:12 + low_current_snooze_s: Seconds = Field( + 3600.0, description="'Don't ask again' period for the low-current dialog." + ) + # aaredaq gui/panels/sample_queue_panel.py:59 + tell_warning_recovery_pause_s: Seconds = Field( + 600.0, description="Pause after a TELL warning before auto-resume." + ) + # aaredaq gui/panels/automation_panel.py:43 + default_sample_estimate_s: Seconds = Field( + 150.0, description="Per-sample ETA before any sample has completed." + ) + + +class GuiPollingConfig(BaseModel): + # aaredaq gui/threads/daq_worker.py:158 + status_poll_ms: Millis = Field(500, description="DAQ status polling period.") + # aaredaq gui/threads/daq_worker.py:58 + spreadsheet_every_n_polls: int = Field( + 25, + ge=1, + description="Refresh the spreadsheet every N status polls (25 × 500 ms = 12.5 s; comment says 5 s).", + ) + # aaredaq gui/threads/daq_worker.py:217 + baton_tick_ms: Millis = Field(1000, description="Baton countdown tick.") + # aaredaq gui/threads/daq_worker.py:1897,2077,2351 + stream_restart_ms: Millis = Field( + 1000, description="Delay before restarting a dropped SSE stream." + ) + # aaredaq gui/panels/automation_panel.py:53 + automation_refresh_ms: Millis = Field(1000, description="Automation panel refresh.") + # aaredaq gui/main_window.py:880 + idle_check_ms: Millis = Field(60_000, description="Idle-session check period.") + # aaredaq gui/main_window.py:885 + remote_close_poll_ms: Millis = Field(1000, description="Remote-close request poll.") + # aaredaq gui/widgets/status_bar.py:155 + status_message_clear_ms: Millis = Field( + 5000, description="How long transient status-bar messages stay." + ) + # aaredaq gui/panels/illumination_panel.py:11 + readback_grace_s: Seconds = Field( + 1.5, description="Ignore readback drift for this long after a set." + ) + # aaredaq gui/threads/prediction_subscriber.py:28 + max_prediction_frames_in_flight: int = Field( + 2, ge=1, description="Back-pressure on the prediction stream." + ) + + +class GuiSmartRotationConfig(BaseModel): + # aaredaq gui/panels/smart_rotation_panel.py:29 + kdose_1a_ph_um2_gy: Positive = Field(2000.0, description="Dose-rate constant at 1 Å.") + # aaredaq gui/panels/smart_rotation_panel.py:36 + glass_transition_k: Positive = Field( + 200.0, description="Temperature above which dose limits tighten." + ) + # aaredaq gui/panels/smart_rotation_panel.py:51 + visible_res_min_a: Positive = Field(0.8, description="Resolution slider minimum.") + # aaredaq gui/panels/smart_rotation_panel.py:52 + visible_res_max_a: Positive = Field(10.0, description="Resolution slider maximum.") + # aaredaq gui/panels/smart_rotation_panel.py:53 + visible_res_default_a: Positive = Field(2.0, description="Resolution slider default.") + # aaredaq gui/panels/smart_rotation_panel.py:56 + daq_res_eps_a: Positive = Field(0.001, description="Resolution equality tolerance.") + # aaredaq gui/panels/smart_rotation_panel.py:58 + dtz_step_mm: Positive = Field(10.0, description="dtz spinbox step.") + # aaredaq gui/panels/smart_rotation_panel.py:59 + exp_time_step_s: Positive = Field(0.001, description="Exposure spinbox step.") + # aaredaq gui/panels/smart_rotation_panel.py:60 + transmission_sig_digits: int = Field( + 2, ge=0, le=6, description="Transmission display precision." + ) + + @model_validator(mode="after") + def _ordered(self) -> GuiSmartRotationConfig: + if not (self.visible_res_min_a <= self.visible_res_default_a <= self.visible_res_max_a): + raise ValueError( + "visible_res_min_a <= visible_res_default_a <= visible_res_max_a required" + ) + return self + + +class GuiFilenameConfig(BaseModel): + # aaredaq gui/panels/file_path_panel.py:64 + directory_template: str = Field( + "{date}/{puck}/{position}", + description="Default directory template. The one default here that deliberately differs from the code: the shipped value is '{date}/{puck}/{pos}', and {pos} is never substituted — see rotation_filename_path.md §6.6.", + ) + # aaredaq gui/panels/file_path_panel.py:77 + prefix_template: str = Field("{sample}", description="Default file-prefix template.") + # aaredaq gui/panels/file_path_panel.py:92 + run_number_min: int = Field(1, ge=0, description="Run-number spinbox minimum.") + # aaredaq gui/panels/file_path_panel.py:92 + run_number_max: int = Field(999, ge=1, description="Run-number spinbox maximum.") + + @model_validator(mode="after") + def _ordered(self) -> GuiFilenameConfig: + if self.run_number_min >= self.run_number_max: + raise ValueError("run_number_min must be < run_number_max") + return self + + +class GuiConfig(BaseModel): + automation: GuiAutomationConfig = Field(default_factory=GuiAutomationConfig) + polling: GuiPollingConfig = Field(default_factory=GuiPollingConfig) + smart_rotation: GuiSmartRotationConfig = Field(default_factory=GuiSmartRotationConfig) + filenames: GuiFilenameConfig = Field(default_factory=GuiFilenameConfig) + # aaredaq gui/panels/abr_tweak_panel.py:12 + abr_step_um: Positive = Field(5.0, description="ABR tweak step.") + # aaredaq gui/panels/monochromator_panel.py:19 + energy_at_target_tol_kev: Positive = Field( + 0.001, description="Energy considered 'at target' within this." + ) + # aaredaq gui/panels/smargon_trace_panel.py:36 + smargon_trace_table_rows: int = Field( + 20, ge=1, description="Rows shown in the smargon trace table." + ) + + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- + + +class LoggingConfig(BaseModel): + """Logger levels. All DEBUG today with no override path.""" + + # aarecommon config/logger.py:88 + root: LogLevel = Field("WARNING", description="Root logger.") + # aarecommon config/logger.py:82 + aare_daq: LogLevel = Field("DEBUG", description="'aareDAQ' logger.") + # aarecommon config/logger.py:83 + aare_gui: LogLevel = Field("DEBUG", description="'aareGUI' logger.") + # aarecommon config/logger.py:63 + uvicorn: LogLevel = Field("DEBUG", description="uvicorn server logger.") + # aarecommon config/logger.py:64 + uvicorn_access: LogLevel = Field("INFO", description="uvicorn access log.") + # aarecommon config/logger.py:84 + redis_lock: LogLevel = Field("WARNING", description="redis_lock library.") + # aarecommon config/logger.py:85 + urllib3: LogLevel = Field("WARNING", description="urllib3 library.") + # aarecommon config/logger.py:86 + matplotlib: LogLevel = Field("WARNING", description="matplotlib library.") + + +# --------------------------------------------------------------------------- +# Root +# --------------------------------------------------------------------------- + + +class AareConfig(BaseModel): + """All user-configurable behaviour of the Aare DAQ and GUI for one beamline. + + Load order suggested: built-in defaults → per-beamline YAML → Redis overrides + for the operator-tunable groups (``automation``, ``gui.automation``, + ``hardware.lamps``), so local contacts can change those without a deploy. + """ + + network: NetworkConfig + paths: PathsConfig = Field(default_factory=PathsConfig) + server: ServerConfig = Field(default_factory=ServerConfig) + auth: AuthConfig = Field(default_factory=AuthConfig) + hardware: HardwareConfig = Field(default_factory=HardwareConfig) + timeouts: TimeoutsConfig = Field(default_factory=TimeoutsConfig) + automation: AutomationConfig = Field(default_factory=AutomationConfig) + detector: DetectorDatasetConfig = Field(default_factory=DetectorDatasetConfig) + gui: GuiConfig = Field(default_factory=GuiConfig) + logging: LoggingConfig = Field(default_factory=LoggingConfig)