Files
AareDAQ/src/aare/daq/config.py
T
2026-07-27 14:45:12 +02:00

1292 lines
48 KiB
Python

import base64
import io
import json
import time
from dataclasses import asdict, is_dataclass
from datetime import datetime
from typing import List, Tuple
import numpy as np
import redis
import redis_lock
from aarecommon.config.beamline import cfg_get
from aarecommon.config.logger import setup_logger
from aarecommon.errors.exception_handler import BeamlineBusyException
from aarecommon.math.coordinate import AerotechCoordinate, Coordinate
from aarecommon.models.auth import (
BatonHolderInfo,
BatonRequest,
BatonRequestStatus,
BatonTransferQueue,
)
from aarecommon.models.automation import AutomationProgress
from aarecommon.models.beamline import MXBeamline
from aarecommon.models.models import (
BeamlineSettingsModel,
BeamlineStateEnum,
BeamMarkCoeffModel,
CryojetSettingsModel,
CrystalSize,
FluorescenceSpectrumOutputModel,
OpenGuiSessionInfo,
SampleCameraSettings,
SampleShortInfo,
SampleShortInfoList,
SessionsStateEnum,
SessionStatus,
SimpleScanParameters,
SimpleStrategyInputModel,
ZoomModeEnum,
ZoomModel,
zoom_manager,
)
from aare.daq.config_model import LocalContactConfigModel
# TODO WHAT SHOULD THIS BE? This should be in the YAMl file it is beamline specific
ABR_POS_MOUNT = AerotechCoordinate(at_mm=Coordinate(x=0, y=0, z=0), omega_deg=0)
# TODO WHAT SHOULD THIS BE? This should be in the YAMl file it is beamline specific
ABR_POS_MOUNT = AerotechCoordinate(at_mm=Coordinate(x=0, y=0, z=0), omega_deg=0)
ABR_OMEGA_MOUNT = 0.0
DEFAULT_LENS_MAGNIFICATION = 10.0
logger = setup_logger("aareDAQ")
# Serialize the NumPy array to a Base64 string
def numpy_to_base64(array: np.ndarray) -> str:
buffer = io.BytesIO() # Create an in-memory buffer
# Save the array as a binary file to the buffer
np.save(buffer, array, allow_pickle=False)
# Reset buffer's position to the beginning
buffer.seek(0)
# Base64 encode and convert bytes to string
return base64.b64encode(buffer.read()).decode()
# Retrieve and decode the array
def base64_to_numpy(encoded_str: str | None) -> np.ndarray | None:
if encoded_str is None:
return None
decoded = base64.b64decode(encoded_str) # Base64 decode
buffer = io.BytesIO(decoded) # Convert bytes to a buffer
return np.load(buffer) # Load buffer as a NumPy array
class BeamlineConfig:
"""
Manages the configuration and state of a beamline system by interacting with a Redis
backend. Provides methods for updating status, settings, and related state details.
This class encapsulates the logic needed to manage beamline states and corresponding
operations such as setting busy flags, updating or retrieving settings, and handling
different beamline-related data such as background images. By connecting to a Redis
database, it ensures that the beamline's state and configurations are stored and
retrieved efficiently. Each beamline instance is identified by a unique name, and all
operations are performed with atomic safety using Redis locking mechanisms.
Attributes:
__bl (str): The beamline's unique identifier or name.
__client (redis.Redis): Redis client instance used for interacting with the datastore.
"""
GUI_SESSION_EXPIRE_SECONDS = 60 * 10
def __init__(self, bl: MXBeamline):
self.__mxb = bl
self.__bl = bl.value.lower()
if bl is MXBeamline.SIMULATED:
host = "localhost"
else:
host = cfg_get("daq.hardware.redis_url", f"{self.__bl}-redis.psi.ch")
self.__client = redis.Redis(host=host, port=6379, db=0, decode_responses=True)
self.simulated_detector = bl is MXBeamline.SIMULATED
self._initialize_optional_yaml_defaults()
def _initialize_optional_yaml_defaults(self) -> None:
raw_dtz_safe_position = cfg_get("daq.hardware.dtz_safe_position")
if raw_dtz_safe_position in (None, ""):
return
if self.dtz_safe_position is not None:
return
try:
self.dtz_safe_position = float(raw_dtz_safe_position)
logger.info(
f"Initialized dtz_safe_position from beamline config: {self.dtz_safe_position}"
)
except (TypeError, ValueError) as e:
logger.warning(
f"Ignoring invalid daq.hardware.dtz_safe_position value: "
f"{raw_dtz_safe_position!r} ({e})"
)
# GUI session management
def _gui_sessions_index_key(self) -> str:
return f"{self.__bl}:gui_sessions"
def _gui_session_key(self, session: int) -> str:
return f"{self.__bl}:gui_session:{session}"
def _write_gui_session(
self, payload: OpenGuiSessionInfo, expiry_sec: int | None = None
) -> None:
expiry = int(expiry_sec or self.GUI_SESSION_EXPIRE_SECONDS)
pipe = self.__client.pipeline()
pipe.set(self._gui_session_key(payload.session), payload.model_dump_json())
pipe.expire(self._gui_session_key(payload.session), expiry)
pipe.sadd(self._gui_sessions_index_key(), payload.session)
pipe.execute()
def _current_gui_session_ttl(self, session: int) -> int | None:
try:
ttl = int(self.__client.ttl(self._gui_session_key(session)))
except Exception:
return None
if ttl > 0:
return ttl
return None
def _read_gui_session(self, session: int) -> OpenGuiSessionInfo | None:
raw = self.__client.get(self._gui_session_key(session))
if raw is None:
return None
try:
return OpenGuiSessionInfo(**json.loads(raw))
except Exception:
logger.warning(f"Failed to parse GUI session info for session {session}")
return None
def touch_gui_session(
self, *, session: int, username: str, staff: bool = False, expiry_sec: int
) -> OpenGuiSessionInfo:
existing = self._read_gui_session(session)
holder = self.baton_holder
holds_baton = bool(holder is not None and holder.session == session)
payload = OpenGuiSessionInfo(
session=session,
username=username,
staff=staff,
last_seen_ts=time.time(),
last_interaction_ts=existing.last_interaction_ts if existing is not None else None,
close_requested=existing.close_requested if existing is not None else False,
close_requested_by=existing.close_requested_by if existing is not None else None,
close_requested_at=existing.close_requested_at if existing is not None else None,
close_grace_seconds=existing.close_grace_seconds if existing is not None else None,
holds_baton=holds_baton,
)
self._write_gui_session(payload, expiry_sec=expiry_sec)
self.purge_expired_gui_sessions()
return payload
def update_gui_interaction(
self, session: int, last_interaction_ts: float
) -> OpenGuiSessionInfo | None:
payload = self._read_gui_session(session)
if payload is None:
self.__client.srem(self._gui_sessions_index_key(), session)
return None
payload.last_interaction_ts = last_interaction_ts
ttl = self._current_gui_session_ttl(session)
self._write_gui_session(payload, expiry_sec=ttl)
return payload
def request_gui_close(
self, *, session: int, requested_by: str, grace_seconds: int = 60
) -> OpenGuiSessionInfo | None:
payload = self._read_gui_session(session)
if payload is None:
self.__client.srem(self._gui_sessions_index_key(), session)
return None
payload.close_requested = True
payload.close_requested_by = requested_by
payload.close_requested_at = time.time()
payload.close_grace_seconds = grace_seconds
ttl = self._current_gui_session_ttl(session)
self._write_gui_session(payload, expiry_sec=ttl)
return payload
def clear_gui_close_request(self, session: int) -> None:
payload = self._read_gui_session(session)
if payload is None:
self.__client.srem(self._gui_sessions_index_key(), session)
return
payload.close_requested = False
payload.close_requested_by = None
payload.close_requested_at = None
payload.close_grace_seconds = None
ttl = self._current_gui_session_ttl(session)
self._write_gui_session(payload, expiry_sec=ttl)
def remove_gui_session(self, session: int) -> None:
pipe = self.__client.pipeline()
pipe.delete(self._gui_session_key(session))
pipe.srem(self._gui_sessions_index_key(), session)
pipe.execute()
self.purge_expired_gui_sessions()
def purge_expired_gui_sessions(self) -> None:
session_ids = self.__client.smembers(self._gui_sessions_index_key())
if not session_ids:
return
expired_ids: list[str] = []
for session_id in session_ids:
if not self.__client.exists(self._gui_session_key(int(session_id))):
expired_ids.append(session_id)
if expired_ids:
self.__client.srem(self._gui_sessions_index_key(), *expired_ids)
def get_open_gui_sessions(self) -> list[OpenGuiSessionInfo]:
self.purge_expired_gui_sessions()
session_ids = self.__client.smembers(self._gui_sessions_index_key())
if not session_ids:
return []
holder = self.baton_holder
holder_session = holder.session if holder is not None else None
sessions: list[OpenGuiSessionInfo] = []
for session_id in sorted((int(s) for s in session_ids)):
payload = self._read_gui_session(session_id)
if payload is not None:
payload.holds_baton = payload.session == holder_session
sessions.append(payload)
return sessions
def get_gui_session(self, session: int) -> OpenGuiSessionInfo | None:
payload = self._read_gui_session(session)
if payload is None:
self.__client.srem(self._gui_sessions_index_key(), session)
return None
holder = self.baton_holder
payload.holds_baton = bool(holder is not None and holder.session == payload.session)
return payload
# Session and authentication management
@property
def allow_non_staff_request_from_staff(self) -> bool:
raw = self.__client.get(f"{self.__bl}:allow_non_staff_request_from_staff")
if raw is None:
return False
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
@allow_non_staff_request_from_staff.setter
def allow_non_staff_request_from_staff(self, enabled: bool) -> None:
if enabled:
self.__client.set(f"{self.__bl}:allow_non_staff_request_from_staff", "1")
else:
self.__client.delete(f"{self.__bl}:allow_non_staff_request_from_staff")
def generate_session(self) -> int:
return int(self.__client.incr(f"{self.__bl}:session"))
@property
def active_session(self) -> int | None:
"""
Read active_session from Redis and convert to int.
Returns:
int if present and valid, otherwise None.
"""
baton = self.baton_holder
if baton is None:
return None
return baton.session
def session_status(self, session: int) -> SessionStatus:
return SessionStatus(session=self.session_state(session), current_pgroup=self.pgroup)
def session_state(self, session: int) -> SessionsStateEnum:
holder = self.baton_holder
pending = self.pending_baton_request
if holder is None:
return SessionsStateEnum.Vacant
if holder.session == session:
# You are the holder. Check if someone else requested from you.
if (
pending
and pending.status == BatonRequestStatus.PENDING
and pending.holder_session == session
):
return SessionsStateEnum.PendingElseToYou
return SessionsStateEnum.OwnedByYou
else:
# Someone else is the holder. Check if you requested from them.
if (
pending
and pending.status == BatonRequestStatus.PENDING
and pending.requester_session == session
):
return SessionsStateEnum.PendingYouToElse
return SessionsStateEnum.OwnedByElse
def try_set_active_session(self, session: int, expiry_sec: int) -> None:
with redis_lock.Lock(self.__client, f"{self.__bl}:active_session_lock", expire=10):
active = self.active_session
if active is None:
self.__client.set(f"{self.__bl}:active_session", session)
elif active != session:
raise Exception(
"There is already active session with different id. Try again later."
)
self.__client.expire(f"{self.__bl}:active_session", expiry_sec)
# TODO finish setting this up!
def try_extend_active_session(self, session: int, expiry_sec: int) -> None:
with redis_lock.Lock(self.__client, f"{self.__bl}:active_session_lock", expire=10):
active = self.active_session
if active is None:
raise Exception("There is no active session with given id. Try again later.")
# if active == session:
# self.__client.expire(f"{self.__bl}:active_session", expiry_sec, gt=True)
if active == session:
key = f"{self.__bl}:active_session"
current_ttl = self.__client.ttl(key)
# Redis compatibility:
# some Redis servers do not support EXPIRE with GT option.
# Emulate "extend only if greater" manually while holding the lock.
if current_ttl is None or current_ttl < 0 or current_ttl < expiry_sec:
self.__client.expire(key, expiry_sec)
else:
raise Exception(
"There is already active session with different id. Try again later."
)
def end_active_session(self, session: int) -> None:
with redis_lock.Lock(self.__client, f"{self.__bl}:active_session_lock", expire=10):
active = self.active_session
if active is None:
return
if active == session:
self.__client.delete(f"{self.__bl}:active_session")
self.__client.delete(f"{self.__bl}:baton_holder")
def force_set_active_session(self, session: int, expiry_sec: int) -> None:
# Ensure that there is no active try-set for active session
with redis_lock.Lock(self.__client, f"{self.__bl}:active_session_lock", expire=10):
self.__client.set(f"{self.__bl}:active_session", session)
self.__client.expire(f"{self.__bl}:active_session", expiry_sec)
# ========== BATON SYSTEM ==========
@property
def baton_holder(self) -> BatonHolderInfo | None:
"""Get information about the current baton holder."""
tmp = self.__client.get(f"{self.__bl}:baton_holder")
if tmp is None:
return None
try:
return BatonHolderInfo(**json.loads(tmp))
except Exception:
return None
@baton_holder.setter
def baton_holder(self, info: BatonHolderInfo | None) -> None:
if info is None:
self.__client.delete(f"{self.__bl}:baton_holder")
else:
self.__client.set(f"{self.__bl}:baton_holder", info.model_dump_json())
@property
def pending_baton_request(self) -> BatonRequest | None:
"""Get the current pending baton request, if any."""
tmp = self.__client.get(f"{self.__bl}:baton_request")
if tmp is None:
return None
try:
return BatonRequest(**json.loads(tmp))
except Exception:
return None
def set_pending_baton_request(
self, request: BatonRequest | None, timeout_sec: int = 30
) -> None:
"""Set a pending baton request with auto-expiry for timeout."""
if request is None:
self.__client.delete(f"{self.__bl}:baton_request")
else:
self.__client.set(f"{self.__bl}:baton_request", request.model_dump_json())
# Add a few seconds buffer so we can detect timeout vs expiry
self.__client.expire(f"{self.__bl}:baton_request", timeout_sec + 5)
def clear_pending_baton_request(self) -> None:
self.__client.delete(f"{self.__bl}:baton_request")
@property
def queued_baton_transfer(self) -> BatonTransferQueue | None:
"""Get queued transfer waiting for beamline to be available."""
tmp = self.__client.get(f"{self.__bl}:baton_transfer_queue")
if tmp is None:
return None
try:
return BatonTransferQueue(**json.loads(tmp))
except Exception:
return None
@queued_baton_transfer.setter
def queued_baton_transfer(self, transfer: BatonTransferQueue | None) -> None:
if transfer is None:
self.__client.delete(f"{self.__bl}:baton_transfer_queue")
else:
self.__client.set(f"{self.__bl}:baton_transfer_queue", transfer.model_dump_json())
def can_transfer_baton_now(self) -> bool:
"""Check if baton can be transferred (beamline not mid-operation)."""
# Can't transfer while beamline is busy
if self.state_busy:
return False
# Add automation queue check here when you implement it
# if self.automation_queue_running:
# return False
return True
def execute_baton_transfer(
self,
to_session: int,
to_username: str,
to_is_staff: bool,
to_pgroup: str | None,
expiry_sec: int,
) -> None:
"""
Atomically transfer the baton to a new holder.
Use existing active_session_lock for consistency.
"""
with redis_lock.Lock(self.__client, f"{self.__bl}:active_session_lock", expire=10):
self.__client.set(f"{self.__bl}:active_session", to_session)
self.__client.expire(f"{self.__bl}:active_session", expiry_sec)
self.baton_holder = BatonHolderInfo(
username=to_username, session=to_session, is_staff=to_is_staff, pgroup=to_pgroup
)
# Clear any pending request or queued transfer
self.clear_pending_baton_request()
self.queued_baton_transfer = None
def process_queued_transfer_if_ready(self, expiry_sec: int) -> bool:
"""
Check if there's a queued transfer and beamline is now available.
Returns True if transfer was executed.
"""
queued = self.queued_baton_transfer
if queued is None:
return False
if not self.can_transfer_baton_now():
return False
self.execute_baton_transfer(
to_session=queued.target_session,
to_username=queued.target_username,
to_is_staff=queued.target_is_staff,
to_pgroup=queued.target_pgroup,
expiry_sec=expiry_sec,
)
return True
@property
def pgroup(self) -> str | None:
tmp = self.__client.get(f"{self.__bl}:pgroup")
return tmp
@pgroup.setter
def pgroup(self, pgroup: str | None) -> None:
if pgroup is None:
self.__client.delete(f"{self.__bl}:pgroup")
else:
self.__client.set(f"{self.__bl}:pgroup", pgroup)
@property
def commissioning_mode(self) -> bool:
tmp = self.__client.get(f"{self.__bl}:commissioning_mode")
if tmp is None:
return False
return True
@commissioning_mode.setter
def commissioning_mode(self, commisioning_mode: bool) -> None:
if commisioning_mode:
self.__client.set(f"{self.__bl}:commissioning_mode", "1")
else:
self.__client.delete(f"{self.__bl}:commissioning_mode")
# Beamline state management
# Atomic check if beamline is busy and if not set state to busy
def try_set_busy(self, timeout: int | None = None):
with redis_lock.Lock(self.__client, f"{self.__bl}:move_state_lock", expire=10):
if self.state_busy:
raise BeamlineBusyException("Beamline is busy")
self.state_busy = True
if timeout is not None:
self.__client.expire(f"{self.__bl}:busy", timeout)
def set_busy(self, target: BeamlineStateEnum, timeout: int | None = None):
self.try_set_busy(timeout=timeout)
curr_state = self.state
if curr_state != target:
self.state_busy = False
raise Exception("Beamline is not in a proper state")
def start_moving(
self, target: BeamlineStateEnum, timeout: int | None = None
) -> BeamlineStateEnum:
self.try_set_busy(timeout=timeout)
curr_state = self.state
if target == curr_state:
self.state_busy = False
else:
self.state = BeamlineStateEnum.Moving
return curr_state
@property
def state(self) -> BeamlineStateEnum:
raw_value = self.__client.get(f"{self.__bl}:state")
if raw_value is None:
return BeamlineStateEnum.Maintenance
try:
int_value = int(raw_value) # Ensure it's an integer
return BeamlineStateEnum(int_value) # Convert to BeamlineStateEnum
except (ValueError, KeyError):
raise ValueError(
f"Invalid 'mx_state' value: {raw_value}. Expected integer corresponding to a BeamlineStateEnum."
)
@state.setter
def state(self, state: BeamlineStateEnum):
self.__client.set(f"{self.__bl}:state", state.value)
@property
def state_busy(self) -> bool:
return self.__client.get(f"{self.__bl}:busy") is not None
@state_busy.setter
def state_busy(self, i: bool):
if i:
self.__client.set(f"{self.__bl}:busy", "1")
else:
self.__client.delete(f"{self.__bl}:busy")
# Other beamline settings
@property
def tell_mount_count(self) -> int:
return int(self.__client.incr(f"{self.__bl}:tell_mount_count"))
def pixel_to_mm(self, zoom: float) -> float:
cfg = self.settings
base_pixel_in_mm = 1.0 / (
cfg.camera_translation_factor_b * np.exp(cfg.camera_translation_factor_a * zoom)
)
# Apply lens magnification correction relative to the default 10x lens.
# A lower magnification lens (e.g. 5x) makes each pixel cover more physical space.
lens_magnification = cfg_get("daq.hardware.lens_magnification", DEFAULT_LENS_MAGNIFICATION)
try:
lens_magnification = float(lens_magnification)
except (TypeError, ValueError):
lens_magnification = DEFAULT_LENS_MAGNIFICATION
if lens_magnification <= 0:
lens_magnification = DEFAULT_LENS_MAGNIFICATION
return base_pixel_in_mm * (DEFAULT_LENS_MAGNIFICATION / lens_magnification)
def zoom_for_pixel_to_mm(self, target_pixel_in_mm: float) -> float:
"""Inverse of :meth:`pixel_to_mm`: the zoom at which one pixel covers
``target_pixel_in_mm`` millimetres.
pixel_to_mm(z) = lens_factor / (b * exp(a*z)) =>
z = ln(lens_factor / (b * target)) / a
"""
if target_pixel_in_mm <= 0:
raise ValueError(f"target_pixel_in_mm must be > 0, got {target_pixel_in_mm}")
cfg = self.settings
a = cfg.camera_translation_factor_a
b = cfg.camera_translation_factor_b
lens_magnification = cfg_get("daq.hardware.lens_magnification", DEFAULT_LENS_MAGNIFICATION)
try:
lens_magnification = float(lens_magnification)
except (TypeError, ValueError):
lens_magnification = DEFAULT_LENS_MAGNIFICATION
if lens_magnification <= 0:
lens_magnification = DEFAULT_LENS_MAGNIFICATION
lens_factor = DEFAULT_LENS_MAGNIFICATION / lens_magnification
return float(np.log(lens_factor / (b * target_pixel_in_mm)) / a)
@property
def beam_center(self) -> Tuple[float, float]:
tmp_x = self.__client.get(f"{self.__bl}:beam_center_x")
tmp_y = self.__client.get(f"{self.__bl}:beam_center_y")
if tmp_x:
val_x = float(tmp_x)
else:
val_x = 0
if tmp_y:
val_y = float(tmp_y)
else:
val_y = 0
return val_x, val_y
@beam_center.setter
def beam_center(self, data: Tuple[float, float]):
self.__client.set(f"{self.__bl}:beam_center_x", data[0])
self.__client.set(f"{self.__bl}:beam_center_y", data[1])
@property
def beam_size_mm(self) -> Coordinate:
tmp_x = self.__client.get(f"{self.__bl}:beam_size_x")
tmp_y = self.__client.get(f"{self.__bl}:beam_size_y")
if tmp_x:
val_x = float(tmp_x)
else:
val_x = 0.04
if tmp_y:
val_y = float(tmp_y)
else:
val_y = 0.04
return Coordinate(x=val_x, y=val_y)
@beam_size_mm.setter
def beam_size_mm(self, data: Coordinate):
self.__client.set(f"{self.__bl}:beam_size_x", data.x)
self.__client.set(f"{self.__bl}:beam_size_y", data.y)
def __get_settings(self) -> BeamlineSettingsModel:
tmp = self.__client.get(f"{self.__bl}:settings")
if tmp is None:
return BeamlineSettingsModel()
data_dict = json.loads(tmp)
model = BeamlineSettingsModel(**data_dict)
return model
@property
def settings(self) -> BeamlineSettingsModel:
with redis_lock.Lock(self.__client, f"{self.__bl}:settings_lock", expire=10):
return self.__get_settings()
@settings.setter
def settings(self, data: BeamlineSettingsModel):
with redis_lock.Lock(self.__client, f"{self.__bl}:settings_lock", expire=10):
current = self.__get_settings()
updated_data = current.model_copy(update=data.model_dump(exclude_unset=True))
self.__client.set(f"{self.__bl}:settings", updated_data.model_dump_json())
@property
def cryojet_settings(self) -> CryojetSettingsModel:
tmp = self.__client.get(f"{self.__bl}:cryojet_settings")
if tmp is None:
return CryojetSettingsModel()
data_dict = json.loads(tmp)
return CryojetSettingsModel(**data_dict)
@cryojet_settings.setter
def cryojet_settings(self, data: CryojetSettingsModel):
self.__client.set(f"{self.__bl}:cryojet_settings", data.model_dump_json())
def get_alc_bkg(self, zoom: float, exp: float, gain: float) -> np.ndarray | None:
return base64_to_numpy(self.__client.get(f"{self.__bl}:bkg{zoom:.1f}_{exp:.2f}_{gain:.1f}"))
def put_alc_bkg(self, zoom: float, exp: float, gain: float, data: np.ndarray):
self.__client.set(f"{self.__bl}:bkg{zoom:.1f}_{exp:.2f}_{gain:.1f}", numpy_to_base64(data))
@property
def spreadsheet(self) -> SampleShortInfoList:
tmp = self.__client.get(f"{self.__bl}:sample_spreadsheet")
if tmp is None:
return SampleShortInfoList(s=[])
data_dict = json.loads(tmp)
return SampleShortInfoList(**data_dict)
def spreadsheet_pgroup(self, pgroups: List[str]) -> SampleShortInfoList:
sample = self.spreadsheet
sample.s = list(filter(lambda x: x.user in pgroups, sample.s))
return sample
@spreadsheet.setter
def spreadsheet(self, data: SampleShortInfoList):
self.__client.set(f"{self.__bl}:sample_spreadsheet", data.model_dump_json())
def listen_changes_spreadsheet(self) -> redis.client.PubSub:
self.__client.config_set("notify-keyspace-events", "KEA")
pubsub = self.__client.pubsub()
pubsub.psubscribe(f"__keyspace@0__:{self.__bl}:sample_spreadsheet")
return pubsub
@property
def reference_tools(self) -> SampleShortInfoList:
tmp = self.__client.get(f"{self.__bl}:reference-tools")
if tmp is None:
return SampleShortInfoList(s=[])
data_dict = json.loads(tmp)
return SampleShortInfoList(**data_dict)
@reference_tools.setter
def reference_tools(self, data: SampleShortInfoList):
self.__client.set(f"{self.__bl}:reference-tools", data.model_dump_json())
def listen_changes_reference_tools(self) -> redis.client.PubSub:
self.__client.config_set("notify-keyspace-events", "KEA")
pubsub = self.__client.pubsub()
pubsub.psubscribe(f"__keyspace@0__:{self.__bl}:reference-tools")
return pubsub
@property
def current_sample(self) -> SampleShortInfo | None:
tmp = self.__client.get(f"{self.__bl}:current_sample")
if tmp is None:
return None
data_dict = json.loads(tmp)
return SampleShortInfo(**data_dict)
@current_sample.setter
def current_sample(self, sample: SampleShortInfo | None):
if sample is None:
self.__client.delete(f"{self.__bl}:current_sample")
else:
self.__client.set(f"{self.__bl}:current_sample", sample.model_dump_json())
@property
def beam_mark_coeff(self) -> BeamMarkCoeffModel:
tmp = self.__client.get(f"{self.__bl}:beam_center_camera")
if tmp is None:
return BeamMarkCoeffModel()
data_dict = json.loads(tmp)
return BeamMarkCoeffModel(**data_dict)
@beam_mark_coeff.setter
def beam_mark_coeff(self, data: BeamMarkCoeffModel):
self.__client.set(f"{self.__bl}:beam_center_camera", data.model_dump_json())
# TODO tidy up zoom functions
@property
def zoom_mode(self) -> ZoomModeEnum:
raw_value = self.__client.get(f"{self.__bl}:zoom_mode")
if raw_value is None:
print("no zoom mode given, defaulting to user mode")
return ZoomModeEnum.User
try:
int_value = int(raw_value) # Ensure it's an integer
return ZoomModeEnum(int_value) # Convert to ZoomModeEnum
except (ValueError, KeyError):
raise ValueError(
f"Invalid 'mx_zoom_mode' value: {raw_value}. Expected integer corresponding to a ZoomModeEnum."
)
@zoom_mode.setter
def zoom_mode(self, mode: ZoomModeEnum):
self.__client.set(f"{self.__bl}:zoom_mode", mode.value)
@staticmethod
def zoom_setting_string(mode: ZoomModeEnum = ZoomModeEnum.User) -> str:
if mode == ZoomModeEnum.BeamLocation:
setting_string = "bl_zoom_settings"
elif mode == ZoomModeEnum.LoopCenter:
setting_string = "alc_zoom_settings"
elif mode == ZoomModeEnum.User:
setting_string = "zoom_settings"
else:
print("invalid zoom_settings mode using manual user settings")
setting_string = "zoom_settings"
return setting_string
@property
def zoom_settings(self) -> ZoomModel:
mode = self.zoom_mode
if not mode or not isinstance(mode, ZoomModeEnum):
raise Exception("incorrect zoom settings mode used")
tmp = self.__client.get(f"{self.__bl}:{self.zoom_setting_string(mode)}")
if tmp is None:
return zoom_manager(mode, self.__mxb)
data_dict = json.loads(tmp)
return ZoomModel(**data_dict)
@zoom_settings.setter
def zoom_settings(self, data: ZoomModel):
mode = self.zoom_mode
if not mode or not isinstance(mode, ZoomModeEnum):
raise Exception("incorrect zoom settings mode used")
self.__client.set(f"{self.__bl}:{self.zoom_setting_string(mode)}", data.model_dump_json())
def save_zoom_camera_setting(
self, zoom_value: float, settings: SampleCameraSettings, mode: ZoomModeEnum | None = None
) -> None:
"""Insert/replace the camera settings for a single zoom stop in a given
mode's table (persisted to Redis under that mode's key). Defaults to the
active zoom mode; pass ``mode`` to target a specific one explicitly."""
mode = mode or self.zoom_mode
key = f"{self.__bl}:{self.zoom_setting_string(mode)}"
tmp = self.__client.get(key)
model = ZoomModel(**json.loads(tmp)) if tmp is not None else zoom_manager(mode, self.__mxb)
model.z[zoom_value] = settings
self.__client.set(key, model.model_dump_json())
@property
def abr_meas_pos(self) -> AerotechCoordinate:
tmp = self.__client.get(f"{self.__bl}:abr_meas_pos")
if tmp is None:
return ABR_POS_MOUNT
data_dict = json.loads(tmp)
return AerotechCoordinate(**data_dict)
@abr_meas_pos.setter
def abr_meas_pos(self, data: AerotechCoordinate):
self.__client.set(f"{self.__bl}:abr_meas_pos", data.model_dump_json())
@property
def dtz(self) -> float | None:
tmp = self.__client.get(f"{self.__bl}:dtz")
if tmp is None:
return None
return float(tmp)
@dtz.setter
def dtz(self, dtz: float):
self.__client.set(f"{self.__bl}:dtz", dtz)
@property
def dtz_safe_position(self) -> float | None:
tmp = self.__client.get(f"{self.__bl}:dtz_safe_position")
if tmp is None:
return None
return float(tmp)
@dtz_safe_position.setter
def dtz_safe_position(self, dtz: float):
self.__client.set(f"{self.__bl}:dtz_safe_position", dtz)
@property
def xrf(self) -> FluorescenceSpectrumOutputModel | None:
tmp = self.__client.get(f"{self.__bl}:xrf")
if tmp is None:
return None
data_dict = json.loads(tmp)
return FluorescenceSpectrumOutputModel(**data_dict)
@xrf.setter
def xrf(self, data: FluorescenceSpectrumOutputModel | None):
if data is None:
self.__client.delete(f"{self.__bl}:xrf")
else:
self.__client.set(f"{self.__bl}:xrf", data.model_dump_json())
def clear_mark_beam(self):
self.__client.delete(f"{self.__bl}:beam_mark")
def mark_beam(self, x_pxl: float, y_pxl: float, zoom: float):
self.__client.hset(
f"{self.__bl}:beam_mark", mapping={f"{zoom}": json.dumps({"x": x_pxl, "y": y_pxl})}
)
vals = self.__client.hgetall(f"{self.__bl}:beam_mark")
if len(vals) >= 3:
zooms = []
x_pxls = []
y_pxls = []
for k, v in vals.items():
zooms.append(float(k))
x_pxls.append(float(json.loads(v)["x"]))
y_pxls.append(float(json.loads(v)["y"]))
model = BeamMarkCoeffModel(
coeff_x=tuple(np.polyfit(zooms, x_pxls, deg=2)),
coeff_y=tuple(np.polyfit(zooms, y_pxls, deg=2)),
)
self.beam_mark_coeff = model
def get_beam_mark(self, zoom: float) -> tuple[float, float]:
model = self.beam_mark_coeff
x_pxl = np.polyval(model.coeff_x, zoom)
y_pxl = np.polyval(model.coeff_y, zoom)
return float(x_pxl), float(y_pxl)
@property
def crystal_size(self) -> CrystalSize:
tmp = self.__client.get(f"{self.__bl}:crystal_size")
if tmp is None:
return CrystalSize(x=0, y=0, z=0)
data_dict = json.loads(tmp)
return CrystalSize(**data_dict)
@crystal_size.setter
def crystal_size(self, xtal_size: CrystalSize):
self.__client.set(f"{self.__bl}:crystal_size", xtal_size.model_dump_json())
@property
def last_best_res(self) -> float | None:
tmp = self.__client.get(f"{self.__bl}:last_best_res")
if tmp is None:
return None
return float(tmp)
@last_best_res.setter
def last_best_res(self, best_res: float | None):
if best_res is None:
self.__client.delete(f"{self.__bl}:last_best_res")
else:
self.__client.set(f"{self.__bl}:last_best_res", best_res)
@property
def last_best_b_factor(self) -> float | None:
tmp = self.__client.get(f"{self.__bl}:last_best_b_factor")
if tmp is None:
return None
return float(tmp)
@last_best_b_factor.setter
def last_best_b_factor(self, last_best_b_factor: float | None):
if last_best_b_factor is None:
self.__client.delete(f"{self.__bl}:last_best_b_factor")
else:
self.__client.set(f"{self.__bl}:last_best_b_factor", last_best_b_factor)
def _mount_failure_streak_key(self) -> str:
return f"{self.__bl}:mount_fail_count"
def get_mount_failure_streak(self) -> int:
value = self.__client.get(self._mount_failure_streak_key())
return int(value) if value else 0
def increment_mount_failure_streak(self) -> int:
return int(self.__client.incr(self._mount_failure_streak_key()))
def reset_mount_failure_streak(self) -> None:
self.__client.delete(self._mount_failure_streak_key())
def get_mount_fail_count(self) -> int:
return self.get_mount_failure_streak()
def record_mount_failure(self) -> int:
return self.increment_mount_failure_streak()
def record_mount_success(self):
self.reset_mount_failure_streak()
@property
def simple_input_parameters(self) -> SimpleStrategyInputModel | None:
tmp = self.__client.get(f"{self.__bl}:simple_input_params")
if tmp is None:
return None
data_dict = json.loads(tmp)
return SimpleStrategyInputModel(**data_dict)
@simple_input_parameters.setter
def simple_input_parameters(self, input_params: SimpleStrategyInputModel | None):
if input_params is None:
self.__client.delete(f"{self.__bl}:simple_input_params")
else:
self.__client.set(f"{self.__bl}:simple_input_params", input_params.model_dump_json())
@property
def auto_params(self) -> SimpleScanParameters | None:
tmp = self.__client.get(f"{self.__bl}:auto_params")
if tmp is None:
logger.debug(f"auto_params missing in redis key {self.__bl}:auto_params")
return None
try:
data_dict = json.loads(tmp)
return SimpleScanParameters(**data_dict)
except Exception as e:
logger.error(f"failed to parse auto_params from redis: {e}; raw={tmp}")
return None
@auto_params.setter
def auto_params(self, params: SimpleScanParameters | None):
if params is None:
self.__client.delete(f"{self.__bl}:auto_params")
else:
self.__client.set(f"{self.__bl}:auto_params", params.model_dump_json())
def _automation_progress_key(self) -> str:
return f"{self.__bl}:automation_progress"
def _automation_progress_seq_key(self) -> str:
return f"{self.__bl}:automation_progress_seq"
def reset_automation_progress(self) -> None:
self.__client.set(self._automation_progress_seq_key(), 0)
self.__client.delete(self._automation_progress_key())
def get_automation_progress_state(self) -> dict:
seq_raw = self.__client.get(self._automation_progress_seq_key())
payload_raw = self.__client.get(self._automation_progress_key())
seq = int(seq_raw) if seq_raw is not None else 0
progress = json.loads(payload_raw) if payload_raw else None
return {"seq": seq, "progress": progress}
def set_automation_progress_state(self, progress: AutomationProgress | dict) -> dict:
def _json_default(value):
if isinstance(value, datetime):
return value.isoformat()
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
if isinstance(progress, dict):
payload = dict(progress)
elif hasattr(progress, "model_dump"):
payload = progress.model_dump()
elif is_dataclass(progress):
payload = asdict(progress)
else:
raise TypeError(f"Unsupported automation progress type: {type(progress).__name__}")
next_seq = int(self.__client.incr(self._automation_progress_seq_key()))
self.__client.set(
self._automation_progress_key(),
json.dumps(payload, separators=(",", ":"), default=_json_default),
)
return {"seq": next_seq, "progress": payload}
@property
def failed_mount_count(self) -> int:
tmp = self.__client.get(f"{self.__bl}:failed_mount_count")
if tmp is None:
return 0
try:
return int(tmp)
except (TypeError, ValueError):
logger.warning(
"Failed Mount Count is not an integer, resetting to 0.",
extra={"beamline": self.__bl, "tmp": tmp},
)
return 0
@failed_mount_count.setter
def failed_mount_count(self, count: int):
if count == 0:
self.__client.delete(f"{self.__bl}:failed_mount_count")
else:
self.__client.set(f"{self.__bl}:failed_mount_count", count)
def increment_failed_mount_count(self) -> int:
return int(self.__client.incr(f"{self.__bl}:failed_mount_count"))
def _runtime_sim_key(self, name: str) -> str:
return f"{self.__bl}:runtime:simulate:{name}"
def get_runtime_simulated(self, name: str, default: bool = False) -> bool:
raw = self.__client.get(self._runtime_sim_key(name))
if raw is None:
return default
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
def set_runtime_simulated(self, name: str, enabled: bool) -> None:
key = self._runtime_sim_key(name)
if enabled:
self.__client.set(key, "1")
else:
self.__client.delete(key)
@property
def simulate_bec(self) -> bool:
return self.get_runtime_simulated("bec", default=False)
@simulate_bec.setter
def simulate_bec(self, enabled: bool) -> None:
self.set_runtime_simulated("bec", enabled)
@property
def simulate_tell(self) -> bool:
return self.get_runtime_simulated("tell", default=False)
@simulate_tell.setter
def simulate_tell(self, enabled: bool) -> None:
self.set_runtime_simulated("tell", enabled)
@property
def simulate_aerotech(self) -> bool:
return self.get_runtime_simulated("aerotech", default=False)
@simulate_aerotech.setter
def simulate_aerotech(self, enabled: bool) -> None:
self.set_runtime_simulated("aerotech", enabled)
@property
def simulate_smargon(self) -> bool:
return self.get_runtime_simulated("smargon", default=False)
@simulate_smargon.setter
def simulate_smargon(self, enabled: bool) -> None:
self.set_runtime_simulated("smargon", enabled)
@property
def runtime_simulation_state(self) -> dict[str, bool]:
return {
"bec": self.simulate_bec,
"detector": bool(self.simulated_detector),
"tell": self.simulate_tell,
"aerotech": self.simulate_aerotech,
"smargon": self.simulate_smargon,
}
@staticmethod
def _coerce_optional_float(value) -> float | None:
if value is None:
return None
if isinstance(value, np.ndarray):
if value.size == 0:
return None
value = value.flatten().tolist()
if isinstance(value, (list, tuple)):
if len(value) == 0:
return None
value = value[0]
try:
return float(value)
except (TypeError, ValueError):
logger.warning(f"Failed to coerce cached numeric value to float: {value!r}")
return None
def _detector_metadata_key(self) -> str:
return f"{self.__bl}:detector_metadata"
def get_detector_metadata(self) -> dict:
try:
raw = self.__client.get(self._detector_metadata_key())
if raw in (None, "", b""):
return {}
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
payload = json.loads(str(raw))
return payload if isinstance(payload, dict) else {}
except Exception as e:
logger.warning(f"Failed to read detector metadata from Redis: {e}")
return {}
def set_detector_metadata(self, payload: dict) -> dict:
safe_payload = dict(payload or {})
safe_payload["dtz_low"] = self._coerce_optional_float(safe_payload.get("dtz_low"))
safe_payload["dtz_high"] = self._coerce_optional_float(safe_payload.get("dtz_high"))
safe_payload["pixel_size_mm"] = self._coerce_optional_float(
safe_payload.get("pixel_size_mm")
)
safe_payload["updated_at"] = datetime.now().isoformat(timespec="seconds")
self.__client.set(self._detector_metadata_key(), json.dumps(safe_payload))
return safe_payload
@property
def cached_detector_metadata(self) -> dict:
return self.get_detector_metadata()
@property
def cached_dtz_low(self) -> float | None:
value = self.get_detector_metadata().get("dtz_low")
return self._coerce_optional_float(value)
@property
def cached_dtz_high(self) -> float | None:
value = self.get_detector_metadata().get("dtz_high")
return self._coerce_optional_float(value)
@property
def local_contact_links(self) -> dict[str, str | None]:
detector_frontend = None
smargon_frontend = cfg_get(
"daq.hardware.smargon_frontend_url",
f"http://{self.__mxb.name.lower()}-smargopolo.psi.ch:8080/",
)
aerotech_frontend = cfg_get(
"daq.hardware.aerotech_url",
f"http://mx-{self.__mxb.name.lower()}-queue-01.psi.ch:5234/",
)
tell_hint = "Please check TELL status via Remmina / VNC."
if self.__mxb is MXBeamline.X06DA:
detector_frontend = "http://sls-gpu-001:8080/frontend"
elif self.__mxb is MXBeamline.X10SA:
detector_frontend = "http://sls-gpu-002:8080/frontend"
elif self.__mxb is MXBeamline.SIMULATED:
detector_frontend = None
smargon_frontend = None
aerotech_frontend = None
return {
"aerotech": aerotech_frontend,
"detector": detector_frontend,
"smargon": smargon_frontend,
"tell_hint": tell_hint,
}
def get_local_contact_config(self) -> LocalContactConfigModel:
default = LocalContactConfigModel()
try:
redis_key = f"{self.__bl}:local_contact_config"
raw_value = self.__client.get(redis_key)
if raw_value in (None, "", b""):
return default
if isinstance(raw_value, bytes):
raw_value = raw_value.decode("utf-8")
return LocalContactConfigModel.model_validate_json(str(raw_value))
except Exception as e:
logger.warning(f"Failed to read Local Contact config from Redis: {e}")
return default
def set_local_contact_config(
self, config: LocalContactConfigModel | dict
) -> LocalContactConfigModel:
validated = LocalContactConfigModel.model_validate(config)
try:
redis_key = f"{self.__bl}:local_contact_config"
self.__client.set(redis_key, validated.model_dump_json())
logger.info(f"Saved Local Contact config to Redis: {redis_key}")
except Exception as e:
logger.error(f"Failed to write Local Contact config to Redis: {e}")
raise
return validated
@property
def local_contact_config(self) -> LocalContactConfigModel:
return self.get_local_contact_config()
@local_contact_config.setter
def local_contact_config(self, value: LocalContactConfigModel | dict) -> None:
self.set_local_contact_config(value)
if __name__ == "__main__":
from aarecommon.config.beamline import mx_beamline
cfg = BeamlineConfig(bl=mx_beamline())
# cfg.allow_non_staff_request_from_staff = True
# cfg.state_busy = False
# fg.abr_meas_pos = AerotechCoordinate(at_mm=Coordinate(x=0.0,y=0.0,z=0.0))
cfg.dtz_safe_position = 300.0