1292 lines
48 KiB
Python
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
|