From c6698dca06d24ea9307d4195dbd47e6924b2875e Mon Sep 17 00:00:00 2001 From: Dawn Date: Thu, 11 Jun 2026 15:55:10 +0200 Subject: [PATCH 01/76] remove Apache access log-based authentication and simplify Kerberos proxy handling --- src/aare/daq/auth.py | 34 ---------------------------------- src/aare/daq/server.py | 13 +++++-------- 2 files changed, 5 insertions(+), 42 deletions(-) diff --git a/src/aare/daq/auth.py b/src/aare/daq/auth.py index 027d0c82..e13c1452 100644 --- a/src/aare/daq/auth.py +++ b/src/aare/daq/auth.py @@ -3,7 +3,6 @@ import ipaddress import logging import os import pwd -import re import uuid from datetime import datetime, timedelta, UTC from typing import List @@ -35,10 +34,6 @@ BATON_REQUEST_TIMEOUT_SECONDS = 30 STAFF_GROUP = "unx-MXgroup" SUPER_USERS = ["e10019", "e11206", "e18147"] -APACHE_ACCESS_LOG = "/var/log/httpd/daq-access.log" -# Common Log Format: IP - username [timestamp] "request" status bytes ... -_LOG_PATTERN = re.compile(r'^\S+ \S+ (\S+) \[.*?\] ".*?" (\d+)') - oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") class TokenData(BaseModel): @@ -55,35 +50,6 @@ def create_access_token(token: TokenData): return encoded_jwt -def authenticate_from_apache_log() -> str: - """ - Read the Apache access log and return the username from the most recent - 200 response. Apache and the DAQ server are co-located on the same machine. - """ - try: - with open(APACHE_ACCESS_LOG, 'r') as f: - lines = f.readlines() - except OSError as e: - raise AuthenticationException( - message=f"Cannot read Apache access log: {e}", - status_code=401, - headers={"WWW-Authenticate": "Bearer"}, - code=AuthErrorCode.INVALID_TOKEN, - ) from e - - for line in reversed(lines): - m = _LOG_PATTERN.match(line) - if m and m.group(2) == '200' and m.group(1) != '-': - return m.group(1) - - raise AuthenticationException( - message="No authenticated user found in Apache access log", - status_code=401, - headers={"WWW-Authenticate": "Bearer"}, - code=AuthErrorCode.INVALID_TOKEN, - ) - - def _is_loopback(host: str | None) -> bool: if host is None: return False diff --git a/src/aare/daq/server.py b/src/aare/daq/server.py index df4bd402..7317fc0f 100644 --- a/src/aare/daq/server.py +++ b/src/aare/daq/server.py @@ -260,21 +260,18 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends """ Authenticate a user and return an access token. - When the request carries an X-Remote-User header (set by the Apache Kerberos - proxy), the username is taken from that header and the proxy origin is verified. - Otherwise the username from the form data is used (local / dev access). + The request must carry an X-Remote-User header set by the Apache Kerberos + proxy. The client obtains a token by authenticating via Kerberos (NEGOTIATE) + against the Apache proxy, which forwards the request with X-Remote-User set. Args: request: The incoming HTTP request (used to inspect headers and client IP). - form_data: OAuth2 password request form containing username and password. + form_data: OAuth2 password request form (unused, required by OAuth2 spec). Returns: A dictionary containing the access token and token type. """ - if request.headers.get("X-Remote-User"): - username = auth.authenticate_from_proxy_header(request) - else: - username = auth.authenticate_from_apache_log() + username = auth.authenticate_from_proxy_header(request) data = await run_in_threadpool(auth.authenticate_user, cfg, username) return {"access_token": data, "token_type": "bearer"} From 04be8e1922ca9f0a46f242e2b36cb4bbba106822 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Thu, 11 Jun 2026 16:44:21 +0200 Subject: [PATCH 02/76] tokens WIP --- src/aare/daq/server.py | 2 +- src/aare/devices/bec_worker.py | 5 +---- src/aare/gui/auth.py | 24 +++++------------------- src/aare/gui/gui.py | 1 + 4 files changed, 8 insertions(+), 24 deletions(-) diff --git a/src/aare/daq/server.py b/src/aare/daq/server.py index 7317fc0f..9fb4e0a9 100644 --- a/src/aare/daq/server.py +++ b/src/aare/daq/server.py @@ -256,7 +256,7 @@ async def automation_progress_event_stream() -> AsyncGenerator[str, None]: return @app.post("/token") -async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()): +async def login(request: Request): """ Authenticate a user and return an access token. diff --git a/src/aare/devices/bec_worker.py b/src/aare/devices/bec_worker.py index 59130e81..a7fb78cc 100644 --- a/src/aare/devices/bec_worker.py +++ b/src/aare/devices/bec_worker.py @@ -351,11 +351,8 @@ class BECClientWorker: else: method = "sample" - if method == "beamline": + if method in {"beamline", "sample", "sample_environment"}: self.position_devices, self.planner = self.__init_beamline_environment() - elif method in {"sample", "sample_environment"}: - self.position_devices = self.__initialise_sample_environment_devices() - self.planner = self.__planner() else: raise ValueError( "Invalid BEC reinitialisation method. " diff --git a/src/aare/gui/auth.py b/src/aare/gui/auth.py index 3bbe26bb..74c009f9 100644 --- a/src/aare/gui/auth.py +++ b/src/aare/gui/auth.py @@ -1,4 +1,3 @@ -import json import subprocess import jwt @@ -23,11 +22,7 @@ def auth(base_url: str | None, cert_path: str | None) -> str: cacert = f"{cert_path}" try: token_result = subprocess.run( - ['curl', '-s', '--cacert', cacert, - '--negotiate', '-u', ':', - '-X', 'POST', url, - '-d', 'username=&password=', - '-H', 'Content-Type: application/x-www-form-urlencoded'], + ['curl', '-s', '--cacert', cacert, '--negotiate', '-u', ':', url, "-XPOST"], capture_output=True, text=True, timeout=18.0, @@ -60,21 +55,12 @@ def auth(base_url: str | None, cert_path: str | None) -> str: "Check Kerberos ticket is valid (kinit) and server is reachable." ) - try: - response_json = json.loads(token_result.stdout) - except json.JSONDecodeError as e: - logger.error(f"Token response not JSON. stdout: {token_result.stdout[:500]}") + token = token_result.stdout.strip() + if not token: + logger.error(f"Empty token response. stderr: {token_result.stderr[:500]}") raise RuntimeError( - "Authentication failed (invalid server response). " + "Authentication failed (empty server response). " "The server may be starting up or misconfigured." - ) from e - - token = response_json.get("access_token") - if not token or not isinstance(token, str): - logger.error(f"Missing access_token. Keys: {list(response_json.keys())}. Server response {response_json}") - raise RuntimeError( - "Authentication failed (missing token in server response). " - "The server may be starting up." ) return token \ No newline at end of file diff --git a/src/aare/gui/gui.py b/src/aare/gui/gui.py index bd50867e..63869c14 100644 --- a/src/aare/gui/gui.py +++ b/src/aare/gui/gui.py @@ -137,6 +137,7 @@ def main(): try: splash.set_progress(50, f"Connecting to {base_url or 'backend'}...") token = auth(base_url, cert_path) + logger.info(f"Authentication token: {token}") if not token or token.count(".") != 2: raise RuntimeError( "Authentication did not return a valid token. " From 166a9e10ee9ce6949cd580b206f923c08ba86c04 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Thu, 11 Jun 2026 16:45:41 +0200 Subject: [PATCH 03/76] Kerberos running --- src/aare/gui/auth.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/aare/gui/auth.py b/src/aare/gui/auth.py index 74c009f9..fcd2e6c0 100644 --- a/src/aare/gui/auth.py +++ b/src/aare/gui/auth.py @@ -1,3 +1,4 @@ +import json import subprocess import jwt @@ -7,6 +8,7 @@ from aare.common.logger_config import setup_logger logger = setup_logger('aareGUI') + def auth(base_url: str | None, cert_path: str | None) -> str: curr_user = get_user() if base_url is None: @@ -46,7 +48,7 @@ def auth(base_url: str | None, cert_path: str | None) -> str: logger.error(f"Token request curl error: {e}") raise RuntimeError( "Cannot reach AareDAQ server (unknown error). " - ) + ) from e if token_result.returncode != 0: logger.error(f"Token curl exited {token_result.returncode}. stderr: {token_result.stderr[:500]}") @@ -55,12 +57,21 @@ def auth(base_url: str | None, cert_path: str | None) -> str: "Check Kerberos ticket is valid (kinit) and server is reachable." ) - token = token_result.stdout.strip() - if not token: - logger.error(f"Empty token response. stderr: {token_result.stderr[:500]}") + try: + response_json = json.loads(token_result.stdout) + except json.JSONDecodeError as e: + logger.error(f"Token response not JSON. stdout: {token_result.stdout[:500]}") raise RuntimeError( - "Authentication failed (empty server response). " + "Authentication failed (invalid server response). " "The server may be starting up or misconfigured." + ) from e + + token = response_json.get("access_token") + if not token or not isinstance(token, str): + logger.error(f"Missing access_token. Keys: {list(response_json.keys())}. Server response {response_json}") + raise RuntimeError( + "Authentication failed (missing token in server response). " + "The server may be starting up." ) return token \ No newline at end of file From 0e4ea638d284592813a404eeac977f250e894a18 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 12 Jun 2026 09:41:28 +0200 Subject: [PATCH 04/76] removed with_retry --- src/aare/daq/operations/common/runtime.py | 33 --------------- tests/unit/daq/test_runtime_retry.py | 51 ----------------------- 2 files changed, 84 deletions(-) delete mode 100644 tests/unit/daq/test_runtime_retry.py diff --git a/src/aare/daq/operations/common/runtime.py b/src/aare/daq/operations/common/runtime.py index 64b163d2..aa2aee38 100644 --- a/src/aare/daq/operations/common/runtime.py +++ b/src/aare/daq/operations/common/runtime.py @@ -41,39 +41,6 @@ class FaceDetectionProgressReporter(Protocol): def emit_progress(self, payload: dict) -> None: ... -T = TypeVar("T") - - -def with_retry( - func: Callable[[], T], - *, - max_attempts: int, - on: tuple[type[Exception], ...], - escalate: type[AareException] | None = None, - escalate_message: str | None = None, - escalate_critical: bool | None = None, -) -> T: - attempts = max(1, int(max_attempts)) - last_error: Exception | None = None - for _ in range(attempts): - try: - return func() - except on as error: - last_error = error - - if escalate is not None: - kwargs: dict[str, bool] = {} - if escalate_critical is not None: - kwargs["critical"] = escalate_critical - message = escalate_message or str(last_error) - raise escalate(message, **kwargs) from last_error - - if last_error is not None: - raise last_error - - raise RuntimeError("with_retry exhausted without a captured exception") - - @dataclass class DAQRuntimeState: sample_provider: SampleProvider diff --git a/tests/unit/daq/test_runtime_retry.py b/tests/unit/daq/test_runtime_retry.py deleted file mode 100644 index e57b9dd2..00000000 --- a/tests/unit/daq/test_runtime_retry.py +++ /dev/null @@ -1,51 +0,0 @@ -import sys -import types - -import pytest - - -if "jfjoch_client.models.scan_result" not in sys.modules: - jfjoch_client_mod = types.ModuleType("jfjoch_client") - jfjoch_client_models_mod = types.ModuleType("jfjoch_client.models") - jfjoch_client_scan_result_mod = types.ModuleType("jfjoch_client.models.scan_result") - - jfjoch_client_scan_result_mod.ScanResult = dict - jfjoch_client_models_mod.scan_result = jfjoch_client_scan_result_mod - jfjoch_client_mod.models = jfjoch_client_models_mod - sys.modules["jfjoch_client"] = jfjoch_client_mod - sys.modules["jfjoch_client.models"] = jfjoch_client_models_mod - sys.modules["jfjoch_client.models.scan_result"] = jfjoch_client_scan_result_mod - -from aare.common.exception_handler import MountingFailed -from aare.daq.operations.common.runtime import with_retry - - -def test_with_retry_succeeds_on_later_attempt(): - calls = {"count": 0} - - def flaky_call() -> str: - calls["count"] += 1 - if calls["count"] < 3: - raise MountingFailed("temporary") - return "ok" - - assert with_retry(flaky_call, max_attempts=5, on=(MountingFailed,)) == "ok" - assert calls["count"] == 3 - - -def test_with_retry_escalates_on_exhaustion(): - def always_fail() -> None: - raise MountingFailed("still failing") - - with pytest.raises(MountingFailed) as exc: - with_retry( - always_fail, - max_attempts=2, - on=(MountingFailed,), - escalate=MountingFailed, - escalate_message="Mount failed 2 times in a row, stopping automation.", - escalate_critical=True, - ) - - assert str(exc.value) == "Mount failed 2 times in a row, stopping automation." - assert exc.value.critical is True From 8cba001a8bce2a61f3a518ab6ead9267c2077199 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 12 Jun 2026 13:50:31 +0200 Subject: [PATCH 05/76] removed unused devices, assocaited tests and old sam cam test file --- src/aare/daq/devices.py | 53 --- src/aare/gui/sam_cam_test.py | 739 ------------------------------- tests/unit/daq/test_workflows.py | 34 +- 3 files changed, 2 insertions(+), 824 deletions(-) delete mode 100644 src/aare/gui/sam_cam_test.py diff --git a/src/aare/daq/devices.py b/src/aare/daq/devices.py index ceffcf07..02febc74 100644 --- a/src/aare/daq/devices.py +++ b/src/aare/daq/devices.py @@ -185,59 +185,6 @@ class BeamlineDevices: def set_zoom(self, value: float, /, wait: bool = True): self.__zoom.move(value, wait=wait) -# Collimator - @property - def collimator(self) -> float: - return self.__collimator_pos.get() - - @collimator.setter - def collimator(self, value: float): - self.set_collimator(value, wait=True) - - def set_collimator(self, value: float, /, wait: bool = True): - self.__collimator_pos.move(value, wait=wait) - -# Scintillator - @property - def scintillator(self) -> float: - return self.__scintillator_pos.get() - - @scintillator.setter - def scintillator(self, value: float): - self.set_scintillator(value, wait=True) - - def set_scintillator(self, value: float, /, wait: bool = True): - self.__scintillator_pos.put(value, wait=wait) - -# Reflector (backlight?) - @property - def reflector_up(self) -> bool: - return self.__back_light_pos.position.upper() == StagePositionEnum.MEASURE.name - - @reflector_up.setter - def reflector_up(self, value: StagePositionEnum): - self.set_reflector_up(value, wait=True) - - def set_reflector_up(self, value: StagePositionEnum, /, wait: bool = True): - self.__back_light_pos.move(value, wait=wait) - -# Beamstop - @property - def beamstop_stage_up(self) -> bool: - return False - - @beamstop_stage_up.setter - def beamstop_stage_up(self, value: bool): - pass - - @property - def beamstop_z(self) -> float: - return 35.0 - - @beamstop_z.setter - def beamstop_z(self, value: float): - pass - # Optics @property def energy_kev(self) -> float: diff --git a/src/aare/gui/sam_cam_test.py b/src/aare/gui/sam_cam_test.py deleted file mode 100644 index abe00a96..00000000 --- a/src/aare/gui/sam_cam_test.py +++ /dev/null @@ -1,739 +0,0 @@ -from __future__ import annotations - -import time -from dataclasses import dataclass -from time import sleep, perf_counter -from typing import Callable, Optional - -import numpy as np - -from aare.common.beamline import mx_beamline -from aare.devices.area_detector import epicsAD -from aare.daq.devices import BeamlineDevices - -# Python -@dataclass(frozen=True) -class MeteringConfig: - """ - Metering and robustness parameters for dark-background + bright sample scenes. - """ - # Use central ROI to reduce chance of metering random bright junk near edges. - # 1.0 = full frame, 0.7 = central 70% in width/height. - center_roi: float = 0.7 - - # Dynamic threshold: thr = percentile(gray, bg_percentile) + delta_dn - bg_percentile: float = 10.0 - delta_dn: int = 15 - - # If mask is too small, fall back to a larger ROI or whole frame. - min_mask_fraction: float = 0.002 # 0.2% of ROI pixels - - # Winsorization for brightness metric (NOT for clipping metric): - # clamp values above winsor_high before computing percentiles. - winsor_high: int = 245 - subsample: int = 2 - - -@dataclass(frozen=True) -class TargetConfig: - """ - Control objectives. - """ - # Hard constraint: keep saturated fraction small. - clip_limit: float = 0.003 # 0.3% of metered pixels >= clip_level - - # Clip threshold (8-bit): treat >=254 as "near-saturated". - clip_level: int = 254 - - # Percentile target of metered (masked) pixels. For metallic base variability, - # p80 is usually more stable than p90. - percentile: float = 80.0 - percentile_target: float = 160.0 - - # Optional: if the metered pixels are too sparse, you can skip updates. - min_metered_pixels: int = 5000 - - -@dataclass(frozen=True) -class LimitsConfig: - """ - Exposure/gain bounds and stepping. - """ - exposure_min_s: float = 0.0001 - exposure_max_s: float = 1.0 - - exposure_effective_min_s: float = 0.002 - exposure_quantum_s: float = 1e-6 - - gain_min: float = 36.0 - gain_max: float = 512.0 - - # Update aggressiveness - exposure_k: float = 0.35 # proportional factor for percentile error - exposure_clip_drop: float = 0.7 # multiply exposure by this when clipping too high - - gain_step: float = 1.0 # gain adjustment step when exposure hits bounds - - # Safety: limit how fast exposure can change to avoid oscillations - max_exposure_scale_up: float = 1.25 - max_exposure_scale_down: float = 0.75 - - -@dataclass -class Metrics: - bg: float - thr: int - n_total: int - n_metered: int - mask_fraction: float - clip_frac: float - p50: float - p70: float - p80: float - p90: float - mean: float - - -def _center_crop(gray: np.ndarray, frac: float) -> np.ndarray: - if frac >= 1.0: - return gray - if frac <= 0.0: - raise ValueError("center_roi must be in (0, 1].") - h, w = gray.shape[:2] - rh = max(1, int(h * frac)) - rw = max(1, int(w * frac)) - y0 = (h - rh) // 2 - x0 = (w - rw) // 2 - return gray[y0:y0 + rh, x0:x0 + rw] - -def _percentile_from_hist(hist: np.ndarray, percentile: float) -> int: - """ - hist: counts per DN bin [0..255] - percentile: 0..100 - returns: DN value (0..255) - """ - total = int(hist.sum()) - if total <= 0: - return 0 - k = int(np.ceil((percentile / 100.0) * total)) - c = np.cumsum(hist) - return int(np.searchsorted(c, k, side="left")) - -def _hist_u8(a: np.ndarray) -> np.ndarray: - """ - Fast histogram for uint8 array -> length 256. - """ - return np.bincount(a.ravel(), minlength=256) - -def compute_metrics(gray: np.ndarray, met: MeteringConfig, tgt: TargetConfig) -> Metrics: - """ - Compute robust metering metrics for 8-bit dark-background scenes. - - Optimized: - - avoids np.percentile on million-pixel arrays (uses 256-bin histograms) - - optional subsampling - """ - if gray.ndim != 2: - raise ValueError("compute_metrics expects a 2D grayscale image.") - - g = gray - if g.dtype != np.uint8: - g = np.clip(g, 0, 255).astype(np.uint8) - - roi_full = _center_crop(g, met.center_roi) - - # Optional subsampling for speed - s = int(getattr(met, "subsample", 1)) - if s > 1: - roi = roi_full[::s, ::s] - else: - roi = roi_full - - n_total = int(roi.size) - - # Background percentile from histogram - hist_roi = _hist_u8(roi) - bg_dn = _percentile_from_hist(hist_roi, met.bg_percentile) - thr = int(min(255, max(0, bg_dn + int(met.delta_dn)))) - - mask = roi > thr - n_metered = int(mask.sum()) - mask_fraction = float(n_metered / max(1, n_total)) - - # If mask too small, fall back to full frame (still subsampled) - if mask_fraction < met.min_mask_fraction: - roi_full = g - if s > 1: - roi = roi_full[::s, ::s] - else: - roi = roi_full - n_total = int(roi.size) - - hist_roi = _hist_u8(roi) - bg_dn = _percentile_from_hist(hist_roi, met.bg_percentile) - thr = int(min(255, max(0, bg_dn + int(met.delta_dn)))) - - mask = roi > thr - n_metered = int(mask.sum()) - mask_fraction = float(n_metered / max(1, n_total)) - - # Clip fraction on ROI (not just masked) - # Uses histogram so it is cheap. - hist_roi = _hist_u8(roi) - clip_bins = hist_roi[int(tgt.clip_level):].sum() - clip_frac = float(clip_bins / max(1, n_total)) - - if n_metered == 0: - # Approx mean from hist (avoids np.mean) - mean_roi = float(np.dot(np.arange(256, dtype=np.float64), hist_roi) / max(1, n_total)) - return Metrics( - bg=float(bg_dn), - thr=thr, - n_total=n_total, - n_metered=0, - mask_fraction=0.0, - clip_frac=clip_frac, - p50=0.0, - p70=0.0, - p80=0.0, - p90=0.0, - mean=mean_roi, - ) - - # Histogram of masked pixels with winsorization applied: - # - compute histogram of masked values - # - fold bins above winsor_high into winsor_high - vals = roi[mask] - hist_vals = _hist_u8(vals) - - wh = int(met.winsor_high) - if wh < 255: - hist_vals[wh] += hist_vals[wh + 1:].sum() - hist_vals[wh + 1:] = 0 - - p50 = float(_percentile_from_hist(hist_vals, 50.0)) - p70 = float(_percentile_from_hist(hist_vals, 70.0)) - p80 = float(_percentile_from_hist(hist_vals, 80.0)) - p90 = float(_percentile_from_hist(hist_vals, 90.0)) - - mean_vals = float(np.dot(np.arange(256, dtype=np.float64), hist_vals) / max(1, hist_vals.sum())) - - return Metrics( - bg=float(bg_dn), - thr=thr, - n_total=n_total, - n_metered=n_metered, - mask_fraction=mask_fraction, - clip_frac=clip_frac, - p50=p50, - p70=p70, - p80=p80, - p90=p90, - mean=mean_vals, - ) - -def _get_percentile_value(m: Metrics, percentile: float) -> float: - if abs(percentile - 50.0) < 1e-6: - return m.p50 - if abs(percentile - 70.0) < 1e-6: - return m.p70 - if abs(percentile - 80.0) < 1e-6: - return m.p80 - if abs(percentile - 90.0) < 1e-6: - return m.p90 - # If you want arbitrary percentiles, compute them directly in compute_metrics. - raise ValueError("This implementation supports percentile ∈ {50, 80, 90} for speed/stability.") - - -class AutoExposureController: - """ - Camera-agnostic controller: you inject how to read image and how to set/get exposure/gain. - - This keeps the logic testable and usable both in DAQ and GUI contexts. - """ - def __init__( - self, - get_gray_image: Callable[[], np.ndarray], - get_exposure_s: Callable[[], float], - set_exposure_s: Callable[[float], None], - get_gain: Callable[[], float], - set_gain: Callable[[float], None], - metering: MeteringConfig | None = None, - target: TargetConfig | None = None, - limits: LimitsConfig | None = None, - get_frame_id: Callable[[], int] | None = None, - ): - self.get_gray_image = get_gray_image - self.get_exposure_s = get_exposure_s - self.set_exposure_s = set_exposure_s - self.get_gain = get_gain - self.set_gain = set_gain - self.get_frame_id = get_frame_id - - self.metering = metering or MeteringConfig() - self.target = target or TargetConfig() - self.limits = limits or LimitsConfig() - - # simple exponential smoothing for metrics - self._ema_clip: Optional[float] = None - self._ema_p: Optional[float] = None - - def _clamp(self, x: float, lo: float, hi: float) -> float: - # Enforce "effective" minimum to avoid PV rounding to 0. - lo_eff = max(lo, self.limits.exposure_effective_min_s) - - x = max(lo_eff, min(hi, x)) - - q = float(self.limits.exposure_quantum_s) - if q > 0: - x = round(x / q) * q - - # Re-enforce bounds after rounding - x = max(lo_eff, min(hi, x)) - return x - - def _wait_frames(self, frames: int, timeout_s: float = 0.5) -> bool: - """ - Wait for `frames` new frames (by frame counter), if get_frame_id is available. - - Returns: - True if the requested number of frames were observed, False on timeout. - """ - if self.get_frame_id is None: - sleep(0.04 * frames) - return True - - start_id = int(self.get_frame_id()) - deadline = perf_counter() + float(timeout_s) - target_id = start_id + int(frames) - - while perf_counter() < deadline: - if int(self.get_frame_id()) >= target_id: - return True - sleep(0.002) - - return False - - def _settle_after_change(self, new_exp_s: float, base_settle_s: float, fps:float = 25.0) -> None: - """ - Wait long enough that the next acquired frame reflects the new exposure. - """ - ok = self._wait_frames(frames=1, timeout_s=0.25) - if not ok: - # If frame IDs aren't advancing reliably, avoid reusing the same buffer. - # Keep it small to preserve speed. - sleep(min(0.02, max(0.0, float(new_exp_s)))) - - t_extra = max(0.0, float(new_exp_s) - (1.0 / float(fps))) - if t_extra > 0: - sleep(min(t_extra, 0.35)) - - if base_settle_s > 0: - sleep(float(base_settle_s)) - - def _set_exposure_if_changed(self, new_exp: float, current_exp: float) -> bool: - q = float(self.limits.exposure_quantum_s) if self.limits.exposure_quantum_s > 0 else 0.0 - eps = max(1e-9, 0.5 * q) - if abs(new_exp - current_exp) <= eps: - return False - self.set_exposure_s(new_exp) - return True - - def _set_gain_if_changed(self, new_gain: float, current_gain: float) -> bool: - if abs(new_gain - current_gain) < 1e-6: - return False - self.set_gain(new_gain) - return True - - def time_test(self): - t0 = perf_counter() - gray = self.get_gray_image() - t1 = perf_counter() - m = compute_metrics(gray, self.metering, self.target) - t2 = perf_counter() - exp = float(self.get_exposure_s()) - gain = float(self.get_gain()) - t3 = perf_counter() - print("gray:", t1 - t0, "metrics:", t2 - t1, "pvs:", t3 - t2) - - def run_once( - self, - settle_s: float = 0.15, - max_iters: int = 12, - verbose: bool = True, - deadband_dn: float = 12.0, - metering_unreliable_boost: float = 1.4, - ) -> tuple[float, float, Metrics]: - - base_settle_s = float(settle_s) - last_m = None - - # Make EMA more responsive so it doesn't lag by ~10 iterations - ema_alpha_p = 0.60 - ema_pv: float | None = None - - # 1 stable frame is typically enough once the loop is well behaved - stable_needed = 1 - stable_count = 0 - - # Use raw pv for the first few iterations to avoid EMA-lag overshoot - raw_control_iters = 3 - - for i in range(max_iters): - t0 = perf_counter() - gray = self.get_gray_image() - t1 = perf_counter() - m = compute_metrics(gray, self.metering, self.target) - t2 = perf_counter() - - exp = float(self.get_exposure_s()) - gain = float(self.get_gain()) - pv_raw = float(_get_percentile_value(m, self.target.percentile)) - t3 = perf_counter() - - last_m = m - - if ema_pv is None: - ema_pv = pv_raw - else: - ema_pv = ema_alpha_p * pv_raw + (1.0 - ema_alpha_p) * ema_pv - - if verbose: - print( - f"[AE once {i + 1:02d}/{max_iters}] exp={exp:.6f}s gain={gain:.2f} " - f"clip={m.clip_frac:.4f} p{int(self.target.percentile)}={pv_raw:.1f} (ema_p={ema_pv:.1f}) " - f"mask={m.mask_fraction * 100:.2f}% thr={m.thr} n={m.n_metered} " - f"timing(gray={t1 - t0:.3f}s metrics={t2 - t1:.3f}s pvs={t3 - t2:.3f}s)" - ) - - # Stop condition: use raw pv (not EMA) so we don't "wait out" lag - in_clip = (m.clip_frac <= self.target.clip_limit) - in_p = (abs(self.target.percentile_target - pv_raw) <= deadband_dn) - if in_clip and in_p: - stable_count += 1 - if stable_count >= stable_needed: - break - else: - stable_count = 0 - - # 1) clipping protection (unchanged) - if m.clip_frac > self.target.clip_limit: - r = min(8.0, m.clip_frac / max(1e-12, self.target.clip_limit)) - adaptive_drop = 0.85 - (r - 1.0) * (0.85 - 0.35) / (8.0 - 1.0) - adaptive_drop = self._clamp(adaptive_drop, 0.35, 0.90) - - new_exp = self._clamp(exp * adaptive_drop, self.limits.exposure_min_s, self.limits.exposure_max_s) - if self._set_exposure_if_changed(new_exp, exp): - self._settle_after_change(new_exp, base_settle_s) - continue - - # 2) unreliable metering (unchanged) - if m.n_metered < self.target.min_metered_pixels: - boost = float(max(1.05, min(metering_unreliable_boost, self.limits.max_exposure_scale_up))) - new_exp = self._clamp(exp * boost, self.limits.exposure_min_s, self.limits.exposure_max_s) - if self._set_exposure_if_changed(new_exp, exp): - self._settle_after_change(new_exp, base_settle_s) - continue - - # 3) main control: use pv_raw for the first few steps, then EMA - pv_for_control = pv_raw if i < raw_control_iters else float(ema_pv) - - err = float(self.target.percentile_target - pv_for_control) - if abs(err) <= float(deadband_dn): - continue - - pv = max(1.0, float(pv_for_control)) - ratio = float(self.target.percentile_target) / pv - - k = float(self.limits.exposure_k) - scale = ratio ** k - scale = max(self.limits.max_exposure_scale_down, min(self.limits.max_exposure_scale_up, scale)) - - new_exp = self._clamp(exp * scale, self.limits.exposure_min_s, self.limits.exposure_max_s) - if self._set_exposure_if_changed(new_exp, exp): - self._settle_after_change(new_exp, base_settle_s) - - exp = float(self.get_exposure_s()) - gain = float(self.get_gain()) - return exp, gain, last_m if last_m is not None else compute_metrics(self.get_gray_image(), self.metering, self.target) -# Python -def build_controller(sample_cam, lim: LimitsConfig, tgt: TargetConfig, met:MeteringConfig) -> AutoExposureController: - # sample_cam should be whatever your EPICS/AD wrapper object is. - # Important: ensure camera is in manual exposure/gain mode before control. - - def get_gray() -> np.ndarray: - img = sample_cam.get_image(gray=True) - return img # may be float; controller converts/clamps to uint8 - - def get_exp() -> float: - return float(sample_cam.expo_rbv.get()) - - def set_exp(v: float) -> None: - sample_cam.expo.put(float(v), wait=False) - - def get_gain() -> float: - return float(sample_cam.gain_rbv.get()) - - def set_gain(v: float) -> None: - sample_cam.gain.put(float(v), wait=False) - - def get_frame_id() -> int: - return int(sample_cam.uid.get()) - - return AutoExposureController(get_gray, get_exp, set_exp, get_gain, set_gain, - metering=met, target=tgt, limits=lim, get_frame_id=get_frame_id) - -def _now_s() -> float: - return perf_counter() - -def _fmt_ms(s: float) -> str: - return f"{s * 1000.0:.1f} ms" - -def _condition_name(reflector_up: bool, back_light: float) -> str: - if reflector_up and back_light > 0.91: - return "reflector_up + backlight_max" - if reflector_up and back_light <= 0.91: - return "reflector_up + backlight_off" - return "reflector_down" - -def _select_profiles(devs) -> tuple[MeteringConfig, TargetConfig, LimitsConfig]: - """ - Choose metering/targets/limits based on current lighting/reflector state. - - IMPORTANT: - - Use a fine exposure_quantum_s for backlight if you want sub-ms exposures. - - exposure_effective_min_s can be as low as 50 us in backlight mode (per your tests). - """ - # You can keep your existing metering/targets here, or define distinct profiles. - # These are conservative defaults; tweak as needed. - metering_front = MeteringConfig(center_roi=0.7, bg_percentile=10.0, delta_dn=15, winsor_high=230) - targets_front = TargetConfig(clip_limit=0.01, percentile=70.0, percentile_target=160.0, min_metered_pixels=5000) - - metering_back = MeteringConfig(center_roi=0.6, bg_percentile=10.0, delta_dn=8, winsor_high=245) - targets_back = TargetConfig(clip_limit=0.02, percentile=70.0, percentile_target=170.0, min_metered_pixels=2000) - - if devs.reflector_up and devs.back_light > 0.91: - limits = LimitsConfig( - exposure_min_s=0.00005, - exposure_max_s=0.1, - exposure_effective_min_s=0.00005, - exposure_quantum_s=1e-6, # NOT 0.001: you want sub-ms capability here - gain_min=0.0, - gain_max=36.0, - exposure_k=0.70, - max_exposure_scale_up=2.2, - max_exposure_scale_down=0.45, - ) - return metering_back, targets_back, limits - - if devs.reflector_up: - limits = LimitsConfig( - exposure_min_s=0.0005, - exposure_max_s=0.2, - exposure_effective_min_s=0.001, # you said 1 ms is safe in backlight mode - exposure_quantum_s=1e-4, - gain_min=0.0, - gain_max=36.0, - exposure_k=0.80, - max_exposure_scale_up=2.2, - max_exposure_scale_down=0.45, - ) - return metering_back, targets_back, limits - - limits = LimitsConfig( - exposure_min_s=0.002, - exposure_max_s=0.5, - exposure_effective_min_s=0.002, - exposure_quantum_s=1e-4, - gain_min=0.0, - gain_max=36.0, - exposure_k=0.80, - max_exposure_scale_up=2.2, - max_exposure_scale_down=0.45, - ) - return metering_front, targets_front, limits - -def _bench_run_one(ctrl: AutoExposureController, - start_exp_s: float, - start_gain: float, - settle_s: float, - max_iters: int, - label: str, - tol_dn: float) -> dict: - """ - Sets starting exposure/gain, then times run_once convergence. - """ - # Prime starting point - ctrl.set_gain(float(start_gain)) - ctrl.set_exposure_s(float(start_exp_s)) - ctrl._settle_after_change(float(start_exp_s), settle_s) - - t0 = _now_s() - end_exp, end_gain, m = ctrl.run_once(settle_s=settle_s, max_iters=max_iters, verbose=False) - dt = _now_s() - t0 - - pv = _get_percentile_value(m, ctrl.target.percentile) - ok = (m.clip_frac <= ctrl.target.clip_limit) and (abs(ctrl.target.percentile_target - pv) <= float(tol_dn)) - - return { - "label": label, - "t_s": dt, - "start_exp_s": start_exp_s, - "start_gain": start_gain, - "end_exp_s": float(end_exp), - "end_gain": float(end_gain), - "clip": float(m.clip_frac), - "p": float(pv), - "mask_frac": float(m.mask_fraction), - "ok": bool(ok), - "n_metered": int(m.n_metered), - "tol_dn": float(tol_dn), - } - -def benchmark_controller(devs, - sample_cam, - expected_exp_s: dict[str, float], - tolerance_dn: dict[str, float] | None = None, - trials: int = 5, - settle_s: float = 0.01, - max_iters: int = 20, - start_gain: float = 0.0) -> None: - """ - Benchmarks controller convergence speed for three lighting conditions. - - tolerance_dn: optional per-condition tolerance on the chosen percentile metric. - """ - cond = _condition_name(devs.reflector_up, float(devs.back_light)) - met, tgt, lim = _select_profiles(devs) - ctrl = build_controller(sample_cam, lim=lim, tgt=tgt, met=met) - - exp_expected = float(expected_exp_s.get(cond, 0.01)) - exp_min = float(lim.exposure_min_s) - exp_max = float(lim.exposure_max_s) - - tol_dn = 5.0 if tolerance_dn is None else float(tolerance_dn.get(cond, 5.0)) - - starts = [ - ("start=min", exp_min), - ("start=expected", exp_expected), - ("start=max", exp_max), - ] - - print(f"\n=== Benchmark: {cond} ===") - print(f"limits: exp[{lim.exposure_min_s} .. {lim.exposure_max_s}] effective_min={lim.exposure_effective_min_s} quantum={lim.exposure_quantum_s}") - print(f"target: p{int(tgt.percentile)}={tgt.percentile_target} clip_limit={tgt.clip_limit} tol=±{tol_dn:.1f}DN settle_s(base)={settle_s} max_iters={max_iters} trials={trials}") - - results: list[dict] = [] - for label, start_exp in starts: - for k in range(trials): - r = _bench_run_one( - ctrl=ctrl, - start_exp_s=start_exp, - start_gain=start_gain, - settle_s=settle_s, - max_iters=max_iters, - label=label, - tol_dn=tol_dn, - ) - r["trial"] = k + 1 - results.append(r) - - # Print summary - for label, _ in starts: - rr = [r for r in results if r["label"] == label] - times = np.array([r["t_s"] for r in rr], dtype=float) - ok_rate = sum(1 for r in rr if r["ok"]) / max(1, len(rr)) - print( - f"{label:14s} mean={_fmt_ms(times.mean())} p95={_fmt_ms(np.percentile(times, 95))} " - f"min={_fmt_ms(times.min())} max={_fmt_ms(times.max())} ok={ok_rate*100:.0f}%" - ) - - print("\nlabel,trial,t_ms,start_exp_ms,end_exp_ms,start_gain,end_gain,clip,p,mask_frac,tol_dn,ok,n_metered") - for r in results: - print( - f"{r['label']},{r['trial']},{r['t_s']*1000.0:.3f}," - f"{r['start_exp_s']*1000.0:.6f},{r['end_exp_s']*1000.0:.6f}," - f"{r['start_gain']:.2f},{r['end_gain']:.2f}," - f"{r['clip']:.6f},{r['p']:.3f},{r['mask_frac']:.6f},{r['tol_dn']:.1f},{int(r['ok'])},{r['n_metered']}" - ) - - -sample_cam = epicsAD(f"{mx_beamline().name.upper()}-ES-MS:") -devs = BeamlineDevices(mx_beamline()) -metering = MeteringConfig(center_roi=0.7, bg_percentile=10.0, delta_dn=15, winsor_high=230) -targets = TargetConfig(clip_limit=0.01, percentile=70.0, percentile_target=160.0) - - -# -if devs.reflector_up and devs.back_light > 0.91: - limits = LimitsConfig(exposure_min_s=0.00005, exposure_max_s=0.1, exposure_effective_min_s=0.00005, - exposure_quantum_s=1e-6, - gain_min=0.0, gain_max=36.0, exposure_k=0.70, max_exposure_scale_up=1.6, - max_exposure_scale_down=0.6) - metering = MeteringConfig( - center_roi=0.6, - bg_percentile=10.0, - delta_dn=8, - winsor_high=245, - ) - targets = TargetConfig( - clip_limit=0.02, # allow more clipping (bright field can clip) - percentile=70.0, - percentile_target=170.0, - min_metered_pixels=2000, # can be lower because backlight mask is usually strong - ) - -elif devs.reflector_up: - limits = LimitsConfig(exposure_min_s=0.0005, exposure_max_s=0.2, exposure_effective_min_s=0.001, - exposure_quantum_s=1e-4, - gain_min=0.0, gain_max=36.0, exposure_k=0.80, max_exposure_scale_up=2.2, - max_exposure_scale_down=0.6) - - metering = MeteringConfig( - center_roi=0.7, - bg_percentile=10.0, - delta_dn=10, - winsor_high=230, - ) - targets = TargetConfig( - clip_limit=0.02, # allow more clipping (bright field can clip) - percentile=70.0, - percentile_target=170.0, - min_metered_pixels=5000, # can be lower because backlight mask is usually strong - ) - -else: - limits = LimitsConfig(exposure_min_s=0.002, exposure_max_s=0.5, exposure_effective_min_s=0.002, - exposure_quantum_s=0.001, - gain_min=0.0, gain_max=36.0, exposure_k=0.60, max_exposure_scale_up=1.6, - max_exposure_scale_down=0.6) - targets = TargetConfig( - clip_limit=0.02, - percentile=70.0, - percentile_target=160.0, - min_metered_pixels=5000, - ) - metering = MeteringConfig( - center_roi=0.7, - bg_percentile=10.0, - delta_dn=15, - winsor_high=230, - ) -ctrl = build_controller(sample_cam, lim=limits, tgt=targets, met=metering) - -# After centering and stationary: -st = time.perf_counter() -exp, gain, m = ctrl.run_once(settle_s=0.0, max_iters=20, verbose=True, deadband_dn=10.0, metering_unreliable_boost=1.3) -print(f"run_once took {time.perf_counter() - st} s") - -# expected = { -# "reflector_up + backlight_max": 0.001, # ~1 ms typical -# "reflector_up + backlight_off": 0.025, # adjust based on your observed behaviour -# "reflector_down": 0.052, # 52 ms you reported -# } -# tolerance = { -# "reflector_up + backlight_max": 5.0, -# "reflector_up + backlight_off": 5.0, -# "reflector_down": 5.0, -# } -# benchmark_controller(devs=devs, sample_cam=sample_cam, expected_exp_s=expected, trials=5, settle_s=0.01, max_iters=20, start_gain=0.0) \ No newline at end of file diff --git a/tests/unit/daq/test_workflows.py b/tests/unit/daq/test_workflows.py index 3a466b63..38543aec 100644 --- a/tests/unit/daq/test_workflows.py +++ b/tests/unit/daq/test_workflows.py @@ -1,6 +1,6 @@ import pytest from unittest.mock import MagicMock, patch -from aare.daq.workflows import move_bsz, common_2rse, sa2se, sa2rse, sa2xtal_snapshot, dc2xtal_snapshot, xtal_snapshot2dc, xtal_snapshot2sa, dc2rse, se2sa, sa2dc, dc2sa, sa2xrf, sa2dh, dh2sa +from aare.daq.workflows import common_2rse, sa2se, sa2rse, sa2xtal_snapshot, dc2xtal_snapshot, xtal_snapshot2dc, xtal_snapshot2sa, dc2rse, se2sa, sa2dc, dc2sa, sa2xrf, sa2dh, dh2sa from aare.daq.config import ABR_POS_MOUNT from aare.common.models import StagePositionEnum from aare.devices.area_detector import AutoEnum @@ -10,9 +10,6 @@ from aare.devices.bec_worker import BeamlineState @pytest.fixture def mock_devs(): devs = MagicMock() - devs.bsz.position = 0.0 - devs.beamstop_stage_up = False - devs.reflector_up = False devs.bec_worker = MagicMock() devs.bec_worker.planner = MagicMock() devs.bec_worker.move_to = MagicMock() @@ -37,35 +34,8 @@ def _assert_bec_moved(devs, state): ), f"BEC was not asked to move to {state}" -def test_move_bsz_no_move(mock_devs): - mock_devs.bsz.position = 1.0 - move_bsz(mock_devs, 1.05) - assert mock_devs.beamstop_z.call_count == 0 - -def test_move_bsz_with_move(mock_devs): - mock_devs.bsz.position = 0.0 - mock_devs.beamstop_stage_up = False - mock_devs.reflector_up = True - - move_bsz(mock_devs, 1.0) - - assert mock_devs.reflector_up is True - assert mock_devs.beamstop_stage_up is False - assert mock_devs.beamstop_z == 1.0 - - -def test_common_2rse_no_bec(mock_devs, mock_cfg): - mock_devs.bec_worker = None - common_2rse(mock_devs, mock_cfg) - - assert mock_devs.collimator == 20.0 - assert mock_devs.scintillator == 20.0 - mock_devs.smargon_move_home.assert_called_once() - assert mock_devs.beamstop_stage_up == StagePositionEnum.PARK - - -def test_common_2rse_with_bec(mock_devs, mock_cfg): +def test_common_2rse(mock_devs, mock_cfg): mock_devs.bec_worker = MagicMock() mock_devs.bec_worker.planner = MagicMock() mock_devs.bec_worker.move_to = MagicMock() From 9fc81ff3bfdac1880e2819e5d8c893e3005532e8 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 12 Jun 2026 13:50:47 +0200 Subject: [PATCH 06/76] auth: corrected supre_user accounts list --- src/aare/daq/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aare/daq/auth.py b/src/aare/daq/auth.py index e13c1452..118d91b5 100644 --- a/src/aare/daq/auth.py +++ b/src/aare/daq/auth.py @@ -32,7 +32,7 @@ SESSION_EXPIRE_SECONDS = 60 * 10 BATON_REQUEST_TIMEOUT_SECONDS = 30 STAFF_GROUP = "unx-MXgroup" -SUPER_USERS = ["e10019", "e11206", "e18147"] +SUPER_USERS = ["e10019", "e11206", "e18747"] oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") From 72d826dbdab14e84dfa2c27014e862a49c012c9f Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 12 Jun 2026 13:51:13 +0200 Subject: [PATCH 07/76] bec_worker: removed superfluous devices and added frontlight brightness --- src/aare/devices/bec_worker.py | 50 ++-------------------------------- 1 file changed, 3 insertions(+), 47 deletions(-) diff --git a/src/aare/devices/bec_worker.py b/src/aare/devices/bec_worker.py index a7fb78cc..b812b1ad 100644 --- a/src/aare/devices/bec_worker.py +++ b/src/aare/devices/bec_worker.py @@ -82,7 +82,7 @@ class BECClientWorker: try: self.position_devices, self.planner = self.__init_beamline_environment() self.__backlight_brightness = self.position_devices['bl_bright'] - self.__detector_cover = self.position_devices['det_cov'] + self.__frontlight_brightness = self.position_devices['fl_bright'] except Exception as e: logger.error(f"Error initialising BEC devices: {e}") #raise self._raise_bec_error(e, operation="create planner") @@ -358,9 +358,9 @@ class BECClientWorker: "Invalid BEC reinitialisation method. " "Expected one of: auto, beamline, sample." ) - + #TODO move these into Init beamline env self.__backlight_brightness = self.position_devices['bl_bright'] - self.__detector_cover = self.position_devices['det_cov'] + self.__frontlight_brightness = self.position_devices['fl_bright'] logger.info(f"Reinitialised BEC planner and position devices using method={method}") return self.list_position_devices() except Exception as e: @@ -451,50 +451,6 @@ class BECClientWorker: except Exception as e: self._raise_bec_error(e, operation=f"scans.mv:det_y:{value}", tags=["det_z"]) - @property - def detector_cover(self) -> DetectorCoverEnum: - """Returns the detector cover POSTION. - If actual is True, returns the positon as an Enum: 2 is open, 1 is closed. - otherwise returns the position value. - """ - if self.simulated: - return DetectorCoverEnum.CLOSED - try: - return DetectorCoverEnum(self.__detector_cover.position) - except Exception as e: - self._raise_bec_error(e, operation=f"get_det_cov", tags=["det_cov"]) - raise - - @detector_cover.setter - def detector_cover(self, position:DetectorCoverEnum): - """Move the detector cover to the specified position""" - if self.simulated: - logger.info(f"Simulating detector cover move to {position.value}") - return - try: - self.__detector_cover.move(position) - except Exception as e: - self._raise_bec_error(e, operation=f"position_devices['det_cov'].move:{position.value}", tags=["det_cov"]) - raise - - def detector_cover_toggle(self): - """Toggle the detector cover""" - if self.simulated: - return - try: - current = self.detector_cover - if current is DetectorCoverEnum.OPEN: - target = DetectorCoverEnum.CLOSED - else: - target = DetectorCoverEnum.OPEN - self.detector_cover = target - except Exception as e: - self._raise_bec_error( - e, - operation=f"detector cover toggle, could not change cover open/close ", - tags=["det_cov"] - ) - @property def backlight_brightness(self) -> BrightnessEnum: """returns backlight brightness as an Enum: 'off' or 'on', can also be a value... From 49fb0be5072e02a97db7ccdcfd608ec0ece59a88 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 12 Jun 2026 13:53:54 +0200 Subject: [PATCH 08/76] added option to save dtz safe position in redis but currently unused, added detector limit modifier so we do not hit soft limits by mistak in yaml file. Moved Detector_Z low and high limit check to the device and out of workflows. --- src/aare/common/config/x06da.yaml | 6 +-- src/aare/common/config/x06sa.yaml | 3 -- src/aare/common/config/x10sa.yaml | 5 +-- src/aare/daq/config.py | 12 +++++- src/aare/daq/devices.py | 26 ++++++++---- src/aare/daq/workflows.py | 70 +++++-------------------------- 6 files changed, 42 insertions(+), 80 deletions(-) diff --git a/src/aare/common/config/x06da.yaml b/src/aare/common/config/x06da.yaml index 9b17f3e5..e2e3a11a 100644 --- a/src/aare/common/config/x06da.yaml +++ b/src/aare/common/config/x06da.yaml @@ -12,6 +12,7 @@ gui: daq: daq_url: "https://mx-x06da-queue-01.psi.ch" cert_path: "/sls/x06da/misc/.cert/6d.crt" + shared: jfjoch: jfjoch_url: "http://sls-gpu-001:8080" @@ -24,9 +25,6 @@ daq: bec_url: "x06da-bec-001.psi.ch" redis_url: "x06da-redis.psi.ch" aarelc_url: "http://sls-gpu-003:9090" - states: - backlight_levels: {off: 0.0, half: 0.98, max: 1.2} - frontlight_levels: {off: 1.49, half: 2.0, max: 3.0} db: aaredb_url: "https://mx-aaredb-dmz-01.psi.ch/dispatcher" @@ -44,3 +42,5 @@ daq: start_omega_deg: 0.0 increment_omega_deg: 0.2 steps: 1800 + + detector_limit_modifier: 2.0 \ No newline at end of file diff --git a/src/aare/common/config/x06sa.yaml b/src/aare/common/config/x06sa.yaml index 7e022dc7..72d52b13 100644 --- a/src/aare/common/config/x06sa.yaml +++ b/src/aare/common/config/x06sa.yaml @@ -22,9 +22,6 @@ daq: tell_url: "" bec_url: "x06sa-bec-001.psi.ch" redis_url: "" - states: - backlight_levels: { off: 0.0, half: 0.98, max: 1.2 } - frontlight_levels: { off: 1.49, half: 2.0, max: 3.0 } db: aaredb_url: "https://mx-aaredb-dmz-01.psi.ch/dispatcher" diff --git a/src/aare/common/config/x10sa.yaml b/src/aare/common/config/x10sa.yaml index cc370a2d..cf919a9c 100644 --- a/src/aare/common/config/x10sa.yaml +++ b/src/aare/common/config/x10sa.yaml @@ -24,9 +24,6 @@ daq: bec_url: "x10sa-bec-001.psi.ch" redis_url: "x10sa-redis.psi.ch" aarelc_url: "http://sls-gpu-003:9090" - states: - backlight_levels: {off: 0.0, half: 0.98, max: 1.2} - frontlight_levels: {off: 1.49, half: 2.0, max: 3.0} db: aaredb_url: "https://mx-aaredb-dmz-01.psi.ch/dispatcher" @@ -45,4 +42,4 @@ daq: increment_omega_deg: 0.2 steps: 1800 - detector_distance_minimum: 170 \ No newline at end of file + detector_limit_modifier: 2.0 \ No newline at end of file diff --git a/src/aare/daq/config.py b/src/aare/daq/config.py index 17e3225f..52a2cf2d 100644 --- a/src/aare/daq/config.py +++ b/src/aare/daq/config.py @@ -47,7 +47,6 @@ ABR_POS_MOUNT = AerotechCoordinate( at_mm=Coordinate(x=0, y=0, z=0), omega_deg=0 ) -#Coordinate(x=-18, y=0, z=0) ABR_OMEGA_MOUNT = 0.0 logger = setup_logger("aareDAQ") @@ -808,6 +807,17 @@ class BeamlineConfig: 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 200.0 + 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") diff --git a/src/aare/daq/devices.py b/src/aare/daq/devices.py index 02febc74..d1c0193e 100644 --- a/src/aare/daq/devices.py +++ b/src/aare/daq/devices.py @@ -8,7 +8,7 @@ import numpy as np from epics import PV -from aare.common.beamline import MXBeamline +from aare.common.beamline import MXBeamline, cfg_get from aare.common.coordinate import SmargonCoordinate, AerotechCoordinate from aare.common.logger_config import setup_logger from aare.common.logger_events import log_timing @@ -37,12 +37,9 @@ class BeamlineDevices: logger.debug("initialising BEC worker done") self.__smargon = smargon.Smargon(beamline) - self.__dtz = MyMotor(f"{BEAMLINE}-ES-DET:TRZ") - self.__dty = MyMotor(f"{BEAMLINE}-ES-DET:TRY") - - self.__det_cov = EnumPV(name="det_cov", - setpv=f"{BEAMLINE}-ES-DETCOV:SET", - getpv=f"{BEAMLINE}-ES-DETCOV:GET") + #only used at the moment to get hlm and llm from BEC + self.__dtz = self.bec_worker.dev.det_z + self.dtz_mod = cfg_get('daq.detector_limit_modifier', 1.0) self.__sample_cam = epicsAD(f"{BEAMLINE}-ES-MS:") @@ -297,18 +294,29 @@ class BeamlineDevices: # Detector Z @property def dtz(self) -> float: - return self.__dtz.readback + return self.bec_worker.get_det_z() @dtz.setter def dtz(self, value: float): self.set_dtz(value, wait=True) def set_dtz(self, value: float, /, wait: bool = True): - if value < self.detector_distance_minimum: + if value < self.__dtz.low_limit: #raise ValueError(f"Detector distance cannot be less than {self.detector_distance_minimum} mm") logger.warning(f"Requested detector distance {value} is less than minimum: {self.detector_distance_minimum}, setting to minimum") value = self.detector_distance_minimum self.__dtz.move(value, wait=wait) + logger.warning(f"Requested detector distance {value} is less than minimum: {self.__dtz.low_limit}, setting to minimum") + value = self.__dtz.low_limit + self.dtz_mod + if value > self.__dtz.high_limit: + logger.warning(f"Requested detector distance {value} is greater than maximum: {self.__dtz.high_limit}, setting to maximum") + value = self.__dtz.high_limit - self.dtz_mod + if wait: + status = self.bec_worker.det_z(value, timeout=60) + return status + else: + status = self.bec_worker.det_z(value) + return status @property def dtz_low(self) -> float: diff --git a/src/aare/daq/workflows.py b/src/aare/daq/workflows.py index eb2156c4..aff97a52 100644 --- a/src/aare/daq/workflows.py +++ b/src/aare/daq/workflows.py @@ -10,52 +10,14 @@ import time logger = setup_logger("aareDAQ") -SAFE_POSITION = 200 - -def move_bsz(devs: BeamlineDevices, target: float): - if abs(target - devs.bsz.position) > 0.1: - beamstop_stage_measure = devs.beamstop_stage_up - reflector_measure = devs.reflector_up - devs.reflector_up = False - devs.beamstop_stage_up = True - devs.beamstop_z = target - - if reflector_measure: - devs.reflector_up = True - - if not beamstop_stage_measure: - devs.beamstop_stage_up = False - - -def wait_for_dc_devices(devs: BeamlineDevices): - # Should wait for DTZ - pass - -def wait_for_se_devices(devs: BeamlineDevices, cfg: BeamlineConfig): - # Should wait for Cryojet go far - pass +SAFE_POSITION = 600 def common_2rse(devs: BeamlineDevices, cfg: BeamlineConfig): - if not devs.bec_worker: - print('move collimator and scintillator to 20') - devs.collimator = 20.0 - devs.scintillator = 20.0 - print('move smargon home') - devs.smargon_move_home() - print('try to move aerotech') - devs.aerotech_pos = ABR_POS_MOUNT - #print('move bl to park') - #devs.reflector_up = StagePositionEnum.PARK - print('move bs to park') - devs.beamstop_stage_up = StagePositionEnum.PARK - #print('move cryo to park') - #devs.__cryojet_pos = StagePositionEnum.PARK - else: - devs.bec_worker.move_to(BeamlineState.ROBOT_SAMPLE_EXCHANGE) - print('move smargon home') - devs.smargon_move_home() - print('try to move aerotech') - devs.aerotech_pos = ABR_POS_MOUNT + devs.bec_worker.move_to(BeamlineState.ROBOT_SAMPLE_EXCHANGE) + print('move smargon home') + devs.smargon_move_home() + print('try to move aerotech') + devs.aerotech_pos = ABR_POS_MOUNT def rse2se(devs: BeamlineDevices, cfg: BeamlineConfig): devs.samcam_auto(AutoEnum.AUTO) @@ -145,19 +107,6 @@ def rse2sa(devs: BeamlineDevices, cfg: BeamlineConfig): def sa2dc(devs: BeamlineDevices, cfg: BeamlineConfig): start = time.perf_counter() det_z_pos = cfg.dtz - #TODO det_z_minimum - if not det_z_pos: - logger.error("Detector Z position not set, defaulting to 170") - det_z_pos = 99 - cfg.dtz = 99 - if det_z_pos and det_z_pos < 99: - logger.warning(f"Detector Z position is less than 170: {det_z_pos}, setting to 170") - det_z_pos = 99 - cfg.dtz = 99 - if det_z_pos and det_z_pos > 830: - logger.warning(f"Detector Z position is greater than 1200: {det_z_pos}, setting to 1200") - det_z_pos = 800 - cfg.dtz = 800 status = devs.bec_worker.det_z(value=det_z_pos) logger.info(f"start detector move to {cfg.dtz} at {time.perf_counter() - start:.2f}") devs.samcam_auto(AutoEnum.AUTO) @@ -190,7 +139,7 @@ def xrf2sa(devs: BeamlineDevices, cfg: BeamlineConfig): """XrfCollection to sample alignment""" pass - +#todo CONNECT WS AND BA MODES def sa2ws(devs: BeamlineDevices, cfg: BeamlineConfig): pass @@ -209,16 +158,17 @@ def ba2sa(devs: BeamlineDevices, cfg: BeamlineConfig): def sa2bl(devs: BeamlineDevices, cfg: BeamlineConfig): devs.bec_worker.move_to(BeamlineState.BEAM_VISUALISATION) devs.samcam_settings.exposure = 0.001 + #TODO SAMCAM SETTINGS SHOULD BE HARDCODED FOR BEAM VISUALISATION STATE NOT AUTO def bl2sa(devs: BeamlineDevices, cfg: BeamlineConfig): devs.bec_worker.move_to(BeamlineState.SAMPLE_ALIGNMENT) devs.samcam_auto(AutoEnum.ONCE) def bl2ba(devs: BeamlineDevices, cfg: BeamlineConfig): - devs.scintillator = 20.0 + pass def ba2bl(devs: BeamlineDevices, cfg: BeamlineConfig): - devs.scintillator = 20.0 + pass def sa2dh(devs: BeamlineDevices, cfg: BeamlineConfig): common2dh(devs, cfg) From 27025eb91d30c5a0a9b75fc735dd9044bc1b3f3c Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 12 Jun 2026 13:54:49 +0200 Subject: [PATCH 09/76] exp_hutch_shutter added back in --- src/aare/daq/daq.py | 5 ++++- src/aare/daq/devices.py | 4 +--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/aare/daq/daq.py b/src/aare/daq/daq.py index db5ca148..1f116092 100644 --- a/src/aare/daq/daq.py +++ b/src/aare/daq/daq.py @@ -2755,6 +2755,8 @@ class AareDAQ: f"State already set to {target}", extra={"from_state": curr_state, "to_state": target}, ) + #TODO unify BeamlineStateEnum and BeamlineState and allow transition from same state to same state to recover motor positions! + #TODO or just allow BEC thingy return elif target != curr_state: self.__cfg.state = BeamlineStateEnum.Moving @@ -2946,7 +2948,7 @@ class AareDAQ: back_light=self.back_light, cryojet_K=self.__devs.cryojet_temp, shutter_open=self.__devs.shutter, - exp_shutter_open=self.__devs.shutter, + exp_shutter_open=self.__devs.exp_shutter.state(), flux_ph_s=self.__devs.full_flux, sample_camera=self.__devs.samcam_settings, name=self.__bl, @@ -3000,6 +3002,7 @@ class AareDAQ: except AerotechCommunicationError as e: logger.warning(f"Aeroetch error in _safe_geom: {e}") aerotech_error = f"Cannot connect to Aerotech: {e}" + self.__devs.exp_shutter.close() aerotech_connected = False except Exception as e: logger.warning(f"Unexpected error in _safe_geom: {e}") diff --git a/src/aare/daq/devices.py b/src/aare/daq/devices.py index d1c0193e..cd6a6b5c 100644 --- a/src/aare/daq/devices.py +++ b/src/aare/daq/devices.py @@ -36,6 +36,7 @@ class BeamlineDevices: self.bec_worker = BECClientWorker(beamline) logger.debug("initialising BEC worker done") self.__smargon = smargon.Smargon(beamline) + self.exp_shutter = ExperimentalHutchShutter(beamline=self._beamline) #only used at the moment to get hlm and llm from BEC self.__dtz = self.bec_worker.dev.det_z @@ -303,9 +304,6 @@ class BeamlineDevices: def set_dtz(self, value: float, /, wait: bool = True): if value < self.__dtz.low_limit: #raise ValueError(f"Detector distance cannot be less than {self.detector_distance_minimum} mm") - logger.warning(f"Requested detector distance {value} is less than minimum: {self.detector_distance_minimum}, setting to minimum") - value = self.detector_distance_minimum - self.__dtz.move(value, wait=wait) logger.warning(f"Requested detector distance {value} is less than minimum: {self.__dtz.low_limit}, setting to minimum") value = self.__dtz.low_limit + self.dtz_mod if value > self.__dtz.high_limit: From 8a682a6b9703e5ff695ffcbdefdba2d86083b582 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 12 Jun 2026 13:55:02 +0200 Subject: [PATCH 10/76] exp_hutch_shutter added back in (import) --- src/aare/daq/devices.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/aare/daq/devices.py b/src/aare/daq/devices.py index cd6a6b5c..79f9e439 100644 --- a/src/aare/daq/devices.py +++ b/src/aare/daq/devices.py @@ -16,6 +16,7 @@ from aare.common.models import SampleCameraSettings, StagePositionEnum from aare.devices import smargon, aerotech from aare.devices.area_detector import epicsAD, AutoEnum from aare.devices.enum_pv import EnumPV +from aare.devices.experimental_hutch_shutter import ExperimentalHutchShutter from aare.devices.my_motor import MyMotor from aare.devices.set_get_pv import SetGetPV, PredefinedPV From ac1ad2cf2deb92b4405ee8092ade96f821ac5e7a Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 12 Jun 2026 13:55:27 +0200 Subject: [PATCH 11/76] anneal functionality added, including wait time, SHould be tested/optimised later. --- src/aare/daq/devices.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/aare/daq/devices.py b/src/aare/daq/devices.py index 79f9e439..4aee1ebd 100644 --- a/src/aare/daq/devices.py +++ b/src/aare/daq/devices.py @@ -1,4 +1,5 @@ # Abstractions of devices for beamline +import time # Each "standard" device needs three elements: # - property to read device value @@ -208,8 +209,12 @@ class BeamlineDevices: def cryojet_temp(self) -> float: return self.__cryojet_temperature_get.get() - def anneal(self, time: float): - pass + def anneal(self, wait_time: float): + logger.warning("Untested annealing procedure, use with caution") + logger.info(f"Annealing for {wait_time} seconds") + self.tell.anneal_in() + time.sleep(wait_time) + self.tell.anneal_out() @property def cryojet_pos(self) -> StagePositionEnum: From 6daf0a24f6b71a85405ff8534f89934e8d197c45 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 12 Jun 2026 13:55:45 +0200 Subject: [PATCH 12/76] comments regarding front and back light --- src/aare/daq/devices.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/aare/daq/devices.py b/src/aare/daq/devices.py index 4aee1ebd..7d8365d2 100644 --- a/src/aare/daq/devices.py +++ b/src/aare/daq/devices.py @@ -70,6 +70,10 @@ class BeamlineDevices: timeout=10.0 ) + + # self.__front_light = self.bec_worker.dev.fl_bright # need wrapper on bec_worker layer + # self.__back_light = self.bec_worker.dev.bl_bright #need wrapper on bec_worker layer + self.__back_light_pos = EnumPV(name = "back_light_pos", setpv = f"{BEAMLINE}-ES-BL:POS-SET", getpv = f"{BEAMLINE}-ES-BL:POS-GET", From a44f865812437c63938afecf11a16bffbd58af77 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 12 Jun 2026 13:56:23 +0200 Subject: [PATCH 13/76] devices: removed further unused epics variables --- src/aare/daq/devices.py | 42 +---------------------------------------- 1 file changed, 1 insertion(+), 41 deletions(-) diff --git a/src/aare/daq/devices.py b/src/aare/daq/devices.py index 7d8365d2..6c668fb1 100644 --- a/src/aare/daq/devices.py +++ b/src/aare/daq/devices.py @@ -27,10 +27,10 @@ from aare.devices.zmq_client import ZMQCameraClient logger = setup_logger("aareDAQ") + class BeamlineDevices: def __init__(self, beamline: MXBeamline): self._beamline = beamline - self.detector_distance_minimum = 170 BEAMLINE = beamline.value.upper() self.tell = make_tell_client(beamline) self.aerotech = aerotech.AerotechController(beamline) @@ -46,13 +46,6 @@ class BeamlineDevices: self.__sample_cam = epicsAD(f"{BEAMLINE}-ES-MS:") - self.__zmq_camera: ZMQCameraClient | None = None - self.__use_zmq_camera = False - #TODO make this ZMQCameraCLient work - it kills the threads that it gets a lbox request in... - if self.__use_zmq_camera: - self.__zmq_camera = ZMQCameraClient(beamline) - logger.info(f"ZMQ camera source configured: {beamline}") - self.__front_light = PredefinedPV(name='front_light', setpv=f"{BEAMLINE}-ES-FL:SET", getpv=f"{BEAMLINE}-ES-FL:SET", @@ -286,22 +279,6 @@ class BeamlineDevices: """ return int(self.__sample_cam.uid.get()) - @property - def zmq_camera_enabled(self) -> bool: - """Check if ZMQ camera source is enabled.""" - return self.__use_zmq_camera - - @zmq_camera_enabled.setter - def zmq_camera_enabled(self, enabled: bool): - """Enable or disable ZMQ camera source (fallback to EPICS when disabled).""" - self.__use_zmq_camera = enabled and self.__zmq_camera is not None - logger.info(f"ZMQ camera source {'enabled' if self.__use_zmq_camera else 'disabled'}") - - @property - def zmq_camera_connected(self) -> bool: - """Check if ZMQ camera is currently connected.""" - return self.__zmq_camera is not None and self.__zmq_camera.is_connected() - # Detector Z @property def dtz(self) -> float: @@ -375,14 +352,6 @@ class BeamlineDevices: incremental=incremental, ) - @property - def aerotech_lock(self) -> bool: - return False - - @aerotech_lock.setter - def aerotech_lock(self, val: bool): - pass - def aerotech_stop(self): pass @@ -410,15 +379,6 @@ class BeamlineDevices: def smargon_initialize(self): self.__smargon.initialize() - @property - def det_cov(self): - return devs.bec_worker.get_det_cov() - - @det_cov.setter - def det_cov(self, val: DetectorCoverEnum): - devs.bec_worker.det_cov(val) - - if __name__ == "__main__": from aare.common.beamline import mx_beamline beamline = mx_beamline() From 1ba656a18cf23bf88afae5dcd4aad43888482c7f Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 12 Jun 2026 13:57:11 +0200 Subject: [PATCH 14/76] devices: added trasnmission --- src/aare/daq/devices.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/aare/daq/devices.py b/src/aare/daq/devices.py index 6c668fb1..0bb45df4 100644 --- a/src/aare/daq/devices.py +++ b/src/aare/daq/devices.py @@ -102,6 +102,12 @@ class BeamlineDevices: self.__cryojet_temperature_get = PV(f"{BEAMLINE}-ES-CS:TEMP_RBV") self.__cryojet_temperature_set = PV(f"{BEAMLINE}-ES-CS:TEMP.VAL") + self.__transmission = SetGetPV( + name = "transmission", + setpv = f"{BEAMLINE}-ES-SSFI:TRANSM-SET", + getpv = f"{BEAMLINE}-ES-SSFI:TRANSM-GET" + ) + self.magnet_position_sensor = PV(f"{BEAMLINE}-ES-DFS:CBOX-CMP1") self.magnet_position_sensor_readout = PV(f"{BEAMLINE}-ES-DFS:CBOX-USER1") #self.magnet_position_sensor_readout = PV(f"{BEAMLINE}-ES-DFS:CBOX-REFVAL1") @@ -137,14 +143,15 @@ class BeamlineDevices: # Transmission @property def transmission(self) -> float: - return 1.0 + return self.__transmission.value @transmission.setter def transmission(self, value: float): self.set_transmission(value, wait=False) def set_transmission(self, value: float, /, wait: bool = True): - pass + logger.warning("Setting Transmission is untested") + self.__transmission.move(value, wait=wait) # Lamp light @property From 5b73c0799318cccd86474291e0cc1bbe15bf2c4a Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 12 Jun 2026 13:57:30 +0200 Subject: [PATCH 15/76] devices: added fastshutter --- src/aare/daq/devices.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/aare/daq/devices.py b/src/aare/daq/devices.py index 0bb45df4..200c9ead 100644 --- a/src/aare/daq/devices.py +++ b/src/aare/daq/devices.py @@ -108,6 +108,8 @@ class BeamlineDevices: getpv = f"{BEAMLINE}-ES-SSFI:TRANSM-GET" ) + self.__fast_shutter = PV(f"{BEAMLINE}-ES-SHUTTER:SET") + self.magnet_position_sensor = PV(f"{BEAMLINE}-ES-DFS:CBOX-CMP1") self.magnet_position_sensor_readout = PV(f"{BEAMLINE}-ES-DFS:CBOX-USER1") #self.magnet_position_sensor_readout = PV(f"{BEAMLINE}-ES-DFS:CBOX-REFVAL1") @@ -234,11 +236,11 @@ class BeamlineDevices: # Shutter @property def shutter(self) -> bool: - return False + return self.__fast_shutter.get() @shutter.setter def shutter(self, opened: bool): - pass + self.__fast_shutter.put(opened) # Sample camera @property From 1e851955d7f71f4066b9184162e1e38d4946c3b3 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 12 Jun 2026 13:58:08 +0200 Subject: [PATCH 16/76] devices: added ring current, removed iold epics variables, added notes to self --- src/aare/daq/devices.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/src/aare/daq/devices.py b/src/aare/daq/devices.py index 200c9ead..eb665af9 100644 --- a/src/aare/daq/devices.py +++ b/src/aare/daq/devices.py @@ -72,21 +72,7 @@ class BeamlineDevices: getpv = f"{BEAMLINE}-ES-BL:POS-GET", timeout = 10.0) - self.__collimator_pos = MyMotor(f"{BEAMLINE}-ES-COL:TRY") - self.__collimator_X = MyMotor(f"{BEAMLINE}-ES-COL:TRX") # HOW TO HANDLE!!! - - self.__scintillator_pos = PV(f"{BEAMLINE}-ES-SCL:TRY") # how to handle!!! - self.__scintillator_z = PV(f"{BEAMLINE}-ES-SCL:TRZ") - - self.__beamstop_pos = EnumPV(name = "beamstop_pos", - setpv = f"{BEAMLINE}-ES-BS:POS-SET", - getpv = f"{BEAMLINE}-ES-BS:POS-GET", - timeout = 10.0) - - self.__beamstop_x = MyMotor(f"{BEAMLINE}-ES-BS:TRX") - self.__beamstop_y = MyMotor(f"{BEAMLINE}-ES-BS:TRY") - self.__beamstop_z = MyMotor(f"{BEAMLINE}-ES-BS:TRZ") - + self.__ringcurrent = self.bec_worker.ring_current self.__zoom = SetGetPV(name = f"zoom", setpv = f"{BEAMLINE}-ES-MS:ZOOM.VAL", @@ -204,6 +190,7 @@ class BeamlineDevices: @property def flux(self) -> float: + #TODO FLUX return 0 @property @@ -362,6 +349,7 @@ class BeamlineDevices: ) def aerotech_stop(self): + #TODO link cancel in GUI to cancel in aerotech if not already done pass # Smargon goniometer From 6fb579ead32ab54fc91d13a4de8da64e886cb712 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Fri, 12 Jun 2026 14:57:56 +0200 Subject: [PATCH 17/76] Updated pyproject.toml aaredb dependency to version 0.1.2a5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 78bfe0ec..3aca9e93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "python-redis-lock==4.0.0", "fastapi==0.115.13", "uvicorn==0.34.2", - "aaredb==0.1.2a1", + "aaredb==0.1.2a5", "python_multipart==0.0.20", "websocket-client==1.8.0", "sseclient-py==1.8.0", From 9420fabe423e340180e308a13cbdd7b5efffb43f Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 10:44:46 +0200 Subject: [PATCH 18/76] Removed old scripts --- scripts/auto_focus_demo.py | 670 ------------------------------------- scripts/bec_client.py | 52 --- 2 files changed, 722 deletions(-) delete mode 100644 scripts/auto_focus_demo.py delete mode 100644 scripts/bec_client.py diff --git a/scripts/auto_focus_demo.py b/scripts/auto_focus_demo.py deleted file mode 100644 index 39cd3f98..00000000 --- a/scripts/auto_focus_demo.py +++ /dev/null @@ -1,670 +0,0 @@ -import time -from typing import Callable, Iterable - -import cv2 -import numpy as np -from aare.common.autofocus_tools import focus_measure_edges -from aare.common.beamline import mx_beamline -from aare.common.coordinate import Coordinate, SmargonCoordinate -from aare.common.models import AutofocusSettings -from aare.daq.config import BeamlineConfig -from aare.daq.daq import AareDAQ -from aare.daq.devices import BeamlineDevices - -def make_circular_mask(shape_hw: tuple[int, int], center_x: float, center_y: float, radius: float) -> np.ndarray: - h, w = int(shape_hw[0]), int(shape_hw[1]) - y, x = np.ogrid[:h, :w] - return (x - float(center_x)) ** 2 + (y - float(center_y)) ** 2 <= float(radius) ** 2 - -def _parabola_vertex(x1, y1, x2, y2, x3, y3) -> float | None: - # Fit parabola through 3 points; return vertex x if it's a maximum. - denom = (x1 - x2) * (x1 - x3) * (x2 - x3) - if abs(denom) < 1e-15: - return None - a = (x3 * (y2 - y1) + x2 * (y1 - y3) + x1 * (y3 - y2)) / denom - b = (x3**2 * (y1 - y2) + x2**2 * (y3 - y1) + x1**2 * (y2 - y3)) / denom - if a >= 0: - return None - return float(-b / (2 * a)) - -class AutofocusController: - """ - Fast autofocus: bracket -> ternary -> optional parabola. - - You inject: - - get_gray_image(): np.ndarray (2D) - - get_frame_id(): int (UniqueId) OR None - - move_to(z): move stage to requested z (units are up to you) - - wait_for_stop(): block until motion ends - - The key speed/robustness trick is waiting for a *new frame id* after motion. - """ - def __init__( - self, - *, - get_gray_image, - focus_measure, - move_to, - wait_for_stop, - get_frame_id=None, - fps: float = 25.0, - ): - self.get_gray_image = get_gray_image - self.focus_measure = focus_measure - self.move_to = move_to - self.wait_for_stop = wait_for_stop - self.get_frame_id = get_frame_id - self.fps = float(fps) - - self._uid_stuck_count = 0 - self._uid_stuck_disable_after = 3 - - def _wait_new_frames(self, frames: int = 1, timeout_s: float = 0.12) -> bool: - """ - Wait for new frames by UniqueId. - If uid appears stuck (common in standalone tests if acquisition isn't running), - quickly fall back to a short sleep so autofocus stays fast. - """ - if self.get_frame_id is None or self._uid_stuck_count >= self._uid_stuck_disable_after: - time.sleep(max(0.0, float(frames)) / max(1e-6, self.fps)) - return True - start = int(self.get_frame_id()) - print(f"Waiting for {frames} frames (uid={start})...") - target = start + int(frames) - deadline = time.perf_counter() + float(timeout_s) - - while time.perf_counter() < deadline: - if int(self.get_frame_id()) >= target: - self._uid_stuck_count = 0 - print(f"Acquired {frames} frames (uid={target}, start={start})") - return True - time.sleep(0.001) - - # uid didn't advance in time -> count as "stuck" and fall back - print(f"UID stuck for {timeout_s} s, falling back to sleep...") - self._uid_stuck_count += 1 - time.sleep(1.0 / max(1e-6, self.fps)) - return False - - def _score_at(self, z, mask: np.ndarray | None, robust_frames: int) -> float: - st = time.perf_counter() - self.move_to(z) - print(f"move_to command to z={z:.2f} (t={time.perf_counter() - st:.5f} s)") - st = time.perf_counter() - self.wait_for_stop() - print(f"wait_for_stop command (t={time.perf_counter() - st:.5f} s)") - - # Ensure next image is not a stale buffer - print("Waiting for new frame...") - st = time.perf_counter() - self._wait_new_frames(frames=1, timeout_s=0.4) - print(f"Acquired new frame (t={time.perf_counter() - st:.5f} s)") - st = time.perf_counter() - if robust_frames <= 1: - gray = self.get_gray_image() - print(f"got grey image (t={time.perf_counter() - st:.5f} s)") - return float(self.focus_measure(gray, mask)) - - vals: list[float] = [] - for _ in range(int(robust_frames)): - gray = self.get_gray_image() - vals.append(float(self.focus_measure(gray, mask))) - self._wait_new_frames(frames=1, timeout_s=0.4) - print(f'Got values after {time.perf_counter() - st:.5f} s: {vals}') - return float(np.median(np.asarray(vals, dtype=np.float64))) - - def run_once( - self, - *, - z0: float, - z_range: float, - mask: np.ndarray | None = None, - robust_frames: int = 1, - ternary_iters: int = 4, - do_parabola: bool = True, - edge_stop: bool = True, - flat_rel_tol: float = 0.03, - ) -> tuple[float, float]: - """ - Returns (best_z, best_focus). - - edge_stop: - If True and the best bracket point is at ±0.5*z_range, stop early. - (Means the peak is likely outside the search window.) - - flat_rel_tol: - If (max-min)/max is below this, treat focus curve as flat and stop early. - """ - R = float(z_range) - - # 1) 5-point bracket - zs = np.array( - [z0 - 0.5 * R, z0 - 0.25 * R, z0, z0 + 0.25 * R, z0 + 0.5 * R], - dtype=np.float64, - ) - fs = np.array([self._score_at(float(z), mask, robust_frames) for z in zs], dtype=np.float64) - f0 = float(fs[2]) # z0 - f_max = float(fs.max()) - if f0 > 0 and (f_max / f0) < 1.05: # <5% improvement available - return float(zs[2]), f0 - - best_i = int(np.argmax(fs)) - z_best = float(zs[best_i]) - f_best = float(fs[best_i]) - - # Early exit if the curve is basically flat (no meaningful improvement) - f_min = float(fs.min()) - if f_max > 0 and (f_max - f_min) / f_max < float(flat_rel_tol): - return z_best, f_best - - # Early exit if best is at range edge: bracket does not contain a maximum - if edge_stop and (best_i == 0 or best_i == len(zs) - 1): - return z_best, f_best - - # Local bracket for ternary search - iL = max(0, best_i - 1) - iR = min(len(zs) - 1, best_i + 1) - zL, zR = float(zs[iL]), float(zs[iR]) - - sampled: dict[float, float] = {float(zs[i]): float(fs[i]) for i in range(len(zs))} - - if zL == zR: - return z_best, f_best - - # 2) ternary search in local bracket (assumes unimodal-ish) - for _ in range(int(ternary_iters)): - a, b = (zL, zR) if zL < zR else (zR, zL) - z1 = a + (b - a) / 3.0 - z2 = b - (b - a) / 3.0 - - if z1 not in sampled: - sampled[z1] = self._score_at(float(z1), mask, robust_frames) - if z2 not in sampled: - sampled[z2] = self._score_at(float(z2), mask, robust_frames) - - if sampled[z1] < sampled[z2]: - zL = z1 - else: - zR = z2 - - # 3) optional 3-point parabola around current best sample - if do_parabola and len(sampled) >= 3: - items = sorted(sampled.items(), key=lambda t: t[0]) - zz = np.array([p[0] for p in items], dtype=np.float64) - ff = np.array([p[1] for p in items], dtype=np.float64) - k = int(np.argmax(ff)) - - if 0 < k < len(zz) - 1: - zv = _parabola_vertex( - float(zz[k - 1]), float(ff[k - 1]), - float(zz[k]), float(ff[k]), - float(zz[k + 1]), float(ff[k + 1]), - ) - if zv is not None and float(zz[k - 1]) <= zv <= float(zz[k + 1]): - if zv not in sampled: - sampled[zv] = self._score_at(float(zv), mask, robust_frames) - - z_best, f_best = max(sampled.items(), key=lambda t: t[1]) - return float(z_best), float(f_best) - -def __auto_focus(settings: AutofocusSettings) -> float: - """ - Fast autofocus on Smargon Z: - - bracket (5 points) - - ternary search (few iters) - - optional parabola refine - - Returns: - Best Z offset in mm (beamline Z delta) relative to the starting position. - """ - geom = daq.sample_geometry - start_smargon = devs.smargon_pos - - # ROI center: use provided, else use beam location (beam mark) - center_x = float(settings.center_x_pxl) if settings.center_x_pxl is not None else float(geom.beam_location_pxl.x) - center_y = float(settings.center_y_pxl) if settings.center_y_pxl is not None else float(geom.beam_location_pxl.y) - radius_pxl = float(settings.radius_pxl) - - z_range_mm = float(settings.z_range_um) / 1000.0 - z_steps = int(settings.z_steps) - - # Build mask once (needs image shape) - first = daq.camera_image_gray - if first is None: - raise RuntimeError("Autofocus: no camera image available.") - if first.ndim != 2: - raise RuntimeError("Autofocus: expected grayscale image (2D).") - - #mask = make_circular_mask(first.shape[:2], center_x=center_x, center_y=center_y, radius=radius_pxl) - - height, width = first.shape - y, x = np.ogrid[:height, :width] - mask = (x - center_x) ** 2 + (y - center_y) ** 2 <= radius_pxl ** 2 - - def move_to_delta_z_mm(dz_mm: float) -> None: - # Apply relative motion in *beamline Z* via the geometry transform - sh_new = start_smargon.sh_mm + geom.smargon_nudge(Coordinate(z=float(dz_mm))) - target = SmargonCoordinate( - sh_mm=sh_new, - phi_deg=start_smargon.phi_deg, - chi_deg=start_smargon.chi_deg, - ) - devs.smargon_pos = target - - def wait_for_stop() -> None: - devs.smargon_wait(timeout=30) - - def get_gray() -> np.ndarray: - img = daq.camera_image_gray - if img is None: - raise RuntimeError("Autofocus: failed to acquire image.") - return img - - ctrl = StepwiseAutofocus( - get_gray_image=get_gray, - focus_measure=focus_measure_edges, - move_to=move_to_delta_z_mm, - wait_for_stop=wait_for_stop, - get_frame_id=devs.samcam_frame_id, - fps=25.0, - ) - - # Robustness vs speed: - # - 1 is fastest - # - 2 is more stable (median of 2 frames) and often still < 1 s total - robust_frames = 1 - - best_dz, best_f, zs, fs = ctrl.run(z0=0.0, z_range=z_range_mm, z_steps=z_steps, mask=mask, - refine=False, include_baseline=False) - - # Move to the best position (controller ends at last probed z; ensure final is best) - move_to_delta_z_mm(best_dz) - wait_for_stop() - - print( - f"Autofocus complete: best_dz={best_dz * 1000.0:.1f} um, focus={best_f:.2f}, " - f"roi_center=({center_x:.1f},{center_y:.1f}), r={radius_pxl:.1f}px" - ) - - return float(best_dz) - - # def auto_focus(self, settings: AutofocusSettings) -> float: - # """ - # Public autofocus method. Only allowed in SampleAlignment state. - # Returns best Z offset in mm (beamline Z delta) relative to start. - # """ - # self.__cfg.set_busy(BeamlineStateEnum.SampleAlignment) - # try: - # best_dz_mm = self.__auto_focus(settings) - # self.__cfg.state_busy = False - # return best_dz_mm - # except Exception as e: - # logger.error(f"Autofocus failed: {e}") - # self.__cfg.state_busy = False - # raise -class StepwiseAutofocus: - def __init__(self, *, get_gray_image, focus_measure, move_to, wait_for_stop, get_frame_id=None, fps=25.0): - self.get_gray_image = get_gray_image - self.focus_measure = focus_measure - self.move_to = move_to - self.wait_for_stop = wait_for_stop - self.get_frame_id = get_frame_id - self.fps = float(fps) - - def _wait_new_frame(self, timeout_s: float = 0.25) -> None: - if self.get_frame_id is None: - time.sleep(1.0 / max(1e-6, self.fps)) - return - start = int(self.get_frame_id()) - deadline = time.perf_counter() + float(timeout_s) - while time.perf_counter() < deadline: - if int(self.get_frame_id()) > start: - return - time.sleep(0.001) - # fallback: don't hang - time.sleep(1.0 / max(1e-6, self.fps)) - - def score_at(self, z: float, mask=None) -> float: - self.move_to(float(z)) - self.wait_for_stop() - self._wait_new_frame(timeout_s=0.25) - gray = self.get_gray_image() - return float(self.focus_measure(gray, mask)) - - def run( - self, - *, - z0: float, - z_range: float, - z_steps: int, - mask=None, - refine: bool = False, - include_baseline: bool = False, - ) -> tuple[float, float, np.ndarray, np.ndarray]: - """ - Returns: - (best_z, best_focus, z_positions, focus_values) - - If include_baseline=False and refine=False, this will evaluate focus exactly `z_steps` times. - """ - z_steps = int(z_steps) - if z_steps < 3: - raise ValueError("z_steps must be >= 3 for a meaningful scan.") - - if include_baseline: - _ = self.score_at(float(z0), mask=mask) - - zs = np.linspace(z0 - 0.5 * float(z_range), z0 + 0.5 * float(z_range), z_steps, dtype=np.float64) - fs = np.empty_like(zs) - - for i, z in enumerate(zs): - fs[i] = self.score_at(float(z), mask=mask) - - best_i = int(np.argmax(fs)) - best_z = float(zs[best_i]) - best_f = float(fs[best_i]) - - if refine and 0 < best_i < (len(zs) - 1): - dz = float(zs[best_i + 1] - zs[best_i]) - z_candidates = np.array([best_z - dz, best_z, best_z + dz], dtype=np.float64) - f_candidates = np.array([self.score_at(float(zc), mask=mask) for zc in z_candidates], dtype=np.float64) - j = int(np.argmax(f_candidates)) - best_z = float(z_candidates[j]) - best_f = float(f_candidates[j]) - - return best_z, best_f, zs, fs - -def __auto_focus_with_aerotech(settings: AutofocusSettings) -> float: - """ - 1) Fast focus scan on Aerotech GMZ (true focus axis) - 2) Return GMZ to home position - 3) Apply one Smargon move to preserve the focus (using a local Jacobian estimate) - - Returns: - Smargon delta (in the same "beamline z command" units you use in geom.smargon_nudge(Coordinate(z=...))). - """ - geom = daq.sample_geometry - start_smargon = devs.smargon_pos - - center_x = float(geom.beam_location_pxl.x) - center_y = float(geom.beam_location_pxl.y) - radius_pxl = float(settings.radius_pxl) - - z_range_mm = float(settings.z_range_um) / 1000.0 - z_steps = int(settings.z_steps) - - def get_gray() -> np.ndarray: - """ - Match GUI pipeline: - - if Bayer: debayer -> RGB - - flip horizontally - - convert to gray (uint8) - """ - img = daq.camera_image # <-- NOTE: use raw, not camera_image_gray - if img is None: - raise RuntimeError("Autofocus: failed to acquire image.") - - # If already grayscale - if img.ndim == 2: - bayer = img.astype(np.uint8, copy=False) - rgb = cv2.cvtColor(bayer, cv2.COLOR_BAYER_GB2RGB) - rgb = rgb[:, ::-1, :].copy() - gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY) - return gray - - # If RGB-like - if img.ndim == 3 and img.shape[2] >= 3: - rgb = img[:, :, :3] - rgb = rgb[:, ::-1, :].copy() - if rgb.dtype != np.uint8: - rgb = np.clip(rgb, 0, 255).astype(np.uint8) - gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY) - return gray - - raise RuntimeError(f"Autofocus: unexpected image shape {img.shape}") - - - first = get_gray() - print(first.shape[:2]) - print(center_x, center_y, radius_pxl) - print((first.shape[1]-1) - center_x) - if first is None or first.ndim != 2: - raise RuntimeError("Autofocus: no grayscale image available.") - mask = make_circular_mask(first.shape[:2], center_x=center_x, center_y=center_y, radius=radius_pxl) - - def score_focus() -> float: - gray = get_gray() - - # Ensure we're comparing apples-to-apples in logs - g = gray - if g.dtype != np.uint8: - g_u8 = np.clip(g, 0, 255).astype(np.uint8) - else: - g_u8 = g - - roi = g_u8[mask] - mean_dn = float(roi.mean()) if roi.size else 0.0 - std_dn = float(roi.std()) if roi.size else 0.0 - - raw = float(focus_measure_edges(g_u8, mask)) - - # Normalize to reduce exposure/gain dependence (gradient energy scales ~ intensity^2) - norm = raw / ((mean_dn + 1e-6) ** 2) - - print(f"AF: mean={mean_dn:.1f} std={std_dn:.1f} raw_focus={raw:.2f} norm_focus={norm:.6f}") - return norm - - # --------- - # A) Aerotech GMZ scan (relative to current GMZ = "home" for this autofocus call) - # --------- - aero0 = devs.aerotech_pos - gmz0 = float(aero0.z) - - gmz_offsets = np.linspace(-0.5 * z_range_mm, 0.5 * z_range_mm, z_steps, dtype=np.float64) - gmz_scores = [] - - for dz in gmz_offsets: - devs.aerotech.move_motor_linear("Z", gmz0 + float(dz), 10) - # wait 1 new frame after motion so we don't score an old buffer - start_uid = devs.samcam_frame_id() - t_deadline = time.perf_counter() + 0.25 - while time.perf_counter() < t_deadline and devs.samcam_frame_id() == start_uid: - time.sleep(0.001) - gmz_scores.append(score_focus()) - - gmz_scores = np.asarray(gmz_scores, dtype=np.float64) - best_i = int(np.argmax(gmz_scores)) - best_gmz_offset = float(gmz_offsets[best_i]) - best_focus = float(gmz_scores[best_i]) - - # Move GMZ back to "home" (gmz0) - devs.aerotech.move_motor_absolute("Z", gmz0, 10000) - - # If best was ~0 anyway, nothing to bake in - if abs(best_gmz_offset) < 1e-6: - print(f"Aerotech prefocus: best_gmz_offset≈0, focus={best_focus:.2f}") - return 0.0 - - # --------- - # B) Estimate local Jacobian: how Aerotech GMZ changes per unit Smargon beamline-z command - # We do two probe moves in the Smargon command space and measure GMZ readback. - # --------- - def move_smargon_beamline_dz(dz_mm: float) -> None: - sh_new = start_smargon.sh_mm + geom.smargon_nudge(Coordinate(z=float(dz_mm))) - target = SmargonCoordinate( - sh_mm=sh_new, - phi_deg=start_smargon.phi_deg, - chi_deg=start_smargon.chi_deg, - ) - devs.smargon_pos = target - devs.smargon_wait(timeout=30) - - move_smargon_beamline_dz(best_gmz_offset) - - print( - f"Aerotech prefocus: best_gmz_offset={best_gmz_offset*1000} um, focus={best_focus:.2f} " - f"Smargon_start: {start_smargon.sh_mm} um, Smargon_end: {devs.smargon_pos.sh_mm} um" - ) - return float(best_gmz_offset) - -def focus_measure_laplacian(gray: np.ndarray, mask: np.ndarray | None = None) -> float: - """ - Fast focus metric: variance of Laplacian. - - Notes: - - Works best on uint8 images. - - Use a mask/ROI to avoid scoring irrelevant background. - """ - if gray is None: - return 0.0 - if gray.ndim != 2: - raise ValueError(f"Expected 2D grayscale image, got shape={gray.shape}") - - g = gray - if g.dtype != np.uint8: - g = np.clip(g, 0, 255).astype(np.uint8) - - if mask is not None: - roi = g[mask] - if roi.size < 64: # too few pixels -> unstable variance - return 0.0 - # Laplacian needs 2D input; reshape ROI to a thin image is awkward. - # Better: compute Laplacian on full image and then mask the result. - lap = cv2.Laplacian(g, cv2.CV_64F, ksize=3) - v = float(lap[mask].var()) - return v - - lap = cv2.Laplacian(g, cv2.CV_64F, ksize=3) - return float(lap.var()) - - -def _wait_for_new_uid( - get_frame_id: Callable[[], int] | None, - last_uid: int | None, - *, - frames: int = 1, - timeout_s: float = 0.30, - poll_s: float = 0.002, - fallback_sleep_s: float = 0.04, -) -> int | None: - """ - Wait until UniqueId advances by `frames`. - Returns the new uid (or last_uid if we couldn't observe advancement). - """ - if get_frame_id is None: - time.sleep(fallback_sleep_s) - return last_uid - - try: - uid0 = int(get_frame_id()) if last_uid is None else int(last_uid) - except Exception: - time.sleep(fallback_sleep_s) - return last_uid - - target = uid0 + int(frames) - deadline = time.perf_counter() + float(timeout_s) - - while time.perf_counter() < deadline: - try: - uid = int(get_frame_id()) - except Exception: - uid = uid0 - - if uid >= target: - return uid - - time.sleep(poll_s) - - # Timeout: don't hang autofocus; just do a small sleep to reduce stale-buffer chance. - time.sleep(fallback_sleep_s) - return uid0 - - -def autofocus_gpt( - z_positions: Iterable[float], - move_stage_fn: Callable[[float], None], - *, - get_frame_id: Callable[[], int] | None = None, - wait_for_stop: Callable[[], None] | None = None, - mask: np.ndarray | None = None, - robust_frames: int = 1, -) -> tuple[float, list[tuple[float, float]]]: - """ - Simple autofocus scan with reliability improvements: - - waits for a new UniqueId after motion (avoids scoring stale frames) - - optional median-of-N scoring per z - """ - measures: list[tuple[float, float]] = [] - last_uid: int | None = None - - # Prime last_uid so the first point also waits for a "fresh" frame - if get_frame_id is not None: - try: - last_uid = int(get_frame_id()) - except Exception: - last_uid = None - - for z in z_positions: - move_stage_fn(float(z)) - if wait_for_stop is not None: - wait_for_stop() - - # Wait for camera to deliver a frame AFTER the move - last_uid = _wait_for_new_uid(get_frame_id, last_uid, frames=1, timeout_s=0.35) - - if robust_frames <= 1: - img = daq.camera_image_gray - score = focus_measure_laplacian(img, mask=mask) - else: - vals: list[float] = [] - for _ in range(int(robust_frames)): - img = daq.camera_image_gray - vals.append(focus_measure_laplacian(img, mask=mask)) - last_uid = _wait_for_new_uid(get_frame_id, last_uid, frames=1, timeout_s=0.35) - score = float(np.median(np.asarray(vals, dtype=np.float64))) - - measures.append((float(z), float(score))) - print(f"Z={z:.6f}, sharpness={score:.3f}") - - best_z = max(measures, key=lambda x: x[1])[0] - return best_z, measures - -# ---- Example z positions ---- -coarse = np.linspace(-0.1, 0.1, 10) # 0 to 200 microns in 10µm steps - -def move_stage(z): - # Insert your hardware code here: - print(z) - devs.aerotech.move_motor_absolute("Z", z, 1000) - #devs.aerotech.controller.read_status() - # e.g. serial.write(f"MOVE Z {z}") - - -def get_frame_id(): - return int(devs.samcam_frame_id()) - -if __name__ == "__main__": - devs = BeamlineDevices(mx_beamline()) - cfg = BeamlineConfig(mx_beamline()) - daq = AareDAQ(cfg, bl=mx_beamline()) - zoom = devs.zoom - beam_center = cfg.get_beam_mark(zoom) - settings = AutofocusSettings(center_x_pxl=beam_center[0], center_y_pxl=beam_center[1], - radius_pxl=30, z_range_um=400, z_steps=40) - st = time.perf_counter() - best_z, curve = autofocus_gpt(coarse, move_stage, get_frame_id=get_frame_id) - move_stage(0) - #move_stage(best_z) - geom = daq.sample_geometry - start_smargon = devs.smargon_pos - - sh_new = start_smargon.sh_mm + geom.smargon_nudge(Coordinate(z=float(best_z))) - target = SmargonCoordinate( - sh_mm=sh_new, - phi_deg=start_smargon.phi_deg, - chi_deg=start_smargon.chi_deg, - ) - devs.smargon_pos = target - devs.smargon_wait(timeout=30) - print("Best focus at:", best_z) - print(f"Total time: {time.perf_counter() - st:.5f} s") \ No newline at end of file diff --git a/scripts/bec_client.py b/scripts/bec_client.py deleted file mode 100644 index 72032420..00000000 --- a/scripts/bec_client.py +++ /dev/null @@ -1,52 +0,0 @@ -from bec_lib.client import BECClient -from bec_lib.service_config import ServiceConfig -from bec_lib.user_macros import UserMacros -import sys - -sys.path.append("/sls/MX/applications/test_scripts/bec_tes") - -service_config = ServiceConfig(redis={"host": "x06da-bec-001", "port": 6379}) -client = BECClient(service_config, name="Martins-Custom-Client") -client.start() -client.macros.load_user_macro("/sls/MX/applications/test_scripts/bec_tes/calculator.py") -client.macros.load_user_macro("/sls/MX/applications/test_scripts/bec_tes/pxiii_parameters.py") -client.macros.load_user_macro("/sls/MX/applications/test_scripts/bec_tes/pxiii_energy.py") -client.macros.load_user_macro("/sls/MX/applications/test_scripts/bec_tes/mx_methods.py") -client.macros.load_user_macro("/sls/MX/applications/test_scripts/bec_tes/mx_basics.py") -client.macros.load_user_macro("/sls/MX/applications/test_scripts/bec_tes/energy_check.py") -#client.macros.load_user_macro("/sls/MX/applications/test_scripts/bec_tes/y") -dev=client.device_manager.devices -macros = client.macros -scans = client.scans - -try: - energy_ev = validate_energy(13) - current_energy = get_current_energy() - energy_diff = calculate_energy_difference(current_energy, energy_ev) - dccm_pos = get_dccm_motors_positions(energy_ev) - print(dev.dccm_theta1) - print(dev.dccm_theta2) - print( - f"Moving DCCM theta1: {dccm_pos['theta1_angle']: .5g} deg, theta2: {dccm_pos['theta2_angle']: .5g} deg, " - # f"DCM pitch: {dcm_pos['dcm_pitch']: .5g} mrad, " - ) - theta = scans.umv(dev.dccm_theta1, dccm_pos["theta1_angle"], relative=False) - theta.wait() - theta_2 = scans.umv(dev.dccm_theta2, dccm_pos["theta2_angle"], relative=False) - theta_2.wait() - set_mirror_stripe(energy_ev) - print( - f"Energy difference: {energy_diff: .5g} eV, current energy: {current_energy: .5g} eV" - ) - mono_pitch_scan(scans, plot=False) - #bl_energy(energy_ev=13, scans=scans, plot=False) -except Exception as e: - print(e) - - - -# print(dev.det_z.read()) -# status = scans.mv(dev.det_z, 770.0, relative=False) -# status.wait() -# print(dev.det_z.read()) -client.shutdown() \ No newline at end of file From e69fcc62155139766e959cae58dd341e4f4fd59d Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 10:45:54 +0200 Subject: [PATCH 19/76] Removed old samcam functions --- src/aare/daq/daq.py | 10 ---------- src/aare/daq/devices.py | 24 ------------------------ 2 files changed, 34 deletions(-) diff --git a/src/aare/daq/daq.py b/src/aare/daq/daq.py index 1f116092..f5450f82 100644 --- a/src/aare/daq/daq.py +++ b/src/aare/daq/daq.py @@ -1820,16 +1820,6 @@ class AareDAQ: logger.debug(f"Failed to change mounted sample: {e}") raise - @property - def camera_image(self) -> np.ndarray | None: - image = self.__devs.samcam_get_image(gray=False) - return image - - @property - def camera_image_gray(self) -> np.ndarray | None: - image = self.__devs.samcam_get_image(gray=True) - return image - def list_loaded_pucks(self) -> List[PuckLoadedInfo]: return [] diff --git a/src/aare/daq/devices.py b/src/aare/daq/devices.py index eb665af9..eade4f22 100644 --- a/src/aare/daq/devices.py +++ b/src/aare/daq/devices.py @@ -241,30 +241,6 @@ class BeamlineDevices: def samcam_settings(self, settings: SampleCameraSettings): self.__sample_cam.setup(settings.gain, settings.exposure) - def samcam_get_image(self, /, gray: bool = False) -> np.ndarray: - """ - Get the current sample camera image. - - Primary source: ZMQ stream (same source as GUI) - Fallback: EPICS area_detector - - Args: - gray: If True, return grayscale image - - Returns: - numpy array with the image - """ - # Try ZMQ source first if configured - if self.__use_zmq_camera and self.__zmq_camera is not None: - logger.debug("we are using zmq camera for mlbox and screenshots") - image = self.__zmq_camera.get_image(gray=gray) - if image is not None: - return image - logger.warning("ZMQ camera unavailable, falling back to EPICS area_detector") - - # Fallback to EPICS area_detector - return self.__sample_cam.get_image(gray=gray) - @log_timing(logger, "auto exposure of camera") def samcam_auto(self, state: AutoEnum): self.__sample_cam.set_auto(state) From eec03592a488815dac891ab278bd5e7dfed79a6d Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 11:41:04 +0200 Subject: [PATCH 20/76] yaml: added detector_limit_modifier and maximum_flux to yamls --- src/aare/common/config/x06da.yaml | 3 ++- src/aare/common/config/x10sa.yaml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/aare/common/config/x06da.yaml b/src/aare/common/config/x06da.yaml index e2e3a11a..49e50b7a 100644 --- a/src/aare/common/config/x06da.yaml +++ b/src/aare/common/config/x06da.yaml @@ -43,4 +43,5 @@ daq: increment_omega_deg: 0.2 steps: 1800 - detector_limit_modifier: 2.0 \ No newline at end of file + detector_limit_modifier: 2.0 + maximum_flux: 4e11 \ No newline at end of file diff --git a/src/aare/common/config/x10sa.yaml b/src/aare/common/config/x10sa.yaml index cf919a9c..ca40696e 100644 --- a/src/aare/common/config/x10sa.yaml +++ b/src/aare/common/config/x10sa.yaml @@ -42,4 +42,5 @@ daq: increment_omega_deg: 0.2 steps: 1800 - detector_limit_modifier: 2.0 \ No newline at end of file + detector_distance_limit_modifier: 2.0 + maximum_flux: 1e12 \ No newline at end of file From 28f6627f94feda269b0d89ca6f1e57a27d7a33a5 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 11:41:43 +0200 Subject: [PATCH 21/76] workflows: changed workflows to use to ABR_POS and ABR_OMEGA MOUNT --- src/aare/daq/workflows.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/aare/daq/workflows.py b/src/aare/daq/workflows.py index aff97a52..8238c22d 100644 --- a/src/aare/daq/workflows.py +++ b/src/aare/daq/workflows.py @@ -1,6 +1,6 @@ -from aare.common.models import StagePositionEnum, SampleCameraSettings +from aare.common.models import SampleCameraSettings from aare.daq.devices import BeamlineDevices -from aare.daq.config import BeamlineConfig, ABR_POS_MOUNT +from aare.daq.config import BeamlineConfig, ABR_POS_MOUNT, ABR_OMEGA_MOUNT from aare.devices.area_detector import AutoEnum from aare.common.logger_config import setup_logger @@ -18,6 +18,7 @@ def common_2rse(devs: BeamlineDevices, cfg: BeamlineConfig): devs.smargon_move_home() print('try to move aerotech') devs.aerotech_pos = ABR_POS_MOUNT + devs.aerotech_omega = ABR_OMEGA_MOUNT def rse2se(devs: BeamlineDevices, cfg: BeamlineConfig): devs.samcam_auto(AutoEnum.AUTO) @@ -28,7 +29,8 @@ def m2se(devs: BeamlineDevices, cfg: BeamlineConfig): devs.samcam_auto(AutoEnum.AUTO) devs.bec_worker.move_to(BeamlineState.MANUAL_SAMPLE_EXCHANGE) logger.info(f"moved to sample exchange, now moving to {cfg.abr_meas_pos}") - devs.aerotech_pos = cfg.abr_meas_pos + devs.aerotech_pos = ABR_POS_MOUNT + devs.aerotech_omega = ABR_OMEGA_MOUNT devs.smargon_move_home() devs.samcam_auto(AutoEnum.ONCE) @@ -42,12 +44,14 @@ def sa2se(devs: BeamlineDevices, cfg: BeamlineConfig): devs.samcam_auto(AutoEnum.AUTO) devs.bec_worker.move_to(BeamlineState.MANUAL_SAMPLE_EXCHANGE) logger.info(f"moved to sample exchange, now moving to {cfg.abr_meas_pos}") - devs.aerotech_pos = cfg.abr_meas_pos + devs.aerotech_pos = ABR_POS_MOUNT + devs.aerotech_omega = ABR_OMEGA_MOUNT devs.smargon_move_home() devs.samcam_auto(AutoEnum.ONCE) except Exception as e: logger.error(f"Failed to move to sample exchange: {e}") - devs.aerotech_pos = cfg.abr_meas_pos + devs.aerotech_pos = ABR_POS_MOUNT + devs.aerotech_omega = ABR_OMEGA_MOUNT devs.smargon_move_home() raise From e7f358ee0a9e8a81a7a93507e24eacfc2ca47600 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 14:08:16 +0200 Subject: [PATCH 22/76] aarelf_infer - beam is not setup yet --- src/aare/common/aarelc_infer.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/aare/common/aarelc_infer.py b/src/aare/common/aarelc_infer.py index 5087c3be..f0287476 100644 --- a/src/aare/common/aarelc_infer.py +++ b/src/aare/common/aarelc_infer.py @@ -1,7 +1,7 @@ import cv2 import numpy as np from aarelcinfer_client import Client, AuthenticatedClient -from aarelcinfer_client.api import config, predictions +from aarelcinfer_client.api import config, predictions#, beam from aarelcinfer_client.models import RuntimeConfigPatchModel, LatestPredictionModel import io from PIL import Image @@ -32,6 +32,7 @@ class AareLCInferWrapper: self._host = host self._api_config = config self._api_predictions = predictions + #self._api_beam = beam def get_config(self) -> config.ConfigSnapshotResponse: return self._api_config.get_config(self.client) @@ -48,6 +49,9 @@ class AareLCInferWrapper: def get_latest_prediction_bundle(self): return self._api_predictions.get_latest_prediction_bundle(self.client) + # def send_samcam_details(self, beam_mark, beam_dimensions): + # return self._api_beam.set_beam_mark(beam_mark, beam_dimensions) + if __name__ == "__main__": wrapper = AareLCInferWrapper(bl=mx_beamline()) From 57d830617df4858b8a6cf16cebd4118a13b02687 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 14:08:29 +0200 Subject: [PATCH 23/76] bec: check current energy added --- src/aare/devices/bec_worker.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/aare/devices/bec_worker.py b/src/aare/devices/bec_worker.py index b812b1ad..93905a7c 100644 --- a/src/aare/devices/bec_worker.py +++ b/src/aare/devices/bec_worker.py @@ -385,12 +385,14 @@ class BECClientWorker: additonal_text=[f"New dcm_pitch position: {self.dev.dcm_pitch.position:5f}"] ) - def __get_current_energy(self): + def check_current_energy(self): """Get the current energy from the BEC in eV""" - return get_current_energy() + energy_ev = get_current_energy() + energy_kev = energy_ev / 1000 + return energy_kev def change_energy(self, value: float | int, plot: bool = False): - current_energy = self.__get_current_energy() + current_energy = self.check_current_energy() logger.info(f"Current energy: {current_energy:.1f} eV") logger.info(f"Change energy requested: from {current_energy:.1f} to {value:.1f} eV") try: @@ -403,8 +405,8 @@ class BECClientWorker: tags=["energy_change"] ) - if abs(value - self.__get_current_energy()) > 1: - logger.warning(f"Energy change may have failed, current energy: {self.__get_current_energy()} eV") + if abs(value - self.check_current_energy()) > 1: + logger.warning(f"Energy change may have failed, current energy: {self.check_current_energy()} eV") self.scilog_msg( message=f"Moved from {current_energy:.1f} eV to {value:.1f} eV", @@ -415,7 +417,7 @@ class BECClientWorker: f"New dcm_pitch position: {self.dev.dcm_pitch.position:4g} ", f"Previous energy: {current_energy:.1f} eV ", f"Requested energy: {value:.1f} eV ", - f"New current energy: {self.__get_current_energy():.1f} eV"] + f"New current energy: {self.check_current_energy():.1f} eV"] ) From 051df0321cee3d57bb312676a942bbd95b8b6f01 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 14:09:11 +0200 Subject: [PATCH 24/76] ddevice + yaml: updated yaml and cfg_get in devices.py --- src/aare/daq/devices.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/aare/daq/devices.py b/src/aare/daq/devices.py index eade4f22..9a6b911c 100644 --- a/src/aare/daq/devices.py +++ b/src/aare/daq/devices.py @@ -42,8 +42,8 @@ class BeamlineDevices: #only used at the moment to get hlm and llm from BEC self.__dtz = self.bec_worker.dev.det_z - self.dtz_mod = cfg_get('daq.detector_limit_modifier', 1.0) - + self.dtz_mod = cfg_get('daq.detector_distance_limit_modifier', 1.0) + #TODO convert epics pvs to BEC self.__sample_cam = epicsAD(f"{BEAMLINE}-ES-MS:") self.__front_light = PredefinedPV(name='front_light', @@ -180,9 +180,7 @@ class BeamlineDevices: # Optics @property def energy_kev(self) -> float: - if self._beamline == MXBeamline.X06DA: - return 13.0 - return 12.4 + return self.bec_worker.check_current_energy() @property def ring_current(self) -> float: @@ -191,11 +189,15 @@ class BeamlineDevices: @property def flux(self) -> float: #TODO FLUX - return 0 + return self.transmission * self.full_flux @property def full_flux(self) -> float: - return 0 + #TODO wire real flux - i0 needed + max_flux = cfg_get('daq.maximum_flux') + if max_flux is None: + return 0.0 + return max_flux # Cryojet @property From c913477a2b9c9080fb6c8098d8c01950f4e44d44 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 14:09:52 +0200 Subject: [PATCH 25/76] auth: increased timeout in auth --- src/aare/gui/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aare/gui/auth.py b/src/aare/gui/auth.py index fcd2e6c0..a9f6514e 100644 --- a/src/aare/gui/auth.py +++ b/src/aare/gui/auth.py @@ -27,7 +27,7 @@ def auth(base_url: str | None, cert_path: str | None) -> str: ['curl', '-s', '--cacert', cacert, '--negotiate', '-u', ':', url, "-XPOST"], capture_output=True, text=True, - timeout=18.0, + timeout=120.0, ) except subprocess.TimeoutExpired as e: logger.error(f"Token request curl timed out: {e}") From 642b7466ed22e3d8c293fbb2c4a2a244b92f6381 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 14:10:57 +0200 Subject: [PATCH 26/76] daq: default collection paramters pulled form yaml --- src/aare/daq/daq.py | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/src/aare/daq/daq.py b/src/aare/daq/daq.py index f5450f82..84143a57 100644 --- a/src/aare/daq/daq.py +++ b/src/aare/daq/daq.py @@ -20,7 +20,7 @@ from aare.daq.config import BeamlineConfig, ABR_POS_MOUNT from aare.daq.config import BeamlineStateEnum from aare.daq.devices import BeamlineDevices from aare.daq.mlbox import MlBox -from aare.common.beamline import MXBeamline +from aare.common.beamline import MXBeamline, cfg_get from aare.common.coordinate import Coordinate, SmargonCoordinate, AerotechCoordinate from aare.common.diffraction_geometry import DiffractionGeometry from aare.common.logger_config import setup_logger @@ -1700,7 +1700,7 @@ class AareDAQ: def save_abr_meas_pos(self): self.__cfg.set_busy(BeamlineStateEnum.SampleAlignment) try: - self.__cfg.abr_meas_pos = AerotechCoordinate(at_mm=self.__devs.aerotech_pos.at_mm, omega_deg=0.0) + self.__cfg.abr_meas_pos = AerotechCoordinate(at_mm=self.__devs.aerotech_pos.at_mm) self.__cfg.state_busy = False except Exception: self.__cfg.state_busy = False @@ -2326,17 +2326,43 @@ class AareDAQ: # return sample return SampleShortInfoList(s=[x for x in self.sample_spreadsheet.s if x.user == pgroup]) - def get_auto_raster_params(self) -> SimpleScanParameters: + def get_beamline_default_raster_params(self) -> SimpleScanParameters: + default_exp_time_s = cfg_get("daq.data_collection_settings.default_raster_scan_settings.exp_time_s", 0.01) + default_transmission = cfg_get("daq.data_collection_settings.default_raster_scan_settings.transmission", 1.0) + default_dtz = cfg_get("daq.data_collection_settings.default_raster_scan_settings.dtz", 250) + return SimpleScanParameters( + dtz=default_dtz, + exp_time_s=default_exp_time_s, + transmission=default_transmission + ) + def get_beamline_default_rotation_params(self) -> SimpleScanParameters: + default_exp_time_s = cfg_get("daq.data_collection_settings.default_rotation_settings.exp_time_s", 0.01) + default_transmission = cfg_get("daq.data_collection_settings.default_rotation_settings.transmission", 1.0) + default_dtz = cfg_get("daq.data_collection_settings.default_rotation_settings.dtz", 250) + default_start_omega_deg = cfg_get("daq.data_collection_settings.default_rotation_settings.start_omega_deg", 0.0) + default_increment_omega_deg = cfg_get("daq.data_collection_settings.default_rotation_settings.incr_omega_deg", 0.2) + default_steps = cfg_get("daq.data_collection_settings.default_rotation_settings.steps", 1800) + return SimpleScanParameters( + dtz=default_dtz, + exp_time_s=default_exp_time_s, + transmission=default_transmission, + start_omega_deg=default_start_omega_deg, + incr_omega_deg=default_increment_omega_deg, + steps=default_steps + ) + + def get_auto_raster_params(self) -> SimpleScanParameters: + default_params = self.get_beamline_default_raster_params() if self.status.sample is None: - return SimpleScanParameters(dtz=150, exp_time_s=0.04, transmission=1.0) + return default_params aaredb_params = self.status.sample.aaredb_params if hasattr(self.status.sample, "aaredb_params") else None if aaredb_params is None: - return SimpleScanParameters(dtz=150, exp_time_s=0.04, transmission=1.0) + return default_params - params = SimpleScanParameters() + params = default_params # Exposure exp = getattr(aaredb_params, 'exposure', None) @@ -2362,7 +2388,6 @@ class AareDAQ: logger.debug(f"dtz: {params.dtz}") except Exception as e: logger.error(f"Failed to calculate dtz for resolution {res}: {e}") - params.dtz = 150 # Fallback default # Adjust exposure time based on resolution if res <= 1.5: @@ -2371,9 +2396,6 @@ class AareDAQ: params.exp_time_s = 0.04 else: params.exp_time_s = 0.08 - else: - params.dtz = 150 - params.exp_time_s = 0.01 return params From 730e82716062756c2e1629ad16d3ee24cf0f3ac8 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 14:12:05 +0200 Subject: [PATCH 27/76] daq_worker: error logging added to status request --- src/aare/gui/threads/daq_worker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aare/gui/threads/daq_worker.py b/src/aare/gui/threads/daq_worker.py index 4fb8965f..71c1d447 100644 --- a/src/aare/gui/threads/daq_worker.py +++ b/src/aare/gui/threads/daq_worker.py @@ -525,7 +525,7 @@ class DAQWorker(QObject): if aerotech_detail: err_msg = aerotech_detail except Exception: - pass + logger.error(f"Exception reading status response: {e}") if status == 403: self._last_status_can_read = False From b7024f1b0bc550c54ce2522b91255c686bfab306 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 14:12:22 +0200 Subject: [PATCH 28/76] GUI: removed bad logging statment --- src/aare/gui/gui.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/aare/gui/gui.py b/src/aare/gui/gui.py index 63869c14..bd50867e 100644 --- a/src/aare/gui/gui.py +++ b/src/aare/gui/gui.py @@ -137,7 +137,6 @@ def main(): try: splash.set_progress(50, f"Connecting to {base_url or 'backend'}...") token = auth(base_url, cert_path) - logger.info(f"Authentication token: {token}") if not token or token.count(".") != 2: raise RuntimeError( "Authentication did not return a valid token. " From 30c43537bc79b6f672380d6a841c881f0946a399 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 14:12:36 +0200 Subject: [PATCH 29/76] jfjoch: changes cellparameters to unitcell in dataset settings --- src/aare/devices/jfjoch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/aare/devices/jfjoch.py b/src/aare/devices/jfjoch.py index 9b4e1649..56873201 100644 --- a/src/aare/devices/jfjoch.py +++ b/src/aare/devices/jfjoch.py @@ -179,8 +179,8 @@ class JFJochWrapper: dataset_settings.xray_fluorescence_spectrum = xrf if s.sample.aaredb_params: - if s.sample.aaredb_params.cellparameters: - unit_cell_db = s.sample.aaredb_params.cellparameters + if s.sample.aaredb_params.unitcell: + unit_cell_db = s.sample.aaredb_params.unitcell unit_cell_split = unit_cell_db.replace(",", " ").split() unit_cell_floats = [float(x) for x in unit_cell_split] unit_cell = jfjoch_client.UnitCell( From 05f98d8e48e474d03f0e10eb2620e3e5396099c0 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 14:13:47 +0200 Subject: [PATCH 30/76] mainwindow: tempoarily remove seconday beamline camera view --- src/aare/gui/main_window.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 648e820f..295585c3 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -282,12 +282,12 @@ class MainWindow(QMainWindow): self.video_tab.addTab(self.beamline_view_panel, "Beamline view") self.video_tab.addTab(self.beamline_combined_panel, "Beamline combined view") - if cfg_get("gui.cameras.secondary_beamline_camera_url", None): - self.secondary_beamline_view = VideoGraphicsView() - self.secondary_beamline_view_panel = AxisVideoPanel("Secondary view", self.secondary_beamline_view, - parent=top_widget) - self.secondary_beamline_view_panel.refresh_requested.connect(self.refresh_axis_cameras) - self.video_tab.addTab(self.secondary_beamline_view_panel, "Secondary view") + # if cfg_get("gui.cameras.secondary_beamline_camera_url", None): + # self.secondary_beamline_view = VideoGraphicsView() + # self.secondary_beamline_view_panel = AxisVideoPanel("Secondary view", self.secondary_beamline_view, + # parent=top_widget) + # self.secondary_beamline_view_panel.refresh_requested.connect(self.refresh_axis_cameras) + # self.video_tab.addTab(self.secondary_beamline_view_panel, "Secondary view") self.compact_sample_camera = SampleCameraImageLabel( geom=geom, From 105f52987b3fa45feea9c156ad8636150c13fef7 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 14:14:03 +0200 Subject: [PATCH 31/76] tell; try to catch if no events occur and state goes to ready --- src/aare/devices/tell_client.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/aare/devices/tell_client.py b/src/aare/devices/tell_client.py index ae5136ec..039c5001 100755 --- a/src/aare/devices/tell_client.py +++ b/src/aare/devices/tell_client.py @@ -213,6 +213,17 @@ class TellClient: timeout=wait_timeout, ) logger.info(f"event: {event} occurred with value: {value}") + if event == "state" and value == 'Ready': + logger.warning('READY does not mean mount success') + try: + msg = self.check_command_ok( + timeout=wait_timeout, msg=f"Mount {segment}{puck}-{sample}: " + ) + logger.info(f"Check command okay response: {msg}") + return TellEventValueEnum.SUCCESS + except Exception as e: + raise TellCommunicationError(f"Error: {e}") + if event is None or event == "state": logger.info(f"event: {event} occurred with value: {value}, checking command completed okay") self.check_command_ok( From 898fa26cdde7115605c09fe29c23a9530b15e031 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 14:14:48 +0200 Subject: [PATCH 32/76] trying to make redis expire more backwards compatible as each beamline has different version of redis --- src/aare/daq/config.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/aare/daq/config.py b/src/aare/daq/config.py index 52a2cf2d..4ef95a19 100644 --- a/src/aare/daq/config.py +++ b/src/aare/daq/config.py @@ -342,8 +342,18 @@ class BeamlineConfig: 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: - self.__client.expire(f"{self.__bl}:active_session", expiry_sec, gt=True) + 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." From a178762c7e481946253caa4757892dee10ecb37e Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 16:56:34 +0200 Subject: [PATCH 33/76] workflows: added new transitions removed mysterious ws mode that ahs never been used. --- src/aare/daq/workflows.py | 42 ++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/src/aare/daq/workflows.py b/src/aare/daq/workflows.py index 8238c22d..53834b66 100644 --- a/src/aare/daq/workflows.py +++ b/src/aare/daq/workflows.py @@ -141,38 +141,56 @@ def sa2xrf(devs: BeamlineDevices, cfg: BeamlineConfig): def xrf2sa(devs: BeamlineDevices, cfg: BeamlineConfig): """XrfCollection to sample alignment""" - pass + devs.samcam_auto(AutoEnum.AUTO) + devs.bec_worker.move_to(BeamlineState.SAMPLE_ALIGNMENT) + devs.samcam_auto(AutoEnum.ONCE) -#todo CONNECT WS AND BA MODES -def sa2ws(devs: BeamlineDevices, cfg: BeamlineConfig): - pass +def common2flux_measurement(devs: BeamlineDevices, cfg: BeamlineConfig): + devs.bec_worker.move_to(BeamlineState.FLUX_MEASUREMENT) +def sa2flux_measurement(devs: BeamlineDevices, cfg: BeamlineConfig): + devs.bec_worker.move_to(BeamlineState.FLUX_MEASUREMENT) -def ws2sa(devs: BeamlineDevices, cfg: BeamlineConfig): - pass +def flux_measurement2sa(devs: BeamlineDevices, cfg: BeamlineConfig): + devs.bec_worker.move_to(BeamlineState.SAMPLE_ALIGNMENT) +def flux_measurement2ba(devs: BeamlineDevices, cfg: BeamlineConfig): + devs.bec_worker.move_to(BeamlineState.BEAMSTOP_ALIGNMENT) + +def flux_measurement2bl(devs: BeamlineDevices, cfg: BeamlineConfig): + devs.bec_worker.move_to(BeamlineState.BEAM_VISUALISATION) + +def ba2flux_measurement(devs: BeamlineDevices, cfg: BeamlineConfig): + devs.bec_worker.move_to(BeamlineState.FLUX_MEASUREMENT) + +def bl2flux_measurement(devs: BeamlineDevices, cfg: BeamlineConfig): + devs.bec_worker.move_to(BeamlineState.FLUX_MEASUREMENT) def sa2ba(devs: BeamlineDevices, cfg: BeamlineConfig): - pass - + """Sample alignment to BeamstopAlignment""" + devs.bec_worker.move_to(BeamlineState.BEAMSTOP_ALIGNMENT) + devs.samcam_auto(AutoEnum.ONCE) def ba2sa(devs: BeamlineDevices, cfg: BeamlineConfig): - pass + devs.bec_worker.move_to(BeamlineState.SAMPLE_ALIGNMENT) + devs.samcam_auto(AutoEnum.ONCE) def sa2bl(devs: BeamlineDevices, cfg: BeamlineConfig): devs.bec_worker.move_to(BeamlineState.BEAM_VISUALISATION) devs.samcam_settings.exposure = 0.001 - #TODO SAMCAM SETTINGS SHOULD BE HARDCODED FOR BEAM VISUALISATION STATE NOT AUTO + #TODO SAMCAM SETTINGS SHOULD BE DOEN via zoom settings def bl2sa(devs: BeamlineDevices, cfg: BeamlineConfig): devs.bec_worker.move_to(BeamlineState.SAMPLE_ALIGNMENT) devs.samcam_auto(AutoEnum.ONCE) def bl2ba(devs: BeamlineDevices, cfg: BeamlineConfig): - pass + devs.bec_worker.move_to(BeamlineState.BEAMSTOP_ALIGNMENT) + devs.samcam_auto(AutoEnum.ONCE) def ba2bl(devs: BeamlineDevices, cfg: BeamlineConfig): - pass + devs.bec_worker.move_to(BeamlineState.BEAM_VISUALISATION) + devs.samcam_settings.exposure = 0.001 def sa2dh(devs: BeamlineDevices, cfg: BeamlineConfig): common2dh(devs, cfg) From 6a1b18c132168fe3f23cc858c40761ebf94c7ab1 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 16:57:45 +0200 Subject: [PATCH 34/76] Beamline state panel: exposed new beamline state changes to GUI --- src/aare/common/models.py | 4 + src/aare/daq/daq.py | 146 +++++++++++++++++--- src/aare/daq/server.py | 30 ++++ src/aare/gui/panels/beamline_state_panel.py | 52 +++++-- src/aare/gui/threads/daq_worker.py | 8 ++ 5 files changed, 203 insertions(+), 37 deletions(-) diff --git a/src/aare/common/models.py b/src/aare/common/models.py index b061513d..746eb9c7 100644 --- a/src/aare/common/models.py +++ b/src/aare/common/models.py @@ -370,6 +370,8 @@ class BeamlineStateEnum(Enum): Moving = 8 RobotSampleExchange = 9 XtalSnapshot = 10 + BeamstopAlignment = 11 + FluxMeasurement = 12 def display_name(self) -> str: return { @@ -383,6 +385,8 @@ class BeamlineStateEnum(Enum): BeamlineStateEnum.Moving: "Moving", BeamlineStateEnum.RobotSampleExchange: "Robot sample exchange", BeamlineStateEnum.XtalSnapshot: "Xtal snapshot", + BeamlineStateEnum.BeamstopAlignment: "Beamstop alignment", + BeamlineStateEnum.FluxMeasurement: "Flux measurement", }.get(self, "-") diff --git a/src/aare/daq/daq.py b/src/aare/daq/daq.py index 84143a57..6e1f34aa 100644 --- a/src/aare/daq/daq.py +++ b/src/aare/daq/daq.py @@ -2827,6 +2827,41 @@ class AareDAQ: elif target == BeamlineStateEnum.SampleExchange: workflows.bl2sa(self.__devs, self.__cfg) workflows.sa2se(self.__devs, self.__cfg) + elif target == BeamlineStateEnum.BeamstopAlignment: + workflows.bl2ba(self.__devs, self.__cfg) + elif target == BeamlineStateEnum.FluxMeasurement: + workflows.bl2flux_measurement(self.__devs, self.__cfg) + else: + raise TransformationInvalidException( + f"Cannot go from {curr_state} to {target}, not implemented" + ) + case BeamlineStateEnum.BeamstopAlignment: + if target == BeamlineStateEnum.SampleAlignment: + workflows.ba2sa(self.__devs, self.__cfg) + elif target == BeamlineStateEnum.SampleExchange: + workflows.ba2sa(self.__devs, self.__cfg) + workflows.sa2se(self.__devs, self.__cfg) + elif target == BeamlineStateEnum.FluxMeasurement: + workflows.ba2flux_measurement(self.__devs, self.__cfg) + elif target == BeamlineStateEnum.BeamLocation: + workflows.ba2bl(self.__devs, self.__cfg) + elif target == BeamlineStateEnum.SampleExchange: + workflows.bl2sa(self.__devs, self.__cfg) + workflows.sa2se(self.__devs, self.__cfg) + else: + raise TransformationInvalidException( + f"Cannot go from {curr_state} to {target}, not implemented" + ) + case BeamlineStateEnum.FluxMeasurement: + if target == BeamlineStateEnum.SampleAlignment: + workflows.flux_measurement2sa(self.__devs, self.__cfg) + elif target == BeamlineStateEnum.SampleExchange: + workflows.flux_measurement2sa(self.__devs, self.__cfg) + workflows.sa2se(self.__devs, self.__cfg) + elif target == BeamlineStateEnum.BeamLocation: + workflows.flux_measurement2bl(self.__devs, self.__cfg) + elif target == BeamlineStateEnum.BeamstopAlignment: + workflows.flux_measurement2ba(self.__devs, self.__cfg) else: raise TransformationInvalidException( f"Cannot go from {curr_state} to {target}, not implemented" @@ -2844,6 +2879,10 @@ class AareDAQ: workflows.sa2xrf(self.__devs, self.__cfg) elif target == BeamlineStateEnum.BeamLocation: workflows.sa2bl(self.__devs, self.__cfg) + elif target == BeamlineStateEnum.BeamstopAlignment: + workflows.sa2ba(self.__devs, self.__cfg) + elif target == BeamlineStateEnum.FluxMeasurement: + workflows.sa2flux_measurement(self.__devs, self.__cfg) elif target == BeamlineStateEnum.XtalSnapshot: workflows.sa2xtal_snapshot(self.__devs, self.__cfg) else: @@ -2923,7 +2962,9 @@ class AareDAQ: @property def diffraction_geometry(self) -> DiffractionGeometry: try: + #start = time.perf_counter() det_cfg = self.__jfjoch.detector() + #logger.debug(f"Safe diffraction geometry call took {time.perf_counter() - start:.3f}s") return DiffractionGeometry( energy_keV=self.__devs.energy_kev, dtz_mm=self.__devs.dtz, @@ -2954,19 +2995,62 @@ class AareDAQ: @property def beamline_status(self) -> BeamlineStatus: try: + # start = time.perf_counter() + ring_current = self.__devs.ring_current + # logger.debug(f"Safe ring current call took {time.perf_counter() - start:.3f}s") + # start = time.perf_counter() + front_light = self.front_light + # logger.debug(f"Safe front light call took {time.perf_counter() - start:.3f}s") + # start = time.perf_counter() + back_light = self.back_light + # logger.debug(f"Safe back light call took {time.perf_counter() - start:.3f}s") + # start = time.perf_counter() + cryojet_temp = self.__devs.cryojet_temp + # logger.debug(f"Safe cryojet temp call took {time.perf_counter() - start:.3f}s") + # start = time.perf_counter() + shutter_open = self.__devs.shutter + # logger.debug(f"Safe shutter call took {time.perf_counter() - start:.3f}s") + # start = time.perf_counter() + exp_shutter_open = self.__devs.exp_shutter.state() + # logger.debug(f"Safe exp shutter call took {time.perf_counter() - start:.3f}s") + # start = time.perf_counter() + flux = self.__devs.full_flux + # logger.debug(f"Safe flux call took {time.perf_counter() - start:.3f}s") + # start = time.perf_counter() + samcam_settings = self.__devs.samcam_settings + # logger.debug(f"Safe samcam call took {time.perf_counter() - start:.3f}s") + # start = time.perf_counter() + bl = self.__bl + # logger.debug(f"Safe bl call took {time.perf_counter() - start:.3f}s") + # start = time.perf_counter() + transmission = self.__devs.transmission + # logger.debug(f"Safe transmission call took {time.perf_counter() - start:.3f}s") + # start = time.perf_counter() + zoom = self.__devs.zoom + # logger.debug(f"Safe zoom call took {time.perf_counter() - start:.3f}s") + # start = time.perf_counter() + commisioning_mode = self.__cfg.commissioning_mode + # logger.debug(f"Safe commissioning_mode call took {time.perf_counter() - start:.3f}s") + start = time.perf_counter() + #TODO work this one out, do it on startup! + dtz_min = self.__devs.dtz_low + logger.debug(f"Safe dtz_min call took {time.perf_counter() - start:.3f}s") + start = time.perf_counter() + dtz_max = self.__devs.dtz_high + logger.debug(f"Safe dtz_max call took {time.perf_counter() - start:.3f}s") return BeamlineStatus( - ring_current_mA=self.__devs.ring_current, - front_light=self.front_light, - back_light=self.back_light, - cryojet_K=self.__devs.cryojet_temp, - shutter_open=self.__devs.shutter, - exp_shutter_open=self.__devs.exp_shutter.state(), - flux_ph_s=self.__devs.full_flux, - sample_camera=self.__devs.samcam_settings, - name=self.__bl, - transmission=self.__devs.transmission, - zoom=self.__devs.zoom, - commissioning_mode=self.__cfg.commissioning_mode, + ring_current_mA=ring_current, + front_light=front_light, + back_light=back_light, + cryojet_K=cryojet_temp, + shutter_open=shutter_open, + exp_shutter_open=exp_shutter_open, + flux_ph_s=flux, + sample_camera=samcam_settings, + name=bl, + transmission=transmission, + zoom=zoom, + commissioning_mode=commisioning_mode, dtz_min=20, dtz_max=1000, ) @@ -3089,22 +3173,37 @@ class AareDAQ: @property def status(self) -> DAQStatusModel: try: + #og_start = time.perf_counter() safe_sample, tell_ok, tell_err = self._safe_sample() + #logger.debug(f"Safe sample info call took {time.perf_counter() - og_start:.3f}s") + #start = time.perf_counter() safe_geom, smargon_ok, smargon_err, aerotech_ok, aerotech_err = self._safe_geom() + #logger.debug(f"safe geom call took {time.perf_counter() - start:.3f}s") + #start = time.perf_counter() safe_tell_state = self._safe_tell_state() - - return DAQStatusModel( - state=self.state, - busy=self.busy, - geom=safe_geom, - bl=self._safe_beamline_status(), - sample=safe_sample, - session=SessionStatus( + #logger.debug(f"Safe tell call call took {time.perf_counter() - start:.3f}s") + #start = time.perf_counter() + safe_beamline_status = self._safe_beamline_status() + #logger.debug(f"Safe beamline status call took {time.perf_counter() - start:.3f}s") + #start = time.perf_counter() + safe_diffraction_geom = self._safe_diffraction_geometry() + #logger.debug(f"Safe diffraction geometry call took {time.perf_counter() - start:.3f}s") + #start = time.perf_counter() + session_status = SessionStatus( current_pgroup=self.__cfg.pgroup, session=self.__cfg.session_state(0), # 0 is dummy session staff=False - ), - diffraction=self._safe_diffraction_geometry(), + ) + #logger.debug(f"Safe session status call took {time.perf_counter() - start:.3f}s") + #start = time.perf_counter() + status = DAQStatusModel( + state=self.state, + busy=self.busy, + geom=safe_geom, + bl=safe_beamline_status, + sample=safe_sample, + session=session_status, + diffraction=safe_diffraction_geom, box=self.__saved_box, last_best_res=self.__cfg.last_best_res, last_best_b_factor=self.__cfg.last_best_b_factor, @@ -3117,6 +3216,9 @@ class AareDAQ: aerotech_connected=aerotech_ok, aerotech_error=aerotech_err, ) + #logger.debug(f"Creating DAQStatusModel took {time.perf_counter() - start:.3f}s") + #logger.debug(f"returning status call took {time.perf_counter() - og_start:.3f}s") + return status except Exception as e: logger.error(f"Failed to retrieve DAQ status: {e}") diff --git a/src/aare/daq/server.py b/src/aare/daq/server.py index 9fb4e0a9..5ee42868 100644 --- a/src/aare/daq/server.py +++ b/src/aare/daq/server.py @@ -1259,6 +1259,36 @@ async def beam_location(token: str = Depends(oauth2_scheme)): auth.check_jwt_staff(cfg, auth.parse_token(token)) daq.state = BeamlineStateEnum.BeamLocation +@app.post("/state/beamstop_alignment") +async def beamstop_alignment(token: str = Depends(oauth2_scheme)): + """ + Transition beamline state to BeamstopAlignment. + + Args: + token: OAuth2 access token. + + Returns: + "OK" on success. + """ + auth.check_jwt_staff(cfg, auth.parse_token(token)) + daq.state = BeamlineStateEnum.BeamstopAlignment + return "OK" + +@app.post("/state/flux_measurement") +async def flux_measurement(token: str = Depends(oauth2_scheme)): + """ + Transition beamline state to FluxMeasurement. + + Args: + token: OAuth2 access token. + + Returns: + "OK" on success. + """ + auth.check_jwt_staff(cfg, auth.parse_token(token)) + daq.state = BeamlineStateEnum.FluxMeasurement + return "OK" + @app.post("/state/data_collection") async def data_collection(token: str = Depends(oauth2_scheme)): """ diff --git a/src/aare/gui/panels/beamline_state_panel.py b/src/aare/gui/panels/beamline_state_panel.py index ddafb216..38f5a4b6 100644 --- a/src/aare/gui/panels/beamline_state_panel.py +++ b/src/aare/gui/panels/beamline_state_panel.py @@ -53,9 +53,11 @@ class BeamlineStatePanel(QFrame): xtal_snapshot = Signal() xray_fluorescence = Signal() robot_sample_exchange = Signal() + beamstop_alignment = Signal() + flux_measurement = Signal() set_width = 400 - map_height = 470 + map_height = 542 title_height = 50 collapsed_height = 50 station_radius = 8 @@ -85,14 +87,16 @@ class BeamlineStatePanel(QFrame): self._label_hover_bg = "rgba(244, 196, 48, 0.22)" self._group_colors: dict[BeamlineStateEnum, QColor] = { - BeamlineStateEnum.DewarTransfer: QColor(128, 90, 213), # Purple - BeamlineStateEnum.SampleExchange: QColor(237, 137, 54), # Orange - BeamlineStateEnum.RobotSampleExchange: QColor(237, 137, 54), # Orange - BeamlineStateEnum.SampleAlignment: QColor(72, 187, 120), # Green - BeamlineStateEnum.BeamLocation: QColor(72, 187, 120), # Green - BeamlineStateEnum.DataCollection: QColor(236, 72, 153), # Pink - BeamlineStateEnum.XtalSnapshot: QColor(236, 72, 153), # Pink - BeamlineStateEnum.XrayFluorescence: QColor(236, 72, 153), # Pink + BeamlineStateEnum.DewarTransfer: QColor(128, 90, 213), # Purple + BeamlineStateEnum.SampleExchange: QColor(237, 137, 54), # Orange + BeamlineStateEnum.RobotSampleExchange: QColor(237, 137, 54), # Orange + BeamlineStateEnum.SampleAlignment: QColor(72, 187, 120), # Green + BeamlineStateEnum.BeamLocation: QColor(72, 187, 120), # Green + BeamlineStateEnum.BeamstopAlignment: QColor(72, 187, 120), # Green + BeamlineStateEnum.FluxMeasurement: QColor(72, 187, 120), # Green + BeamlineStateEnum.DataCollection: QColor(236, 72, 153), # Pink + BeamlineStateEnum.XtalSnapshot: QColor(236, 72, 153), # Pink + BeamlineStateEnum.XrayFluorescence: QColor(236, 72, 153), # Pink } self._group_label_colors: dict[BeamlineStateEnum, str] = { @@ -105,6 +109,8 @@ class BeamlineStatePanel(QFrame): BeamlineStateEnum.RobotSampleExchange: "rgba(237, 137, 54, 0.16)", BeamlineStateEnum.SampleAlignment: "rgba(72, 187, 120, 0.16)", BeamlineStateEnum.BeamLocation: "rgba(72, 187, 120, 0.16)", + BeamlineStateEnum.BeamstopAlignment: "rgba(72, 187, 120, 0.16)", + BeamlineStateEnum.FluxMeasurement: "rgba(72, 187, 120, 0.16)", BeamlineStateEnum.DataCollection: "rgba(236, 72, 153, 0.14)", BeamlineStateEnum.XtalSnapshot: "rgba(236, 72, 153, 0.14)", BeamlineStateEnum.XrayFluorescence: "rgba(236, 72, 153, 0.14)", @@ -112,13 +118,20 @@ class BeamlineStatePanel(QFrame): self._stations = [ StationSpec(BeamlineStateEnum.DewarTransfer, "Dewar transfer", 54, 140, True, "Dewar transfer mode"), - StationSpec(BeamlineStateEnum.SampleExchange, "Manual sample exchange", 54, 176, True, "Manual sample exchange mode"), - StationSpec(BeamlineStateEnum.RobotSampleExchange, "Robot sample exchange", 54, 212, True, "Robot-assisted sample exchange"), - StationSpec(BeamlineStateEnum.SampleAlignment, "Sample alignment", 54, 248, True, "Sample centring and alignment mode"), + StationSpec(BeamlineStateEnum.SampleExchange, "Manual sample exchange", 54, 176, True, + "Manual sample exchange mode"), + StationSpec(BeamlineStateEnum.RobotSampleExchange, "Robot sample exchange", 54, 212, True, + "Robot-assisted sample exchange"), + StationSpec(BeamlineStateEnum.SampleAlignment, "Sample alignment", 54, 248, True, + "Sample centring and alignment mode"), StationSpec(BeamlineStateEnum.BeamLocation, "Beam location", 54, 284, True, "Beam location mode"), - StationSpec(BeamlineStateEnum.DataCollection, "Data collection", 54, 320, True, "Measurement / collection mode"), - StationSpec(BeamlineStateEnum.XtalSnapshot, "Crystal snapshot", 54, 356, True, "Crystal snapshot mode"), - StationSpec(BeamlineStateEnum.XrayFluorescence, "XRF", 54, 392, True, "X-ray fluorescence mode"), + StationSpec(BeamlineStateEnum.BeamstopAlignment, "Beamstop alignment", 54, 320, True, + "Beamstop alignment mode"), + StationSpec(BeamlineStateEnum.FluxMeasurement, "Flux measurement", 54, 356, True, "Flux measurement mode"), + StationSpec(BeamlineStateEnum.DataCollection, "Data collection", 54, 392, True, + "Measurement / collection mode"), + StationSpec(BeamlineStateEnum.XtalSnapshot, "Crystal snapshot", 54, 428, True, "Crystal snapshot mode"), + StationSpec(BeamlineStateEnum.XrayFluorescence, "XRF", 54, 464, True, "X-ray fluorescence mode"), ] self._segments = [ @@ -126,7 +139,12 @@ class BeamlineStatePanel(QFrame): (BeamlineStateEnum.SampleExchange, BeamlineStateEnum.RobotSampleExchange), (BeamlineStateEnum.RobotSampleExchange, BeamlineStateEnum.SampleAlignment), (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.BeamLocation), + (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.BeamstopAlignment), + (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.FluxMeasurement), (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.DataCollection), + (BeamlineStateEnum.BeamLocation, BeamlineStateEnum.BeamstopAlignment), + (BeamlineStateEnum.BeamLocation, BeamlineStateEnum.FluxMeasurement), + (BeamlineStateEnum.BeamstopAlignment, BeamlineStateEnum.FluxMeasurement), (BeamlineStateEnum.DataCollection, BeamlineStateEnum.XtalSnapshot), (BeamlineStateEnum.DataCollection, BeamlineStateEnum.XrayFluorescence), ] @@ -339,6 +357,10 @@ class BeamlineStatePanel(QFrame): self.robot_sample_exchange.emit() elif state == BeamlineStateEnum.BeamLocation: self.beam_location.emit() + elif state == BeamlineStateEnum.BeamstopAlignment: + self.beamstop_alignment.emit() + elif state == BeamlineStateEnum.FluxMeasurement: + self.flux_measurement.emit() elif state == BeamlineStateEnum.DataCollection: self.data_collection.emit() elif state == BeamlineStateEnum.XtalSnapshot: diff --git a/src/aare/gui/threads/daq_worker.py b/src/aare/gui/threads/daq_worker.py index 71c1d447..f03bf2a0 100644 --- a/src/aare/gui/threads/daq_worker.py +++ b/src/aare/gui/threads/daq_worker.py @@ -771,6 +771,14 @@ class DAQWorker(QObject): def beam_location(self): self.generic_post("state/beam_location") + @Slot() + def beamstop_alignment(self): + self.generic_post("state/beamstop_alignment") + + @Slot() + def flux_measurement(self): + self.generic_post("state/flux_measurement") + @Slot() def data_collection(self): self.generic_post("state/data_collection") From a842c88e40c10547c6590eb95685027f1b41e992 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 16:58:03 +0200 Subject: [PATCH 35/76] busy overlay: user messages are now robot --- src/aare/gui/widgets/busy_overlay.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/aare/gui/widgets/busy_overlay.py b/src/aare/gui/widgets/busy_overlay.py index 11551b0a..0c2119ee 100644 --- a/src/aare/gui/widgets/busy_overlay.py +++ b/src/aare/gui/widgets/busy_overlay.py @@ -55,7 +55,7 @@ def build_busy_overlay_style( if activity_value == "mounting": return BusyOverlayStyle( - text="TELL MOUNTING", + text="ROBOT MOUNTING", badge_bg="#d64545", badge_fg="#ffffff", overlay_fill=QColor(190, 40, 40, 185), @@ -66,7 +66,7 @@ def build_busy_overlay_style( if activity_value == "unmounting": return BusyOverlayStyle( - text="TELL UNMOUNTING", + text="ROBOT UNMOUNTING", badge_bg="#e67e22", badge_fg="#ffffff", overlay_fill=QColor(230, 126, 34, 190), @@ -77,7 +77,7 @@ def build_busy_overlay_style( if activity_value == "drying": return BusyOverlayStyle( - text="TELL DRYING", + text="ROBOT DRYING", badge_bg="#f1c40f", badge_fg="#3b2f00", overlay_fill=QColor(241, 196, 15, 195), @@ -88,7 +88,7 @@ def build_busy_overlay_style( if activity_value == "cooling": return BusyOverlayStyle( - text="TELL COOLING", + text="ROBOT COOLING", badge_bg="#3498db", badge_fg="#ffffff", overlay_fill=QColor(52, 152, 219, 190), From ecd5bc49083d90f99b8105985f841166e50cacff Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 16:58:21 +0200 Subject: [PATCH 36/76] devices: transmission PVS are different at X10SA and X06DA --- src/aare/daq/devices.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/aare/daq/devices.py b/src/aare/daq/devices.py index 9a6b911c..b5280c4b 100644 --- a/src/aare/daq/devices.py +++ b/src/aare/daq/devices.py @@ -88,11 +88,18 @@ class BeamlineDevices: self.__cryojet_temperature_get = PV(f"{BEAMLINE}-ES-CS:TEMP_RBV") self.__cryojet_temperature_set = PV(f"{BEAMLINE}-ES-CS:TEMP.VAL") - self.__transmission = SetGetPV( - name = "transmission", - setpv = f"{BEAMLINE}-ES-SSFI:TRANSM-SET", - getpv = f"{BEAMLINE}-ES-SSFI:TRANSM-GET" - ) + if self._beamline == MXBeamline.X06DA: + self.__transmission = SetGetPV( + name="transmission", + setpv=f"{BEAMLINE}-ES-BCFI:TRANSM-SET", + getpv=f"{BEAMLINE}-ES-BCFI:TRANSM-GET" + ) + else: + self.__transmission = SetGetPV( + name = "transmission", + setpv = f"{BEAMLINE}-ES-SSFI:TRANSM-SET", + getpv = f"{BEAMLINE}-ES-SSFI:TRANSM-GET" + ) self.__fast_shutter = PV(f"{BEAMLINE}-ES-SHUTTER:SET") From d3772bdbb61f0d8d8130a42fb7efdb12ec58a95b Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 16:58:38 +0200 Subject: [PATCH 37/76] logging: suppress successful local contact/device_state and smart_params --- src/aare/common/logger_config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/aare/common/logger_config.py b/src/aare/common/logger_config.py index ac9beb4c..e0dbca01 100644 --- a/src/aare/common/logger_config.py +++ b/src/aare/common/logger_config.py @@ -31,6 +31,8 @@ class IgnoreSuccessfulStatusAccessFilter(logging.Filter): '"GET /status HTTP/1.1" 200', '"GET /sample/reference_tools HTTP/1.1" 200', '"GET /sample/spreadsheet HTTP/1.1" 200', + '"GET /local_contact/device_state HTTP/1.1" 200', + '"POST /scan/smart_params HTTP/1.1" 200', ) _SUPPRESSED_EXPECTED_403_MESSAGES = ( From 721390c8ed6386bc315902dda1fc08334ebe6990 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 16:58:55 +0200 Subject: [PATCH 38/76] main_window: connect new beamline state buttons to daq worker --- src/aare/gui/main_window.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 295585c3..1425e45f 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -677,6 +677,8 @@ class MainWindow(QMainWindow): self.beamline_state_panel.sample_exchange.connect(self.daq.sample_exchange) self.beamline_state_panel.sample_alignment.connect(self.daq.sample_alignment) self.beamline_state_panel.beam_location.connect(self.daq.beam_location) + self.beamline_state_panel.beamstop_alignment.connect(self.daq.beamstop_alignment) + self.beamline_state_panel.flux_measurement.connect(self.daq.flux_measurement) self.beamline_state_panel.data_collection.connect(self.daq.data_collection) self.beamline_state_panel.xtal_snapshot.connect(self.daq.xtal_snapshot) self.beamline_state_panel.xray_fluorescence.connect(self.daq.xray_fluorescence) From ade2c6df6390febf10616293d39261bf13f44a6f Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 15 Jun 2026 16:59:14 +0200 Subject: [PATCH 39/76] raster: added back COM calculation, needs testing --- src/aare/daq/operations/raster/service.py | 64 ++++++++++++++--------- 1 file changed, 38 insertions(+), 26 deletions(-) diff --git a/src/aare/daq/operations/raster/service.py b/src/aare/daq/operations/raster/service.py index 53204387..02561827 100644 --- a/src/aare/daq/operations/raster/service.py +++ b/src/aare/daq/operations/raster/service.py @@ -362,8 +362,6 @@ class RasterService: ) else: scan_result = self.ctx.jfjoch.wait_till_done(60) - com = None - if scan_result is None: self.logger.error( "JFJoch returned no ScanResult for raster", @@ -373,32 +371,46 @@ class RasterService: {"exp_time_s": request.exp_time_s}, ), ) + raise RasterScanException("JFJoch returned no ScanResult for raster") - if request.n_x == 1: - x = request.grid_size_mm.x / 2.0 - else: - x = ((request.n_x - 1) * request.grid_size_mm.x) / 2.0 - y = ((request.n_y - 1) * request.grid_size_mm.y) / 2.0 - - target_coor_offset = self.ctx.sample_geometry.smargon_nudge(Coordinate(x=x, y=y)) - - self.logger.info( - "Calculated raster centre offset", - extra=merge_log_context( - sample_log_context(self.ctx.sample), - raster_request_log_context(request), - { - "centre_offset_x_mm": target_coor_offset.x, - "centre_offset_y_mm": target_coor_offset.y, - "centre_offset_z_mm": target_coor_offset.z, - "grid_half_width_x_mm": x, - "grid_half_height_y_mm": y, - "top_left_x_mm": getattr(request.smargon_top_left.sh_mm, "x", None), - "top_left_y_mm": getattr(request.smargon_top_left.sh_mm, "y", None), - "top_left_z_mm": getattr(request.smargon_top_left.sh_mm, "z", None), - }, - ), + result_array = create_quality_filtered_array( + scan_result.images, + "spots_low_res", + min_spots=None, + min_efficiency=1.0, + min_background=None, + min_low_res_spots=None, ) + com = raster_centre_of_mass(result_array, scan_result.images) + if com is None: + if request.n_x == 1: + x = request.grid_size_mm.x / 2.0 + else: + x = ((request.n_x - 1) * request.grid_size_mm.x) / 2.0 + y = ((request.n_y - 1) * request.grid_size_mm.y) / 2.0 + + target_coor_offset = self.ctx.sample_geometry.smargon_nudge(Coordinate(x=x, y=y)) + + self.logger.info( + "Calculated raster centre offset", + extra=merge_log_context( + sample_log_context(self.ctx.sample), + raster_request_log_context(request), + { + "centre_offset_x_mm": target_coor_offset.x, + "centre_offset_y_mm": target_coor_offset.y, + "centre_offset_z_mm": target_coor_offset.z, + "grid_half_width_x_mm": x, + "grid_half_height_y_mm": y, + "top_left_x_mm": getattr(request.smargon_top_left.sh_mm, "x", None), + "top_left_y_mm": getattr(request.smargon_top_left.sh_mm, "y", None), + "top_left_z_mm": getattr(request.smargon_top_left.sh_mm, "z", None), + }, + ), + ) + else: + target_coor = com.get_com_mm(request) + target_coor_offset = self.ctx.sample_geometry.smargon_nudge(target_coor) self.logger.info(f"moving Smargon to grid centre offset {target_coor_offset}") target_smargon = SmargonCoordinate( From 67ea402dd4fcd7d06e83c720ecbcd13e6da7f53d Mon Sep 17 00:00:00 2001 From: appleb_m Date: Tue, 16 Jun 2026 12:09:00 +0200 Subject: [PATCH 40/76] Defining a bec device pv as a variable that can be called is significantly faster than calling motor.postion or motor.low_limit everytime. (~6 us compared to 200 ms) --- src/aare/daq/devices.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/aare/daq/devices.py b/src/aare/daq/devices.py index b5280c4b..bfa463bf 100644 --- a/src/aare/daq/devices.py +++ b/src/aare/daq/devices.py @@ -40,8 +40,10 @@ class BeamlineDevices: self.__smargon = smargon.Smargon(beamline) self.exp_shutter = ExperimentalHutchShutter(beamline=self._beamline) - #only used at the moment to get hlm and llm from BEC - self.__dtz = self.bec_worker.dev.det_z + #faster to define the dtz object here than in functions and then use + self.__dtz_rbk = self.bec_worker.dev.det_z.position + self.__dtz_llm= self.bec_worker.dev.det_z.low_limit + self.__dtz_hlm= self.bec_worker.dev.det_z.high_limit self.dtz_mod = cfg_get('daq.detector_distance_limit_modifier', 1.0) #TODO convert epics pvs to BEC self.__sample_cam = epicsAD(f"{BEAMLINE}-ES-MS:") @@ -263,20 +265,20 @@ class BeamlineDevices: # Detector Z @property def dtz(self) -> float: - return self.bec_worker.get_det_z() + return self.__dtz_rbk @dtz.setter def dtz(self, value: float): self.set_dtz(value, wait=True) def set_dtz(self, value: float, /, wait: bool = True): - if value < self.__dtz.low_limit: + if value < self.__dtz_llm: #raise ValueError(f"Detector distance cannot be less than {self.detector_distance_minimum} mm") - logger.warning(f"Requested detector distance {value} is less than minimum: {self.__dtz.low_limit}, setting to minimum") - value = self.__dtz.low_limit + self.dtz_mod - if value > self.__dtz.high_limit: - logger.warning(f"Requested detector distance {value} is greater than maximum: {self.__dtz.high_limit}, setting to maximum") - value = self.__dtz.high_limit - self.dtz_mod + logger.warning(f"Requested detector distance {value} is less than minimum: {self.__dtz_llm}, setting to minimum") + value = self.__dtz_llm + self.dtz_mod + if value > self.__dtz_hlm: + logger.warning(f"Requested detector distance {value} is greater than maximum: {self.__dtz_hlm}, setting to maximum") + value = self.__dtz_hlm - self.dtz_mod if wait: status = self.bec_worker.det_z(value, timeout=60) return status @@ -286,11 +288,11 @@ class BeamlineDevices: @property def dtz_low(self) -> float: - return self.__dtz.get("LLM") + return self.__dtz_llm @property def dtz_high(self) -> float: - return self.__dtz.get("HLM") + return self.__dtz_hlm @property def aerotech_pos(self) -> AerotechCoordinate: From e00e7701f3d39aba6c89b71b5989493e2f367a1c Mon Sep 17 00:00:00 2001 From: appleb_m Date: Tue, 16 Jun 2026 12:09:51 +0200 Subject: [PATCH 41/76] config: remove ABR_POS_ALIGN_DEF and use ABR_POS_MOUNT instead --- src/aare/daq/config.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/aare/daq/config.py b/src/aare/daq/config.py index 4ef95a19..f7f85672 100644 --- a/src/aare/daq/config.py +++ b/src/aare/daq/config.py @@ -42,7 +42,6 @@ from aare.common.logger_config import setup_logger from aare.common.exception_handler import BeamlineBusyException #TODO WHAT SHOULD THIS BE? This should be in the YAMl file it is beamline specific -ABR_POS_ALIGN_DEF = AerotechCoordinate(at_mm=Coordinate(x=-18, y=-0.266, z=0)) ABR_POS_MOUNT = AerotechCoordinate( at_mm=Coordinate(x=0, y=0, z=0), omega_deg=0 @@ -797,7 +796,7 @@ class BeamlineConfig: def abr_meas_pos(self) -> AerotechCoordinate: tmp = self.__client.get(f"{self.__bl}:abr_meas_pos") if tmp is None: - return ABR_POS_ALIGN_DEF + return ABR_POS_MOUNT data_dict = json.loads(tmp) return AerotechCoordinate(**data_dict) @@ -1106,7 +1105,7 @@ class BeamlineConfig: def local_contact_links(self) -> dict[str, str | None]: detector_frontend = None smargon_frontend = cfg_get( - "daq.hardware.smargon_url", + "daq.hardware.smargon_frontend_url", f"http://{self.__mxb.name.lower()}-smargopolo.psi.ch:8080/", ) aerotech_frontend = cfg_get( From b8ee526d044254a490aa29d952fd2e9e6a70fb47 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Tue, 16 Jun 2026 12:11:59 +0200 Subject: [PATCH 42/76] raster: logging to track com --- src/aare/daq/operations/raster/service.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/aare/daq/operations/raster/service.py b/src/aare/daq/operations/raster/service.py index 02561827..3e83a929 100644 --- a/src/aare/daq/operations/raster/service.py +++ b/src/aare/daq/operations/raster/service.py @@ -383,6 +383,7 @@ class RasterService: ) com = raster_centre_of_mass(result_array, scan_result.images) if com is None: + self.logger.info("Calcualted COM is None using centre image") if request.n_x == 1: x = request.grid_size_mm.x / 2.0 else: @@ -409,8 +410,27 @@ class RasterService: ), ) else: + self.logger.info("Calcualted COM is not None, procedding") target_coor = com.get_com_mm(request) target_coor_offset = self.ctx.sample_geometry.smargon_nudge(target_coor) + self.logger.info( + "Calculated raster centre offset", + extra=merge_log_context( + sample_log_context(self.ctx.sample), + raster_request_log_context(request), + { + "centre_offset_x_mm": target_coor_offset.x, + "centre_offset_y_mm": target_coor_offset.y, + "centre_offset_z_mm": target_coor_offset.z, + "grid_half_width_x_mm": x, + "grid_half_height_y_mm": y, + "top_left_x_mm": getattr(request.smargon_top_left.sh_mm, "x", None), + "top_left_y_mm": getattr(request.smargon_top_left.sh_mm, "y", None), + "top_left_z_mm": getattr(request.smargon_top_left.sh_mm, "z", None), + }, + ), + ) + self.logger.info(f"moving Smargon to grid centre offset {target_coor_offset}") target_smargon = SmargonCoordinate( From 7eb977d51a2dd82ad961dd1638b8abc98e423c1b Mon Sep 17 00:00:00 2001 From: appleb_m Date: Tue, 16 Jun 2026 12:12:30 +0200 Subject: [PATCH 43/76] tell:debugging, trying to catch and interpret roboto returns busy during wait for events correctly --- src/aare/devices/tell_client.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/aare/devices/tell_client.py b/src/aare/devices/tell_client.py index 039c5001..d4cdc1d7 100755 --- a/src/aare/devices/tell_client.py +++ b/src/aare/devices/tell_client.py @@ -221,9 +221,18 @@ class TellClient: ) logger.info(f"Check command okay response: {msg}") return TellEventValueEnum.SUCCESS - except Exception as e: - raise TellCommunicationError(f"Error: {e}") - + except Exception: + raise + if event == "state" and value == 'Busy': + logger.warning('got busy response form robot, waiting for mount to complete') + try: + msg = self.check_command_ok( + timeout=wait_timeout, msg=f"Mount {segment}{puck}-{sample}: " + ) + logger.info(f"Check command okay response: {msg}") + return TellEventValueEnum.SUCCESS + except Exception: + raise if event is None or event == "state": logger.info(f"event: {event} occurred with value: {value}, checking command completed okay") self.check_command_ok( From cbd0c635c7983482226bcbc133ff8085f13b5b5e Mon Sep 17 00:00:00 2001 From: appleb_m Date: Tue, 16 Jun 2026 12:13:41 +0200 Subject: [PATCH 44/76] Change how detector metadate is stored - now in redis - so status updates are faster. --- src/aare/daq/config.py | 62 +++++++++ src/aare/daq/daq.py | 138 +++++++++++++-------- src/aare/daq/server.py | 31 ++++- src/aare/gui/panels/local_contact_panel.py | 5 + src/aare/gui/threads/daq_worker.py | 27 ++++ 5 files changed, 206 insertions(+), 57 deletions(-) diff --git a/src/aare/daq/config.py b/src/aare/daq/config.py index f7f85672..77a64327 100644 --- a/src/aare/daq/config.py +++ b/src/aare/daq/config.py @@ -1101,6 +1101,68 @@ class BeamlineConfig: "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 diff --git a/src/aare/daq/daq.py b/src/aare/daq/daq.py index 6e1f34aa..f71d3381 100644 --- a/src/aare/daq/daq.py +++ b/src/aare/daq/daq.py @@ -70,7 +70,6 @@ from aare.daq.operations.common.runtime import ( ) from aare.devices.area_detector import AutoEnum from aare.devices.jfjoch import JFJochWrapper -from aare.devices.mx_lib import clean_filename from aare.common.exception_handler import ( TransformationInvalidException, @@ -241,6 +240,36 @@ class AareDAQ: pgroup_provider=_DAQPGroupProvider(self), ) + def _cached_detector_metadata(self) -> dict: + return self.__cfg.cached_detector_metadata + + def refresh_detector_metadata_cache(self) -> dict[str, object]: + det_cfg = self.__jfjoch.detector() + dtz_low = self.__cfg._coerce_optional_float(self.__devs.dtz_low) + dtz_high = self.__cfg._coerce_optional_float(self.__devs.dtz_high) + + payload = self.__cfg.set_detector_metadata( + { + "detector_description": det_cfg.description, + "detector_serial_number": det_cfg.serial_number, + "detector_width": det_cfg.width, + "detector_height": det_cfg.height, + "pixel_size_mm": det_cfg.pixel_size_mm, + "dtz_low": dtz_low, + "dtz_high": dtz_high, + } + ) + logger.info( + "Refreshed hardware metadata cache", + extra={ + "detector_description": payload.get("detector_description"), + "detector_serial_number": payload.get("detector_serial_number"), + "dtz_low": payload.get("dtz_low"), + "dtz_high": payload.get("dtz_high"), + }, + ) + return payload + def get_runtime_simulation_state(self) -> dict[str, bool]: return self.__cfg.runtime_simulation_state @@ -2083,7 +2112,7 @@ class AareDAQ: def dtz(self) -> float: tmp = self.__cfg.dtz if tmp is None: - return 150.0 + return cfg_get('daq.data_collection_settings.default_raster_scan_settings.dtz', 200) else: return tmp @@ -2092,15 +2121,33 @@ class AareDAQ: self.__cfg.try_set_busy(timeout=360) state = self.__cfg.state - if val < self.__devs.dtz_low or val > self.__devs.dtz_high: + dtz_low = self.__cfg.cached_dtz_low + dtz_high = self.__cfg.cached_dtz_high + + if dtz_low is None or dtz_high is None: + logger.warning("DTZ limits not found in cache, refreshing hardware metadata") + try: + self.refresh_detector_metadata_cache() + except Exception as e: + self.__cfg.state_busy = False + raise RuntimeError(f"DTZ limits unavailable and refresh failed: {e}") from e + dtz_low = self.__cfg.cached_dtz_low + dtz_high = self.__cfg.cached_dtz_high + + if dtz_low is None or dtz_high is None: self.__cfg.state_busy = False - raise RuntimeError(f"dtz={val} outside limits {self.__devs.dtz_low} to {self.__devs.dtz_high}") + raise RuntimeError("DTZ limits are unavailable") + + if val < dtz_low or val > dtz_high: + self.__cfg.state_busy = False + raise RuntimeError(f"dtz={val} outside limits {dtz_low} to {dtz_high}") if state == BeamlineStateEnum.DataCollection: self.__cfg.state_busy = False raise RuntimeError("Cannot set dtz during data collection") elif state == BeamlineStateEnum.SampleAlignment: self.__devs.set_dtz(val, wait=False) + self.__cfg.dtz = val self.__cfg.state_busy = False @@ -2962,30 +3009,39 @@ class AareDAQ: @property def diffraction_geometry(self) -> DiffractionGeometry: try: - #start = time.perf_counter() - det_cfg = self.__jfjoch.detector() - #logger.debug(f"Safe diffraction geometry call took {time.perf_counter() - start:.3f}s") + metadata = self._cached_detector_metadata() + width = int(metadata.get("detector_width", 1)) + height = int(metadata.get("detector_height", 1)) + pixel_size_mm = float(metadata.get("pixel_size_mm", 0.15)) + detector_description = str(metadata.get("detector_description", "unavailable")) + detector_serial_number = str(metadata.get("detector_serial_number", "unavailable")) + energy=self.__devs.energy_kev + dtz=self.__devs.dtz + beam_center=self.__cfg.beam_center return DiffractionGeometry( - energy_keV=self.__devs.energy_kev, - dtz_mm=self.__devs.dtz, - detector_size_pxl=(det_cfg.width, det_cfg.height), - pixel_size_mm=det_cfg.pixel_size_mm, - beam_center_pxl=self.__cfg.beam_center, - detector_description=det_cfg.description, - detector_serial_number=det_cfg.serial_number, + energy_keV=energy, + dtz_mm=dtz, + detector_size_pxl=(width, height), + pixel_size_mm=pixel_size_mm, + beam_center_pxl=beam_center, + detector_description=detector_description, + detector_serial_number=detector_serial_number, poni_rot1_rad=-0.001396263, poni_rot2_rad=-0.003839724, ) - except JFJochCommunicationError as e: + except Exception as e: logger.warning( - f"Falling back to default diffraction geometry because detector metadata is unavailable: {e}" + f"Falling back to default diffraction geometry because cached detector metadata is unavailable: {e}" ) + energy=self.__devs.energy_kev + dtz=self.__devs.dtz + beam_center=self.__cfg.beam_center return DiffractionGeometry( - energy_keV=self.__devs.energy_kev, - dtz_mm=self.__devs.dtz, + energy_keV=energy, + dtz_mm=dtz, detector_size_pxl=(1, 1), pixel_size_mm=0.15, - beam_center_pxl=self.__cfg.beam_center, + beam_center_pxl=beam_center, detector_description="unavailable", detector_serial_number="unavailable", poni_rot1_rad=-0.001396263, @@ -2995,49 +3051,26 @@ class AareDAQ: @property def beamline_status(self) -> BeamlineStatus: try: - # start = time.perf_counter() ring_current = self.__devs.ring_current - # logger.debug(f"Safe ring current call took {time.perf_counter() - start:.3f}s") - # start = time.perf_counter() front_light = self.front_light - # logger.debug(f"Safe front light call took {time.perf_counter() - start:.3f}s") - # start = time.perf_counter() back_light = self.back_light - # logger.debug(f"Safe back light call took {time.perf_counter() - start:.3f}s") - # start = time.perf_counter() cryojet_temp = self.__devs.cryojet_temp - # logger.debug(f"Safe cryojet temp call took {time.perf_counter() - start:.3f}s") - # start = time.perf_counter() shutter_open = self.__devs.shutter - # logger.debug(f"Safe shutter call took {time.perf_counter() - start:.3f}s") - # start = time.perf_counter() exp_shutter_open = self.__devs.exp_shutter.state() - # logger.debug(f"Safe exp shutter call took {time.perf_counter() - start:.3f}s") - # start = time.perf_counter() flux = self.__devs.full_flux - # logger.debug(f"Safe flux call took {time.perf_counter() - start:.3f}s") - # start = time.perf_counter() samcam_settings = self.__devs.samcam_settings - # logger.debug(f"Safe samcam call took {time.perf_counter() - start:.3f}s") - # start = time.perf_counter() bl = self.__bl - # logger.debug(f"Safe bl call took {time.perf_counter() - start:.3f}s") - # start = time.perf_counter() transmission = self.__devs.transmission - # logger.debug(f"Safe transmission call took {time.perf_counter() - start:.3f}s") - # start = time.perf_counter() zoom = self.__devs.zoom - # logger.debug(f"Safe zoom call took {time.perf_counter() - start:.3f}s") - # start = time.perf_counter() commisioning_mode = self.__cfg.commissioning_mode - # logger.debug(f"Safe commissioning_mode call took {time.perf_counter() - start:.3f}s") - start = time.perf_counter() - #TODO work this one out, do it on startup! - dtz_min = self.__devs.dtz_low - logger.debug(f"Safe dtz_min call took {time.perf_counter() - start:.3f}s") - start = time.perf_counter() - dtz_max = self.__devs.dtz_high - logger.debug(f"Safe dtz_max call took {time.perf_counter() - start:.3f}s") + + dtz_min = self.__cfg.cached_dtz_low + dtz_max = self.__cfg.cached_dtz_high + + if dtz_min is None or dtz_max is None: + logger.warning("DTZ limits missing from cache, using conservative defaults in beamline_status") + dtz_min = 20.0 + dtz_max = 1000.0 return BeamlineStatus( ring_current_mA=ring_current, front_light=front_light, @@ -3051,9 +3084,10 @@ class AareDAQ: transmission=transmission, zoom=zoom, commissioning_mode=commisioning_mode, - dtz_min=20, - dtz_max=1000, + dtz_min=dtz_min, + dtz_max=dtz_max, ) + except Exception as e: logger.exception(f"Failed to retrieve beamline status: {e}") raise diff --git a/src/aare/daq/server.py b/src/aare/daq/server.py index 5ee42868..d9ecfaed 100644 --- a/src/aare/daq/server.py +++ b/src/aare/daq/server.py @@ -95,6 +95,11 @@ async def lifespan(application: FastAPI): except Exception as e: logger.warning(f"Failed to reset automation progress Redis keys: {e}") + try: + daq.refresh_detector_metadata_cache() + except Exception as e: + logger.warning(f"Initial hardware metadata refresh failed: {e}") + # ── Initial TELL sync ── try: daq.sync_current_sample_from_tell(force=True) @@ -331,7 +336,6 @@ async def status(token: str = Depends(oauth2_scheme)) -> DAQStatusModel: else: own_gui = cfg.get_gui_session(data.session) full.open_guis = [own_gui] if own_gui is not None else [] - return full @app.get("/beamline/geometry") async def sample_geometry( @@ -488,6 +492,7 @@ async def smargon(val: SmargonCoordinate, token: str = Depends(oauth2_scheme)): logger.debug(f"Setting smargon to {val}") auth.check_jwt_rw(cfg, auth.parse_token(token)) daq.smargon = val + logger.debug(f"smargon set to {daq.smargon}") return "OK" @@ -656,6 +661,7 @@ async def local_contact_simulation_state(token: str = Depends(oauth2_scheme)) -> auth.check_jwt_staff_only(data) return daq.get_runtime_simulation_state() + @app.get("/local_contact/device_state") async def local_contact_device_state(token: str = Depends(oauth2_scheme)) -> dict: """ @@ -720,6 +726,23 @@ async def local_contact_restart_device( result["message"] = f"{device} backend restarted." return result +@app.post("/local_contact/resync/hardware_metadata") +async def local_contact_resync_hardware_metadata( + token: str = Depends(oauth2_scheme), +) -> dict: + """ + Refresh cached detector metadata and DTZ limits. Staff only. + """ + data = auth.parse_token(token) + auth.check_jwt_staff_only(data) + + payload = daq.refresh_detector_metadata_cache() + return { + "ok": True, + "message": "Hardware metadata cache resynced.", + "payload": payload, + } + @app.post("/beamline/goto_abr_meas_pos") async def goto_abr_meas_pos(token: str = Depends(oauth2_scheme)): """ @@ -1577,8 +1600,6 @@ async def set_smart_params(p: SimpleScanParameters, token: str = Depends(oauth2_ Returns: "OK" on success. """ - #token_data = auth.parse_token(token) - #logger.debug(f"{token_data.session} Try to set smart params: {p}") auth.check_jwt_rw(cfg, auth.parse_token(token)) cfg.auto_params = p return "OK" @@ -2410,10 +2431,10 @@ async def maintenance(token: str = Depends(oauth2_scheme)) -> str: def main(): # Remove in production! - urllib3.disable_warnings() + #urllib3.disable_warnings() # Run the application using uvicorn - uvicorn.run("aare.daq.server:app", host="127.0.0.1", port=5210, workers=2, proxy_headers=False, log_config=get_uvicorn_logging_config()) + uvicorn.run("aare.daq.server:app", host="127.0.0.1", port=5210, workers=4, proxy_headers=False, log_config=get_uvicorn_logging_config()) if __name__ == "__main__": diff --git a/src/aare/gui/panels/local_contact_panel.py b/src/aare/gui/panels/local_contact_panel.py index 25b9e38a..88fea597 100644 --- a/src/aare/gui/panels/local_contact_panel.py +++ b/src/aare/gui/panels/local_contact_panel.py @@ -435,6 +435,11 @@ class LocalContactPanel(QFrame): row, 1, ) + grid.addWidget(self._make_button( + "Resync detector/DTZ hardware cache", + self._daq.resync_local_contact_detector_metadata, + "Resyncing detector metadata and DTZ limits cache.", + )) wrapper = QGroupBox("Detector actions", tab) wrapper_layout = QVBoxLayout(wrapper) diff --git a/src/aare/gui/threads/daq_worker.py b/src/aare/gui/threads/daq_worker.py index f03bf2a0..43363426 100644 --- a/src/aare/gui/threads/daq_worker.py +++ b/src/aare/gui/threads/daq_worker.py @@ -93,6 +93,7 @@ class DAQWorker(QObject): fluorimeter_update = Signal(list, list, int) fluorimeter_spectrum_update = Signal(FluorescenceSpectrumOutputModel) sample_resync_completed = Signal(str) + detector_metadata_resync_completed = Signal(str) automation_critical_failure = Signal(str) manual_collection_critical_failure = Signal(str) @@ -636,6 +637,20 @@ class DAQWorker(QObject): logger.error(f"Sample resync failed: {e}") self.http_error.emit(str(e)) + def _handle_detector_metadata_resync_response(self, reply: QNetworkReply): + try: + response_data = self.handle_response(reply) + payload = json.loads(response_data) if response_data else {} + message = str(payload.get("message") or "Hardware metadata cache resynced.") + logger.info(message) + self.status_message.emit(message, False) + self.detector_metadata_resync_completed.emit(message) + self.send_status_request() + self.load_local_contact_device_state() + except Exception as e: + logger.error(f"Hardware metadata resync failed: {e}") + self.http_error.emit(str(e)) + def _handle_recovery_action_response(self, reply: QNetworkReply, default_message: str): try: response_data = self.handle_response(reply) @@ -1344,6 +1359,18 @@ class DAQWorker(QObject): reply = self.__net_manager.post(request, QByteArray(b"")) reply.finished.connect(lambda: self._handle_sample_resync_response(reply)) + @Slot() + def resync_local_contact_detector_metadata(self): + if self.__base_url is None: + logger.info("POST /local_contact/resync/detector_metadata") + return + + request = QNetworkRequest(QUrl(f"{self.__base_url}/local_contact/resync/detector_metadata")) + request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8")) + request.setRawHeader(b"Content-Type", b"application/json") + reply = self.__net_manager.post(request, QByteArray(b"")) + reply.finished.connect(lambda: self._handle_detector_metadata_resync_response(reply)) + @Slot(float) def anneal(self, time_s: float): self.generic_post(f"beamline/anneal?time_s={time_s:.1f}") From 76d58cb0906b75650bfd2f93b9a14857c49ab68f Mon Sep 17 00:00:00 2001 From: appleb_m Date: Tue, 16 Jun 2026 12:13:54 +0200 Subject: [PATCH 45/76] added smargon_frontend_url to yaml files --- src/aare/common/config/x06da.yaml | 1 + src/aare/common/config/x10sa.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/src/aare/common/config/x06da.yaml b/src/aare/common/config/x06da.yaml index 49e50b7a..1cd9f3c9 100644 --- a/src/aare/common/config/x06da.yaml +++ b/src/aare/common/config/x06da.yaml @@ -20,6 +20,7 @@ shared: daq: hardware: smargon_url: "http://x06da-smargopolo.psi.ch:3000" + smargon_frontend_url: "http://x06da-smargopolo.psi.ch:8080" aerotech_url: "http://mx-x06da-queue-01.psi.ch:5234" # Adjust if needed tell_url: "http://x06da-tell.psi.ch:22222" bec_url: "x06da-bec-001.psi.ch" diff --git a/src/aare/common/config/x10sa.yaml b/src/aare/common/config/x10sa.yaml index ca40696e..b9f70c1e 100644 --- a/src/aare/common/config/x10sa.yaml +++ b/src/aare/common/config/x10sa.yaml @@ -19,6 +19,7 @@ shared: daq: hardware: smargon_url: "http://x10sa-smargopolo.psi.ch:3000" + smargon_frontend_url: "http://x10sa-smargopolo.psi.ch:8080" aerotech_url: "http://mx-x10sa-queue-01.psi.ch:5234" # Adjust if needed tell_url: "http://PC17488:22222" bec_url: "x10sa-bec-001.psi.ch" From f7e5900dc5c671741a08e49e2795c90e806edf01 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Tue, 16 Jun 2026 14:15:40 +0200 Subject: [PATCH 46/76] aarelc_infer.py and yaml updated url for 6D --- src/aare/common/aarelc_infer.py | 16 ++++++++-------- src/aare/common/config/x06da.yaml | 6 ++++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/aare/common/aarelc_infer.py b/src/aare/common/aarelc_infer.py index f0287476..454aa9da 100644 --- a/src/aare/common/aarelc_infer.py +++ b/src/aare/common/aarelc_infer.py @@ -1,7 +1,7 @@ import cv2 import numpy as np from aarelcinfer_client import Client, AuthenticatedClient -from aarelcinfer_client.api import config, predictions#, beam +from aarelcinfer_client.api import config, predictions, beam from aarelcinfer_client.models import RuntimeConfigPatchModel, LatestPredictionModel import io from PIL import Image @@ -16,10 +16,10 @@ class AareLCInferWrapper: bl: MXBeamline, secret: str = "1s3ng@rd", ): - if bl == MXBeamline.X10SA: - host = cfg_get("daq.hardware.aarelc_url", "http://sls-gpu-003:9090") - elif bl == MXBeamline.X06DA: - host = cfg_get("daq.hardware.aarelc_url", "http://sls-gpu-003:9094") + if bl == MXBeamline.X10SA or bl == MXBeamline.X06DA: + host = cfg_get("daq.hardware.aarelc_url") + if host is None: + raise Exception("AareLCInferWrapper: AareLC URL not configured") elif bl == MXBeamline.X06SA: raise NotImplementedError(f"AareLCInferWrapper not implemented for {bl}") elif bl == MXBeamline.SIMULATED: @@ -32,7 +32,7 @@ class AareLCInferWrapper: self._host = host self._api_config = config self._api_predictions = predictions - #self._api_beam = beam + self._api_beam = beam def get_config(self) -> config.ConfigSnapshotResponse: return self._api_config.get_config(self.client) @@ -49,8 +49,8 @@ class AareLCInferWrapper: def get_latest_prediction_bundle(self): return self._api_predictions.get_latest_prediction_bundle(self.client) - # def send_samcam_details(self, beam_mark, beam_dimensions): - # return self._api_beam.set_beam_mark(beam_mark, beam_dimensions) + def send_samcam_details(self, beam_mark, beam_dimensions): + return self._api_beam.set_beam_mark(beam_mark, beam_dimensions) if __name__ == "__main__": diff --git a/src/aare/common/config/x06da.yaml b/src/aare/common/config/x06da.yaml index 1cd9f3c9..9d330747 100644 --- a/src/aare/common/config/x06da.yaml +++ b/src/aare/common/config/x06da.yaml @@ -25,7 +25,7 @@ daq: tell_url: "http://x06da-tell.psi.ch:22222" bec_url: "x06da-bec-001.psi.ch" redis_url: "x06da-redis.psi.ch" - aarelc_url: "http://sls-gpu-003:9090" + aarelc_url: "http://sls-gpu-003:9094" db: aaredb_url: "https://mx-aaredb-dmz-01.psi.ch/dispatcher" @@ -45,4 +45,6 @@ daq: steps: 1800 detector_limit_modifier: 2.0 - maximum_flux: 4e11 \ No newline at end of file + maximum_flux: 4e11 + dtz_minimum: 106.0 + dtz_maximum: 800.0 \ No newline at end of file From d242473cd6c59fe35c71ed1e230abc71b33410d3 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Tue, 16 Jun 2026 14:16:05 +0200 Subject: [PATCH 47/76] detector_metadata: endpoint in sevrer was incorrect. --- src/aare/daq/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/aare/daq/server.py b/src/aare/daq/server.py index d9ecfaed..1c5b610b 100644 --- a/src/aare/daq/server.py +++ b/src/aare/daq/server.py @@ -726,8 +726,8 @@ async def local_contact_restart_device( result["message"] = f"{device} backend restarted." return result -@app.post("/local_contact/resync/hardware_metadata") -async def local_contact_resync_hardware_metadata( +@app.post("/local_contact/resync/detector_metadata") +async def local_contact_resync_detector_metadata( token: str = Depends(oauth2_scheme), ) -> dict: """ From 271e3e6ce379cee3a6512b89928fb5bacc7cff9f Mon Sep 17 00:00:00 2001 From: appleb_m Date: Wed, 17 Jun 2026 11:38:25 +0200 Subject: [PATCH 48/76] yaml updates --- src/aare/common/config/x06da.yaml | 5 ++--- src/aare/common/config/x10sa.yaml | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/aare/common/config/x06da.yaml b/src/aare/common/config/x06da.yaml index 9d330747..0d2ed002 100644 --- a/src/aare/common/config/x06da.yaml +++ b/src/aare/common/config/x06da.yaml @@ -26,6 +26,7 @@ daq: bec_url: "x06da-bec-001.psi.ch" redis_url: "x06da-redis.psi.ch" aarelc_url: "http://sls-gpu-003:9094" + dtz_safe_position: null db: aaredb_url: "https://mx-aaredb-dmz-01.psi.ch/dispatcher" @@ -45,6 +46,4 @@ daq: steps: 1800 detector_limit_modifier: 2.0 - maximum_flux: 4e11 - dtz_minimum: 106.0 - dtz_maximum: 800.0 \ No newline at end of file + maximum_flux: 4e11 \ No newline at end of file diff --git a/src/aare/common/config/x10sa.yaml b/src/aare/common/config/x10sa.yaml index b9f70c1e..5c5d4240 100644 --- a/src/aare/common/config/x10sa.yaml +++ b/src/aare/common/config/x10sa.yaml @@ -25,6 +25,7 @@ daq: bec_url: "x10sa-bec-001.psi.ch" redis_url: "x10sa-redis.psi.ch" aarelc_url: "http://sls-gpu-003:9090" + dtz_safe_position: 300 db: aaredb_url: "https://mx-aaredb-dmz-01.psi.ch/dispatcher" From 37d3e55da32c93a45b69d7caba2ffbe0842ab17b Mon Sep 17 00:00:00 2001 From: appleb_m Date: Wed, 17 Jun 2026 11:39:57 +0200 Subject: [PATCH 49/76] workflows.py: added safe dtz positon check --- src/aare/daq/config.py | 26 ++++++++++++++++++++++++-- src/aare/daq/workflows.py | 36 +++++++++++++++++++++++++++++------- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/aare/daq/config.py b/src/aare/daq/config.py index 77a64327..049c1da4 100644 --- a/src/aare/daq/config.py +++ b/src/aare/daq/config.py @@ -98,6 +98,27 @@ class BeamlineConfig: 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 @@ -820,7 +841,7 @@ class BeamlineConfig: def dtz_safe_position(self) -> float | None: tmp = self.__client.get(f"{self.__bl}:dtz_safe_position") if tmp is None: - return 200.0 + return None return float(tmp) @dtz_safe_position.setter @@ -1197,4 +1218,5 @@ if __name__ == "__main__": cfg = BeamlineConfig(bl=mx_beamline()) # cfg.allow_non_staff_request_from_staff = True # cfg.state_busy = False - cfg.abr_meas_pos = AerotechCoordinate(at_mm=Coordinate(x=0.0,y=0.0,z=0.0)) \ No newline at end of file + #fg.abr_meas_pos = AerotechCoordinate(at_mm=Coordinate(x=0.0,y=0.0,z=0.0)) + cfg.dtz_safe_position = 300.0 \ No newline at end of file diff --git a/src/aare/daq/workflows.py b/src/aare/daq/workflows.py index 53834b66..4a1fa4bc 100644 --- a/src/aare/daq/workflows.py +++ b/src/aare/daq/workflows.py @@ -1,10 +1,10 @@ from aare.common.models import SampleCameraSettings -from aare.daq.devices import BeamlineDevices -from aare.daq.config import BeamlineConfig, ABR_POS_MOUNT, ABR_OMEGA_MOUNT +from aare.common.logger_config import setup_logger from aare.devices.area_detector import AutoEnum -from aare.common.logger_config import setup_logger from aare.devices.bec_worker import BeamlineState +from aare.daq.devices import BeamlineDevices +from aare.daq.config import BeamlineConfig, ABR_POS_MOUNT, ABR_OMEGA_MOUNT import time @@ -12,7 +12,32 @@ logger = setup_logger("aareDAQ") SAFE_POSITION = 600 + +def _move_detector_to_safe_position_if_needed(devs: BeamlineDevices, cfg: BeamlineConfig): + safe_position = cfg.dtz_safe_position + current_dtz = cfg.dtz + + if safe_position is None: + logger.debug("No dtz_safe_position configured; skipping detector move") + return + + if current_dtz is None: + logger.debug("Current dtz is unknown; skipping detector safe-position move") + return + + if current_dtz < safe_position: + logger.info(f"Moving detector to safe position {safe_position}") + return devs.bec_worker.det_z(value=safe_position) + + logger.debug( + f"Detector already at or beyond safe position " + f"(current={current_dtz}, safe={safe_position}); skipping move" + ) + return None + + def common_2rse(devs: BeamlineDevices, cfg: BeamlineConfig): + _move_detector_to_safe_position_if_needed(devs, cfg) devs.bec_worker.move_to(BeamlineState.ROBOT_SAMPLE_EXCHANGE) print('move smargon home') devs.smargon_move_home() @@ -123,9 +148,7 @@ def sa2dc(devs: BeamlineDevices, cfg: BeamlineConfig): logger.info(f"sam cam auto exposure in {time.perf_counter() - start:.2f}") def dc2sa(devs: BeamlineDevices, cfg: BeamlineConfig): - status = None - if cfg.dtz and cfg.dtz < SAFE_POSITION: - status = devs.bec_worker.det_z(value=SAFE_POSITION) + status = _move_detector_to_safe_position_if_needed(devs, cfg) devs.samcam_auto(AutoEnum.AUTO) devs.bec_worker.move_to(BeamlineState.SAMPLE_ALIGNMENT) devs.aerotech_pos = cfg.abr_meas_pos @@ -215,7 +238,6 @@ def common2maintenance(devs: BeamlineDevices, cfg: BeamlineConfig): if __name__ == "__main__": from aare.common.beamline import mx_beamline - import time beamline = mx_beamline() config = BeamlineConfig(beamline) devices = BeamlineDevices(beamline) From 333957adbc05db254ff685836c67ed4e78def781 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Wed, 17 Jun 2026 11:40:11 +0200 Subject: [PATCH 50/76] better erro hadnling for status bar --- src/aare/gui/threads/daq_worker.py | 1 + src/aare/gui/widgets/status_bar.py | 154 +++++++++++++++-------------- 2 files changed, 80 insertions(+), 75 deletions(-) diff --git a/src/aare/gui/threads/daq_worker.py b/src/aare/gui/threads/daq_worker.py index 43363426..c37dbab0 100644 --- a/src/aare/gui/threads/daq_worker.py +++ b/src/aare/gui/threads/daq_worker.py @@ -137,6 +137,7 @@ class DAQWorker(QObject): self._status_request_min_interval = 0.5 self._smargon_retry_interval_s = 2.0 self._smargon_log_min_interval_s = 10.0 + self._last_status_can_read: bool | None = None self._device_error_log_min_interval_s = 10.0 self._last_device_error_log_ts: dict[str, float] = {"tell": 0.0, "smargon": 0.0} diff --git a/src/aare/gui/widgets/status_bar.py b/src/aare/gui/widgets/status_bar.py index 14d4751c..75a0e6f4 100644 --- a/src/aare/gui/widgets/status_bar.py +++ b/src/aare/gui/widgets/status_bar.py @@ -132,88 +132,92 @@ class StatusBar(QStatusBar): @Slot(DAQStatusModel) def update_daq_status(self, status: DAQStatusModel): - self.__status = status - if status.bl.flux_ph_s is None: - self.flux.set_value(f"0") - else: - self.flux.set_value(f"{(status.bl.flux_ph_s / 1e9):.0f}") - - if status.bl.transmission is None: - self.transmission.set_value(f"(moving)") - else: - self.transmission.set_value(f"{status.bl.transmission:.5f}") - - if status.bl.ring_current_mA < 5.0: - self.ring_current.set_value(f"{status.bl.ring_current_mA:.2f}", "red") - elif status.bl.ring_current_mA < 390.0: - self.ring_current.set_value(f"{status.bl.ring_current_mA:.2f}", "orange") - else: - self.ring_current.set_value(f"{status.bl.ring_current_mA:.2f}") - - self.wvl.set_value(f"{status.diffraction.wavelength_angstrom:.2f}") - - if status.bl.cryojet_K < 110.0: - self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", "blue") - elif status.bl.cryojet_K < 250.0: - self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", "orange") - else: - self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", "red") - - if status.bl.shutter_open: - self.shutter_label.setText(f"""Shutter: Open ☢️ """) - else: - self.shutter_label.setText(f"""Shutter: Closed 🚪 """) - - if status.bl.exp_shutter_open: - self.exp_shutter_label.setText("""ExpHutch Shutter: Open """) - else: - self.exp_shutter_label.setText("""ExpHutch Shutter: Closed 🚪 """) - - if status.session.current_pgroup is not None: - self.pgroup_label.setText(f"""p-group: {status.session.current_pgroup} """) - else: - self.pgroup_label.setText(f"Inactive p-group ") - - self.state_label.setText(f"""State: {status.state.display_name()} """) - - tell_text = "—" - tell_color = "rgb(55, 67, 87)" - if status.tell_state is not None: - tell_text = status.tell_state.activity.display_name() - - if status.tell_state.activity.value == "error": - tell_color = "red" - elif status.tell_state.activity.value in {"mounting", "unmounting", "drying", "cooling"}: - tell_color = "orange" + try: + self.__status = status + if status.bl.flux_ph_s is None: + self.flux.set_value(f"0") else: - tell_color = "green" + self.flux.set_value(f"{(status.bl.flux_ph_s / 1e9):.0f}") - self.tell_state_label.setText(f"Tell: {tell_text} ") - self.tell_state_label.setStyleSheet(f"color: {tell_color};") + if status.bl.transmission is None: + self.transmission.set_value(f"(moving)") + else: + self.transmission.set_value(f"{status.bl.transmission:.5f}") - if status.busy: - busy_flag = """ Busy 🔒 """ - else: - busy_flag = """ Idle 🔓 """ + if status.bl.ring_current_mA < 5.0: + self.ring_current.set_value(f"{status.bl.ring_current_mA:.2f}", "red") + elif status.bl.ring_current_mA < 390.0: + self.ring_current.set_value(f"{status.bl.ring_current_mA:.2f}", "orange") + else: + self.ring_current.set_value(f"{status.bl.ring_current_mA:.2f}") - html_content = f"""Beamline: {busy_flag} """ + if status.diffraction.wavelength_angstrom: + self.wvl.set_value(f"{status.diffraction.wavelength_angstrom:.2f}") - self.busy_label.setText(html_content) + if status.bl.cryojet_K < 110.0: + self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", "blue") + elif status.bl.cryojet_K < 250.0: + self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", "orange") + else: + self.cryo_label.set_value(f"{status.bl.cryojet_K:.1f}", "red") - session_flag = "" - if status.session.session == SessionsStateEnum.Vacant: - session_flag = """ Vacant 🔓 """ - elif status.session.session == SessionsStateEnum.OwnedByYou: - session_flag = """ Owned ⬤ """ - elif status.session.session == SessionsStateEnum.OwnedByElse: - session_flag = """ Other 🔒 """ - elif status.session.session == SessionsStateEnum.PendingYouToElse: - session_flag = """ Waiting... ⏳ """ - elif status.session.session == SessionsStateEnum.PendingElseToYou: - session_flag = """ Request! ⚡ """ + if status.bl.shutter_open: + self.shutter_label.setText(f"""Shutter: Open ☢️ """) + else: + self.shutter_label.setText(f"""Shutter: Closed 🚪 """) - html_content_session = f"""Session: {session_flag}""" - self.session_label.setText(html_content_session) + if status.bl.exp_shutter_open: + self.exp_shutter_label.setText("""ExpHutch Shutter: Open """) + else: + self.exp_shutter_label.setText("""ExpHutch Shutter: Closed 🚪 """) + + if status.session.current_pgroup is not None: + self.pgroup_label.setText(f"""p-group: {status.session.current_pgroup} """) + else: + self.pgroup_label.setText(f"Inactive p-group ") + + self.state_label.setText(f"""State: {status.state.display_name()} """) + + tell_text = "—" + tell_color = "rgb(55, 67, 87)" + if status.tell_state is not None: + tell_text = status.tell_state.activity.display_name() + + if status.tell_state.activity.value == "error": + tell_color = "red" + elif status.tell_state.activity.value in {"mounting", "unmounting", "drying", "cooling"}: + tell_color = "orange" + else: + tell_color = "green" + + self.tell_state_label.setText(f"Tell: {tell_text} ") + self.tell_state_label.setStyleSheet(f"color: {tell_color};") + + if status.busy: + busy_flag = """ Busy 🔒 """ + else: + busy_flag = """ Idle 🔓 """ + + html_content = f"""Beamline: {busy_flag} """ + + self.busy_label.setText(html_content) + + session_flag = "" + if status.session.session == SessionsStateEnum.Vacant: + session_flag = """ Vacant 🔓 """ + elif status.session.session == SessionsStateEnum.OwnedByYou: + session_flag = """ Owned ⬤ """ + elif status.session.session == SessionsStateEnum.OwnedByElse: + session_flag = """ Other 🔒 """ + elif status.session.session == SessionsStateEnum.PendingYouToElse: + session_flag = """ Waiting... ⏳ """ + elif status.session.session == SessionsStateEnum.PendingElseToYou: + session_flag = """ Request! ⚡ """ + + html_content_session = f"""Session: {session_flag}""" + self.session_label.setText(html_content_session) + except Exception as e: + logger.error(f"Error updating DAQ status in status bar: {e}") @Slot(BatonStatus) def update_baton_status(self, status: BatonStatus): From dde084538cc8874cb571003824a5d96e2d611e04 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Wed, 17 Jun 2026 11:40:20 +0200 Subject: [PATCH 51/76] updated workflows test --- tests/unit/daq/test_workflows.py | 43 ++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/unit/daq/test_workflows.py b/tests/unit/daq/test_workflows.py index 38543aec..067fb655 100644 --- a/tests/unit/daq/test_workflows.py +++ b/tests/unit/daq/test_workflows.py @@ -21,6 +21,7 @@ def mock_cfg(): cfg = MagicMock() cfg.abr_meas_pos = "MEAS_POS" cfg.dtz = None + cfg.dtz_safe_position = None return cfg @@ -47,6 +48,48 @@ def test_common_2rse(mock_devs, mock_cfg): assert mock_devs.aerotech_pos == ABR_POS_MOUNT +def test_common_2rse_moves_detector_to_safe_position_when_configured(mock_devs, mock_cfg): + mock_cfg.dtz = 200 + mock_cfg.dtz_safe_position = 600 + + common_2rse(mock_devs, mock_cfg) + + mock_devs.bec_worker.det_z.assert_called_once_with(value=600) + _assert_bec_moved(mock_devs, BeamlineState.ROBOT_SAMPLE_EXCHANGE) + + +def test_common_2rse_ignores_missing_detector_safe_position(mock_devs, mock_cfg): + mock_cfg.dtz = 200 + mock_cfg.dtz_safe_position = None + + common_2rse(mock_devs, mock_cfg) + + mock_devs.bec_worker.det_z.assert_not_called() + _assert_bec_moved(mock_devs, BeamlineState.ROBOT_SAMPLE_EXCHANGE) + + +def test_dc2sa_moves_detector_to_safe_position_when_configured(mock_devs, mock_cfg): + mock_cfg.dtz = 200 + mock_cfg.dtz_safe_position = 600 + + dc2sa(mock_devs, mock_cfg) + + mock_devs.bec_worker.det_z.assert_called_once_with(value=600) + _assert_bec_moved(mock_devs, BeamlineState.SAMPLE_ALIGNMENT) + assert mock_devs.aerotech_pos == mock_cfg.abr_meas_pos + + +def test_dc2sa_ignores_missing_detector_safe_position(mock_devs, mock_cfg): + mock_cfg.dtz = 200 + mock_cfg.dtz_safe_position = None + + dc2sa(mock_devs, mock_cfg) + + mock_devs.bec_worker.det_z.assert_not_called() + _assert_bec_moved(mock_devs, BeamlineState.SAMPLE_ALIGNMENT) + assert mock_devs.aerotech_pos == mock_cfg.abr_meas_pos + + def test_sa2se(mock_devs, mock_cfg): mock_devs.bec_worker = MagicMock() mock_devs.bec_worker.planner = MagicMock() From 5bc3e90bb4ab595b2fbe30c95989505d40ff38eb Mon Sep 17 00:00:00 2001 From: appleb_m Date: Wed, 17 Jun 2026 11:40:34 +0200 Subject: [PATCH 52/76] tell: debuging. --- src/aare/daq/tellupdater.py | 12 ++++++++++++ src/aare/devices/tell_backend.py | 7 ++++--- src/aare/devices/tell_client.py | 16 ++++++++++------ 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/aare/daq/tellupdater.py b/src/aare/daq/tellupdater.py index 531159c3..7ce91a3f 100644 --- a/src/aare/daq/tellupdater.py +++ b/src/aare/daq/tellupdater.py @@ -61,6 +61,12 @@ TRACKED_MOTION_SYNC_EVENTS = { "Sample get on Puck", }, } +TRACKED_STATE_EVENTS = { + "state": { + "Ready", + "Busy" + } +} latest_tell_events = {} tell_event_history = deque(maxlen=25) @@ -186,6 +192,12 @@ def extract_tracked_tell_event(event_name, event_data): return event_name, normalized_data return None + if event_name in TRACKED_STATE_EVENTS: + allowed_values = TRACKED_STATE_EVENTS[event_name] + if normalized_data in allowed_values: + return event_name, normalized_data + return None + return None diff --git a/src/aare/devices/tell_backend.py b/src/aare/devices/tell_backend.py index 8a1670c1..a4a181de 100644 --- a/src/aare/devices/tell_backend.py +++ b/src/aare/devices/tell_backend.py @@ -70,12 +70,12 @@ class PShellTellBackend: def __init__(self, bl: MXBeamline): self._url = self._resolve_url(bl) - print(f"Connecting TELL p-shell service at {self._url} ...", end="") + logger.info(f"Connecting TELL p-shell service at {self._url} ...") hostname = urlparse(self._url).hostname try: requests.get(f"{self._url}/history/0", timeout=1.0) except requests.exceptions.RequestException as e: - print(f"...connection to {hostname} failed") + logger.error(f"...connection to {hostname} failed") raise TellCommunicationError( f"TELL connection failed ({hostname})", base_url=self._url, @@ -83,7 +83,7 @@ class PShellTellBackend: operation="GET", ) from e except requests.ReadTimeout as e: - print(f"...PShell service {hostname} is down") + logger.error(f"...PShell service {hostname} is down") raise TellCommunicationError( f"TELL connection timedout ({hostname})", base_url=self._url, @@ -92,6 +92,7 @@ class PShellTellBackend: ) from e self._pshell = PShellClient(self._url) + logger.info(f"...connected to {hostname}") @staticmethod def _resolve_url(bl: MXBeamline) -> str: diff --git a/src/aare/devices/tell_client.py b/src/aare/devices/tell_client.py index d4cdc1d7..2f8e7e2d 100755 --- a/src/aare/devices/tell_client.py +++ b/src/aare/devices/tell_client.py @@ -52,6 +52,8 @@ class TellEventValueEnum(Enum): UNMOUNT = "unmount" MOUNT = "mount" MANUAL = "manual" + READY = "Ready" + BUSY = "Busy" class TellClient: @@ -203,18 +205,18 @@ class TellClient: logger.info("waiting for mount to complete") try: if wait and segment in "ABCDEF": + logger.info("Waiting for events from TELL:") event, value = self.backend.wait_events( { "state": None, - "Motion Task": "dry", + "Motion Task": None,#"dry", "Gripper detection": None, "Motion Sync": "Robot Clear after mount", }, timeout=wait_timeout, ) logger.info(f"event: {event} occurred with value: {value}") - if event == "state" and value == 'Ready': - logger.warning('READY does not mean mount success') + if event == "state" and str(value) == '"Ready"': try: msg = self.check_command_ok( timeout=wait_timeout, msg=f"Mount {segment}{puck}-{sample}: " @@ -223,7 +225,7 @@ class TellClient: return TellEventValueEnum.SUCCESS except Exception: raise - if event == "state" and value == 'Busy': + if event == "state" and value == '"Busy"': logger.warning('got busy response form robot, waiting for mount to complete') try: msg = self.check_command_ok( @@ -238,7 +240,10 @@ class TellClient: self.check_command_ok( timeout=wait_timeout, msg=f"Mount {segment}{puck}-{sample}: " ) - return value + if value.lower() == "ready" or value.lower() == '"ready"' or value == "Ready" or str(value.lower()) == "ready" or str(value.lower()) == '"ready"': + return TellEventValueEnum.SUCCESS + else: + raise Exception(f"Unexpected event: {event} occurred with value: {value}") elif ( event == TellEventTypeEnum.GIPPER_DETECTION.value and value == TellEventValueEnum.NO_PIN_IN_GRIPPER.value @@ -306,7 +311,6 @@ class TellClient: return self._last_cmd_id def dry(self, heat_time=None, speed=None, wait_cold=None, wait=False): - #TODO add timeout variable???? self.backend.wait_state("Ready", timeout=30.0) self._last_cmd_id = self.start_cmd("dry", heat_time, speed, wait_cold) if wait: From 5f5c672111aa87084773e4f2ce43d898db75b6d6 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Wed, 17 Jun 2026 11:40:49 +0200 Subject: [PATCH 53/76] pyproject.toml: should be sseclient not sseclient-py --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3aca9e93..e8295748 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "aaredb==0.1.2a5", "python_multipart==0.0.20", "websocket-client==1.8.0", - "sseclient-py==1.8.0", + "sseclient==0.0.27", #Was sseclient-py==1.8.0 but this was broken??? "psi-pshell==2.1.0", "bec_lib>=3.130.3", "bec-ipython-client>=3.130.3", From 4ff30920ce0f2809e46a00f22b21316638665396 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Wed, 17 Jun 2026 11:41:04 +0200 Subject: [PATCH 54/76] devices: fix on bec det_z device --- src/aare/daq/devices.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/aare/daq/devices.py b/src/aare/daq/devices.py index bfa463bf..c7c5da6c 100644 --- a/src/aare/daq/devices.py +++ b/src/aare/daq/devices.py @@ -41,9 +41,7 @@ class BeamlineDevices: self.exp_shutter = ExperimentalHutchShutter(beamline=self._beamline) #faster to define the dtz object here than in functions and then use - self.__dtz_rbk = self.bec_worker.dev.det_z.position - self.__dtz_llm= self.bec_worker.dev.det_z.low_limit - self.__dtz_hlm= self.bec_worker.dev.det_z.high_limit + self.__dtz = self.bec_worker.dev.det_z self.dtz_mod = cfg_get('daq.detector_distance_limit_modifier', 1.0) #TODO convert epics pvs to BEC self.__sample_cam = epicsAD(f"{BEAMLINE}-ES-MS:") @@ -265,20 +263,20 @@ class BeamlineDevices: # Detector Z @property def dtz(self) -> float: - return self.__dtz_rbk + return self.__dtz.read()['det_z']['value'] @dtz.setter def dtz(self, value: float): self.set_dtz(value, wait=True) def set_dtz(self, value: float, /, wait: bool = True): - if value < self.__dtz_llm: + if value < self.__dtz.low_limit: #raise ValueError(f"Detector distance cannot be less than {self.detector_distance_minimum} mm") - logger.warning(f"Requested detector distance {value} is less than minimum: {self.__dtz_llm}, setting to minimum") - value = self.__dtz_llm + self.dtz_mod - if value > self.__dtz_hlm: - logger.warning(f"Requested detector distance {value} is greater than maximum: {self.__dtz_hlm}, setting to maximum") - value = self.__dtz_hlm - self.dtz_mod + logger.warning(f"Requested detector distance {value} is less than minimum: {self.__dtz.low_limit}, setting to minimum") + value = self.__dtz.low_limit + self.dtz_mod + if value > self.__dtz.high_limit: + logger.warning(f"Requested detector distance {value} is greater than maximum: {self.__dtz.high_limit}, setting to maximum") + value = self.__dtz.high_limit - self.dtz_mod if wait: status = self.bec_worker.det_z(value, timeout=60) return status @@ -288,11 +286,13 @@ class BeamlineDevices: @property def dtz_low(self) -> float: - return self.__dtz_llm + logger.info(f"dtz_low: {self.__dtz.low_limit}") + return self.__dtz.low_limit @property def dtz_high(self) -> float: - return self.__dtz_hlm + logger.info(f"dtz_high: {self.__dtz.high_limit}") + return self.__dtz.high_limit @property def aerotech_pos(self) -> AerotechCoordinate: From 9be8c1a3c86869dd84fd0f90b330e91a52500c8f Mon Sep 17 00:00:00 2001 From: appleb_m Date: Wed, 17 Jun 2026 16:24:27 +0200 Subject: [PATCH 55/76] beamlin: renamed second BeamlineConfig to BeamlineYAMLCOnfig --- src/aare/common/beamline.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/aare/common/beamline.py b/src/aare/common/beamline.py index ec144534..03e377e7 100644 --- a/src/aare/common/beamline.py +++ b/src/aare/common/beamline.py @@ -12,7 +12,7 @@ class MXBeamline(Enum): SIMULATED = "SIMULATED" -class BeamlineConfig: +class BeamlineYAMLConfig: def __init__(self): self.beamline: MXBeamline = mx_beamline() self.config = self._load_config() @@ -52,8 +52,8 @@ def get_jfjoch_url(bl: MXBeamline) -> str: case _: raise ValueError(f"unknown beamline {bl}") -def get_beamline_config() -> BeamlineConfig: - beamline_config = BeamlineConfig() +def get_beamline_config() -> BeamlineYAMLConfig: + beamline_config = BeamlineYAMLConfig() return beamline_config def cfg_get(path: str, default: Any = None) -> Any: @@ -119,4 +119,4 @@ def daq_base_url() -> str | None: # Global instance for easy import if __name__ == "__main__": - beamline_config = BeamlineConfig() \ No newline at end of file + beamline_config = BeamlineYAMLConfig() \ No newline at end of file From 087ce0482f6b6f2d513271a1c8ea43d0bc04b94f Mon Sep 17 00:00:00 2001 From: appleb_m Date: Wed, 17 Jun 2026 16:25:02 +0200 Subject: [PATCH 56/76] operations refactor: standardised operations, added rotation operation, updated tests. --- src/aare/daq/daq.py | 308 ++++++++++++------ src/aare/daq/operations/common/models.py | 55 ++++ src/aare/daq/operations/common/runtime.py | 64 +--- src/aare/daq/operations/common/services.py | 118 +++++++ .../daq/operations/face_detection/models.py | 27 +- .../daq/operations/face_detection/service.py | 50 +-- .../daq/operations/loop_centering/analyzer.py | 9 +- .../daq/operations/loop_centering/models.py | 28 +- .../daq/operations/loop_centering/service.py | 18 +- src/aare/daq/operations/mounting/models.py | 18 +- src/aare/daq/operations/mounting/service.py | 50 +-- src/aare/daq/operations/raster/models.py | 37 +-- src/aare/daq/operations/raster/service.py | 156 +++++---- src/aare/daq/operations/rotation/models.py | 27 ++ src/aare/daq/operations/rotation/service.py | 119 +++++++ .../test_face_detection_service.py | 63 ++-- .../test_loop_centering_analyzer.py | 46 +-- .../test_loop_centering_service.py | 38 ++- .../mounting/test_mounting_service.py | 56 ++-- tests/unit/daq/test_mount.py | 11 +- tests/unit/daq/test_raster_logic.py | 81 +++-- tests/unit/daq/test_workflows.py | 5 +- 22 files changed, 945 insertions(+), 439 deletions(-) create mode 100644 src/aare/daq/operations/common/models.py create mode 100644 src/aare/daq/operations/common/services.py diff --git a/src/aare/daq/daq.py b/src/aare/daq/daq.py index f71d3381..7c6781bd 100644 --- a/src/aare/daq/daq.py +++ b/src/aare/daq/daq.py @@ -12,7 +12,6 @@ import numpy as np from aareDB import SampleEventType from jfjoch_client import ScanResult, ScanResultImagesInner -from aare.common.tell_models import TellStateModel, TellPhaseEnum from aare.daq import workflows from aare.daq.aaredb import AareWrapper @@ -20,6 +19,8 @@ from aare.daq.config import BeamlineConfig, ABR_POS_MOUNT from aare.daq.config import BeamlineStateEnum from aare.daq.devices import BeamlineDevices from aare.daq.mlbox import MlBox + +from aare.common.tell_models import TellStateModel, TellPhaseEnum from aare.common.beamline import MXBeamline, cfg_get from aare.common.coordinate import Coordinate, SmargonCoordinate, AerotechCoordinate from aare.common.diffraction_geometry import DiffractionGeometry @@ -47,30 +48,6 @@ from aare.common.automation_models import ( from aare.common.raster_grid import RasterGridRequest, CompletedRasterGrid from aare.common.rotation_scan import RotationScanRequest, CompletedRotationScan from aare.common.sample_geometry import SampleGeometryModel - -from aare.daq.operations.face_detection import FaceDetectionContext, FaceDetectionService, FaceDetectionResult -from aare.daq.operations.loop_centering import LoopCenteringService, LoopCenteringContext -from aare.daq.operations.loop_centering.models import LoopCenteringSettings -from aare.daq.operations.mounting.service import MountingService -from aare.daq.operations.mounting.models import MountingResult, MountingContext -from aare.daq.operations.raster.models import RasterContext -from aare.daq.operations.raster.service import RasterService -from aare.daq.operations.common.ml_bounding_box import get_ml_bounding_box -from aare.daq.operations.screenshot.service import ScreenshotService -from aare.daq.operations.common.simulate_scan_result import ( - build_fake_rotation_result, -) -from aare.daq.operations.common.runtime import ( - DAQRuntimeState, - FaceDetectionProgressEmitter, - OperationServices, - PredictionProvider, - StateController, - TraceWriter, -) -from aare.devices.area_detector import AutoEnum -from aare.devices.jfjoch import JFJochWrapper - from aare.common.exception_handler import ( TransformationInvalidException, StateTransitionFailed, @@ -93,6 +70,55 @@ from aare.common.exception_handler import ( AutoRasterSampleSkipped ) +from aare.daq.operations.face_detection import FaceDetectionContext, FaceDetectionService, FaceDetectionResult +from aare.daq.operations.face_detection.models import ( + FaceDetectionDependencies, + FaceDetectionSettings, +) +from aare.daq.operations.loop_centering import LoopCenteringService, LoopCenteringContext +from aare.daq.operations.loop_centering.models import ( + LoopCenteringSettings, + LoopCenteringDependencies, +) +from aare.daq.operations.mounting.service import MountingService +from aare.daq.operations.mounting.models import ( + MountingResult, + MountingContext, + MountingDependencies, + MountingSettings, +) +from aare.daq.operations.raster.models import ( + RasterContext, + RasterSettings, + RasterDependencies, +) +from aare.daq.operations.raster.service import RasterService +from aare.daq.operations.common.ml_bounding_box import get_ml_bounding_box +from aare.daq.operations.rotation.models import ( + RotationSettings, + RotationDependencies, + RotationContext, +) +from aare.daq.operations.rotation.service import RotationService +from aare.daq.operations.screenshot.service import ScreenshotService +from aare.daq.operations.common.simulate_scan_result import ( + build_fake_rotation_result, +) +from aare.daq.operations.common.runtime import DAQRuntimeState +from aare.daq.operations.common.services import ( + FaceDetectionProgressEmitter, + OperationServices, + PredictionProvider, + StateController, + TraceWriter, + SampleEventPublisher, + ScanIngestionService, + DataCollectionPreparer, +) + +from aare.devices.area_detector import AutoEnum +from aare.devices.jfjoch import JFJochWrapper + logger = setup_logger("aareDAQ") @@ -171,6 +197,45 @@ class _DAQTraceAppender: self._daq._append_smargon_trace(sample_id=sample_id, event=event) +class _DAQSampleEventSender: + def __init__(self, daq: "AareDAQ"): + self._daq = daq + + def send_sample_event(self, sample_id: int, event_type, comment: str | None = None) -> None: + self._daq._AareDAQ__aare.send_sample_event(sample_id, event_type, comment) + + +class _DAQScanIngestor: + def __init__(self, daq: "AareDAQ"): + self._daq = daq + + def ingest_scan(self, *, sample, result, geom, beam_mark_pxl) -> None: + self._daq._AareDAQ__aare.ingest_scan( + sample=sample, + result=result, + geom=geom, + beam_mark_pxl=beam_mark_pxl, + ) + + def ingest_gridscan(self, *, sample, raster_result, raster_request, geom, com, beam_mark_pxl) -> None: + self._daq._AareDAQ__aare.ingest_gridscan( + sample=sample, + raster_result=raster_result, + raster_request=raster_request, + geom=geom, + com=com, + beam_mark_pxl=beam_mark_pxl, + ) + + +class _DAQDatacollectionSetupRunner: + def __init__(self, daq: "AareDAQ"): + self._daq = daq + + def prepare(self, request, screening: bool = False) -> None: + self._daq._AareDAQ__setup_datacollection(request=request, screening=screening) + + class _LoopCenteringPredictionGetter: def __init__(self, daq: "AareDAQ", settings: LoopCenteringSettings): self._daq = daq @@ -835,6 +900,21 @@ class AareDAQ: def _build_operation_services(self) -> OperationServices: return OperationServices( screenshots=self._screenshot_service, + state=StateController( + setter=_DAQStateSetter(self), + ), + traces=TraceWriter( + appender=_DAQTraceAppender(self), + ), + events=SampleEventPublisher( + sender=_DAQSampleEventSender(self), + ), + ingestion=ScanIngestionService( + ingestor=_DAQScanIngestor(self), + ), + datacollection=DataCollectionPreparer( + runner=_DAQDatacollectionSetupRunner(self), + ), ) def _create_loop_centering_settings(self) -> LoopCenteringSettings: @@ -842,85 +922,95 @@ class AareDAQ: def _create_loop_centering_service(self) -> LoopCenteringService: settings = self._create_loop_centering_settings() + services = self._build_operation_services() + services.predictions = PredictionProvider( + getter=_LoopCenteringPredictionGetter(self, settings), + ) return LoopCenteringService( context=LoopCenteringContext( - cfg=self.__cfg, - devs=self.__devs, - mlbox=self.__mlbox, - settings=settings, + deps=LoopCenteringDependencies( + cfg=self.__cfg, + devs=self.__devs, + mlbox=self.__mlbox, + ), runtime=self._build_runtime_state(), - services=self._build_operation_services(), - trace_writer=TraceWriter( - appender=_DAQTraceAppender(self), - ), - prediction_provider=PredictionProvider( - getter=_LoopCenteringPredictionGetter(self, settings), - ), + services=services, + settings=settings, ), logger=logger, ) def _create_face_detection_service(self) -> FaceDetectionService: + services = self._build_operation_services() + services.face_detection_progress = FaceDetectionProgressEmitter( + reporter=_FaceDetectionProgressReporter(self), + ) + return FaceDetectionService( context=FaceDetectionContext( - cfg=self.__cfg, - devs=self.__devs, - mlbox=self.__mlbox, - runtime=self._build_runtime_state(), - progress_emitter=FaceDetectionProgressEmitter( - reporter=_FaceDetectionProgressReporter(self), + deps=FaceDetectionDependencies( + cfg=self.__cfg, + devs=self.__devs, + mlbox=self.__mlbox, ), + runtime=self._build_runtime_state(), + services=services, + settings=FaceDetectionSettings(), ), logger=logger, ) - def _create_mounting_service(self): + def _create_mounting_service(self) -> MountingService: return MountingService( context=MountingContext( - cfg=self.__cfg, - devs=self.__devs, - mount_position=ABR_POS_MOUNT, + deps=MountingDependencies( + cfg=self.__cfg, + devs=self.__devs, + ), + settings=MountingSettings( + mount_position=ABR_POS_MOUNT, + ), ), logger=logger, ) - def _create_raster_service(self): + def _create_raster_service(self) -> RasterService: return RasterService( context=RasterContext( - cfg=self.__cfg, - devs=self.__devs, - mlbox=self.__mlbox, - jfjoch=self.__jfjoch, - aare=self.__aare, - runtime=self._build_runtime_state(), - state_controller=StateController( - setter=_DAQStateSetter(self), + deps=RasterDependencies( + cfg=self.__cfg, + devs=self.__devs, + mlbox=self.__mlbox, + jfjoch=self.__jfjoch, + aare=self.__aare, ), + runtime=self._build_runtime_state(), services=self._build_operation_services(), - auto_raster_max_images=self.AUTO_RASTER_MAX_IMAGES, - auto_raster_min_cell_size_mm=self.AUTO_RASTER_MIN_CELL_SIZE_MM, - auto_raster_skip_if_exceed_max_image_threshold=self.AUTO_RASTER_SKIP_IF_EXCEED_MAX_IMAGE_THRESHOLD, + settings=RasterSettings( + auto_raster_max_images=self.AUTO_RASTER_MAX_IMAGES, + auto_raster_min_cell_size_mm=self.AUTO_RASTER_MIN_CELL_SIZE_MM, + auto_raster_skip_if_exceed_max_image_threshold=self.AUTO_RASTER_SKIP_IF_EXCEED_MAX_IMAGE_THRESHOLD, + ), ), logger=logger, ) - # - # def _create_rotation_service(self): - # return RotationService( - # context=RotationContext( - # cfg=self.__cfg, - # devs=self.__devs, - # jfjoch=self.__jfjoch, - # aare=self.__aare, - # sample_provider=lambda: self.sample, - # sample_geometry_provider=lambda: self.sample_geometry, - # status_provider=lambda: self.status, - # set_state=self.__set_state, - # save_screenshot_db=self.save_screenshot_db, - # ), - # logger=logger, - # ) + def _create_rotation_service(self) -> RotationService: + return RotationService( + context=RotationContext( + deps=RotationDependencies( + cfg=self.__cfg, + devs=self.__devs, + jfjoch=self.__jfjoch, + aare=self.__aare, + ), + runtime=self._build_runtime_state(), + services=self._build_operation_services(), + settings=RotationSettings(), + ), + logger=logger, + ) #-------------------------------------------- # Operation Handlers @@ -1362,6 +1452,56 @@ class AareDAQ: ) return None + # def _execute_rotation_sequence(self, rotation_request: RotationScanRequest) -> CompletedRotationScan | None: + # """ + # Execute rotation scan. + # + # Args: + # rotation_request: Rotation scan parameters + # + # Returns: + # CompletedRotationScan result or None if failed + # """ + # try: + # status= self.status + # if self.__cfg.simulated_detector: + # logger.info("Simulated detector mode enabled; skipping JFJoch start.") + # else: + # self.__jfjoch.measure_rotation(rotation_request, status, self.__cfg.xrf) + # + # self.__setup_datacollection(request=rotation_request) + # if self.sample is not None and self.sample.db_id is not None: + # self.__aare.send_sample_event(self.sample.db_id, SampleEventType.COLLECTING) + # + # self.__set_state(BeamlineStateEnum.DataCollection) + # result = self.__rotation(rotation_request) + # self.__set_state(BeamlineStateEnum.SampleAlignment) + # if self.sample is not None and self.sample.db_id is not None: + # self.save_screenshot_db(self.sample.db_id, "scan_preview") + # self.__aare.send_sample_event(self.sample.db_id, SampleEventType.COLLECTED) + # self.__aare.ingest_scan(sample=self.sample, result=result.result, + # geom=self.sample_geometry, beam_mark_pxl=self.__cfg.get_beam_mark(self.zoom)) + # return result + # except JFJochCommunicationError as e: + # logger.error(f"Rotation sequence failed due to JFJoch Communication error: {e}") + # self._handle_operation_error( + # operation=DAQOperation.ROTATION, + # sample=self.sample, + # error=e, + # event_type=SampleEventType.COLLECTIONFAILED, + # additional_comment=f"JFJoch communication error: {e}" + # ) + # raise + # except Exception as e: + # logger.error(f"Rotation sequence failed: {e}") + # self._handle_operation_error( + # operation=DAQOperation.ROTATION, + # sample=self.sample, + # error=e, + # event_type=SampleEventType.COLLECTIONFAILED + # ) + # raise + def _execute_rotation_sequence(self, rotation_request: RotationScanRequest) -> CompletedRotationScan | None: """ Execute rotation scan. @@ -1373,25 +1513,7 @@ class AareDAQ: CompletedRotationScan result or None if failed """ try: - status= self.status - if self.__cfg.simulated_detector: - logger.info("Simulated detector mode enabled; skipping JFJoch start.") - else: - self.__jfjoch.measure_rotation(rotation_request, status, self.__cfg.xrf) - - self.__setup_datacollection(request=rotation_request) - if self.sample is not None and self.sample.db_id is not None: - self.__aare.send_sample_event(self.sample.db_id, SampleEventType.COLLECTING) - - self.__set_state(BeamlineStateEnum.DataCollection) - result = self.__rotation(rotation_request) - self.__set_state(BeamlineStateEnum.SampleAlignment) - if self.sample is not None and self.sample.db_id is not None: - self.save_screenshot_db(self.sample.db_id, "scan_preview") - self.__aare.send_sample_event(self.sample.db_id, SampleEventType.COLLECTED) - self.__aare.ingest_scan(sample=self.sample, result=result.result, - geom=self.sample_geometry, beam_mark_pxl=self.__cfg.get_beam_mark(self.zoom)) - return result + return self._create_rotation_service().run(rotation_request) except JFJochCommunicationError as e: logger.error(f"Rotation sequence failed due to JFJoch Communication error: {e}") self._handle_operation_error( diff --git a/src/aare/daq/operations/common/models.py b/src/aare/daq/operations/common/models.py new file mode 100644 index 00000000..22d1d305 --- /dev/null +++ b/src/aare/daq/operations/common/models.py @@ -0,0 +1,55 @@ +from dataclasses import dataclass +from typing import Generic, Protocol, TypeVar + +from aare.daq.config import BeamlineConfig +from aare.daq.devices import BeamlineDevices +from aare.daq.operations.common.runtime import DAQRuntimeState +from aare.daq.operations.common.services import OperationServices + + +class OperationDependencies(Protocol): + """Marker protocol for operation dependency bundles.""" + + +class OperationSettings(Protocol): + """Marker protocol for operation settings bundles.""" + + +DepsT = TypeVar("DepsT", bound=OperationDependencies) +SettingsT = TypeVar("SettingsT", bound=OperationSettings) + + +@dataclass +class BeamlineDependencies: + cfg: BeamlineConfig + devs: BeamlineDevices + +@dataclass +class OperationResult: + success: bool + error: Exception | None = None + comment: str | None = None + + @property + def failed(self) -> bool: + return not self.success + + +@dataclass +class BaseOperationContext(Generic[DepsT, SettingsT]): + deps: DepsT + runtime: DAQRuntimeState + services: OperationServices + settings: SettingsT + + @property + def sample(self): + return self.runtime.sample + + @property + def sample_geometry(self): + return self.runtime.sample_geometry + + @property + def status(self): + return self.runtime.status \ No newline at end of file diff --git a/src/aare/daq/operations/common/runtime.py b/src/aare/daq/operations/common/runtime.py index aa2aee38..1f911b42 100644 --- a/src/aare/daq/operations/common/runtime.py +++ b/src/aare/daq/operations/common/runtime.py @@ -1,13 +1,8 @@ from dataclasses import dataclass -from typing import Callable, Protocol, TypeVar - -from aare.common.exception_handler import AareException +from typing import Protocol from aare.common.models import DAQStatusModel, SampleShortInfo from aare.common.sample_geometry import SampleGeometryModel -from aare.daq.config import BeamlineStateEnum - -from aare.daq.operations.screenshot.service import ScreenshotService class SampleProvider(Protocol): @@ -25,23 +20,7 @@ class StatusProvider(Protocol): def status(self) -> DAQStatusModel: ... -class StateSetter(Protocol): - def set_state(self, target: BeamlineStateEnum) -> None: ... - - -class SmargonTraceAppender(Protocol): - def append_smargon_trace(self, *, sample_id: int | None, event: str) -> None: ... - - -class PredictionGetter(Protocol): - def get_predictions(self): ... - - -class FaceDetectionProgressReporter(Protocol): - def emit_progress(self, payload: dict) -> None: ... - - -@dataclass +@dataclass(frozen=True) class DAQRuntimeState: sample_provider: SampleProvider sample_geometry_provider: SampleGeometryProvider @@ -57,41 +36,4 @@ class DAQRuntimeState: @property def status(self) -> DAQStatusModel: - return self.status_provider.status - - -@dataclass -class StateController: - setter: StateSetter - - def set_state(self, target: BeamlineStateEnum) -> None: - self.setter.set_state(target) - - -@dataclass -class TraceWriter: - appender: SmargonTraceAppender - - def append_smargon_trace(self, *, sample_id: int | None, event: str) -> None: - self.appender.append_smargon_trace(sample_id=sample_id, event=event) - - -@dataclass -class PredictionProvider: - getter: PredictionGetter - - def get_predictions(self): - return self.getter.get_predictions() - - -@dataclass -class FaceDetectionProgressEmitter: - reporter: FaceDetectionProgressReporter - - def emit_progress(self, payload: dict) -> None: - self.reporter.emit_progress(payload) - - -@dataclass -class OperationServices: - screenshots: ScreenshotService \ No newline at end of file + return self.status_provider.status \ No newline at end of file diff --git a/src/aare/daq/operations/common/services.py b/src/aare/daq/operations/common/services.py new file mode 100644 index 00000000..701e32e7 --- /dev/null +++ b/src/aare/daq/operations/common/services.py @@ -0,0 +1,118 @@ +from dataclasses import dataclass +from typing import Protocol + +from aare.daq.config import BeamlineStateEnum +from aare.daq.operations.screenshot.service import ScreenshotService + + +class StateSetter(Protocol): + def set_state(self, target: BeamlineStateEnum) -> None: ... + + +class SmargonTraceAppender(Protocol): + def append_smargon_trace(self, *, sample_id: int | None, event: str) -> None: ... + + +class PredictionGetter(Protocol): + def get_predictions(self): ... + + +class FaceDetectionProgressReporter(Protocol): + def emit_progress(self, payload: dict) -> None: ... + + +class SampleEventSender(Protocol): + def send_sample_event(self, sample_id: int, event_type, comment: str | None = None) -> None: ... + + +class ScanIngestor(Protocol): + def ingest_scan(self, *, sample, result, geom, beam_mark_pxl) -> None: ... + + def ingest_gridscan(self, *, sample, raster_result, raster_request, geom, com, beam_mark_pxl) -> None: ... + + +class DatacollectionSetupRunner(Protocol): + def prepare(self, request, screening: bool = False) -> None: ... + + +@dataclass(frozen=True) +class StateController: + setter: StateSetter + + def set_state(self, target: BeamlineStateEnum) -> None: + self.setter.set_state(target) + + +@dataclass(frozen=True) +class TraceWriter: + appender: SmargonTraceAppender + + def append_smargon_trace(self, *, sample_id: int | None, event: str) -> None: + self.appender.append_smargon_trace(sample_id=sample_id, event=event) + + +@dataclass(frozen=True) +class PredictionProvider: + getter: PredictionGetter + + def get_predictions(self): + return self.getter.get_predictions() + + +@dataclass(frozen=True) +class FaceDetectionProgressEmitter: + reporter: FaceDetectionProgressReporter + + def emit_progress(self, payload: dict) -> None: + self.reporter.emit_progress(payload) + + +@dataclass(frozen=True) +class SampleEventPublisher: + sender: SampleEventSender + + def send(self, sample_id: int, event_type, comment: str | None = None) -> None: + self.sender.send_sample_event(sample_id, event_type, comment) + + +@dataclass(frozen=True) +class ScanIngestionService: + ingestor: ScanIngestor + + def ingest_scan(self, *, sample, result, geom, beam_mark_pxl) -> None: + self.ingestor.ingest_scan( + sample=sample, + result=result, + geom=geom, + beam_mark_pxl=beam_mark_pxl, + ) + + def ingest_gridscan(self, *, sample, raster_result, raster_request, geom, com, beam_mark_pxl) -> None: + self.ingestor.ingest_gridscan( + sample=sample, + raster_result=raster_result, + raster_request=raster_request, + geom=geom, + com=com, + beam_mark_pxl=beam_mark_pxl, + ) + + +@dataclass(frozen=True) +class DataCollectionPreparer: + runner: DatacollectionSetupRunner + + def prepare(self, request, screening: bool = False) -> None: + self.runner.prepare(request, screening=screening) + + +@dataclass +class OperationServices: + screenshots: ScreenshotService + state: StateController | None = None + traces: TraceWriter | None = None + predictions: PredictionProvider | None = None + face_detection_progress: FaceDetectionProgressEmitter | None = None + events: SampleEventPublisher | None = None + ingestion: ScanIngestionService | None = None + datacollection: DataCollectionPreparer | None = None \ No newline at end of file diff --git a/src/aare/daq/operations/face_detection/models.py b/src/aare/daq/operations/face_detection/models.py index ec0cfbf2..a837c492 100644 --- a/src/aare/daq/operations/face_detection/models.py +++ b/src/aare/daq/operations/face_detection/models.py @@ -1,18 +1,29 @@ from dataclasses import dataclass -from aare.daq.config import BeamlineConfig -from aare.daq.devices import BeamlineDevices from aare.daq.mlbox import MlBox -from aare.daq.operations.common.runtime import DAQRuntimeState, FaceDetectionProgressEmitter +from aare.daq.operations.common.models import ( + BaseOperationContext, + BeamlineDependencies, +) @dataclass -class FaceDetectionContext: - cfg: BeamlineConfig - devs: BeamlineDevices +class FaceDetectionDependencies(BeamlineDependencies): mlbox: MlBox - runtime: DAQRuntimeState - progress_emitter: FaceDetectionProgressEmitter + + +@dataclass +class FaceDetectionSettings: + steps: int = 14 + step_size: int = 15 + face_min_ratio: float = 0.3 + + +@dataclass +class FaceDetectionContext( + BaseOperationContext[FaceDetectionDependencies, FaceDetectionSettings] +): + pass @dataclass diff --git a/src/aare/daq/operations/face_detection/service.py b/src/aare/daq/operations/face_detection/service.py index e85fcde3..dcc75d5b 100644 --- a/src/aare/daq/operations/face_detection/service.py +++ b/src/aare/daq/operations/face_detection/service.py @@ -1,9 +1,10 @@ import time -import aare.daq.operations.face_detection.utils as fd from aare.common.coordinate import Coordinate, SmargonCoordinate from aare.common.logger_events import log_duration, log_ml_bundle_meta from aare.common.models import MLBoxModel, ZoomModeEnum + +import aare.daq.operations.face_detection.utils as fd from aare.daq.mlbox import MLBoxPredictionResult from aare.daq.operations.face_detection.models import ( FaceDetectionContext, @@ -16,6 +17,12 @@ class FaceDetectionService: self.ctx = context self.logger = logger + def _progress_emitter(self): + emitter = self.ctx.services.face_detection_progress + if emitter is None: + raise RuntimeError("FaceDetectionService requires services.face_detection_progress") + return emitter + def _log_warning(self, message: str) -> None: warning = getattr(self.logger, "warning", None) if callable(warning): @@ -32,7 +39,7 @@ class FaceDetectionService: angle: int, boxes_face: dict[int, tuple[float, float, float, float]], ) -> None: - self.ctx.progress_emitter.emit_progress( + self._progress_emitter().emit_progress( { "running": True, "current_angle_deg": angle, @@ -49,7 +56,7 @@ class FaceDetectionService: "height_fit": {}, "area_fit": {}, } - self.ctx.progress_emitter.emit_progress(payload) + self._progress_emitter().emit_progress(payload) return payload def _centre_correction(self, model: MLBoxModel, tolerance: float = 0.2) -> None: @@ -65,28 +72,31 @@ class FaceDetectionService: if beam_y != 0 and abs(centre_y - beam_y) / abs(beam_y) > tolerance: coord = geom.picture_to_smargon(Coordinate(x=beam_x, y=centre_y)) - self.ctx.devs.smargon_pos = SmargonCoordinate(sh_mm=coord) - self.ctx.devs.smargon_wait(60) + self.ctx.deps.devs.smargon_pos = SmargonCoordinate(sh_mm=coord) + self.ctx.deps.devs.smargon_wait(60) def run( - self, - *, - steps: int = 14, - step_size: int = 15, - face_min_ratio: float = 0.3, + self, + *, + steps: int | None = None, + step_size: int | None = None, + face_min_ratio: float | None = None, ) -> FaceDetectionResult: + steps = self.ctx.settings.steps if steps is None else steps + step_size = self.ctx.settings.step_size if step_size is None else step_size + face_min_ratio = self.ctx.settings.face_min_ratio if face_min_ratio is None else face_min_ratio try: - self.ctx.cfg.zoom_mode = ZoomModeEnum.LoopCenter - self.ctx.devs.lamp_light = 2.5 + self.ctx.deps.cfg.zoom_mode = ZoomModeEnum.LoopCenter + self.ctx.deps.devs.lamp_light = 2.5 - zoom_value = self.ctx.devs.zoom + zoom_value = self.ctx.deps.devs.zoom self.logger.info("Starting face detection sequence") - self.ctx.devs.set_zoom(zoom_value, wait=True) + self.ctx.deps.devs.set_zoom(zoom_value, wait=True) boxes_face: dict[int, tuple[float, float, float, float]] = {} boxes_loop: dict[int, tuple[float, float, float, float]] = {} - curr_angle = int(self.ctx.devs.aerotech_omega) + curr_angle = int(self.ctx.deps.devs.aerotech_omega) total_range = steps * step_size + 1 start_angle = curr_angle if curr_angle + total_range < 720 else 0 end_angle = curr_angle + total_range @@ -94,7 +104,7 @@ class FaceDetectionService: for angle in range(start_angle, end_angle, step_size): self.logger.debug(f"moving to angle: {angle}") rotate_time = time.perf_counter() - self.ctx.devs.aerotech_omega = angle + self.ctx.deps.devs.aerotech_omega = angle log_duration( self.logger, "Completed Aerotech move during face detection", @@ -102,7 +112,7 @@ class FaceDetectionService: extra={"angle_deg": angle}, ) - prediction_result: MLBoxPredictionResult = self.ctx.mlbox.predict( + prediction_result: MLBoxPredictionResult = self.ctx.deps.mlbox.predict( preferred_class=(3, 0), return_image=True, return_bundle_meta=True, @@ -183,7 +193,7 @@ class FaceDetectionService: self.logger.debug(f"chosen fit: {best_name}") self.logger.info(f"best angle: {flat_face_angle}") - self.ctx.devs.aerotech_omega = flat_face_angle + self.ctx.deps.devs.aerotech_omega = flat_face_angle samples_out = fd.get_samples_out(boxes) self.logger.info(f"Face detection sequence complete") @@ -206,7 +216,7 @@ class FaceDetectionService: "best_angle_deg": best_fit_angle_area, }, } - self.ctx.progress_emitter.emit_progress(payload) + self._progress_emitter().emit_progress(payload) return FaceDetectionResult(success=True, payload=payload) except Exception as e: @@ -219,4 +229,4 @@ class FaceDetectionService: comment="Face detection sequence failed", ) finally: - self.ctx.cfg.zoom_mode = ZoomModeEnum.User \ No newline at end of file + self.ctx.deps.cfg.zoom_mode = ZoomModeEnum.User \ No newline at end of file diff --git a/src/aare/daq/operations/loop_centering/analyzer.py b/src/aare/daq/operations/loop_centering/analyzer.py index 50060bdc..66d6eada 100644 --- a/src/aare/daq/operations/loop_centering/analyzer.py +++ b/src/aare/daq/operations/loop_centering/analyzer.py @@ -104,7 +104,7 @@ class LoopCenteringAnalyzer: if box.cls == MLBoxType.PIN: pin = box - best_box = self.ctx.mlbox.get_preferred_class_box( + best_box = self.ctx.deps.mlbox.get_preferred_class_box( boxes, (MLBoxType.CRYSTAL, MLBoxType.LOOP_FACE, MLBoxType.LOOP_ALL, MLBoxType.PIN), ) @@ -124,7 +124,7 @@ class LoopCenteringAnalyzer: centre_y = y1 + (y2 - y1) / 2 centre_x = x1 elif pin: - position_dict = self.ctx.mlbox.check_box_relation(pin, best_box) + position_dict = self.ctx.deps.mlbox.check_box_relation(pin, best_box) if position_dict["overlap_y"] and position_dict["overlap_x"]: centre_y = y1 + (y2 - y1) / 2 centre_x = x1 @@ -157,7 +157,10 @@ class LoopCenteringAnalyzer: zoom_value: float, sample_id: int | None, ) -> AngleAnalysis: - prediction_result = self.ctx.prediction_provider.get_predictions() + if self.ctx.services.predictions is None: + raise RuntimeError("LoopCenteringAnalyzer requires services.predictions") + + prediction_result = self.ctx.services.predictions.get_predictions() log_ml_bundle_meta( self.logger, f"loop_center_angle_{angle_deg}_zoom_{zoom_value:.0f}", diff --git a/src/aare/daq/operations/loop_centering/models.py b/src/aare/daq/operations/loop_centering/models.py index 4313b81a..dc2d8f51 100644 --- a/src/aare/daq/operations/loop_centering/models.py +++ b/src/aare/daq/operations/loop_centering/models.py @@ -1,17 +1,18 @@ from dataclasses import dataclass, field from aare.common.coordinate import SmargonCoordinate -from aare.daq.config import BeamlineConfig -from aare.daq.devices import BeamlineDevices from aare.daq.mlbox import MlBox -from aare.daq.operations.common.runtime import ( - DAQRuntimeState, - OperationServices, - PredictionProvider, - TraceWriter, +from aare.daq.operations.common.models import ( + BaseOperationContext, + BeamlineDependencies, ) +@dataclass +class LoopCenteringDependencies(BeamlineDependencies): + mlbox: MlBox + + @dataclass class AngleAnalysis: angle_deg: int @@ -43,12 +44,7 @@ class LoopCenteringSettings: @dataclass -class LoopCenteringContext: - cfg: BeamlineConfig - devs: BeamlineDevices - mlbox: MlBox - settings: LoopCenteringSettings - runtime: DAQRuntimeState - services: OperationServices - trace_writer: TraceWriter - prediction_provider: PredictionProvider \ No newline at end of file +class LoopCenteringContext( + BaseOperationContext[LoopCenteringDependencies, LoopCenteringSettings] +): + pass \ No newline at end of file diff --git a/src/aare/daq/operations/loop_centering/service.py b/src/aare/daq/operations/loop_centering/service.py index 0fe50966..c4d0f3a7 100644 --- a/src/aare/daq/operations/loop_centering/service.py +++ b/src/aare/daq/operations/loop_centering/service.py @@ -31,7 +31,7 @@ class LoopCenteringService: ) -> AngleAnalysis: self.logger.debug(f"Moving to new omega: {angle}") time_to_move_aerotech = time.perf_counter() - self.ctx.devs.aerotech_omega = angle + self.ctx.deps.devs.aerotech_omega = angle log_duration( self.logger, "Completed Aerotech move during loop centering", @@ -47,8 +47,8 @@ class LoopCenteringService: if analysis.has_valid_target and not analysis.ignore_only and analysis.final_target is not None: time_to_move_smargon = time.perf_counter() - self.ctx.devs.smargon_pos = analysis.final_target - self.ctx.devs.smargon_wait(60) + self.ctx.deps.devs.smargon_pos = analysis.final_target + self.ctx.deps.devs.smargon_wait(60) log_duration( self.logger, "Completed Smargon move during loop centering", @@ -58,7 +58,7 @@ class LoopCenteringService: analysis.moved = True if sample_id is not None and trace_all_alc_moves: - self.ctx.trace_writer.append_smargon_trace( + self.ctx.services.traces.append_smargon_trace( sample_id=sample_id, event=f"alc_move_zoom_{zoom_value:.0f}_angle_{angle}", ) @@ -96,11 +96,11 @@ class LoopCenteringService: try: zoom_value = settings.zoom_value - if abs(self.ctx.devs.zoom - zoom_value) > 1e-6: - self.ctx.devs.samcam_auto(AutoEnum.AUTO) - self.ctx.devs.zoom = zoom_value + if abs(self.ctx.deps.devs.zoom - zoom_value) > 1e-6: + self.ctx.deps.devs.samcam_auto(AutoEnum.AUTO) + self.ctx.deps.devs.zoom = zoom_value time.sleep(0.2) - self.ctx.devs.samcam_auto(AutoEnum.ONCE) + self.ctx.deps.devs.samcam_auto(AutoEnum.ONCE) if sample_id is not None: self.logger.info( @@ -158,7 +158,7 @@ class LoopCenteringService: if valid_seen_correction: if sample_id is not None: self.logger.info(f"sample {sample_id} centered") - self.ctx.trace_writer.append_smargon_trace(sample_id=sample_id, event="alc_success") + self.ctx.services.traces.append_smargon_trace(sample_id=sample_id, event="alc_success") return LoopCenteringResult(success=True) failure_reason = f"ALC exceeded max attempts ({max_attempts})" diff --git a/src/aare/daq/operations/mounting/models.py b/src/aare/daq/operations/mounting/models.py index a29783df..18fc8ad9 100644 --- a/src/aare/daq/operations/mounting/models.py +++ b/src/aare/daq/operations/mounting/models.py @@ -2,15 +2,23 @@ from dataclasses import dataclass from aare.common.coordinate import AerotechCoordinate from aare.common.models import SampleShortInfo -from aare.daq.config import BeamlineConfig -from aare.daq.devices import BeamlineDevices +from aare.daq.operations.common.models import BeamlineDependencies + + +@dataclass +class MountingDependencies(BeamlineDependencies): + pass + + +@dataclass +class MountingSettings: + mount_position: AerotechCoordinate @dataclass class MountingContext: - cfg: BeamlineConfig - devs: BeamlineDevices - mount_position: AerotechCoordinate + deps: MountingDependencies + settings: MountingSettings @dataclass diff --git a/src/aare/daq/operations/mounting/service.py b/src/aare/daq/operations/mounting/service.py index fa33d6cc..3dae5552 100644 --- a/src/aare/daq/operations/mounting/service.py +++ b/src/aare/daq/operations/mounting/service.py @@ -20,22 +20,22 @@ class MountingService: self.logger = logger def reset_mount_failure_counter(self, reason: str) -> None: - previous_count = self.ctx.cfg.get_mount_failure_streak() + previous_count = self.ctx.deps.cfg.get_mount_failure_streak() if previous_count > 0: self.logger.warning( f"Resetting mount failure counter from {previous_count} due to: {reason}" ) else: self.logger.debug(f"Mount failure counter already clear: {reason}") - self.ctx.cfg.reset_mount_failure_streak() + self.ctx.deps.cfg.reset_mount_failure_streak() def _magnet_position_sensor_check(self, timeout: float = 1.0) -> None: - if self.ctx.devs.magnet_position_sensor.value != 0: + if self.ctx.deps.devs.magnet_position_sensor.value != 0: self.logger.warning( "Goniometer is not in position based on magnet position sensor readout" ) for _ in range(round(timeout * 10.0)): - if self.ctx.devs.magnet_position_sensor.value == 0: + if self.ctx.deps.devs.magnet_position_sensor.value == 0: return time.sleep(0.1) @@ -45,7 +45,7 @@ class MountingService: raise Exception("Goniometer is not in position based on magnet position sensor readout") def _handle_consecutive_mount_failure(self) -> None: - count = self.ctx.cfg.increment_mount_failure_streak() + count = self.ctx.deps.cfg.increment_mount_failure_streak() self.logger.warning(f"Consecutive mount failure count is now {count}") if count == DRY_AFTER_FAIL_COUNT: @@ -72,7 +72,7 @@ class MountingService: def _mount_handler(self, target) -> None: self._prepare_mount_hardware() - value = self.ctx.devs.tell.mount( + value = self.ctx.deps.devs.tell.mount( address=target.tell_address(), force=True, auto_unmount=True, @@ -82,8 +82,8 @@ class MountingService: ) if isinstance(value, str): - self.ctx.devs.tell.check_command_ok() - self.logger.error(f"{self.ctx.devs.tell.get_result(self.ctx.devs.tell._last_cmd_id)}") + self.ctx.deps.devs.tell.check_command_ok() + self.logger.error(f"{self.ctx.deps.devs.tell.get_result(self.ctx.deps.devs.tell._last_cmd_id)}") self.logger.error(f"Unexpected string response from Tell mount: {value}") raise CriticalTellException(f"Critical error in TELL mount: unexpected response '{value}'") @@ -115,38 +115,38 @@ class MountingService: raise CriticalTellException(f"Critical error in TELL mount: {message}") def _prepare_mount_hardware(self) -> None: - self.ctx.devs.smargon_move_home() - self.ctx.devs.aerotech_pos = self.ctx.mount_position + self.ctx.deps.devs.smargon_move_home() + self.ctx.deps.devs.aerotech_pos = self.ctx.settings.mount_position self._magnet_position_sensor_check(timeout=360.0) - self.ctx.devs.tell.check_enable_motion() - self.ctx.devs.tell.wait_not_busy() - self.ctx.devs.tell.set_in_mount_position(True) + self.ctx.deps.devs.tell.check_enable_motion() + self.ctx.deps.devs.tell.wait_not_busy() + self.ctx.deps.devs.tell.set_in_mount_position(True) def _unmount_current_sample(self, timeout: float = 60.0): self._prepare_mount_hardware() - previous_sample = self.ctx.cfg.current_sample + previous_sample = self.ctx.deps.cfg.current_sample if previous_sample is not None: self.logger.debug(f"Unmounting sample: {previous_sample}") - self.ctx.devs.tell.unmount(wait=True, timeout=timeout) - self.ctx.cfg.current_sample = None + self.ctx.deps.devs.tell.unmount(wait=True, timeout=timeout) + self.ctx.deps.cfg.current_sample = None return previous_sample def dry(self, *, park: bool, unmount: bool = False) -> None: - self.ctx.devs.tell.check_enable_motion() - self.ctx.devs.tell.wait_not_busy() - self.ctx.devs.tell.set_in_mount_position(True) + self.ctx.deps.devs.tell.check_enable_motion() + self.ctx.deps.devs.tell.wait_not_busy() + self.ctx.deps.devs.tell.set_in_mount_position(True) - if self.ctx.cfg.current_sample and unmount: + if self.ctx.deps.cfg.current_sample and unmount: self._prepare_mount_hardware() self._unmount_current_sample(timeout=60.0) if park: - self.ctx.devs.tell.dry(wait_cold=-1, wait=True) + self.ctx.deps.devs.tell.dry(wait_cold=-1, wait=True) else: - self.ctx.devs.tell.dry(wait=True) + self.ctx.deps.devs.tell.dry(wait=True) def execute(self, *, target) -> MountingResult: - previous_sample = self.ctx.cfg.current_sample + previous_sample = self.ctx.deps.cfg.current_sample try: if target is None: @@ -173,8 +173,8 @@ class MountingService: raise self.logger.debug("Mounting succeeded, resetting mount failure counter") - self.ctx.cfg.reset_mount_failure_streak() - self.ctx.cfg.current_sample = target + self.ctx.deps.cfg.reset_mount_failure_streak() + self.ctx.deps.cfg.current_sample = target return MountingResult( success=True, diff --git a/src/aare/daq/operations/raster/models.py b/src/aare/daq/operations/raster/models.py index 6aa9488e..389c26e2 100644 --- a/src/aare/daq/operations/raster/models.py +++ b/src/aare/daq/operations/raster/models.py @@ -2,46 +2,35 @@ from dataclasses import dataclass from typing import TYPE_CHECKING from aare.common.raster_grid import RasterGridRequest -from aare.daq.operations.common.runtime import ( - DAQRuntimeState, - OperationServices, - StateController, +from aare.daq.mlbox import MlBox +from aare.daq.operations.common.models import ( + BaseOperationContext, + BeamlineDependencies, ) if TYPE_CHECKING: from aare.daq.aaredb import AareWrapper - from aare.daq.config import BeamlineConfig - from aare.daq.devices import BeamlineDevices - from aare.daq.mlbox import MlBox from aare.devices.jfjoch import JFJochWrapper @dataclass -class RasterContext: - cfg: "BeamlineConfig" - devs: "BeamlineDevices" - mlbox: "MlBox" +class RasterDependencies(BeamlineDependencies): + mlbox: MlBox jfjoch: "JFJochWrapper" aare: "AareWrapper" - runtime: DAQRuntimeState - state_controller: StateController - services: OperationServices + +@dataclass +class RasterSettings: auto_raster_max_images: int auto_raster_min_cell_size_mm: float auto_raster_skip_if_exceed_max_image_threshold: bool + wait_for_screenshot_s: float = 0.3 - @property - def sample(self): - return self.runtime.sample - @property - def sample_geometry(self): - return self.runtime.sample_geometry - - @property - def status(self): - return self.runtime.status +@dataclass +class RasterContext(BaseOperationContext[RasterDependencies, RasterSettings]): + pass @dataclass diff --git a/src/aare/daq/operations/raster/service.py b/src/aare/daq/operations/raster/service.py index 3e83a929..3573f926 100644 --- a/src/aare/daq/operations/raster/service.py +++ b/src/aare/daq/operations/raster/service.py @@ -85,7 +85,7 @@ class RasterService: return try: - diffraction_image = self.ctx.jfjoch.get_diffraction_image(image_id) + diffraction_image = self.ctx.deps.jfjoch.get_diffraction_image(image_id) except NotFoundException: self.logger.warning( "JFJoch diffraction preview image was not found after raster; continuing without upload", @@ -101,7 +101,7 @@ class RasterService: ) return - self.ctx.aare.upload_jpg(sample_id, filename, diffraction_image) + self.ctx.deps.aare.upload_jpg(sample_id, filename, diffraction_image) def ml_bounding_box( self, @@ -109,15 +109,15 @@ class RasterService: filename: str | None = None, ) -> RasterGridRequest | None: return get_ml_bounding_box( - mlbox=self.ctx.mlbox, + mlbox=self.ctx.deps.mlbox, sample=self.ctx.sample, sample_geometry=self.ctx.sample_geometry, - upload_image=self.ctx.aare.upload_image, + upload_image=self.ctx.deps.aare.upload_image, logger=self.logger, filename=filename, - max_images=self.ctx.auto_raster_max_images, - min_cell_size_mm=self.ctx.auto_raster_min_cell_size_mm, - skip_if_exceed_max_image_threshold=self.ctx.auto_raster_skip_if_exceed_max_image_threshold, + max_images=self.ctx.settings.auto_raster_max_images, + min_cell_size_mm=self.ctx.settings.auto_raster_min_cell_size_mm, + skip_if_exceed_max_image_threshold=self.ctx.settings.auto_raster_skip_if_exceed_max_image_threshold, ) def auto_center_line_scan_top_left( @@ -137,7 +137,7 @@ class RasterService: line_scan_centre = geom.picture_to_smargon(Coordinate(x=beam_x_pxl, y=beam_y_pxl)) n_y = default_n_y - prediction_result: MLBoxPredictionResult = self.ctx.mlbox.predict( + prediction_result: MLBoxPredictionResult = self.ctx.deps.mlbox.predict( preferred_class=(3, 0), return_image=False, return_bundle_meta=True, @@ -275,17 +275,20 @@ class RasterService: def execute( self, request: RasterGridRequest, - wait_for_screenshot: float | None = 0.3, + wait_for_screenshot: float | None = None, ) -> CompletedRasterGridElem: total_time = request.exp_time_s * request.n_x * request.n_y + request.n_y * 0.3 + if wait_for_screenshot is None: + wait_for_screenshot = self.ctx.settings.wait_for_screenshot_s + if total_time > 1200: raise RasterScanException(f"Raster scan is too long {total_time}s > 20min") status = self.ctx.status smargon_top_left = request.smargon_top_left - self.ctx.devs.set_smargon_pos( + self.ctx.deps.devs.set_smargon_pos( SmargonCoordinate( sh_mm=smargon_top_left.sh_mm, phi_deg=smargon_top_left.phi_deg, @@ -304,17 +307,17 @@ class RasterService: try: if self.ctx.sample is not None and self.ctx.sample.db_id is not None: - self.ctx.aare.create_gridscan_run(self.ctx.sample, request, status) + self.ctx.deps.aare.create_gridscan_run(self.ctx.sample, request, status) - if not self.ctx.cfg.simulated_detector: - self.ctx.jfjoch.wait_till_running(timeout=60.0) + if not self.ctx.deps.cfg.simulated_detector: + self.ctx.deps.jfjoch.wait_till_running(timeout=60.0) else: self.logger.info("Simulated detector mode enabled; faking jfjoch intilalisation.") self.logger.debug( f"Starting grid scan with {request.n_x}x{request.n_y} points, exp time {request.exp_time_s}s" ) - self.ctx.devs.aerotech.grid_scan( + self.ctx.deps.devs.aerotech.grid_scan( grid_elem_size_y_um=request.grid_size_mm.y * 1000, grid_elem_size_x_um=request.grid_size_mm.x * 1000, grid_elem_count_x=request.n_x, @@ -323,24 +326,24 @@ class RasterService: run_async=True, ) - self.ctx.devs.aerotech.wait_till_done( + self.ctx.deps.devs.aerotech.wait_till_done( timeout=int(round(total_time + total_time * 0.1 + 60, 0)) ) - if isinstance(self.ctx.cfg.abr_meas_pos, Coordinate): - coord = self.ctx.cfg.abr_meas_pos + if isinstance(self.ctx.deps.cfg.abr_meas_pos, Coordinate): + coord = self.ctx.deps.cfg.abr_meas_pos else: - coord = self.ctx.cfg.abr_meas_pos.at_mm - self.ctx.devs.aerotech_pos = AerotechCoordinate( + coord = self.ctx.deps.cfg.abr_meas_pos.at_mm + self.ctx.deps.devs.aerotech_pos = AerotechCoordinate( at_mm=coord, - omega_deg=self.ctx.devs.aerotech_omega, + omega_deg=self.ctx.deps.devs.aerotech_omega, ) - self.ctx.devs.aerotech.wait_till_done(timeout=60) + self.ctx.deps.devs.aerotech.wait_till_done(timeout=60) x = None y = None - if self.ctx.cfg.simulated_detector: + if self.ctx.deps.cfg.simulated_detector: self.logger.info("Simulated detector mode enabled; using fake raster result.") scan_result = generate_no_beam_scan_result(request) result_array = create_quality_filtered_array( @@ -361,7 +364,7 @@ class RasterService: chi_deg=request.smargon_top_left.chi_deg, ) else: - scan_result = self.ctx.jfjoch.wait_till_done(60) + scan_result = self.ctx.deps.jfjoch.wait_till_done(60) if scan_result is None: self.logger.error( "JFJoch returned no ScanResult for raster", @@ -439,8 +442,8 @@ class RasterService: chi_deg=request.smargon_top_left.chi_deg, ) - self.ctx.devs.smargon_pos = target_smargon - self.ctx.devs.smargon_wait(timeout=180) + self.ctx.deps.devs.smargon_pos = target_smargon + self.ctx.deps.devs.smargon_wait(timeout=180) self.logger.info( "Moved Smargon to raster centre", @@ -460,7 +463,10 @@ class RasterService: sample_id = self.ctx.sample.db_id if self.ctx.sample and self.ctx.sample.db_id is not None else None if sample_id: self.logger.debug("moving to XtalSnapshot to take a screenshot of the sample") - self.ctx.state_controller.set_state(BeamlineStateEnum.XtalSnapshot) + if self.ctx.services.state is None: + raise RuntimeError("RasterService requires services.state") + self.ctx.services.state.set_state(BeamlineStateEnum.XtalSnapshot) + if wait_for_screenshot and wait_for_screenshot > 0: time.sleep(wait_for_screenshot) @@ -469,14 +475,24 @@ class RasterService: f"{sample_id}_post_raster_{int(request.omega_deg)}deg", ) - self.ctx.aare.ingest_gridscan( - sample=self.ctx.sample, - raster_result=scan_result, - raster_request=request, - geom=self.ctx.sample_geometry, - com=None, - beam_mark_pxl=self.ctx.cfg.get_beam_mark(self.ctx.devs.zoom), - ) + if self.ctx.services.ingestion is not None: + self.ctx.services.ingestion.ingest_gridscan( + sample=self.ctx.sample, + raster_result=scan_result, + raster_request=request, + geom=self.ctx.sample_geometry, + com=None, + beam_mark_pxl=self.ctx.deps.cfg.get_beam_mark(self.ctx.deps.devs.zoom), + ) + else: + self.ctx.deps.aare.ingest_gridscan( + sample=self.ctx.sample, + raster_result=scan_result, + raster_request=request, + geom=self.ctx.sample_geometry, + com=None, + beam_mark_pxl=self.ctx.deps.cfg.get_beam_mark(self.ctx.deps.devs.zoom), + ) if com is not None and com.max_image is not None: diffraction_image_filename = ( @@ -498,11 +514,19 @@ class RasterService: scan_result=scan_result, request=request, ) - self.ctx.aare.send_sample_event( - self.ctx.sample.db_id, - event_type=SampleEventType.RASTERED, - comment=f"Raster completed at {request.omega_deg:.1f} deg", - ) + + if self.ctx.services.events is not None: + self.ctx.services.events.send( + self.ctx.sample.db_id, + SampleEventType.RASTERED, + comment=f"Raster completed at {request.omega_deg:.1f} deg", + ) + else: + self.ctx.deps.aare.send_sample_event( + self.ctx.sample.db_id, + event_type=SampleEventType.RASTERED, + comment=f"Raster completed at {request.omega_deg:.1f} deg", + ) self.logger.info( "Raster finished", @@ -556,11 +580,18 @@ class RasterService: ), ) - self.ctx.aare.send_sample_event( - sample.db_id, - SampleEventType.RASTERING, - comment=f"Raster at {geom.omega_deg:.1f} deg", - ) + if self.ctx.services.events is not None: + self.ctx.services.events.send( + sample.db_id, + SampleEventType.RASTERING, + comment=f"Raster at {geom.omega_deg:.1f} deg", + ) + else: + self.ctx.deps.aare.send_sample_event( + sample.db_id, + SampleEventType.RASTERING, + comment=f"Raster at {geom.omega_deg:.1f} deg", + ) r = self.ml_bounding_box(sample.db_id, f"ml_{geom.omega_deg:.2f}deg") @@ -575,7 +606,7 @@ class RasterService: }, ), ) - self.ctx.devs.aerotech_omega = geom.omega_deg + 90.0 + self.ctx.deps.devs.aerotech_omega = geom.omega_deg + 90.0 time.sleep(0.2) r = self.ml_bounding_box(sample.db_id, f"ml_{geom.omega_deg + 90.0:.2f}deg") @@ -620,14 +651,16 @@ class RasterService: grid.omega_deg = geom.omega_deg status = self.ctx.status - if not self.ctx.cfg.simulated_detector: + if not self.ctx.deps.cfg.simulated_detector: self.logger.info("initialise detector") - self.ctx.jfjoch.measure_raster(grid, status) + self.ctx.deps.jfjoch.measure_raster(grid, status) self.logger.info("detector initialised") else: self.logger.info("Simulated detector mode enabled; using fake raster result.") - self.ctx.state_controller.set_state(BeamlineStateEnum.DataCollection) + if self.ctx.services.state is None: + raise RuntimeError("RasterService requires services.state") + self.ctx.services.state.set_state(BeamlineStateEnum.DataCollection) self.logger.info( "Running first auto-center raster", @@ -644,12 +677,19 @@ class RasterService: res1 = self.execute(grid) grid.omega_deg += 90 - self.ctx.aare.send_sample_event( - self.ctx.sample.db_id, - SampleEventType.RASTERING, - comment=f"Raster at {geom.omega_deg:.1f} deg", - ) - self.ctx.devs.aerotech_omega = grid.omega_deg + if self.ctx.services.events is not None: + self.ctx.services.events.send( + self.ctx.sample.db_id, + SampleEventType.RASTERING, + comment=f"Raster at {geom.omega_deg:.1f} deg", + ) + else: + self.ctx.deps.aare.send_sample_event( + self.ctx.sample.db_id, + SampleEventType.RASTERING, + comment=f"Raster at {geom.omega_deg:.1f} deg", + ) + self.ctx.deps.devs.aerotech_omega = grid.omega_deg grid.n_x = 1 grid.file_prefix = f"{old_prefix}_{grid.omega_deg}deg" @@ -681,14 +721,16 @@ class RasterService: ) status = self.ctx.status - if not self.ctx.cfg.simulated_detector: + if not self.ctx.deps.cfg.simulated_detector: self.logger.info(f"initialise detector for raster at {grid.omega_deg}") - self.ctx.jfjoch.measure_raster(grid, status) + self.ctx.deps.jfjoch.measure_raster(grid, status) self.logger.info("detector initialised") else: self.logger.info("Simulated detector mode enabled; using fake raster result.") - self.ctx.state_controller.set_state(BeamlineStateEnum.DataCollection) + if self.ctx.services.state is None: + raise RuntimeError("RasterService requires services.state") + self.ctx.services.state.set_state(BeamlineStateEnum.DataCollection) self.logger.info( "Running second auto-center raster", diff --git a/src/aare/daq/operations/rotation/models.py b/src/aare/daq/operations/rotation/models.py index e69de29b..42f4bc6b 100644 --- a/src/aare/daq/operations/rotation/models.py +++ b/src/aare/daq/operations/rotation/models.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from aare.daq.operations.common.models import ( + BaseOperationContext, + BeamlineDependencies, +) + +if TYPE_CHECKING: + from aare.daq.aaredb import AareWrapper + from aare.devices.jfjoch import JFJochWrapper + + +@dataclass +class RotationDependencies(BeamlineDependencies): + jfjoch: "JFJochWrapper" + aare: "AareWrapper" + + +@dataclass +class RotationSettings: + preview_filename: str = "scan_preview" + + +@dataclass +class RotationContext(BaseOperationContext[RotationDependencies, RotationSettings]): + pass \ No newline at end of file diff --git a/src/aare/daq/operations/rotation/service.py b/src/aare/daq/operations/rotation/service.py index e69de29b..64f540ed 100644 --- a/src/aare/daq/operations/rotation/service.py +++ b/src/aare/daq/operations/rotation/service.py @@ -0,0 +1,119 @@ +import copy +import time + +from aareDB import SampleEventType + +from aare.common.coordinate import SmargonCoordinate +from aare.common.models import BeamlineStateEnum +from aare.common.rotation_scan import CompletedRotationScan, RotationScanRequest +from aare.daq.operations.common.simulate_scan_result import build_fake_rotation_result +from aare.daq.operations.rotation.models import RotationContext + + +class RotationService: + def __init__(self, *, context: RotationContext, logger): + self.ctx = context + self.logger = logger + + def _prepare_detector(self, request: RotationScanRequest) -> None: + if self.ctx.deps.cfg.simulated_detector: + self.logger.info("Simulated detector mode enabled; skipping JFJoch start.") + return + + self.ctx.deps.jfjoch.measure_rotation( + request, + self.ctx.status, + self.ctx.deps.cfg.xrf, + ) + + def _execute_scan(self, request: RotationScanRequest) -> CompletedRotationScan: + omega_start = self.ctx.deps.devs.aerotech_omega + + if request.exp_time_s < 0.004: + self.logger.warning("Exposure time too short for PXII rotation scan") + request.exp_time_s = 0.004 + + total_time = request.exp_time_s * request.steps + sample = self.ctx.sample + + if sample is not None and sample.db_id is not None: + self.ctx.deps.aare.create_rotation_run(sample, request, self.ctx.status) + + if not self.ctx.deps.cfg.simulated_detector: + self.ctx.deps.jfjoch.wait_till_running(timeout=60.0) + + if request.screening: + self.ctx.deps.devs.aerotech.screening_scan( + rotation_deg=request.steps * request.incr_omega_deg, + wedge_deg=request.wedge_omega_deg, + time_sec=total_time, + steps=request.steps, + run_async=True, + ) + else: + self.ctx.deps.devs.aerotech.rotation_scan( + rotation_deg=request.steps * request.incr_omega_deg, + time_sec=total_time, + start_pos_deg=request.start_omega_deg, + run_async=True, + ) + + if request.start is not None and request.end is not None: + smargon_time_step = request.exp_time_s / float(request.steps) + pos_step = (request.end.sh_mm - request.start.sh_mm) * (1.0 / float(request.steps)) + + for i in range(request.steps): + self.ctx.deps.devs.smargon.target = SmargonCoordinate( + sh_mm=request.start.sh_mm + pos_step * i + ) + time.sleep(smargon_time_step) + + self.ctx.deps.devs.aerotech.wait_till_done(timeout=int(round(total_time + 60, 0))) + self.ctx.deps.devs.aerotech_omega = omega_start + + if self.ctx.deps.cfg.simulated_detector: + self.logger.warning("Detector in simulation mode, returning fake zero rotation result.") + return build_fake_rotation_result(request, start_angle=float(omega_start)) + + scan_result = self.ctx.deps.jfjoch.wait_till_done(60) + return CompletedRotationScan( + request=copy.deepcopy(request), + result=scan_result, + ) + + def run(self, request: RotationScanRequest) -> CompletedRotationScan: + sample = self.ctx.sample + + self._prepare_detector(request) + self.ctx.services.datacollection.prepare(request) + + if sample is not None and sample.db_id is not None and self.ctx.services.events is not None: + self.ctx.services.events.send(sample.db_id, SampleEventType.COLLECTING) + + if self.ctx.services.state is not None: + self.ctx.services.state.set_state(BeamlineStateEnum.DataCollection) + + result = self._execute_scan(request) + + if self.ctx.services.state is not None: + self.ctx.services.state.set_state(BeamlineStateEnum.SampleAlignment) + + if sample is not None and sample.db_id is not None: + if self.ctx.services.screenshots is not None: + self.ctx.services.screenshots.save_to_db( + sample.db_id, + self.ctx.settings.preview_filename, + ) + + if self.ctx.services.events is not None: + self.ctx.services.events.send(sample.db_id, SampleEventType.COLLECTED) + + if self.ctx.services.ingestion is not None: + self.ctx.services.ingestion.ingest_scan( + sample=sample, + result=result.result, + geom=self.ctx.sample_geometry, + beam_mark_pxl=self.ctx.deps.cfg.get_beam_mark(self.ctx.deps.devs.zoom), + ) + + return result \ No newline at end of file diff --git a/tests/unit/daq/operations/face_detection/test_face_detection_service.py b/tests/unit/daq/operations/face_detection/test_face_detection_service.py index 8b3babca..b06c012c 100644 --- a/tests/unit/daq/operations/face_detection/test_face_detection_service.py +++ b/tests/unit/daq/operations/face_detection/test_face_detection_service.py @@ -4,17 +4,18 @@ import pytest from aare.common.coordinate import Coordinate, SmargonCoordinate from aare.common.models import BoundingBoxModel, MLBoxModel, MLBoxType, ZoomModeEnum -from aare.daq.operations.common import runtime from aare.daq.operations.face_detection.models import ( FaceDetectionContext, + FaceDetectionDependencies, FaceDetectionResult, + FaceDetectionSettings, ) from aare.daq.operations.face_detection.service import FaceDetectionService -from aare.daq.operations.common.runtime import ( - DAQRuntimeState, - SampleProvider, - StatusProvider, - FaceDetectionProgressEmitter) +from aare.daq.operations.common.runtime import DAQRuntimeState +from aare.daq.operations.common.services import ( + FaceDetectionProgressEmitter, + OperationServices, +) class DummyGeometry: def __init__(self): @@ -61,22 +62,30 @@ def context(): status_provider=types.SimpleNamespace(status=None), ) - progress_emitter = FaceDetectionProgressEmitter( - reporter=types.SimpleNamespace(emit_progress=emit_progress) + services = OperationServices( + screenshots=types.SimpleNamespace( + save_to_db=lambda *args, **kwargs: None + ), + face_detection_progress=FaceDetectionProgressEmitter( + reporter=types.SimpleNamespace(emit_progress=emit_progress) + ), ) ctx = FaceDetectionContext( - cfg=cfg, - devs=devs, - mlbox=types.SimpleNamespace( - predict=lambda **kwargs: types.SimpleNamespace( - box=None, - target_point=None, - focus=None, + deps=FaceDetectionDependencies( + cfg=cfg, + devs=devs, + mlbox=types.SimpleNamespace( + predict=lambda **kwargs: types.SimpleNamespace( + box=None, + target_point=None, + focus=None, + ), ), ), runtime=runtime_state, - progress_emitter=progress_emitter + services=services, + settings=FaceDetectionSettings(), ) ctx._progress_events = progress_events return ctx @@ -86,7 +95,7 @@ def test_service_returns_empty_payload_when_no_boxes(monkeypatch, context, mock_ service = FaceDetectionService(context=context, logger=mock_logger) monkeypatch.setattr( - context.mlbox, + context.deps.mlbox, "predict", lambda **kwargs: types.SimpleNamespace( box=None, @@ -103,7 +112,7 @@ def test_service_returns_empty_payload_when_no_boxes(monkeypatch, context, mock_ assert result.payload["samples"] == [] assert result.payload["height_fit"] == {} assert result.payload["area_fit"] == {} - assert context.cfg.zoom_mode == ZoomModeEnum.User + assert context.deps.cfg.zoom_mode == ZoomModeEnum.User assert len(context._progress_events) >= 1 assert context._progress_events[-1]["running"] is False @@ -120,7 +129,7 @@ def test_service_prefers_face_boxes_when_ratio_is_high(monkeypatch, context, moc ] ) - monkeypatch.setattr(context.mlbox, "predict", lambda **kwargs: next(predictions)) + monkeypatch.setattr(context.deps.mlbox, "predict", lambda **kwargs: next(predictions)) monkeypatch.setattr( "aare.daq.operations.face_detection.service.fd.get_flat_face", @@ -144,7 +153,7 @@ def test_service_prefers_face_boxes_when_ratio_is_high(monkeypatch, context, moc assert result.payload["running"] is False assert result.payload["height_fit"]["best_angle_deg"] == 50 assert result.payload["area_fit"]["best_angle_deg"] == 45 - assert context.devs.aerotech_omega == 47 + assert context.deps.devs.aerotech_omega == 47 assert [sample["angle"] for sample in result.payload["samples"]] == [0, 30, 90] assert context._progress_events[-1]["running"] is False @@ -162,7 +171,7 @@ def test_service_falls_back_to_loop_all_when_face_ratio_is_low(monkeypatch, cont ] ) - monkeypatch.setattr(context.mlbox, "predict", lambda **kwargs: next(predictions)) + monkeypatch.setattr(context.deps.mlbox, "predict", lambda **kwargs: next(predictions)) captured = {} @@ -185,7 +194,7 @@ def test_service_falls_back_to_loop_all_when_face_ratio_is_low(monkeypatch, cont assert result.success is True assert len(captured["boxes_used"]) == 4 assert sorted(captured["boxes_used"].keys()) == [30, 60, 90, 120] - assert context.devs.aerotech_omega == 60 + assert context.deps.devs.aerotech_omega == 60 assert context._progress_events[-1]["running"] is False @@ -196,16 +205,16 @@ def test_service_applies_centre_correction_when_target_is_far_from_beam(monkeypa service._centre_correction(model, tolerance=0.2) - assert isinstance(context.devs.smargon_pos, SmargonCoordinate) - assert context.devs.smargon_pos.sh_mm.x == pytest.approx(1.0) - assert context.devs.smargon_pos.sh_mm.y == pytest.approx(1.8) + assert isinstance(context.deps.devs.smargon_pos, SmargonCoordinate) + assert context.deps.devs.smargon_pos.sh_mm.x == pytest.approx(1.0) + assert context.deps.devs.smargon_pos.sh_mm.y == pytest.approx(1.8) def test_service_returns_failed_result_when_prediction_raises(monkeypatch, context, mock_logger): service = FaceDetectionService(context=context, logger=mock_logger) monkeypatch.setattr( - context.mlbox, + context.deps.mlbox, "predict", lambda **kwargs: (_ for _ in ()).throw(RuntimeError("prediction failed")), ) @@ -216,5 +225,5 @@ def test_service_returns_failed_result_when_prediction_raises(monkeypatch, conte assert isinstance(result.error, RuntimeError) assert result.comment == "Face detection sequence failed" assert result.payload["running"] is False - assert context.cfg.zoom_mode == ZoomModeEnum.User + assert context.deps.cfg.zoom_mode == ZoomModeEnum.User assert context._progress_events[-1]["running"] is False \ No newline at end of file diff --git a/tests/unit/daq/operations/loop_centering/test_loop_centering_analyzer.py b/tests/unit/daq/operations/loop_centering/test_loop_centering_analyzer.py index 93d8f9aa..76ac5684 100644 --- a/tests/unit/daq/operations/loop_centering/test_loop_centering_analyzer.py +++ b/tests/unit/daq/operations/loop_centering/test_loop_centering_analyzer.py @@ -5,10 +5,16 @@ import pytest from aare.common.coordinate import Coordinate, SmargonCoordinate from aare.common.models import MLBoxType, MLOutputModel from aare.daq.mlbox import MLBoxPredictionsResult -from aare.daq.operations.common.runtime import DAQRuntimeState, TraceWriter, PredictionProvider, OperationServices +from aare.daq.operations.common.runtime import DAQRuntimeState +from aare.daq.operations.common.services import ( + TraceWriter, + PredictionProvider, + OperationServices, +) from aare.daq.operations.loop_centering.analyzer import LoopCenteringAnalyzer from aare.daq.operations.loop_centering.models import ( LoopCenteringContext, + LoopCenteringDependencies, LoopCenteringSettings, ) @@ -43,29 +49,31 @@ def analyzer(mock_logger): ) ctx = LoopCenteringContext( - cfg=types.SimpleNamespace(), - devs=types.SimpleNamespace(), - mlbox=DummyMlBox(), - settings=LoopCenteringSettings(), + deps=LoopCenteringDependencies( + cfg=types.SimpleNamespace(), + devs=types.SimpleNamespace(), + mlbox=DummyMlBox(), + ), runtime=runtime_state, services=OperationServices( screenshots=types.SimpleNamespace( save_to_db=lambda *args, **kwargs: None - ) - ), - trace_writer=TraceWriter( - appender=types.SimpleNamespace(append_smargon_trace=lambda *args, **kwargs: None) - ), - prediction_provider=PredictionProvider( - getter=types.SimpleNamespace( - get_predictions=lambda: MLBoxPredictionsResult( - predictions=None, - image=None, - target_point=None, - focus=None, + ), + traces=TraceWriter( + appender=types.SimpleNamespace(append_smargon_trace=lambda *args, **kwargs: None) + ), + predictions=PredictionProvider( + getter=types.SimpleNamespace( + get_predictions=lambda: MLBoxPredictionsResult( + predictions=None, + image=None, + target_point=None, + focus=None, + ) ) - ) + ), ), + settings=LoopCenteringSettings(), ) return LoopCenteringAnalyzer(context=ctx, logger=mock_logger) @@ -142,7 +150,7 @@ def test_analyze_angle_with_crystal_prediction_returns_valid_target(analyzer): boxes = MLOutputModel() boxes.add_box(MLBoxType.CRYSTAL, (10, 20, 30, 40), conf=0.9) - analyzer.ctx.prediction_provider.getter.get_predictions = lambda: MLBoxPredictionsResult( + analyzer.ctx.services.predictions.getter.get_predictions = lambda: MLBoxPredictionsResult( predictions=boxes, image=None, target_point=(25.0, 30.0), diff --git a/tests/unit/daq/operations/loop_centering/test_loop_centering_service.py b/tests/unit/daq/operations/loop_centering/test_loop_centering_service.py index cd7664d4..41588c87 100644 --- a/tests/unit/daq/operations/loop_centering/test_loop_centering_service.py +++ b/tests/unit/daq/operations/loop_centering/test_loop_centering_service.py @@ -3,10 +3,16 @@ import types import pytest from aare.common.models import LoopCenteringResult -from aare.daq.operations.common.runtime import DAQRuntimeState, OperationServices, TraceWriter, PredictionProvider +from aare.daq.operations.common.runtime import DAQRuntimeState +from aare.daq.operations.common.services import ( + OperationServices, + TraceWriter, + PredictionProvider, +) from aare.daq.operations.loop_centering.models import ( AngleAnalysis, LoopCenteringContext, + LoopCenteringDependencies, LoopCenteringSettings, ) from aare.daq.operations.loop_centering.service import LoopCenteringService @@ -31,24 +37,26 @@ def context(): ) ctx = LoopCenteringContext( - cfg=types.SimpleNamespace(), - devs=devs, - mlbox=types.SimpleNamespace(), - settings=LoopCenteringSettings(), + deps=LoopCenteringDependencies( + cfg=types.SimpleNamespace(), + devs=devs, + mlbox=types.SimpleNamespace(), + ), runtime=runtime_state, services=OperationServices( screenshots=types.SimpleNamespace( save_to_db=lambda sample_id, filename, wait=0.0: screenshots.append((sample_id, filename, wait)) - ) - ), - trace_writer=TraceWriter( - appender=types.SimpleNamespace( - append_smargon_trace=lambda **kwargs: traces.append(kwargs) - ) - ), - prediction_provider=PredictionProvider( - getter=types.SimpleNamespace(get_predictions=lambda: None) + ), + traces=TraceWriter( + appender=types.SimpleNamespace( + append_smargon_trace=lambda **kwargs: traces.append(kwargs) + ) + ), + predictions=PredictionProvider( + getter=types.SimpleNamespace(get_predictions=lambda: None) + ), ), + settings=LoopCenteringSettings(), ) ctx._screenshots = screenshots ctx._traces = traces @@ -126,5 +134,5 @@ def test_service_uses_settings_values(monkeypatch, context, mock_logger): result = service.run(sample_id=3) - assert context.devs.zoom == 250.0 + assert context.deps.devs.zoom == 250.0 assert result.success is False \ No newline at end of file diff --git a/tests/unit/daq/operations/mounting/test_mounting_service.py b/tests/unit/daq/operations/mounting/test_mounting_service.py index 6419e545..5f981d88 100644 --- a/tests/unit/daq/operations/mounting/test_mounting_service.py +++ b/tests/unit/daq/operations/mounting/test_mounting_service.py @@ -5,7 +5,7 @@ from aare.common.coordinate import AerotechCoordinate, Coordinate from aare.common.exception_handler import CriticalTellException, MountingFailed from aare.common.models import DewarAddress, SampleShortInfo from aare.devices.tell_client import TellEventValueEnum -from aare.daq.operations.mounting.models import MountingContext, MountingResult +from aare.daq.operations.mounting.models import MountingContext, MountingResult, MountingDependencies, MountingSettings from aare.daq.operations.mounting.service import MountingService @@ -64,9 +64,13 @@ def _make_context(previous_sample=None): ) return MountingContext( - cfg=cfg, - devs=devs, - mount_position=AerotechCoordinate(at_mm=Coordinate(x=0, y=0, z=0), omega_deg=0), + deps=MountingDependencies( + cfg=cfg, + devs=devs, + ), + settings=MountingSettings( + mount_position=AerotechCoordinate(at_mm=Coordinate(x=0, y=0, z=0), omega_deg=0), + ), ) @@ -84,7 +88,7 @@ def test_execute_mount_success(mock_logger): assert result.mounted_sample == target_sample assert result.previous_sample == previous_sample assert result.did_unmount_previous is True - assert ctx.cfg.current_sample == target_sample + assert ctx.deps.cfg.current_sample == target_sample def test_execute_unmount_success(mock_logger): @@ -99,13 +103,13 @@ def test_execute_unmount_success(mock_logger): assert result.mounted_sample is None assert result.previous_sample == previous_sample assert result.did_unmount_previous is True - assert ctx.cfg.current_sample is None + assert ctx.deps.cfg.current_sample is None def test_execute_mount_returns_failed_result_for_no_pin_in_gripper(mock_logger): target_sample = _make_sample(2, "new") ctx = _make_context() - ctx.devs.tell.mount = lambda **kwargs: TellEventValueEnum.NO_PIN_IN_GRIPPER + ctx.deps.devs.tell.mount = lambda **kwargs: TellEventValueEnum.NO_PIN_IN_GRIPPER service = MountingService(context=ctx, logger=mock_logger) @@ -119,7 +123,7 @@ def test_execute_mount_returns_failed_result_for_no_pin_in_gripper(mock_logger): def test_execute_mount_returns_failed_result_for_unhandled_tell_response(mock_logger): target_sample = _make_sample(2, "new") ctx = _make_context() - ctx.devs.tell.mount = lambda **kwargs: TellEventValueEnum.UNKNOWN + ctx.deps.devs.tell.mount = lambda **kwargs: TellEventValueEnum.UNKNOWN service = MountingService(context=ctx, logger=mock_logger) @@ -136,14 +140,14 @@ def test_dry_unmounts_current_sample_before_drying(mock_logger): unmount_calls = [] dry_calls = [] - ctx.devs.tell.unmount = lambda **kwargs: unmount_calls.append(kwargs) - ctx.devs.tell.dry = lambda **kwargs: dry_calls.append(kwargs) + ctx.deps.devs.tell.unmount = lambda **kwargs: unmount_calls.append(kwargs) + ctx.deps.devs.tell.dry = lambda **kwargs: dry_calls.append(kwargs) service = MountingService(context=ctx, logger=mock_logger) service.dry(park=True, unmount=True) assert len(unmount_calls) == 1 - assert ctx.cfg.current_sample is None + assert ctx.deps.cfg.current_sample is None assert dry_calls == [{"wait_cold": -1, "wait": True}] @@ -152,10 +156,10 @@ def test_execute_mount_triggers_dry_on_third_consecutive_failure(mock_logger): dry_calls = [] ctx = _make_context() - ctx.devs.tell.mount = lambda **kwargs: TellEventValueEnum.NO_PIN_IN_GRIPPER - ctx.devs.tell.dry = lambda **kwargs: dry_calls.append(kwargs) - ctx.cfg.increment_mount_failure_streak() - ctx.cfg.increment_mount_failure_streak() + ctx.deps.devs.tell.mount = lambda **kwargs: TellEventValueEnum.NO_PIN_IN_GRIPPER + ctx.deps.devs.tell.dry = lambda **kwargs: dry_calls.append(kwargs) + ctx.deps.cfg.increment_mount_failure_streak() + ctx.deps.cfg.increment_mount_failure_streak() service = MountingService(context=ctx, logger=mock_logger) @@ -172,12 +176,12 @@ def test_execute_mount_stops_automation_on_fifth_consecutive_failure(mock_logger dry_calls = [] ctx = _make_context() - ctx.devs.tell.mount = lambda **kwargs: TellEventValueEnum.NO_PIN_IN_GRIPPER - ctx.devs.tell.dry = lambda **kwargs: dry_calls.append(kwargs) - ctx.cfg.increment_mount_failure_streak() - ctx.cfg.increment_mount_failure_streak() - ctx.cfg.increment_mount_failure_streak() - ctx.cfg.increment_mount_failure_streak() + ctx.deps.devs.tell.mount = lambda **kwargs: TellEventValueEnum.NO_PIN_IN_GRIPPER + ctx.deps.devs.tell.dry = lambda **kwargs: dry_calls.append(kwargs) + ctx.deps.cfg.increment_mount_failure_streak() + ctx.deps.cfg.increment_mount_failure_streak() + ctx.deps.cfg.increment_mount_failure_streak() + ctx.deps.cfg.increment_mount_failure_streak() service = MountingService(context=ctx, logger=mock_logger) @@ -194,15 +198,15 @@ def test_execute_mount_success_resets_failure_streak(mock_logger): previous_sample = _make_sample(1, "old") target_sample = _make_sample(2, "new") ctx = _make_context(previous_sample=previous_sample) - ctx.cfg.increment_mount_failure_streak() - ctx.cfg.increment_mount_failure_streak() + ctx.deps.cfg.increment_mount_failure_streak() + ctx.deps.cfg.increment_mount_failure_streak() service = MountingService(context=ctx, logger=mock_logger) result = service.execute(target=target_sample) assert result.success is True - assert ctx.cfg.get_mount_failure_streak() == 0 + assert ctx.deps.cfg.get_mount_failure_streak() == 0 def test_execute_mount_critical_tell_error_does_not_increment_failure_streak(mock_logger): @@ -212,7 +216,7 @@ def test_execute_mount_critical_tell_error_does_not_increment_failure_streak(moc def mount(**kwargs): raise CriticalTellException("critical tell problem") - ctx.devs.tell.mount = mount + ctx.deps.devs.tell.mount = mount service = MountingService(context=ctx, logger=mock_logger) @@ -220,4 +224,4 @@ def test_execute_mount_critical_tell_error_does_not_increment_failure_streak(moc assert result.success is False assert isinstance(result.error, CriticalTellException) - assert ctx.cfg.get_mount_failure_streak() == 0 + assert ctx.deps.cfg.get_mount_failure_streak() == 0 \ No newline at end of file diff --git a/tests/unit/daq/test_mount.py b/tests/unit/daq/test_mount.py index 4ab09cfd..8737705a 100644 --- a/tests/unit/daq/test_mount.py +++ b/tests/unit/daq/test_mount.py @@ -9,7 +9,7 @@ from aare.common.models import DewarAddress, SampleShortInfo, DAQOperation from aare.common.tell_models import TellActivityEnum, TellPhaseEnum, TellStateModel from aare.daq.config import ABR_POS_MOUNT, BeamlineStateEnum from aare.daq.daq import AareDAQ -from aare.daq.operations.mounting.models import MountingResult +from aare.daq.operations.mounting.models import MountingResult, MountingContext from aare.daq.operations.mounting.service import MountingService from aare.daq.operations.screenshot.service import ScreenshotService @@ -51,11 +51,10 @@ def bare_daq(): def test_create_mounting_service_builds_expected_context(mock_logger, bare_daq): service = bare_daq._create_mounting_service() - assert isinstance(service, MountingService) - assert service.ctx.cfg is bare_daq._AareDAQ__cfg - assert service.ctx.devs is bare_daq._AareDAQ__devs - assert service.ctx.mount_position == ABR_POS_MOUNT - + assert isinstance(service.ctx, MountingContext) + assert service.ctx.deps.cfg is bare_daq._AareDAQ__cfg + assert service.ctx.deps.devs is bare_daq._AareDAQ__devs + assert service.ctx.settings.mount_position == ABR_POS_MOUNT def test_was_previous_sample_unmounted_since_prefers_tell_phase_confirmation(): previous_sample = _make_sample(1, "old") diff --git a/tests/unit/daq/test_raster_logic.py b/tests/unit/daq/test_raster_logic.py index 88916f44..900a3ad8 100644 --- a/tests/unit/daq/test_raster_logic.py +++ b/tests/unit/daq/test_raster_logic.py @@ -1,3 +1,5 @@ +import types + import pytest from types import SimpleNamespace from unittest.mock import MagicMock @@ -6,7 +8,13 @@ from jfjoch_client.exceptions import NotFoundException from aare.common.coordinate import Coordinate, SmargonCoordinate from aare.common.raster_grid import RasterGridRequest -from aare.daq.operations.raster.models import RasterContext +from aare.daq.operations.common.runtime import DAQRuntimeState +from aare.daq.operations.common.services import OperationServices +from aare.daq.operations.raster.models import ( + RasterContext, + RasterDependencies, + RasterSettings, +) from aare.daq.operations.raster.service import RasterService @@ -23,25 +31,52 @@ def make_request(n_x: int, n_y: int, cell_x: float = 0.01, cell_y: float = 0.02) ) -def make_service() -> RasterService: - runtime = SimpleNamespace( - sample=None, - sample_geometry=SimpleNamespace(), - status=SimpleNamespace(), +def _make_raster_context(*, jfjoch, aare, sample=None): + deps = RasterDependencies( + cfg=types.SimpleNamespace( + simulated_detector=False, + abr_meas_pos=types.SimpleNamespace(at_mm=Coordinate(x=0.0, y=0.0, z=0.0)), + get_beam_mark=lambda zoom: (0.0, 0.0), + ), + devs=types.SimpleNamespace( + aerotech_omega=0.0, + zoom=100.0, + ), + mlbox=types.SimpleNamespace(), + jfjoch=jfjoch, + aare=aare, ) - context = RasterContext( - cfg=MagicMock(), - devs=MagicMock(), - mlbox=MagicMock(), - jfjoch=MagicMock(), - aare=MagicMock(), - runtime=runtime, - state_controller=MagicMock(), - services=MagicMock(), + + runtime = DAQRuntimeState( + sample_provider=types.SimpleNamespace(sample=sample), + sample_geometry_provider=types.SimpleNamespace( + sample_geometry=types.SimpleNamespace() + ), + status_provider=types.SimpleNamespace(status=None), + ) + + services = OperationServices( + screenshots=types.SimpleNamespace(save_to_db=lambda *args, **kwargs: None), + ) + + settings = RasterSettings( auto_raster_max_images=4500, auto_raster_min_cell_size_mm=0.005, auto_raster_skip_if_exceed_max_image_threshold=True, ) + + return RasterContext( + deps=deps, + runtime=runtime, + services=services, + settings=settings, + ) + + +def make_service() -> RasterService: + jfjoch = MagicMock() + aare = MagicMock() + context = _make_raster_context(jfjoch=jfjoch, aare=aare) return RasterService(context=context, logger=MagicMock()) @@ -139,8 +174,8 @@ def test_upload_raster_diffraction_preview_skips_out_of_range_image_id(): request=request, ) - service.ctx.jfjoch.get_diffraction_image.assert_not_called() - service.ctx.aare.upload_jpg.assert_not_called() + service.ctx.deps.jfjoch.get_diffraction_image.assert_not_called() + service.ctx.deps.aare.upload_jpg.assert_not_called() def test_upload_raster_diffraction_preview_ignores_not_found(): @@ -149,7 +184,7 @@ def test_upload_raster_diffraction_preview_ignores_not_found(): request = make_request(2, 2) scan_result = MagicMock() scan_result.images = [MagicMock(), MagicMock(), MagicMock(), MagicMock()] - service.ctx.jfjoch.get_diffraction_image.side_effect = NotFoundException() + service.ctx.deps.jfjoch.get_diffraction_image.side_effect = NotFoundException() service._upload_raster_diffraction_preview( sample_id=123, @@ -159,8 +194,8 @@ def test_upload_raster_diffraction_preview_ignores_not_found(): request=request, ) - service.ctx.jfjoch.get_diffraction_image.assert_called_once_with(2) - service.ctx.aare.upload_jpg.assert_not_called() + service.ctx.deps.jfjoch.get_diffraction_image.assert_called_once_with(2) + service.ctx.deps.aare.upload_jpg.assert_not_called() def test_upload_raster_diffraction_preview_uploads_when_present(): @@ -169,7 +204,7 @@ def test_upload_raster_diffraction_preview_uploads_when_present(): request = make_request(2, 2) scan_result = MagicMock() scan_result.images = [MagicMock(), MagicMock(), MagicMock(), MagicMock()] - service.ctx.jfjoch.get_diffraction_image.return_value = b"jpeg-bytes" + service.ctx.deps.jfjoch.get_diffraction_image.return_value = b"jpeg-bytes" service._upload_raster_diffraction_preview( sample_id=123, @@ -179,5 +214,5 @@ def test_upload_raster_diffraction_preview_uploads_when_present(): request=request, ) - service.ctx.jfjoch.get_diffraction_image.assert_called_once_with(2) - service.ctx.aare.upload_jpg.assert_called_once_with(123, "preview", b"jpeg-bytes") \ No newline at end of file + service.ctx.deps.jfjoch.get_diffraction_image.assert_called_once_with(2) + service.ctx.deps.aare.upload_jpg.assert_called_once_with(123, "preview", b"jpeg-bytes") \ No newline at end of file diff --git a/tests/unit/daq/test_workflows.py b/tests/unit/daq/test_workflows.py index 067fb655..142667d6 100644 --- a/tests/unit/daq/test_workflows.py +++ b/tests/unit/daq/test_workflows.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import MagicMock, patch from aare.daq.workflows import common_2rse, sa2se, sa2rse, sa2xtal_snapshot, dc2xtal_snapshot, xtal_snapshot2dc, xtal_snapshot2sa, dc2rse, se2sa, sa2dc, dc2sa, sa2xrf, sa2dh, dh2sa -from aare.daq.config import ABR_POS_MOUNT +from aare.daq.config import ABR_POS_MOUNT, ABR_OMEGA_MOUNT from aare.common.models import StagePositionEnum from aare.devices.area_detector import AutoEnum from aare.devices.bec_worker import BeamlineState @@ -99,7 +99,8 @@ def test_sa2se(mock_devs, mock_cfg): mock_devs.samcam_auto.assert_any_call(AutoEnum.AUTO) _assert_bec_moved(mock_devs, BeamlineState.MANUAL_SAMPLE_EXCHANGE) - assert mock_devs.aerotech_pos == mock_cfg.abr_meas_pos + assert mock_devs.aerotech_pos == ABR_POS_MOUNT + assert mock_devs.aerotech_omega == ABR_OMEGA_MOUNT mock_devs.smargon_move_home.assert_called_once() mock_devs.samcam_auto.assert_any_call(AutoEnum.ONCE) From e66d8aec728fde0c27c75eec81a89548bb04a1bf Mon Sep 17 00:00:00 2001 From: appleb_m Date: Thu, 18 Jun 2026 14:05:54 +0200 Subject: [PATCH 57/76] added portrait_mode.py --- scripts/gui_desginer.py | 586 ++++++++++++++++++++++ src/aare/gui/main_window.py | 214 ++++++++- src/aare/gui/panels/portrait_mode.py | 695 +++++++++++++++++++++++++++ src/aare/gui/widgets/camera_image.py | 7 + 4 files changed, 1495 insertions(+), 7 deletions(-) create mode 100644 scripts/gui_desginer.py create mode 100644 src/aare/gui/panels/portrait_mode.py diff --git a/scripts/gui_desginer.py b/scripts/gui_desginer.py new file mode 100644 index 00000000..228a503c --- /dev/null +++ b/scripts/gui_desginer.py @@ -0,0 +1,586 @@ +from PySide6.QtWidgets import ( + QApplication, QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QFrame, QPushButton, QScrollArea, QStackedWidget, + QSizePolicy, +) +from PySide6.QtCore import Qt, QPointF, QRectF +from PySide6.QtGui import ( + QPainter, QColor, QPen, QLinearGradient, + QFont, QFontMetrics, +) +import sys +import math + + +# --------------------------------------------------------------------------- +# Colour palette +# --------------------------------------------------------------------------- +BG = "#071018" +CARD_BG = "#0E1A26" +ACCENT = "#62D8C8" +ACCENT_DIM = "#1A3A36" +TEXT = "#F5F7FA" +SUBTEXT = "#8A9BB0" +BUTTON_BG = "#132131" +LED_OFF = "#1C2E3E" +LED_ON = ACCENT +ACTIVE_STEP = "#FFFFFF" + + +STYLE = f""" +QWidget {{ + background: {BG}; + color: {TEXT}; + font-family: 'Inter', 'SF Pro Display', Arial, sans-serif; + font-size: 14px; +}} +QScrollArea {{ + border: none; + background: transparent; +}} +QScrollBar:vertical {{ + background: {CARD_BG}; + width: 4px; + border-radius: 2px; +}} +QScrollBar::handle:vertical {{ + background: {ACCENT_DIM}; + border-radius: 2px; + min-height: 20px; +}} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + height: 0px; +}} +""" + + +# --------------------------------------------------------------------------- +# Orbit / camera placeholder widget +# --------------------------------------------------------------------------- +class OrbitWidget(QWidget): + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + + w, h = self.width(), self.height() + cx, cy = w / 2, h / 2 + + # Background gradient + bg = QLinearGradient(0, 0, w, h) + bg.setColorAt(0, QColor("#0D2A2C")) + bg.setColorAt(1, QColor("#0D1E2E")) + p.fillRect(self.rect(), bg) + + # Corner brackets + pen = QPen(QColor(ACCENT)) + pen.setWidth(2) + p.setPen(pen) + size = 28 + margin = 24 + for x, y, dx, dy in [ + (margin, margin, 1, 1), + (w - margin, margin, -1, 1), + (margin, h - margin, 1, -1), + (w - margin, h - margin, -1, -1), + ]: + p.drawLine(x, y, x + dx * size, y) + p.drawLine(x, y, x, y + dy * size) + + # Orbit rings + pen.setWidth(1) + pen.setColor(QColor(ACCENT + "80")) # semi-transparent + p.setPen(pen) + for r in [38, 68, 98]: + p.drawEllipse(QPointF(cx, cy), r, r) + + # Centre dot + p.setBrush(QColor(ACCENT)) + p.setPen(Qt.NoPen) + p.drawEllipse(QPointF(cx, cy), 9, 9) + + # Orbiting dots on outer ring + p.setBrush(QColor("#F5FFFF")) + for angle in range(0, 360, 45): + rad = math.radians(angle) + ox = cx + math.cos(rad) * 98 + oy = cy + math.sin(rad) * 98 + p.drawEllipse(QPointF(ox, oy), 4, 4) + + +# --------------------------------------------------------------------------- +# LED step indicator +# --------------------------------------------------------------------------- +class LEDStages(QWidget): + STEPS = ["Mount", "Centre", "Raster", "Collect"] + + def __init__(self, active_step: int = 1, parent=None): + """ + active_step: 0-based index of the currently active step. + Steps before active_step are shown as 'done' (filled accent), + active_step is highlighted, later steps are dim. + """ + super().__init__(parent) + self._active = active_step + self.setFixedHeight(72) + + def set_active_step(self, step: int): + self._active = step + self.update() + + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + + w = self.width() + n = len(self.STEPS) + step_w = w / n + led_r = 10 + cy = 44 # vertical centre of LEDs + label_y = 16 + + for i, name in enumerate(self.STEPS): + cx = step_w * i + step_w / 2 + + # --- connector line to next step --- + if i < n - 1: + next_cx = step_w * (i + 1) + step_w / 2 + line_color = QColor(ACCENT) if i < self._active else QColor(LED_OFF) + pen = QPen(line_color, 2) + p.setPen(pen) + p.drawLine( + QPointF(cx + led_r + 3, cy), + QPointF(next_cx - led_r - 3, cy), + ) + + # --- LED circle --- + p.setPen(Qt.NoPen) + if i < self._active: + # completed + p.setBrush(QColor(ACCENT)) + p.drawEllipse(QPointF(cx, cy), led_r, led_r) + # tick mark + pen = QPen(QColor(BG), 2) + pen.setCapStyle(Qt.RoundCap) + p.setPen(pen) + p.drawLine( + QPointF(cx - 4, cy), + QPointF(cx - 1, cy + 3), + ) + p.drawLine( + QPointF(cx - 1, cy + 3), + QPointF(cx + 4, cy - 3), + ) + elif i == self._active: + # active — bright with glow ring + glow_pen = QPen(QColor(ACCENT + "55"), 4) + p.setPen(glow_pen) + p.setBrush(Qt.NoBrush) + p.drawEllipse(QPointF(cx, cy), led_r + 4, led_r + 4) + p.setPen(Qt.NoPen) + p.setBrush(QColor(ACTIVE_STEP)) + p.drawEllipse(QPointF(cx, cy), led_r, led_r) + else: + # pending + p.setBrush(QColor(LED_OFF)) + p.drawEllipse(QPointF(cx, cy), led_r, led_r) + + # --- label --- + label_color = QColor(ACCENT) if i <= self._active else QColor(SUBTEXT) + p.setPen(label_color) + font = QFont("Inter", 10) + if i == self._active: + font.setWeight(QFont.Weight.DemiBold) + p.setFont(font) + fm = QFontMetrics(font) + text_w = fm.horizontalAdvance(name) + p.drawText( + QRectF(cx - text_w / 2 - 4, 0, text_w + 8, label_y + 2), + Qt.AlignCenter, + name, + ) + + +# --------------------------------------------------------------------------- +# Transport / control button +# --------------------------------------------------------------------------- +class ControlButton(QPushButton): + def __init__(self, symbol: str, primary: bool = False, parent=None): + super().__init__(parent) + self._symbol = symbol + self._primary = primary + self._hovered = False + size = 64 if primary else 52 + self.setFixedSize(size, size) + self.setMouseTracking(True) + + def enterEvent(self, event): + self._hovered = True + self.update() + + def leaveEvent(self, event): + self._hovered = False + self.update() + + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + rect = self.rect() + cx, cy = rect.width() / 2, rect.height() / 2 + r = min(rect.width(), rect.height()) / 2 - 2 + + if self._primary: + if self._hovered: + p.setBrush(QColor("#FFFFFF")) + else: + p.setBrush(QColor(ACCENT)) + p.setPen(Qt.NoPen) + p.drawEllipse(QPointF(cx, cy), r, r) + sym_color = QColor(BG) + else: + if self._hovered: + p.setBrush(QColor(ACCENT)) + else: + p.setBrush(QColor(BUTTON_BG)) + p.setPen(Qt.NoPen) + p.drawEllipse(QPointF(cx, cy), r, r) + sym_color = QColor(TEXT) if not self._hovered else QColor(BG) + + p.setPen(QPen(sym_color, 2)) + font = QFont("Arial", 16 if self._primary else 13) + p.setFont(font) + p.drawText(rect, Qt.AlignCenter, self._symbol) + + +# --------------------------------------------------------------------------- +# Queue item card +# --------------------------------------------------------------------------- +class QueueItemCard(QFrame): + def __init__( + self, + index: int | str, + title: str, + subtitle: str, + frames_done: int = 0, + frames_total: int = 0, + is_next: bool = False, + parent=None, + ): + super().__init__(parent) + self._index = index + self._title = title + self._subtitle = subtitle + self._frames_done = frames_done + self._frames_total = frames_total + self._is_next = is_next + + self.setFixedHeight(72) + self.setStyleSheet(f""" + QFrame {{ + background: {"#112030" if is_next else "#0C1720"}; + border-radius: 14px; + border: {"1px solid " + ACCENT_DIM if is_next else "none"}; + }} + """) + self._build_layout() + + def _build_layout(self): + layout = QHBoxLayout(self) + layout.setContentsMargins(14, 0, 14, 0) + layout.setSpacing(12) + + # Index badge / play icon + badge = QLabel() + badge.setFixedSize(32, 32) + badge.setAlignment(Qt.AlignCenter) + if self._is_next: + badge.setText("▶") + badge.setStyleSheet(f""" + color: {ACCENT}; + background: {ACCENT_DIM}; + border-radius: 16px; + font-size: 12px; + font-weight: bold; + """) + else: + badge.setText(str(self._index)) + badge.setStyleSheet(f""" + color: {SUBTEXT}; + background: {BUTTON_BG}; + border-radius: 16px; + font-size: 12px; + """) + layout.addWidget(badge) + + # Text block + text_col = QVBoxLayout() + text_col.setSpacing(2) + text_col.setContentsMargins(0, 0, 0, 0) + + title_lbl = QLabel(self._title) + title_lbl.setStyleSheet(f"color: {TEXT}; font-size: 13px; font-weight: 600; background: transparent;") + sub_lbl = QLabel(self._subtitle) + sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: 11px; background: transparent;") + + text_col.addWidget(title_lbl) + text_col.addWidget(sub_lbl) + layout.addLayout(text_col, stretch=1) + + # Frame count + mini bar + right_col = QVBoxLayout() + right_col.setSpacing(4) + right_col.setContentsMargins(0, 0, 0, 0) + right_col.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + + if self._frames_total > 0: + frame_lbl = QLabel(f"{self._frames_done:,} / {self._frames_total:,}") + frame_lbl.setAlignment(Qt.AlignRight) + color = ACCENT if self._is_next else SUBTEXT + frame_lbl.setStyleSheet(f"color: {color}; font-size: 11px; background: transparent;") + right_col.addWidget(frame_lbl) + + bar = MiniProgressBar(self._frames_done, self._frames_total) + right_col.addWidget(bar) + + layout.addLayout(right_col) + + +class MiniProgressBar(QWidget): + def __init__(self, done: int, total: int, parent=None): + super().__init__(parent) + self._done = done + self._total = total + self.setFixedSize(80, 4) + + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + w, h = self.width(), self.height() + # background track + p.setBrush(QColor(LED_OFF)) + p.setPen(Qt.NoPen) + p.drawRoundedRect(0, 0, w, h, 2, 2) + # fill + if self._total > 0: + fill_w = max(4, int(w * self._done / self._total)) + p.setBrush(QColor(ACCENT)) + p.drawRoundedRect(0, 0, fill_w, h, 2, 2) + + +# --------------------------------------------------------------------------- +# Main SAM Camera widget +# --------------------------------------------------------------------------- +class SampleCamera(QWidget): + def __init__(self): + super().__init__() + self.setWindowFlags(Qt.FramelessWindowHint) + self.setAttribute(Qt.WA_TranslucentBackground) + self.resize(390, 860) + self.setStyleSheet(STYLE) + + self._stack = QStackedWidget() + self._player_page = self._build_player() + self._queue_page = self._build_queue() + self._stack.addWidget(self._player_page) + self._stack.addWidget(self._queue_page) + + root = QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.addWidget(self._stack) + + # ------------------------------------------------------------------ + # Player page + # ------------------------------------------------------------------ + def _build_player(self) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + layout.setSpacing(10) + layout.setContentsMargins(0, 0, 0, 0) + + # ── Title ────────────────────────────────────────────────────── + title = QLabel("S A M C A M E R A") + title.setAlignment(Qt.AlignCenter) + title.setStyleSheet(f"color: {ACCENT}; font-size: 13px; letter-spacing: 3px; font-weight: 600;") + layout.addWidget(title) + + # ── Camera card ──────────────────────────────────────────────── + cam_card = QFrame() + cam_card.setStyleSheet(f"QFrame {{ background: {CARD_BG}; border-radius: 18px; }}") + cam_card_layout = QVBoxLayout(cam_card) + cam_card_layout.setContentsMargins(0, 0, 0, 0) + cam_card_layout.setSpacing(0) + orbit = OrbitWidget() + orbit.setMinimumHeight(220) + cam_card_layout.addWidget(orbit) + layout.addWidget(cam_card) + + # ── Sample name ──────────────────────────────────────────────── + name_lbl = QLabel("Crystal Plate 14 · Well C7") + name_lbl.setStyleSheet(f"color: {TEXT}; font-size: 18px; font-weight: 700;") + sub_lbl = QLabel("Serial MX · 1 kHz · Beamline X06SA") + sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: 12px;") + layout.addWidget(name_lbl) + layout.addWidget(sub_lbl) + + # ── LED step indicator ───────────────────────────────────────── + self._leds = LEDStages(active_step=1) # 0=Mount done, 1=Centre active + layout.addWidget(self._leds) + + # ── Transport controls ───────────────────────────────────────── + ctrl_frame = QFrame() + ctrl_frame.setStyleSheet(f"QFrame {{ background: {CARD_BG}; border-radius: 18px; }}") + ctrl_layout = QHBoxLayout(ctrl_frame) + ctrl_layout.setContentsMargins(16, 12, 16, 12) + ctrl_layout.setSpacing(0) + + buttons = [ + ("⏮", False), + ("⏪", False), + ("⏸", True), # primary / highlighted + ("⏩", False), + ("⏭", False), + ] + for sym, primary in buttons: + btn = ControlButton(sym, primary=primary) + ctrl_layout.addWidget(btn, alignment=Qt.AlignCenter) + if not primary: + ctrl_layout.addStretch(1) + layout.addWidget(ctrl_frame) + + # ── Up next header ───────────────────────────────────────────── + up_next_row = QHBoxLayout() + up_next_lbl = QLabel("UP NEXT") + up_next_lbl.setStyleSheet(f"color: {ACCENT}; font-size: 11px; letter-spacing: 2px; font-weight: 700;") + samples_lbl = QLabel("5 SAMPLES") + samples_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: 11px; letter-spacing: 1px;") + up_next_row.addWidget(up_next_lbl) + up_next_row.addStretch() + up_next_row.addWidget(samples_lbl) + layout.addLayout(up_next_row) + + # ── Queue preview cards ──────────────────────────────────────── + queue_items = [ + ("Crystal Plate 14 · Well C8", "Serial MX · 1 kHz", 4100, 6000, True), + ("Crystal Plate 15 · Grid Scan", "Raster · 10 Hz", 0, 2000, False), + ("Crystal Plate 13 · Well A1", "Serial MX · 1 kHz", 0, 5000, False), + ("Crystal Plate 13 · Well B3", "Serial MX · 1 kHz", 0, 4500, False), + ("Crystal Plate 14 · Well D5", "Serial MX · 1 kHz", 0, 6000, False), + ] + + queue_widget = QWidget() + queue_layout = QVBoxLayout(queue_widget) + queue_layout.setSpacing(6) + queue_layout.setContentsMargins(0, 0, 0, 0) + + for i, (title, sub, done, total, is_next) in enumerate(queue_items): + card = QueueItemCard( + index="▶" if is_next else i + 1, + title=title, + subtitle=sub, + frames_done=done, + frames_total=total, + is_next=is_next, + ) + queue_layout.addWidget(card) + + layout.addWidget(queue_widget) + + # ── View full queue button ──────────────────────────────────── + view_btn = self._accent_button("VIEW FULL QUEUE ☰") + view_btn.clicked.connect(lambda: self._stack.setCurrentWidget(self._queue_page)) + layout.addWidget(view_btn) + + return page + + # ------------------------------------------------------------------ + # Queue / full list page + # ------------------------------------------------------------------ + def _build_queue(self) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + layout.setSpacing(10) + layout.setContentsMargins(0, 0, 0, 0) + + title = QLabel("SAMPLE QUEUE") + title.setAlignment(Qt.AlignCenter) + title.setStyleSheet(f"color: {ACCENT}; font-size: 13px; letter-spacing: 3px; font-weight: 700;") + layout.addWidget(title) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + inner = QWidget() + inner_layout = QVBoxLayout(inner) + inner_layout.setSpacing(6) + inner_layout.setContentsMargins(0, 4, 0, 4) + + for i in range(1, 20): + card = QueueItemCard( + index=i, + title=f"Crystal Plate {i} · Well C{i}", + subtitle="Serial MX · 1 kHz", + frames_done=0, + frames_total=6000, + ) + inner_layout.addWidget(card) + + scroll.setWidget(inner) + layout.addWidget(scroll, stretch=1) + + back_btn = self._accent_button("← BACK TO CAMERA") + back_btn.clicked.connect(lambda: self._stack.setCurrentWidget(self._player_page)) + layout.addWidget(back_btn) + + return page + + # ------------------------------------------------------------------ + # Helper: styled accent button + # ------------------------------------------------------------------ + @staticmethod + def _accent_button(text: str) -> QPushButton: + btn = QPushButton(text) + btn.setFixedHeight(48) + btn.setStyleSheet(f""" + QPushButton {{ + background: transparent; + border: 1.5px solid {ACCENT}; + border-radius: 14px; + color: {ACCENT}; + font-size: 12px; + font-weight: 700; + letter-spacing: 1.5px; + }} + QPushButton:hover {{ + background: {ACCENT_DIM}; + }} + QPushButton:pressed {{ + background: {ACCENT}; + color: {BG}; + }} + """) + return btn + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- +def export_png(): + app = QApplication.instance() or QApplication(sys.argv) + ui = SampleCamera() + ui.show() + app.processEvents() + + ui._stack.setCurrentWidget(ui._player_page) + app.processEvents() + ui.grab().save("sam_camera_player.png") + + ui._stack.setCurrentWidget(ui._queue_page) + app.processEvents() + ui.grab().save("sam_camera_queue.png") + + +if __name__ == "__main__": + app = QApplication(sys.argv) + ui = SampleCamera() + ui.show() + sys.exit(app.exec()) \ No newline at end of file diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 1425e45f..507a68a5 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -2,7 +2,7 @@ import time import jwt from PySide6.QtCore import Qt, Slot, Signal, QTimer, QSettings, QEvent -from PySide6.QtGui import QAction, QPixmap, QKeySequence +from PySide6.QtGui import QAction, QPixmap, QKeySequence, QGuiApplication from PySide6.QtWidgets import ( QMainWindow, QWidget, @@ -47,6 +47,7 @@ from aare.gui.panels.axis_video_panel import AxisVideoPanel from aare.gui.panels.fluorescence_panel import FluorescencePanel from aare.gui.panels.automation_panel import AutomationProgressWidget from aare.gui.panels.compact_automation_panel import CompactAutomationPanel +from aare.gui.panels.portrait_mode import PortraitModePanel #Scan Logic from aare.gui.scan_logic.raster_grid_manager import RasterGridManager @@ -311,6 +312,43 @@ class MainWindow(QMainWindow): self.compact_automation_page_layout.setSpacing(0) self.compact_automation_page_layout.addWidget(self.compact_automation_panel) + # ── Portrait mode page ────────────────────────────────────────── + # Uses a dedicated SampleCameraImageLabel so the compact_sample_camera + # remains available for the existing compact automation view. + self.portrait_sample_camera = SampleCameraImageLabel( + geom=geom, + raster=self.raster, + parent=root_widget, + default_image=default_image, + ) + self.portrait_mode_panel = PortraitModePanel( + sample_camera_widget=self.portrait_sample_camera, + parent=root_widget, + ) + self.portrait_mode_panel.setObjectName("portraitModePage") + self.portrait_mode_panel.setStyleSheet( + self.portrait_mode_panel.styleSheet() + + "QWidget#portraitModePage { background-color: #071018; }" + ) + + self.portrait_mode_page = QWidget(parent=root_widget) + self.portrait_mode_page.setObjectName("portraitModePage") + self.portrait_mode_page.setStyleSheet( + """ + QWidget#portraitModePage { + background-color: #071018; + } + """ + ) + portrait_page_layout = QHBoxLayout(self.portrait_mode_page) + portrait_page_layout.setContentsMargins(0, 0, 0, 0) + portrait_page_layout.setSpacing(0) + self.portrait_mode_page.setFixedWidth(self.portrait_mode_panel.PORTRAIT_WIDTH + 24) + portrait_page_layout.addWidget( + self.portrait_mode_panel, + alignment=Qt.AlignmentFlag.AlignHCenter, + ) + top_widget_layout.addWidget(self.video_tab) self._start_axis_camera_threads() @@ -334,6 +372,7 @@ class MainWindow(QMainWindow): self.compact_automation_panel.show_full_view_requested.connect(self._return_from_compact_automation_view) self.compact_automation_panel.annotation_selected.connect(self._handle_compact_annotation) + self.tell_samples_dock = QDockWidget("Sample List", self) self.tell_samples_dock.setObjectName("tell_samples_dock") self.tell_samples_dock.setWidget(self.tell_samples) @@ -425,6 +464,16 @@ class MainWindow(QMainWindow): self.job_list_panel.samples_in_queue_changed.connect( self._refresh_compact_queue_preview ) + # Portrait mode: queue size + running state + preview + self.job_list_panel.automation_running_changed.connect( + self.portrait_mode_panel.set_running + ) + self.job_list_panel.samples_in_queue_changed.connect( + self.portrait_mode_panel.set_samples_in_queue + ) + self.job_list_panel.samples_in_queue_changed.connect( + self._refresh_portrait_queue_preview + ) self.automation_progress_panel.set_samples_in_queue( len(self.job_list_panel.table_model.samples) @@ -478,6 +527,7 @@ class MainWindow(QMainWindow): self.content_stack.addWidget(top_widget) self.content_stack.addWidget(self.compact_automation_page) + self.content_stack.addWidget(self.portrait_mode_page) self.content_stack.setCurrentWidget(top_widget) self._standard_main_page = top_widget @@ -523,6 +573,17 @@ class MainWindow(QMainWindow): self._axis_camera_refresh_timer.timeout.connect(self.refresh_axis_cameras) self._axis_camera_refresh_timer.start() + self.portrait_mode_panel.wire_to_main_window( + job_list_panel=self.job_list_panel, + tell_samples=self.tell_samples, + ) + self.portrait_mode_panel._back_btn.clicked.connect(self._return_from_portrait_mode) + self.portrait_mode_panel.grab_session_requested.connect(self.status_bar.request_baton) + + # Route alert banner signals through portrait-aware interceptors + self.daq.polled_devices_status.connect(self._portrait_alert_primary) + self.daq.detector_error.connect(self._portrait_alert_secondary) + self.daq.baton_status_changed.connect(self.status_bar.update_baton_status) self.daq.baton_status_changed.connect(self._on_baton_status_changed) self.daq.baton_request_result.connect(self._on_baton_request_result) @@ -530,6 +591,7 @@ class MainWindow(QMainWindow): self.daq.baton_timeout_checked.connect(self._on_baton_timeout_checked) self.daq.automation_progress.connect(self.automation_progress_panel.set_progress) self.daq.automation_progress.connect(self.compact_automation_panel.set_progress) + self.daq.automation_progress.connect(self.portrait_mode_panel.set_progress) self.status_bar.baton_request_received.connect(self._show_baton_request_dialog) self.status_bar.baton_request_accepted.connect(self._accept_baton_request) @@ -589,16 +651,20 @@ class MainWindow(QMainWindow): self.prediction_thread = PredictionSubscriber(pred_zmq_url=sample_feed_addr, topic=b"") self.prediction_thread.image.connect(self.sample_camera.update_pixmap) self.prediction_thread.image.connect(self.compact_sample_camera.update_pixmap) + self.prediction_thread.image.connect(self.portrait_sample_camera.update_pixmap) self.prediction_thread.prediction.connect(self.sample_camera.update_detections) self.prediction_thread.prediction.connect(self.compact_sample_camera.update_detections) + self.prediction_thread.prediction.connect(self.portrait_sample_camera.update_detections) self.prediction_thread.prediction.connect(self.prediction_metrics_panel.update_from_prediction) self.prediction_thread.target_point.connect(self.sample_camera.update_target_point) self.prediction_thread.target_point.connect(self.compact_sample_camera.update_target_point) + self.prediction_thread.target_point.connect(self.portrait_sample_camera.update_target_point) self.prediction_thread.target_point.connect(self.target_stability_panel.update_target_point) self.prediction_thread.focus_measure.connect(self.status_bar.update_sharpness) self.prediction_thread.fps_measure.connect(self.status_bar.update_samcam_fps) self.prediction_thread.camera_availability_changed.connect(self.sample_camera.set_camera_available) self.prediction_thread.camera_availability_changed.connect(self.compact_sample_camera.set_camera_available) + self.prediction_thread.camera_availability_changed.connect(self.portrait_sample_camera.set_camera_available) self.prediction_thread.camera_availability_changed.connect(self._on_sample_camera_availability_changed) self.prediction_thread.camera_error.connect(self._on_sample_camera_error) self.prediction_thread.start() @@ -606,6 +672,7 @@ class MainWindow(QMainWindow): self.prediction_thread = None self.sample_camera.set_camera_available(False) self.compact_sample_camera.set_camera_available(False) + self.portrait_sample_camera.set_camera_available(False) self._show_samcam_feed_banner("Sample camera feed unavailable: no stream configured") # @@ -702,6 +769,7 @@ class MainWindow(QMainWindow): self.daq.update.connect(self.raster.update_daq_status) self.daq.update.connect(self.sample_camera.update_daq_status) self.daq.update.connect(self.compact_sample_camera.update_daq_status) + self.daq.update.connect(self.portrait_sample_camera.update_daq_status) self.daq.update.connect(self.tell_samples.update_daq_status) self.daq.update.connect(self.ref_tools_panel.update_daq_status) if self.prediction_thread is not None: @@ -745,12 +813,6 @@ class MainWindow(QMainWindow): self.daq.fluorimeter_spectrum_update.connect(lambda: self.fluor_panel_dock.setVisible(True)) # === Alert/Status Message Routing === - # Primary alert banner: Infrastructure devices (Server/Tell/Smargon/Aerotech) - self.daq.polled_devices_status.connect(self.alert_banner.show_message) - - # Secondary alert banner: Detector errors (JFJoch) - self.daq.detector_error.connect(self.alert_banner_secondary.show_message) - # Status bar: General status messages (not device connection status) self.daq.status_message.connect(self.status_bar.show_connection_message) @@ -1000,6 +1062,139 @@ class MainWindow(QMainWindow): next_next_sample, ) + @Slot() + def enter_portrait_mode(self) -> None: + """Switch to the portrait / phone-screen view and resize the window.""" + self._pre_portrait_geometry = self.saveGeometry() + + self.portrait_mode_panel.set_running(self.job_list_panel.is_running()) + self.portrait_mode_panel.set_samples_in_queue( + len(self.job_list_panel.table_model.samples) + ) + self._refresh_portrait_queue_preview() + self.content_stack.setCurrentWidget(self.portrait_mode_page) + + # ── Camera: scale-to-fit + hide legend ───────────────────────────── + self.portrait_sample_camera.set_show_overlay_legend(False) + try: + self.portrait_sample_camera._SampleCameraImageLabel__autoscale = True + self.portrait_sample_camera._SampleCameraImageLabel__scaling() + except Exception: + pass + + # ── Hide all chrome that contributes to window width ──────────────── + if self.status_bar is not None: + self.status_bar.setVisible(False) + self.menuBar().setVisible(False) + + # Alert banners take up horizontal space even when hidden via QFrame + # — force them to zero height so they cannot influence the minimum width. + self.alert_banner.setVisible(False) + self.alert_banner.setMaximumHeight(0) + self.alert_banner_secondary.setVisible(False) + self.alert_banner_secondary.setMaximumHeight(0) + + # Hide all dock widgets + for dock_attr in ( + "tell_samples_dock", + "job_list_dock", + "manual_sample_dock", + "automation_progress_dock", + "face_panel_dock", + "fluor_panel_dock", + "smargon_trace_dock", + "target_stability_dock", + "prediction_metrics_dock", + "log_dock", + "ref_tools_dock", + ): + dock = getattr(self, dock_attr, None) + if dock is not None: + dock.setVisible(False) + + # ── Resize to phone footprint ─────────────────────────────────────── + screen = QGuiApplication.screenAt(self.geometry().center()) + if screen is None: + screen = QGuiApplication.primaryScreen() + + available = screen.availableGeometry() + portrait_w = self.portrait_mode_panel.PORTRAIT_WIDTH + 24 + portrait_h = min(860, available.height() - 40) + + new_x = available.x() + (available.width() - portrait_w) // 2 + new_y = available.y() + (available.height() - portrait_h) // 2 + + self.setMinimumWidth(portrait_w) + self.setMaximumWidth(portrait_w) + self.resize(portrait_w, portrait_h) + self.move(new_x, new_y) + + @Slot() + def _return_from_portrait_mode(self) -> None: + """Restore the window to its pre-portrait geometry and switch page.""" + # ── Lift hard width cap before restoring geometry ─────────────────── + self.setMinimumWidth(0) + self.setMaximumWidth(16777215) # Qt's QWIDGETSIZE_MAX + + self.content_stack.setCurrentWidget(self._standard_main_page) + + # ── Restore chrome ────────────────────────────────────────────────── + if self.status_bar is not None: + self.status_bar.setVisible(True) + self.menuBar().setVisible(True) + + # Restore alert banners to normal operation + self.alert_banner.setMaximumHeight(16777215) + self.alert_banner_secondary.setMaximumHeight(16777215) + # Replay any pending messages that arrived during portrait mode + self.portrait_mode_panel._flush_portrait_alerts_to_banners( + self.alert_banner, self.alert_banner_secondary + ) + + # ── Restore camera legend ─────────────────────────────────────────── + try: + settings = self.portrait_sample_camera.target_overlay_settings() + self.portrait_sample_camera.set_show_overlay_legend( + settings.get("show_overlay_legend", True) + ) + except Exception: + pass + + if hasattr(self, "_pre_portrait_geometry") and self._pre_portrait_geometry: + self.restoreGeometry(self._pre_portrait_geometry) + self._pre_portrait_geometry = None + + self.tell_samples_dock.setVisible(True) + self.job_list_dock.setVisible(True) + self.manual_sample_dock.setVisible(True) + self.automation_progress_dock.setVisible(False) + self.face_panel_dock.setVisible(False) + self.fluor_panel_dock.setVisible(False) + self.smargon_trace_dock.setVisible(False) + self.target_stability_dock.setVisible(False) + self.prediction_metrics_dock.setVisible(False) + self.log_dock.setVisible(False) + + @Slot(str, bool) + def _portrait_alert_primary(self, msg: str, is_error: bool) -> None: + """Route primary alert banner — use in-panel toast in portrait mode.""" + if self.content_stack.currentWidget() is self.portrait_mode_page: + self.portrait_mode_panel.show_portrait_alert(msg, is_error) + else: + self.alert_banner.show_message(msg, is_error) + + @Slot(str, bool) + def _portrait_alert_secondary(self, msg: str, is_error: bool) -> None: + """Route secondary alert banner — use in-panel toast in portrait mode.""" + if self.content_stack.currentWidget() is self.portrait_mode_page: + self.portrait_mode_panel.show_portrait_alert(msg, is_error) + else: + self.alert_banner_secondary.show_message(msg, is_error) + + @Slot() + def _refresh_portrait_queue_preview(self) -> None: + self.portrait_mode_panel.refresh_queue_preview() + @staticmethod def _annotation_token(annotation: str) -> str: mapping = { @@ -1079,6 +1274,11 @@ class MainWindow(QMainWindow): self._return_main_view_action.triggered.connect(self._return_from_compact_automation_view) menu_bar.addAction(self._return_main_view_action) + self._portrait_mode_action = QAction("Portrait Mode", self) + self._portrait_mode_action.setShortcut(QKeySequence("Ctrl+6")) + self._portrait_mode_action.triggered.connect(self.enter_portrait_mode) + menu_bar.addAction(self._portrait_mode_action) + view_menu = menu_bar.addMenu("View") if self._beamline_state_panel_enabled: diff --git a/src/aare/gui/panels/portrait_mode.py b/src/aare/gui/panels/portrait_mode.py new file mode 100644 index 00000000..d5c1a977 --- /dev/null +++ b/src/aare/gui/panels/portrait_mode.py @@ -0,0 +1,695 @@ +from __future__ import annotations + +import math + +from PySide6.QtCore import Qt, QPointF, QRectF, Signal, Slot, QTimer +from PySide6.QtGui import ( + QColor, QFont, QFontMetrics, QLinearGradient, QPainter, QPen, +) +from PySide6.QtWidgets import ( + QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea, + QSizePolicy, QStackedWidget, QVBoxLayout, QWidget, +) + +from aare.common.automation_models import AutomationProgress, StepStatus, WorkflowStateKind + +# --------------------------------------------------------------------------- +# Colour palette (kept identical to gui_designer.py) +# --------------------------------------------------------------------------- +BG = "#071018" +CARD_BG = "#0E1A26" +ACCENT = "#62D8C8" +ACCENT_DIM = "#1A3A36" +TEXT = "#F5F7FA" +SUBTEXT = "#8A9BB0" +BUTTON_BG = "#132131" +LED_OFF = "#1C2E3E" +ACTIVE_STEP = "#FFFFFF" + +PORTRAIT_STYLE = f""" +QWidget {{ + background: {BG}; + color: {TEXT}; + font-family: 'Inter', 'SF Pro Display', Arial, sans-serif; + font-size: 14px; +}} +QScrollArea {{ + border: none; + background: transparent; +}} +QScrollBar:vertical {{ + background: {CARD_BG}; + width: 4px; + border-radius: 2px; +}} +QScrollBar::handle:vertical {{ + background: {ACCENT_DIM}; + border-radius: 2px; + min-height: 20px; +}} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + height: 0px; +}} +""" + + +# --------------------------------------------------------------------------- +# LED step indicator +# --------------------------------------------------------------------------- +class LEDStages(QWidget): + STEPS = ["Mount", "Centre", "Raster", "Collect"] + + # WorkflowStateKind → LED index + _KIND_TO_INDEX: dict[WorkflowStateKind, int] = { + WorkflowStateKind.MOUNT: 0, + WorkflowStateKind.LOOP_CENTRE: 1, + WorkflowStateKind.RASTER: 2, + WorkflowStateKind.DATA_COLLECTION: 3, + } + + def __init__(self, active_step: int = 0, parent=None): + super().__init__(parent) + self._active = active_step + self.setFixedHeight(72) + + def set_active_step(self, step: int) -> None: + self._active = step + self.update() + + def set_from_progress(self, progress: AutomationProgress) -> None: + """Derive active LED index from an AutomationProgress object.""" + running_index = -1 + last_success = -1 + + for step_state in progress.steps: + idx = self._KIND_TO_INDEX.get(step_state.step) + if idx is None: + continue + if step_state.status == StepStatus.RUNNING: + running_index = idx + elif step_state.status == StepStatus.SUCCESS and idx > last_success: + last_success = idx + + if running_index >= 0: + self.set_active_step(running_index) + elif last_success >= 0: + self.set_active_step(min(last_success + 1, len(self.STEPS) - 1)) + else: + self.set_active_step(0) + + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + + w = self.width() + n = len(self.STEPS) + step_w = w / n + led_r = 10 + cy = 44 + label_y = 16 + + for i, name in enumerate(self.STEPS): + cx = step_w * i + step_w / 2 + + if i < n - 1: + next_cx = step_w * (i + 1) + step_w / 2 + line_color = QColor(ACCENT) if i < self._active else QColor(LED_OFF) + pen = QPen(line_color, 2) + p.setPen(pen) + p.drawLine( + QPointF(cx + led_r + 3, cy), + QPointF(next_cx - led_r - 3, cy), + ) + + p.setPen(Qt.NoPen) + if i < self._active: + p.setBrush(QColor(ACCENT)) + p.drawEllipse(QPointF(cx, cy), led_r, led_r) + pen = QPen(QColor(BG), 2) + pen.setCapStyle(Qt.RoundCap) + p.setPen(pen) + p.drawLine(QPointF(cx - 4, cy), QPointF(cx - 1, cy + 3)) + p.drawLine(QPointF(cx - 1, cy + 3), QPointF(cx + 4, cy - 3)) + elif i == self._active: + glow_pen = QPen(QColor(ACCENT + "55"), 4) + p.setPen(glow_pen) + p.setBrush(Qt.NoBrush) + p.drawEllipse(QPointF(cx, cy), led_r + 4, led_r + 4) + p.setPen(Qt.NoPen) + p.setBrush(QColor(ACTIVE_STEP)) + p.drawEllipse(QPointF(cx, cy), led_r, led_r) + else: + p.setBrush(QColor(LED_OFF)) + p.drawEllipse(QPointF(cx, cy), led_r, led_r) + + label_color = QColor(ACCENT) if i <= self._active else QColor(SUBTEXT) + p.setPen(label_color) + font = QFont("Inter", 10) + if i == self._active: + font.setWeight(QFont.Weight.DemiBold) + p.setFont(font) + fm = QFontMetrics(font) + text_w = fm.horizontalAdvance(name) + p.drawText( + QRectF(cx - text_w / 2 - 4, 0, text_w + 8, label_y + 2), + Qt.AlignCenter, + name, + ) + + +# --------------------------------------------------------------------------- +# Play/Pause primary button +# --------------------------------------------------------------------------- +class PlayPauseButton(QPushButton): + def __init__(self, parent=None): + super().__init__(parent) + self._hovered = False + self._running = False + self.setFixedSize(64, 64) + self.setMouseTracking(True) + + def set_running(self, running: bool) -> None: + self._running = running + self.update() + + def enterEvent(self, event): + self._hovered = True + self.update() + + def leaveEvent(self, event): + self._hovered = False + self.update() + + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + rect = self.rect() + cx, cy = rect.width() / 2, rect.height() / 2 + r = min(rect.width(), rect.height()) / 2 - 2 + + bg_color = QColor("#FFFFFF") if self._hovered else QColor(ACCENT) + p.setBrush(bg_color) + p.setPen(Qt.NoPen) + p.drawEllipse(QPointF(cx, cy), r, r) + + sym = "⏸" if self._running else "▶" + p.setPen(QPen(QColor(BG), 2)) + font = QFont("Arial", 16) + p.setFont(font) + p.drawText(rect, Qt.AlignCenter, sym) + + +# --------------------------------------------------------------------------- +# Queue item card +# --------------------------------------------------------------------------- +class QueueItemCard(QFrame): + def __init__( + self, + index: int | str, + title: str, + subtitle: str, + is_next: bool = False, + parent=None, + ): + super().__init__(parent) + self.setFixedHeight(72) + self.setStyleSheet(f""" + QFrame {{ + background: {"#112030" if is_next else "#0C1720"}; + border-radius: 14px; + border: {"1px solid " + ACCENT_DIM if is_next else "none"}; + }} + """) + + layout = QHBoxLayout(self) + layout.setContentsMargins(14, 0, 14, 0) + layout.setSpacing(12) + + badge = QLabel() + badge.setFixedSize(32, 32) + badge.setAlignment(Qt.AlignCenter) + if is_next: + badge.setText("▶") + badge.setStyleSheet(f""" + color: {ACCENT}; background: {ACCENT_DIM}; + border-radius: 16px; font-size: 12px; font-weight: bold; + """) + else: + badge.setText(str(index)) + badge.setStyleSheet(f""" + color: {SUBTEXT}; background: {BUTTON_BG}; + border-radius: 16px; font-size: 12px; + """) + layout.addWidget(badge) + + text_col = QVBoxLayout() + text_col.setSpacing(2) + text_col.setContentsMargins(0, 0, 0, 0) + + title_lbl = QLabel(title) + title_lbl.setStyleSheet( + f"color: {TEXT}; font-size: 13px; font-weight: 600; background: transparent;" + ) + title_lbl.setWordWrap(False) + sub_lbl = QLabel(subtitle) + sub_lbl.setStyleSheet( + f"color: {SUBTEXT}; font-size: 11px; background: transparent;" + ) + text_col.addWidget(title_lbl) + text_col.addWidget(sub_lbl) + layout.addLayout(text_col, stretch=1) + + +# --------------------------------------------------------------------------- +# Main portrait-mode panel +# --------------------------------------------------------------------------- +class PortraitModePanel(QWidget): + PORTRAIT_WIDTH = 420 + grab_session_requested = Signal() + + def __init__(self, sample_camera_widget: QWidget, parent=None): + super().__init__(parent) + self.setStyleSheet(PORTRAIT_STYLE) + self.setMaximumWidth(self.PORTRAIT_WIDTH) + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) + + self._job_list_panel = None + self._tell_samples = None + self._is_running = False + + # Pending alert messages received while in portrait mode + # Each entry: (msg, is_error) + self._pending_alerts: list[tuple[str, bool]] = [] + + self._stack = QStackedWidget() + self._player_page = self._build_player(sample_camera_widget) + self._queue_page = self._build_queue() + self._stack.addWidget(self._player_page) + self._stack.addWidget(self._queue_page) + + root = QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.addWidget(self._stack) + + # ------------------------------------------------------------------ + # Player page + # ------------------------------------------------------------------ + def _build_player(self, cam_widget: QWidget) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + layout.setSpacing(10) + layout.setContentsMargins(0, 0, 0, 0) + + # Title + title = QLabel("S A M C A M E R A") + title.setAlignment(Qt.AlignCenter) + title.setStyleSheet( + f"color: {ACCENT}; font-size: 13px; letter-spacing: 3px; font-weight: 600;" + ) + layout.addWidget(title) + + # ── Portrait alert toast (hidden by default) ─────────────────────── + self._alert_toast = QFrame() + self._alert_toast.setVisible(False) + self._alert_toast.setStyleSheet(f""" + QFrame {{ + background: #1A0E0E; + border: 1px solid #8f1d2c; + border-radius: 10px; + }} + """) + toast_layout = QHBoxLayout(self._alert_toast) + toast_layout.setContentsMargins(12, 8, 12, 8) + self._alert_toast_label = QLabel("") + self._alert_toast_label.setWordWrap(True) + self._alert_toast_label.setStyleSheet( + f"color: #ffb3bc; font-size: 11px; font-weight: 600; background: transparent;" + ) + toast_layout.addWidget(self._alert_toast_label) + # Dismiss button + dismiss_btn = QPushButton("✕") + dismiss_btn.setFixedSize(20, 20) + dismiss_btn.setStyleSheet(f""" + QPushButton {{ + color: {SUBTEXT}; + background: transparent; + border: none; + font-size: 11px; + }} + QPushButton:hover {{ color: {TEXT}; }} + """) + dismiss_btn.clicked.connect(self._dismiss_portrait_alert) + toast_layout.addWidget(dismiss_btn) + layout.addWidget(self._alert_toast) + + self._alert_toast_timer = QTimer(self) + self._alert_toast_timer.setSingleShot(True) + self._alert_toast_timer.timeout.connect(self._dismiss_portrait_alert) + + # Camera card — wraps the real compact_sample_camera + cam_card = QFrame() + cam_card.setStyleSheet(f"QFrame {{ background: {CARD_BG}; border-radius: 18px; }}") + cam_card_layout = QVBoxLayout(cam_card) + cam_card_layout.setContentsMargins(4, 4, 4, 4) + cam_card_layout.setSpacing(0) + cam_widget.setMinimumHeight(220) + cam_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + cam_card_layout.addWidget(cam_widget) + layout.addWidget(cam_card) + + # Sample name labels + self._name_lbl = QLabel("—") + self._name_lbl.setStyleSheet( + f"color: {TEXT}; font-size: 18px; font-weight: 700;" + ) + self._sub_lbl = QLabel("No sample queued") + self._sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: 12px;") + layout.addWidget(self._name_lbl) + layout.addWidget(self._sub_lbl) + + # LED step indicator + self._leds = LEDStages(active_step=0) + layout.addWidget(self._leds) + + # Play/Pause button (single, centred) + ctrl_frame = QFrame() + ctrl_frame.setStyleSheet( + f"QFrame {{ background: {CARD_BG}; border-radius: 18px; }}" + ) + ctrl_layout = QHBoxLayout(ctrl_frame) + ctrl_layout.setContentsMargins(16, 12, 16, 12) + ctrl_layout.addStretch(1) + self._play_pause_btn = PlayPauseButton() + self._play_pause_btn.clicked.connect(self._on_play_pause_clicked) + ctrl_layout.addWidget(self._play_pause_btn, alignment=Qt.AlignCenter) + ctrl_layout.addStretch(1) + layout.addWidget(ctrl_frame) + + # "UP NEXT" header + up_next_row = QHBoxLayout() + up_next_lbl = QLabel("UP NEXT") + up_next_lbl.setStyleSheet( + f"color: {ACCENT}; font-size: 11px; letter-spacing: 2px; font-weight: 700;" + ) + self._samples_count_lbl = QLabel("0 SAMPLES") + self._samples_count_lbl.setStyleSheet( + f"color: {SUBTEXT}; font-size: 11px; letter-spacing: 1px;" + ) + up_next_row.addWidget(up_next_lbl) + up_next_row.addStretch() + up_next_row.addWidget(self._samples_count_lbl) + layout.addLayout(up_next_row) + + # Preview card container (up to 4 cards) + self._preview_container = QWidget() + self._preview_layout = QVBoxLayout(self._preview_container) + self._preview_layout.setSpacing(6) + self._preview_layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._preview_container) + + # View full queue button + view_btn = self._accent_button("VIEW FULL QUEUE ☰") + view_btn.clicked.connect(self._on_view_full_queue) + layout.addWidget(view_btn) + + # ── Session / utility row ────────────────────────────────────────── + util_row = QHBoxLayout() + util_row.setSpacing(8) + + self._grab_session_btn = self._accent_button("⚡ GRAB SESSION") + self._grab_session_btn.clicked.connect(self.grab_session_requested) + util_row.addWidget(self._grab_session_btn) + + layout.addLayout(util_row) + + # Back to main view button + self._back_btn = self._accent_button("← MAIN VIEW") + layout.addWidget(self._back_btn) + # Connected externally by MainWindow + + return page + + # ------------------------------------------------------------------ + # Queue / full list page + # ------------------------------------------------------------------ + def _build_queue(self) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + layout.setSpacing(10) + layout.setContentsMargins(0, 0, 0, 0) + + title = QLabel("SAMPLE QUEUE") + title.setAlignment(Qt.AlignCenter) + title.setStyleSheet( + f"color: {ACCENT}; font-size: 13px; letter-spacing: 3px; font-weight: 700;" + ) + layout.addWidget(title) + + self._queue_scroll = QScrollArea() + self._queue_scroll.setWidgetResizable(True) + self._queue_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + self._queue_inner = QWidget() + self._queue_inner_layout = QVBoxLayout(self._queue_inner) + self._queue_inner_layout.setSpacing(6) + self._queue_inner_layout.setContentsMargins(0, 4, 0, 4) + + self._queue_scroll.setWidget(self._queue_inner) + layout.addWidget(self._queue_scroll, stretch=1) + + back_btn = self._accent_button("← BACK TO CAMERA") + back_btn.clicked.connect(lambda: self._stack.setCurrentWidget(self._player_page)) + layout.addWidget(back_btn) + + return page + + # ------------------------------------------------------------------ + # Wiring + # ------------------------------------------------------------------ + def wire_to_main_window(self, job_list_panel, tell_samples) -> None: + """Call this from MainWindow after both panels are constructed.""" + self._job_list_panel = job_list_panel + self._tell_samples = tell_samples + + # ------------------------------------------------------------------ + # Public update slots + # ------------------------------------------------------------------ + @Slot(object) + def set_progress(self, progress: AutomationProgress) -> None: + """Driven by daq.automation_progress signal.""" + self._leds.set_from_progress(progress) + + @Slot(bool) + def set_running(self, running: bool) -> None: + self._is_running = running + self._play_pause_btn.set_running(running) + + @Slot(int) + def set_samples_in_queue(self, count: int) -> None: + label = f"{count} SAMPLE{'S' if count != 1 else ''}" + self._samples_count_lbl.setText(label) + + def refresh_queue_preview(self) -> None: + """ + Refresh sample name, subtitle, and preview cards from queue_preview(). + Falls back to tell_samples sorted by location when the queue is empty. + """ + if self._job_list_panel is None: + return + + current, nxt, nxt2 = self._job_list_panel.queue_preview() + + # --- sample name / subtitle --- + if current is not None: + name = str(getattr(current, "sample_name", "") or "—") + puck = str(getattr(current, "puck_name", "") or "") + pin = getattr(current, "pin", None) + subtitle_parts = [puck] + if pin is not None: + subtitle_parts.append(f"Pin {pin}") + self._name_lbl.setText(name) + self._sub_lbl.setText(" · ".join(p for p in subtitle_parts if p)) + else: + self._name_lbl.setText("—") + self._sub_lbl.setText("No sample queued") + + # --- preview cards --- + # Clear existing + while self._preview_layout.count(): + item = self._preview_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + previews = [s for s in [current, nxt, nxt2] if s is not None] + + # If queue is empty, suggest from tell_samples sorted by location + if not previews and self._tell_samples is not None: + raw = list(getattr(self._tell_samples.table_model, "samples", [])) + suggested = sorted( + [s for s in raw if getattr(s, "location", None) is not None], + key=lambda s: s.loc_str_sort() if hasattr(s, "loc_str_sort") else "", + )[:4] + for i, s in enumerate(suggested): + name = str(getattr(s, "sample_name", "") or f"Sample {i + 1}") + puck = str(getattr(s, "puck_name", "") or "") + card = QueueItemCard( + index=i + 1, + title=name, + subtitle=puck, + is_next=False, + ) + self._preview_layout.addWidget(card) + return + + for i, sample in enumerate(previews): + name = str(getattr(sample, "sample_name", "") or f"Sample {i + 1}") + puck = str(getattr(sample, "puck_name", "") or "") + card = QueueItemCard( + index="▶" if i == 0 else i + 1, + title=name, + subtitle=puck, + is_next=(i == 0), + ) + self._preview_layout.addWidget(card) + + # ------------------------------------------------------------------ + # Full queue page population + # ------------------------------------------------------------------ + def _on_view_full_queue(self) -> None: + self._rebuild_full_queue() + self._stack.setCurrentWidget(self._queue_page) + + def _rebuild_full_queue(self) -> None: + # Clear existing cards + while self._queue_inner_layout.count(): + item = self._queue_inner_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + samples = [] + + if self._job_list_panel is not None: + raw = list(getattr(self._job_list_panel.table_model, "samples", [])) + samples = raw + + # Fall back to tell_samples sorted by location if queue is empty + if not samples and self._tell_samples is not None: + raw = list(getattr(self._tell_samples.table_model, "samples", [])) + samples = sorted( + [s for s in raw if getattr(s, "location", None) is not None], + key=lambda s: s.loc_str_sort() if hasattr(s, "loc_str_sort") else "", + ) + + if not samples: + placeholder = QLabel("No samples in queue") + placeholder.setStyleSheet(f"color: {SUBTEXT}; font-size: 13px;") + placeholder.setAlignment(Qt.AlignCenter) + self._queue_inner_layout.addWidget(placeholder) + return + + for i, sample in enumerate(samples): + name = str(getattr(sample, "sample_name", "") or f"Sample {i + 1}") + puck = str(getattr(sample, "puck_name", "") or "") + card = QueueItemCard( + index=i + 1, + title=name, + subtitle=puck, + is_next=False, + ) + self._queue_inner_layout.addWidget(card) + + # ------------------------------------------------------------------ + # Play/Pause handler + # ------------------------------------------------------------------ + @Slot() + def _on_play_pause_clicked(self) -> None: + if self._job_list_panel is None: + return + if self._is_running: + self._job_list_panel.pause_automation() + else: + self._job_list_panel.run() + + # ------------------------------------------------------------------ + # Helper + # ------------------------------------------------------------------ + @staticmethod + def _accent_button(text: str) -> QPushButton: + btn = QPushButton(text) + btn.setFixedHeight(48) + btn.setStyleSheet(f""" + QPushButton {{ + background: transparent; + border: 1.5px solid {ACCENT}; + border-radius: 14px; + color: {ACCENT}; + font-size: 12px; + font-weight: 700; + letter-spacing: 1.5px; + }} + QPushButton:hover {{ + background: {ACCENT_DIM}; + }} + QPushButton:pressed {{ + background: {ACCENT}; + color: {BG}; + }} + """) + return btn + + # ------------------------------------------------------------------ + # Portrait alert toast + # ------------------------------------------------------------------ + + @Slot(str, bool) + def show_portrait_alert(self, msg: str, is_error: bool) -> None: + """Show a compact dark-themed alert inside the portrait panel.""" + if not msg: + self._dismiss_portrait_alert() + return + + # Store for replay when returning to main view + self._pending_alerts.append((msg, is_error)) + + icon = "🛑" if is_error else "✅" + self._alert_toast_label.setText(f"{icon} {msg}") + + border_color = "#8f1d2c" if is_error else "#2a7a44" + text_color = "#ffb3bc" if is_error else "#a8f0c0" + bg_color = "#1A0E0E" if is_error else "#0E1A12" + + self._alert_toast.setStyleSheet(f""" + QFrame {{ + background: {bg_color}; + border: 1px solid {border_color}; + border-radius: 10px; + }} + """) + self._alert_toast_label.setStyleSheet( + f"color: {text_color}; font-size: 11px; font-weight: 600; background: transparent;" + ) + self._alert_toast.setVisible(True) + + # Auto-dismiss success after 5 s; errors persist until dismissed + self._alert_toast_timer.stop() + if not is_error: + self._alert_toast_timer.start(5000) + + @Slot() + def _dismiss_portrait_alert(self) -> None: + self._alert_toast_timer.stop() + self._alert_toast.setVisible(False) + self._alert_toast_label.clear() + + def _flush_portrait_alerts_to_banners(self, primary_banner, secondary_banner) -> None: + """ + Called when returning to main view — replay any error alerts that + arrived during portrait mode so the operator doesn't miss them. + Only the last error (if any) is surfaced to avoid flooding. + """ + errors = [(m, e) for m, e in self._pending_alerts if e] + if errors: + last_msg, last_is_error = errors[-1] + primary_banner.show_message(last_msg, last_is_error) + self._pending_alerts.clear() + self._dismiss_portrait_alert() \ No newline at end of file diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index 42fdfa7b..1d33b830 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -505,6 +505,10 @@ class SampleCameraImageLabel(QGraphicsView): show_coord_action.setCheckable(True) show_coord_action.setChecked(self.__show_coords) + show_detections_action = menu.addAction("Show ML predictions") + show_detections_action.setCheckable(True) + show_detections_action.setChecked(self.__show_detections) + grab_action = menu.addAction("Grab") grab_with_overlay_action = menu.addAction("Grab with overlay") @@ -533,6 +537,9 @@ class SampleCameraImageLabel(QGraphicsView): elif action == scale_action: self.__autoscale = not self.__autoscale self.__scaling() + elif action == show_detections_action: + self.__show_detections = not self.__show_detections + self.update() elif action == delete_action: self.clear_grid.emit() elif action == evaluate_action: From 08d6cbc69940a9ac1ef7f21595c9647efa974bec Mon Sep 17 00:00:00 2001 From: appleb_m Date: Thu, 18 Jun 2026 14:06:14 +0200 Subject: [PATCH 58/76] config, added lens_magnfiication to alter pixel_to_mm calculation --- src/aare/common/config/x06da.yaml | 1 + src/aare/common/config/x10sa.yaml | 1 + src/aare/daq/config.py | 13 ++++++++++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/aare/common/config/x06da.yaml b/src/aare/common/config/x06da.yaml index 0d2ed002..795079f6 100644 --- a/src/aare/common/config/x06da.yaml +++ b/src/aare/common/config/x06da.yaml @@ -27,6 +27,7 @@ daq: redis_url: "x06da-redis.psi.ch" aarelc_url: "http://sls-gpu-003:9094" dtz_safe_position: null + lens_magnification: 10 #or 5 currently db: aaredb_url: "https://mx-aaredb-dmz-01.psi.ch/dispatcher" diff --git a/src/aare/common/config/x10sa.yaml b/src/aare/common/config/x10sa.yaml index 5c5d4240..161abf53 100644 --- a/src/aare/common/config/x10sa.yaml +++ b/src/aare/common/config/x10sa.yaml @@ -26,6 +26,7 @@ daq: redis_url: "x10sa-redis.psi.ch" aarelc_url: "http://sls-gpu-003:9090" dtz_safe_position: 300 + lens_magnification: 10 #or 5 currently db: aaredb_url: "https://mx-aaredb-dmz-01.psi.ch/dispatcher" diff --git a/src/aare/daq/config.py b/src/aare/daq/config.py index 049c1da4..f78d322e 100644 --- a/src/aare/daq/config.py +++ b/src/aare/daq/config.py @@ -47,6 +47,7 @@ ABR_POS_MOUNT = AerotechCoordinate( omega_deg=0 ) ABR_OMEGA_MOUNT = 0.0 +DEFAULT_LENS_MAGNIFICATION = 10.0 logger = setup_logger("aareDAQ") @@ -608,10 +609,20 @@ class BeamlineConfig: def pixel_to_mm(self, zoom: float) -> float: cfg = self.settings - return 1.0 / ( + 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) @property def beam_center(self) -> Tuple[float, float]: From 157619de643d4e05209baa339b38db50ac83c5c5 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Thu, 18 Jun 2026 14:26:12 +0200 Subject: [PATCH 59/76] Added style sheet options - protrait style needs to be added to more places. --- src/aare/gui/main_window.py | 111 +++- src/aare/gui/panels/axis_video_panel.py | 84 +-- src/aare/gui/panels/beamline_state_panel.py | 67 +-- .../gui/panels/compact_automation_panel.py | 104 ---- src/aare/gui/panels/portrait_mode.py | 29 +- src/aare/gui/styles.py | 493 ++++++++++++++++++ src/aare/gui/widgets/alert_banner.py | 59 +-- tests/unit/gui/test_main_window.py | 65 ++- 8 files changed, 692 insertions(+), 320 deletions(-) create mode 100644 src/aare/gui/styles.py diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 507a68a5..1baf7b61 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -2,7 +2,7 @@ import time import jwt from PySide6.QtCore import Qt, Slot, Signal, QTimer, QSettings, QEvent -from PySide6.QtGui import QAction, QPixmap, QKeySequence, QGuiApplication +from PySide6.QtGui import QAction, QPixmap, QKeySequence, QGuiApplication, QActionGroup from PySide6.QtWidgets import ( QMainWindow, QWidget, @@ -11,12 +11,11 @@ from PySide6.QtWidgets import ( QMessageBox, QDockWidget, QTabWidget, - QStackedWidget + QStackedWidget, ) #Common imports from aare.common.auth_models import BatonStatus -from aare.common.beamline import cfg_get from aare.common.coordinate import Coordinate, SmargonCoordinate from aare.common.diffraction_geometry import DiffractionGeometry from aare.common.logger_config import setup_logger @@ -25,6 +24,7 @@ from aare.common.sample_geometry import SampleGeometryModel #Gui Models from aare.gui.models.gui_state_manager import UIStateManager +from aare.gui.styles import build_app_stylesheet, THEME_ORIGINAL, THEME_PORTRAIT #panels from aare.gui.panels.LogPanel import LogDock @@ -95,11 +95,17 @@ class MainWindow(QMainWindow): ): super().__init__() + self._theme_mode = THEME_ORIGINAL + self._theme_action_group = None + self._use_legacy_theme_action = None + self._use_portrait_theme_action = None + self.__base_url = base_url self.__token = token self.__mounting = False self.__samcam_feed_banner_active = False self.__samcam_feed_banner_message = "Sample camera feed unavailable" + self._automation_critical_banner_active = False self._dev_help_dialog = None self._beamline_recovery_dialog = None @@ -166,6 +172,7 @@ class MainWindow(QMainWindow): self.setStyleSheet("background-color: rgb(216, 228, 253);") root_widget = QWidget(parent=self) + root_widget.setObjectName("mainContentRoot") root_layout = QVBoxLayout(root_widget) root_layout.setContentsMargins(0, 0, 0, 0) root_layout.setSpacing(0) @@ -180,6 +187,7 @@ class MainWindow(QMainWindow): root_layout.addWidget(self.content_stack, 1) top_widget = QWidget(parent=root_widget) + top_widget.setObjectName("standardMainPage") top_widget_layout = QHBoxLayout(top_widget) top_widget.setLayout(top_widget_layout) @@ -300,21 +308,12 @@ class MainWindow(QMainWindow): self.compact_automation_page = QWidget(parent=root_widget) self.compact_automation_page.setObjectName("compactAutomationPage") - self.compact_automation_page.setStyleSheet( - """ - QWidget#compactAutomationPage { - background-color: rgb(216, 228, 253); - } - """ - ) self.compact_automation_page_layout = QVBoxLayout(self.compact_automation_page) self.compact_automation_page_layout.setContentsMargins(18, 18, 18, 18) self.compact_automation_page_layout.setSpacing(0) self.compact_automation_page_layout.addWidget(self.compact_automation_panel) # ── Portrait mode page ────────────────────────────────────────── - # Uses a dedicated SampleCameraImageLabel so the compact_sample_camera - # remains available for the existing compact automation view. self.portrait_sample_camera = SampleCameraImageLabel( geom=geom, raster=self.raster, @@ -325,21 +324,9 @@ class MainWindow(QMainWindow): sample_camera_widget=self.portrait_sample_camera, parent=root_widget, ) - self.portrait_mode_panel.setObjectName("portraitModePage") - self.portrait_mode_panel.setStyleSheet( - self.portrait_mode_panel.styleSheet() + - "QWidget#portraitModePage { background-color: #071018; }" - ) self.portrait_mode_page = QWidget(parent=root_widget) self.portrait_mode_page.setObjectName("portraitModePage") - self.portrait_mode_page.setStyleSheet( - """ - QWidget#portraitModePage { - background-color: #071018; - } - """ - ) portrait_page_layout = QHBoxLayout(self.portrait_mode_page) portrait_page_layout.setContentsMargins(0, 0, 0, 0) portrait_page_layout.setSpacing(0) @@ -534,6 +521,8 @@ class MainWindow(QMainWindow): self.setCentralWidget(root_widget) self.setWindowTitle("AareGUI") + self._restore_theme_settings() + self._apply_theme() self.create_menu_bar() self._update_view_mode_actions() self._setup_global_shortcuts() @@ -874,6 +863,23 @@ class MainWindow(QMainWindow): ) self.addAction(self._shortcut_console_log) + def _return_to_main_view_for_shutdown(self) -> None: + try: + if getattr(self, "content_stack", None) is None: + return + + current_widget = self.content_stack.currentWidget() + + if hasattr(self, "portrait_mode_page") and current_widget is self.portrait_mode_page: + self._return_from_portrait_mode() + elif bool(getattr(self, "_in_compact_automation_view", False)): + self._return_from_compact_automation_view() + + if hasattr(self, "content_stack") and hasattr(self, "_standard_main_page"): + self.content_stack.setCurrentWidget(self._standard_main_page) + except Exception as e: + logger.warning(f"Failed to return to main view during shutdown: {e}") + def _restore_samcam_overlay_settings(self) -> None: settings = QSettings("PSI", "AareGUI") show_detections = settings.value("samcam/show_detections", True, type=bool) @@ -1253,9 +1259,29 @@ class MainWindow(QMainWindow): def start_interactive_tutorial(self) -> None: self.tutorial_manager.start("manual_workflow_demo") + def _apply_theme(self) -> None: + self.setStyleSheet(build_app_stylesheet(self._theme_mode)) + + def _restore_theme_settings(self) -> None: + settings = QSettings("PSI", "AareGUI") + self._theme_mode = settings.value("appearance/theme", THEME_ORIGINAL, type=str) + + def _save_theme_settings(self) -> None: + settings = QSettings("PSI", "AareGUI") + settings.setValue("appearance/theme", self._theme_mode) + + @Slot() + def use_legacy_theme(self) -> None: + self._theme_mode = THEME_ORIGINAL + self._apply_theme() + + @Slot() + def use_portrait_theme(self) -> None: + self._theme_mode = THEME_PORTRAIT + self._apply_theme() + def create_menu_bar(self): """Create a menu bar with File->Quit and Help->About.""" - # Main menu bar menu_bar = self.menuBar() file_menu = menu_bar.addMenu("File") @@ -1281,6 +1307,25 @@ class MainWindow(QMainWindow): view_menu = menu_bar.addMenu("View") + self._theme_action_group = QActionGroup(self) + self._theme_action_group.setExclusive(True) + + self._use_legacy_theme_action = QAction("Legacy Theme", self) + self._use_legacy_theme_action.setCheckable(True) + self._use_legacy_theme_action.setChecked(self._theme_mode == THEME_ORIGINAL) + self._use_legacy_theme_action.triggered.connect(self.use_legacy_theme) + self._theme_action_group.addAction(self._use_legacy_theme_action) + + self._use_portrait_theme_action = QAction("Portrait Theme", self) + self._use_portrait_theme_action.setCheckable(True) + self._use_portrait_theme_action.setChecked(self._theme_mode == THEME_PORTRAIT) + self._use_portrait_theme_action.triggered.connect(self.use_portrait_theme) + self._theme_action_group.addAction(self._use_portrait_theme_action) + + view_menu.addAction(self._use_legacy_theme_action) + view_menu.addAction(self._use_portrait_theme_action) + view_menu.addSeparator() + if self._beamline_state_panel_enabled: self._show_beamline_state_action = QAction("Show Beamline State Panel", self) self._show_beamline_state_action.setCheckable(True) @@ -2006,16 +2051,22 @@ class MainWindow(QMainWindow): self._restore_panel_visibility_settings() def closeEvent(self, event) -> None: + try: + self._return_to_main_view_for_shutdown() + except Exception as e: + logger.warning(f"Failed to restore main view before close: {e}") + try: # TODO put all setting related handling into state_manager self.state_manager.save_window(self) self._save_samcam_overlay_settings() self._save_panel_visibility_settings() + self._save_theme_settings() except Exception as e: logger.warning(f"Failed to save main window state: {e}") - # End session before closing so the backend removes this GUI from Redis immediately + # End session before closing so the backend removes this GUI from Redis immediately try: self.daq.end_session_on_close() except Exception as e: @@ -2031,6 +2082,12 @@ class MainWindow(QMainWindow): def cleanup(self): if getattr(self, "_cleanup_done", False): return + + try: + self._return_to_main_view_for_shutdown() + except Exception as e: + logger.warning(f"Failed to restore main view during cleanup: {e}") + self._cleanup_done = True try: @@ -2066,7 +2123,7 @@ class MainWindow(QMainWindow): self._stop_axis_camera_threads() for attr_name in ( - "prediction_thread", + "prediction_thread", ): thread = getattr(self, attr_name, None) if thread is None: diff --git a/src/aare/gui/panels/axis_video_panel.py b/src/aare/gui/panels/axis_video_panel.py index f1fa36ea..216ffd11 100644 --- a/src/aare/gui/panels/axis_video_panel.py +++ b/src/aare/gui/panels/axis_video_panel.py @@ -12,38 +12,18 @@ class AxisVideoPanel(QWidget): super().__init__(parent) self._title_label = QLabel(title, self) - self._title_label.setStyleSheet("font-weight: bold;") self._status_container = QWidget(self) - self._status_container.setStyleSheet( - "QWidget {" - " border-radius: 12px;" - " background-color: #d9e2f2;" - "}" - ) + self._status_container.setObjectName("axisVideoStatusContainer") + self._status_container.setProperty("busyState", "idle") self._status_dot = QLabel(self._status_container) + self._status_dot.setObjectName("axisVideoStatusDot") self._status_dot.setFixedSize(10, 10) - self._status_dot.setStyleSheet( - "QLabel {" - " min-width: 10px;" - " max-width: 10px;" - " min-height: 10px;" - " max-height: 10px;" - " border-radius: 5px;" - " background-color: transparent;" - "}" - ) self._status_label = QLabel("", self._status_container) + self._status_label.setObjectName("axisVideoStatusLabel") self._status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._status_label.setStyleSheet( - "QLabel {" - " background-color: transparent;" - " color: #2f3b52;" - " font-weight: bold;" - "}" - ) status_layout = QHBoxLayout(self._status_container) status_layout.setContentsMargins(10, 6, 12, 6) @@ -72,6 +52,12 @@ class AxisVideoPanel(QWidget): root_layout.addLayout(controls_layout) root_layout.addWidget(self.view) + def _refresh_status_style(self) -> None: + for widget in (self._status_container, self._status_dot, self._status_label): + widget.style().unpolish(widget) + widget.style().polish(widget) + widget.update() + def _all_video_views(self) -> list[VideoGraphicsView]: views: list[VideoGraphicsView] = [] if isinstance(self.view, VideoGraphicsView): @@ -86,55 +72,21 @@ class AxisVideoPanel(QWidget): def set_busy_style(self, style: BusyOverlayStyle | None) -> None: if style is None: self._status_label.setText("") - self._status_container.setStyleSheet( - "QWidget {" - " border-radius: 12px;" - " background-color: #d9e2f2;" - "}" - ) - self._status_dot.setStyleSheet( - "QLabel {" - " min-width: 10px;" - " max-width: 10px;" - " min-height: 10px;" - " max-height: 10px;" - " border-radius: 5px;" - " background-color: transparent;" - "}" - ) - self._status_label.setStyleSheet( - "QLabel {" - " background-color: transparent;" - " color: #2f3b52;" - " font-weight: bold;" - "}" - ) + self._status_container.setProperty("busyState", "idle") + self._status_dot.setStyleSheet("background-color: transparent;") + self._status_label.setStyleSheet("") + self._refresh_status_style() self._status_container.hide() else: self._status_label.setText(style.text) - self._status_container.setStyleSheet( - "QWidget {" - " border-radius: 12px;" - f" background-color: {style.badge_bg};" - "}" - ) + self._status_container.setProperty("busyState", "active") self._status_dot.setStyleSheet( - "QLabel {" - " min-width: 10px;" - " max-width: 10px;" - " min-height: 10px;" - " max-height: 10px;" - " border-radius: 5px;" - f" background-color: {style.accent_dot};" - "}" + f"background-color: {style.accent_dot};" ) self._status_label.setStyleSheet( - "QLabel {" - " background-color: transparent;" - f" color: {style.badge_fg};" - " font-weight: bold;" - "}" + f"color: {style.badge_fg};" ) + self._refresh_status_style() self._status_container.show() for view in self._all_video_views(): diff --git a/src/aare/gui/panels/beamline_state_panel.py b/src/aare/gui/panels/beamline_state_panel.py index 38f5a4b6..9e41a56c 100644 --- a/src/aare/gui/panels/beamline_state_panel.py +++ b/src/aare/gui/panels/beamline_state_panel.py @@ -64,6 +64,7 @@ class BeamlineStatePanel(QFrame): def __init__(self, parent=None): super().__init__(parent) + self.setObjectName("beamlineStatePanel") self.setFrameShape(QFrame.Shape.StyledPanel) self.setFrameShadow(QFrame.Shadow.Raised) self.setFixedWidth(self.set_width) @@ -87,16 +88,16 @@ class BeamlineStatePanel(QFrame): self._label_hover_bg = "rgba(244, 196, 48, 0.22)" self._group_colors: dict[BeamlineStateEnum, QColor] = { - BeamlineStateEnum.DewarTransfer: QColor(128, 90, 213), # Purple - BeamlineStateEnum.SampleExchange: QColor(237, 137, 54), # Orange - BeamlineStateEnum.RobotSampleExchange: QColor(237, 137, 54), # Orange - BeamlineStateEnum.SampleAlignment: QColor(72, 187, 120), # Green - BeamlineStateEnum.BeamLocation: QColor(72, 187, 120), # Green - BeamlineStateEnum.BeamstopAlignment: QColor(72, 187, 120), # Green - BeamlineStateEnum.FluxMeasurement: QColor(72, 187, 120), # Green - BeamlineStateEnum.DataCollection: QColor(236, 72, 153), # Pink - BeamlineStateEnum.XtalSnapshot: QColor(236, 72, 153), # Pink - BeamlineStateEnum.XrayFluorescence: QColor(236, 72, 153), # Pink + BeamlineStateEnum.DewarTransfer: QColor(128, 90, 213), + BeamlineStateEnum.SampleExchange: QColor(237, 137, 54), + BeamlineStateEnum.RobotSampleExchange: QColor(237, 137, 54), + BeamlineStateEnum.SampleAlignment: QColor(72, 187, 120), + BeamlineStateEnum.BeamLocation: QColor(72, 187, 120), + BeamlineStateEnum.BeamstopAlignment: QColor(72, 187, 120), + BeamlineStateEnum.FluxMeasurement: QColor(72, 187, 120), + BeamlineStateEnum.DataCollection: QColor(236, 72, 153), + BeamlineStateEnum.XtalSnapshot: QColor(236, 72, 153), + BeamlineStateEnum.XrayFluorescence: QColor(236, 72, 153), } self._group_label_colors: dict[BeamlineStateEnum, str] = { @@ -153,54 +154,26 @@ class BeamlineStatePanel(QFrame): self._station_widgets: dict[BeamlineStateEnum, QLabel | QPushButton] = {} self.title = QLabel(self) + self.title.setObjectName("beamlineStateTitle") self.title.setText("

Beamline state

") - self.title.setStyleSheet("background-color: #4B0082; color: #ffffff;") self.title.setAlignment(Qt.AlignmentFlag.AlignCenter) self.title.setFixedHeight(self.title_height) self.title.setGeometry(0, 0, self.set_width, self.title_height) self.toggle_button = QPushButton("−", self) + self.toggle_button.setObjectName("beamlineStateToggleButton") self.toggle_button.setToolTip("Minimise beamline state panel") self.toggle_button.setFixedSize(28, 28) self.toggle_button.move(self.set_width - 36, 11) self.toggle_button.clicked.connect(self.toggle_collapsed) - self.toggle_button.setStyleSheet(""" - QPushButton { - border: none; - border-radius: 14px; - background-color: rgba(255, 255, 255, 0.20); - color: white; - font-size: 16px; - font-weight: 700; - } - QPushButton:hover { - background-color: rgba(255, 255, 255, 0.32); - } - """) self.current_label = QLabel("Current: —", self) - self.current_label.setStyleSheet(""" - QLabel { - color: rgb(30, 41, 59); - font-size: 18px; - font-weight: 700; - padding-left: 4px; - background: transparent; - } - """) + self.current_label.setObjectName("beamlineStateCurrentLabel") self.current_label.move(14, 58) self.current_label.adjustSize() self.tell_label = QLabel("Tell: —", self) - self.tell_label.setStyleSheet(""" - QLabel { - color: rgb(55, 67, 87); - font-size: 15px; - font-weight: 600; - padding-left: 4px; - background: transparent; - } - """) + self.tell_label.setObjectName("beamlineStateTellLabel") self.tell_label.move(14, 86) self.tell_label.adjustSize() @@ -580,15 +553,7 @@ class BeamlineStatePanel(QFrame): tell_color = "green" self.tell_label.setText(tell_text) - self.tell_label.setStyleSheet(f""" - QLabel {{ - color: {tell_color}; - font-size: 15px; - font-weight: 600; - padding-left: 4px; - background: transparent; - }} - """) + self.tell_label.setStyleSheet(f"color: {tell_color};") self.tell_label.adjustSize() def set_current_state(self, state: BeamlineStateEnum | None) -> None: diff --git a/src/aare/gui/panels/compact_automation_panel.py b/src/aare/gui/panels/compact_automation_panel.py index 3c64887f..d3545f34 100644 --- a/src/aare/gui/panels/compact_automation_panel.py +++ b/src/aare/gui/panels/compact_automation_panel.py @@ -34,110 +34,6 @@ class CompactAutomationPanel(QFrame): self.setFrameShape(QFrame.Shape.NoFrame) self.setObjectName("compactAutomationPanel") - self.setStyleSheet( - """ - QFrame#compactAutomationPanel { - background: #d8e4fd; - border: none; - border-radius: 18px; - } - - QFrame#compactCameraCard, - QFrame#compactControlsCard, - QFrame#compactProgressCard, - QFrame#compactQueueCard, - QFrame#compactQueueItem { - background: #e6eefc; - border: 1px solid #b9ccee; - border-radius: 16px; - } - - QLabel#compactSectionTitle { - background: transparent; - color: #17324d; - font-size: 14px; - font-weight: 700; - } - - QLabel#compactSectionHint { - background: transparent; - color: #51657d; - font-size: 12px; - } - - QLabel#compactQueueTitle { - background: transparent; - color: #51657d; - font-size: 11px; - font-weight: 700; - } - - QLabel#compactQueueValue { - background: transparent; - color: #10263a; - font-size: 14px; - font-weight: 700; - } - - QToolButton#compactMenuButton { - background: #cbdcf8; - color: #17324d; - border: 1px solid #9fb9e5; - border-radius: 14px; - padding: 10px 14px; - font-size: 18px; - font-weight: 700; - } - - QToolButton#compactMenuButton:hover { - background: #bfd4f6; - } - - QLabel#compactProgressSummary { - background: transparent; - color: #17324d; - font-size: 13px; - padding: 2px 2px 6px 2px; - } - - QLabel#compactProgressStep { - background: #dfe9fb; - border: 1px solid #bfd1ef; - border-radius: 10px; - padding: 8px 6px; - } - - QPushButton#compactPrimaryButton { - background: #2563eb; - color: white; - border: none; - border-radius: 14px; - padding: 14px 18px; - font-size: 15px; - font-weight: 700; - } - - QPushButton#compactPrimaryButton:hover { - background: #1d4ed8; - } - - QPushButton#compactSecondaryButton, - QToolButton#compactSecondaryButton { - background: #dfe9fb; - color: #17324d; - border: 1px solid #b2c7eb; - border-radius: 14px; - padding: 14px 18px; - font-size: 14px; - font-weight: 700; - } - - QPushButton#compactSecondaryButton:hover, - QToolButton#compactSecondaryButton:hover { - background: #d3e1f8; - } - """ - ) main_layout = QVBoxLayout(self) main_layout.setContentsMargins(18, 18, 18, 18) diff --git a/src/aare/gui/panels/portrait_mode.py b/src/aare/gui/panels/portrait_mode.py index d5c1a977..26d7d4fd 100644 --- a/src/aare/gui/panels/portrait_mode.py +++ b/src/aare/gui/panels/portrait_mode.py @@ -26,33 +26,6 @@ BUTTON_BG = "#132131" LED_OFF = "#1C2E3E" ACTIVE_STEP = "#FFFFFF" -PORTRAIT_STYLE = f""" -QWidget {{ - background: {BG}; - color: {TEXT}; - font-family: 'Inter', 'SF Pro Display', Arial, sans-serif; - font-size: 14px; -}} -QScrollArea {{ - border: none; - background: transparent; -}} -QScrollBar:vertical {{ - background: {CARD_BG}; - width: 4px; - border-radius: 2px; -}} -QScrollBar::handle:vertical {{ - background: {ACCENT_DIM}; - border-radius: 2px; - min-height: 20px; -}} -QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ - height: 0px; -}} -""" - - # --------------------------------------------------------------------------- # LED step indicator # --------------------------------------------------------------------------- @@ -269,7 +242,7 @@ class PortraitModePanel(QWidget): def __init__(self, sample_camera_widget: QWidget, parent=None): super().__init__(parent) - self.setStyleSheet(PORTRAIT_STYLE) + self.setObjectName("portraitRoot") self.setMaximumWidth(self.PORTRAIT_WIDTH) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py new file mode 100644 index 00000000..9e2be286 --- /dev/null +++ b/src/aare/gui/styles.py @@ -0,0 +1,493 @@ +from __future__ import annotations + +THEME_ORIGINAL = "original" +THEME_PORTRAIT = "portrait" + + +def build_app_stylesheet(theme: str) -> str: + if theme == THEME_PORTRAIT: + return _portrait_stylesheet() + return _original_stylesheet() + + +def _original_stylesheet() -> str: + return """ + QMainWindow, QWidget { + background-color: rgb(216, 228, 253); + color: rgb(30, 41, 59); + } + + QWidget#mainContentRoot, + QWidget#standardMainPage, + QWidget#compactAutomationPage { + background-color: rgb(216, 228, 253); + } + + QWidget#portraitModePage { + background-color: #071018; + } + + QFrame#compactAutomationPanel { + background: #d8e4fd; + border: none; + border-radius: 18px; + } + + QFrame#compactCameraCard, + QFrame#compactControlsCard, + QFrame#compactProgressCard, + QFrame#compactQueueCard, + QFrame#compactQueueItem { + background: #e6eefc; + border: 1px solid #b9ccee; + border-radius: 16px; + } + + QLabel#compactSectionTitle { + background: transparent; + color: #17324d; + font-size: 14px; + font-weight: 700; + } + + QLabel#compactSectionHint { + background: transparent; + color: #51657d; + font-size: 12px; + } + + QLabel#compactQueueTitle { + background: transparent; + color: #51657d; + font-size: 11px; + font-weight: 700; + } + + QLabel#compactQueueValue { + background: transparent; + color: #10263a; + font-size: 14px; + font-weight: 700; + } + + QToolButton#compactMenuButton { + background: #cbdcf8; + color: #17324d; + border: 1px solid #9fb9e5; + border-radius: 14px; + padding: 10px 14px; + font-size: 18px; + font-weight: 700; + } + + QToolButton#compactMenuButton:hover { + background: #bfd4f6; + } + + QPushButton#compactPrimaryButton { + background: #2563eb; + color: white; + border: none; + border-radius: 14px; + padding: 14px 18px; + font-size: 15px; + font-weight: 700; + } + + QPushButton#compactPrimaryButton:hover { + background: #1d4ed8; + } + + QPushButton#compactSecondaryButton, + QToolButton#compactSecondaryButton { + background: #dfe9fb; + color: #17324d; + border: 1px solid #b2c7eb; + border-radius: 14px; + padding: 14px 18px; + font-size: 14px; + font-weight: 700; + } + + QPushButton#compactSecondaryButton:hover, + QToolButton#compactSecondaryButton:hover { + background: #d3e1f8; + } + + QFrame#alertBanner[alertKind="error"] { + background-color: #fbe4e6; + border: 2px solid #d97a84; + border-radius: 12px; + margin: 8px 12px 8px 12px; + } + + QFrame#alertBanner[alertKind="success"] { + background-color: #e7f6ea; + border: 2px solid #7bbf8e; + border-radius: 12px; + margin: 8px 12px 8px 12px; + } + + QFrame#alertBanner[alertKind="waiting"] { + background-color: #fff8e1; + border: 2px solid #ffb300; + border-radius: 12px; + margin: 8px 12px 8px 12px; + } + + QFrame#alertBanner QLabel { + font-weight: 700; + font-size: 20px; + padding: 2px 6px 2px 6px; + } + + QFrame#alertBanner[alertKind="error"] QLabel { + color: #8f1d2c; + } + + QFrame#alertBanner[alertKind="success"] QLabel { + color: #1f6a3a; + } + + QFrame#alertBanner[alertKind="waiting"] QLabel { + color: #e65100; + } + + QWidget#axisVideoStatusContainer[busyState="idle"] { + border-radius: 12px; + background-color: #d9e2f2; + } + + QWidget#axisVideoStatusContainer[busyState="active"] { + border-radius: 12px; + } + + QLabel#axisVideoStatusDot { + min-width: 10px; + max-width: 10px; + min-height: 10px; + max-height: 10px; + border-radius: 5px; + background-color: transparent; + } + + QLabel#axisVideoStatusLabel { + background-color: transparent; + color: #2f3b52; + font-weight: bold; + } + + QFrame#beamlineStatePanel { + background: rgb(216, 228, 253); + border: 1px solid rgb(185, 204, 238); + border-radius: 12px; + } + + QLabel#beamlineStateTitle { + background-color: #4B0082; + color: #ffffff; + } + + QPushButton#beamlineStateToggleButton { + border: none; + border-radius: 14px; + background-color: rgba(255, 255, 255, 0.20); + color: white; + font-size: 16px; + font-weight: 700; + } + + QPushButton#beamlineStateToggleButton:hover { + background-color: rgba(255, 255, 255, 0.32); + } + + QLabel#beamlineStateCurrentLabel { + color: rgb(30, 41, 59); + font-size: 18px; + font-weight: 700; + padding-left: 4px; + background: transparent; + } + + QLabel#beamlineStateTellLabel { + color: rgb(55, 67, 87); + font-size: 15px; + font-weight: 600; + padding-left: 4px; + background: transparent; + } + + QWidget#portraitRoot, + QWidget#portraitRoot QWidget { + background: #071018; + color: #F5F7FA; + font-family: 'Inter', 'SF Pro Display', Arial, sans-serif; + font-size: 14px; + } + + QWidget#portraitRoot QScrollArea { + border: none; + background: transparent; + } + + QWidget#portraitRoot QScrollBar:vertical { + background: #0E1A26; + width: 4px; + border-radius: 2px; + } + + QWidget#portraitRoot QScrollBar::handle:vertical { + background: #1A3A36; + border-radius: 2px; + min-height: 20px; + } + + QWidget#portraitRoot QScrollBar::add-line:vertical, + QWidget#portraitRoot QScrollBar::sub-line:vertical { + height: 0px; + } + """ + + +def _portrait_stylesheet() -> str: + return """ + QMainWindow, QWidget { + background: #071018; + color: #F5F7FA; + } + + QWidget#mainContentRoot, + QWidget#standardMainPage, + QWidget#compactAutomationPage, + QWidget#portraitModePage { + background: #071018; + } + + QTabWidget::pane, + QScrollArea, + QDockWidget, + QDockWidget > QWidget { + background: #071018; + color: #F5F7FA; + } + + QFrame#compactAutomationPanel { + background: #071018; + border: none; + border-radius: 18px; + } + + QFrame#compactCameraCard, + QFrame#compactControlsCard, + QFrame#compactProgressCard, + QFrame#compactQueueCard, + QFrame#compactQueueItem { + background: #0E1A26; + border: 1px solid #1A3A36; + border-radius: 16px; + } + + QLabel#compactSectionTitle { + background: transparent; + color: #62D8C8; + font-size: 14px; + font-weight: 700; + } + + QLabel#compactSectionHint { + background: transparent; + color: #8A9BB0; + font-size: 12px; + } + + QLabel#compactQueueTitle { + background: transparent; + color: #8A9BB0; + font-size: 11px; + font-weight: 700; + } + + QLabel#compactQueueValue { + background: transparent; + color: #F5F7FA; + font-size: 14px; + font-weight: 700; + } + + QToolButton#compactMenuButton { + background: #132131; + color: #62D8C8; + border: 1px solid #1A3A36; + border-radius: 14px; + padding: 10px 14px; + font-size: 18px; + font-weight: 700; + } + + QToolButton#compactMenuButton:hover { + background: #1A3A36; + } + + QPushButton#compactPrimaryButton { + background: #62D8C8; + color: #071018; + border: none; + border-radius: 14px; + padding: 14px 18px; + font-size: 15px; + font-weight: 700; + } + + QPushButton#compactPrimaryButton:hover { + background: #7ce6d8; + } + + QPushButton#compactSecondaryButton, + QToolButton#compactSecondaryButton { + background: #132131; + color: #F5F7FA; + border: 1px solid #1A3A36; + border-radius: 14px; + padding: 14px 18px; + font-size: 14px; + font-weight: 700; + } + + QPushButton#compactSecondaryButton:hover, + QToolButton#compactSecondaryButton:hover { + background: #1A3A36; + } + + QFrame#alertBanner[alertKind="error"] { + background: #1A0E0E; + border: 2px solid #8f1d2c; + border-radius: 12px; + margin: 8px 12px 8px 12px; + } + + QFrame#alertBanner[alertKind="success"] { + background: #0E1A12; + border: 2px solid #2a7a44; + border-radius: 12px; + margin: 8px 12px 8px 12px; + } + + QFrame#alertBanner[alertKind="waiting"] { + background: #2B2208; + border: 2px solid #ffb300; + border-radius: 12px; + margin: 8px 12px 8px 12px; + } + + QFrame#alertBanner QLabel { + font-weight: 700; + font-size: 20px; + padding: 2px 6px 2px 6px; + } + + QFrame#alertBanner[alertKind="error"] QLabel { + color: #ffb3bc; + } + + QFrame#alertBanner[alertKind="success"] QLabel { + color: #a8f0c0; + } + + QFrame#alertBanner[alertKind="waiting"] QLabel { + color: #ffd166; + } + + QWidget#axisVideoStatusContainer[busyState="idle"] { + border-radius: 12px; + background-color: #132131; + } + + QWidget#axisVideoStatusContainer[busyState="active"] { + border-radius: 12px; + } + + QLabel#axisVideoStatusDot { + min-width: 10px; + max-width: 10px; + min-height: 10px; + max-height: 10px; + border-radius: 5px; + background-color: transparent; + } + + QLabel#axisVideoStatusLabel { + background-color: transparent; + color: #8A9BB0; + font-weight: bold; + } + + QFrame#beamlineStatePanel { + background: #0E1A26; + border: 1px solid #1A3A36; + border-radius: 12px; + } + + QLabel#beamlineStateTitle { + background-color: #132131; + color: #F5F7FA; + } + + QPushButton#beamlineStateToggleButton { + border: none; + border-radius: 14px; + background-color: rgba(255, 255, 255, 0.10); + color: #F5F7FA; + font-size: 16px; + font-weight: 700; + } + + QPushButton#beamlineStateToggleButton:hover { + background-color: rgba(255, 255, 255, 0.18); + } + + QLabel#beamlineStateCurrentLabel { + color: #F5F7FA; + font-size: 18px; + font-weight: 700; + padding-left: 4px; + background: transparent; + } + + QLabel#beamlineStateTellLabel { + color: #8A9BB0; + font-size: 15px; + font-weight: 600; + padding-left: 4px; + background: transparent; + } + + QWidget#portraitRoot, + QWidget#portraitRoot QWidget { + background: #071018; + color: #F5F7FA; + font-family: 'Inter', 'SF Pro Display', Arial, sans-serif; + font-size: 14px; + } + + QWidget#portraitRoot QScrollArea { + border: none; + background: transparent; + } + + QWidget#portraitRoot QScrollBar:vertical { + background: #0E1A26; + width: 4px; + border-radius: 2px; + } + + QWidget#portraitRoot QScrollBar::handle:vertical { + background: #1A3A36; + border-radius: 2px; + min-height: 20px; + } + + QWidget#portraitRoot QScrollBar::add-line:vertical, + QWidget#portraitRoot QScrollBar::sub-line:vertical { + height: 0px; + } + """ \ No newline at end of file diff --git a/src/aare/gui/widgets/alert_banner.py b/src/aare/gui/widgets/alert_banner.py index 2102a2ef..dd5c8716 100644 --- a/src/aare/gui/widgets/alert_banner.py +++ b/src/aare/gui/widgets/alert_banner.py @@ -11,6 +11,9 @@ class AlertBanner(QFrame): def __init__(self, parent=None): super().__init__(parent) + self.setObjectName("alertBanner") + self.setProperty("alertKind", "error") + self._current_message = None self._current_is_error = None self._clear_timer = QTimer(self) @@ -41,6 +44,14 @@ class AlertBanner(QFrame): self.setVisible(False) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + def _set_alert_kind(self, kind: str) -> None: + self.setProperty("alertKind", kind) + self.style().unpolish(self) + self.style().polish(self) + self.style().unpolish(self._label) + self.style().polish(self._label) + self.update() + @Slot(str, bool) def show_message(self, msg: str, is_error: bool = True, auto_clear_ms: int | None = None): """Show error (red) or success (green) message.""" @@ -53,36 +64,10 @@ class AlertBanner(QFrame): if is_error: decorated = f"🛑 {msg} 🛑" - self.setStyleSheet( - "QFrame {" - " background-color: #fbe4e6;" - " border: 2px solid #d97a84;" - " border-radius: 12px;" - " margin: 8px 12px 8px 12px;" - "}" - "QLabel {" - " color: #8f1d2c;" - " font-weight: 700;" - " font-size: 20px;" - " padding: 2px 6px 2px 6px;" - "}" - ) + self._set_alert_kind("error") else: decorated = f"✅ {msg} ✅" - self.setStyleSheet( - "QFrame {" - " background-color: #e7f6ea;" - " border: 2px solid #7bbf8e;" - " border-radius: 12px;" - " margin: 8px 12px 8px 12px;" - "}" - "QLabel {" - " color: #1f6a3a;" - " font-weight: 700;" - " font-size: 20px;" - " padding: 2px 6px 2px 6px;" - "}" - ) + self._set_alert_kind("success") if auto_clear_ms is None: timeout = 5000 else: @@ -123,20 +108,7 @@ class AlertBanner(QFrame): def _apply_waiting_style(self): """Apply yellow/waiting style.""" - self.setStyleSheet( - "QFrame {" - " background-color: #fff8e1;" - " border: 2px solid #ffb300;" - " border-radius: 12px;" - " margin: 8px 12px 8px 12px;" - "}" - "QLabel {" - " color: #e65100;" - " font-weight: 700;" - " font-size: 20px;" - " padding: 2px 6px 2px 6px;" - "}" - ) + self._set_alert_kind("waiting") def _update_waiting_text(self): """Update the waiting message text, including countdown if active.""" @@ -167,5 +139,6 @@ class AlertBanner(QFrame): self._current_message = None self._current_is_error = None self._clear_timer.stop() + self._set_alert_kind("error") self._label.clear() - self.setVisible(False) + self.setVisible(False) \ No newline at end of file diff --git a/tests/unit/gui/test_main_window.py b/tests/unit/gui/test_main_window.py index 546f7251..0f6e026a 100644 --- a/tests/unit/gui/test_main_window.py +++ b/tests/unit/gui/test_main_window.py @@ -3,11 +3,13 @@ from unittest.mock import MagicMock, patch from PySide6.QtCore import Qt from aare.gui.main_window import MainWindow + @pytest.fixture def mock_ui_state(): with patch("aare.gui.main_window.UIStateManager") as mock: yield mock + def test_main_window_init(qtbot, mock_ui_state): with patch("requests.get") as mock_get, \ patch("aare.gui.main_window.DAQWorker"), \ @@ -241,4 +243,65 @@ def test_idle_timeout_does_not_close_while_automation_active(qtbot, mock_ui_stat with patch("aare.gui.main_window.time.time", return_value=111.0): win._check_idle_timeout() - win.close.assert_not_called() \ No newline at end of file + win.close.assert_not_called() + +def test_cleanup_returns_from_portrait_mode(qtbot, mock_ui_state): + with patch("requests.get"), \ + patch("aare.gui.main_window.DAQWorker"), \ + patch("aare.gui.main_window.PredictionSubscriber"), \ + patch("aare.gui.main_window.VideoThread"), \ + patch("aare.gui.main_window.JFJochDBusClient"), \ + patch("aare.gui.main_window.jwt.decode") as mock_jwt: + + mock_jwt.return_value = {"sub": "testuser", "staff": True, "pgroups": ["p123"], "session": 15} + fake_token = "header.payload.signature" + + win = MainWindow( + base_url=None, + token=fake_token, + default_image=None, + zmq_addr=None, + pred_zmq_addr=None, + beamline_cam_addr=None, + gonio_cam_addr=None, + gonio_cam_id=None + ) + qtbot.addWidget(win) + + win.enter_portrait_mode() + assert win.content_stack.currentWidget() is win.portrait_mode_page + + win.cleanup() + + assert win.content_stack.currentWidget() is win._standard_main_page + + +def test_cleanup_returns_from_compact_automation_view(qtbot, mock_ui_state): + with patch("requests.get"), \ + patch("aare.gui.main_window.DAQWorker"), \ + patch("aare.gui.main_window.PredictionSubscriber"), \ + patch("aare.gui.main_window.VideoThread"), \ + patch("aare.gui.main_window.JFJochDBusClient"), \ + patch("aare.gui.main_window.jwt.decode") as mock_jwt: + + mock_jwt.return_value = {"sub": "testuser", "staff": True, "pgroups": ["p123"], "session": 15} + fake_token = "header.payload.signature" + + win = MainWindow( + base_url=None, + token=fake_token, + default_image=None, + zmq_addr=None, + pred_zmq_addr=None, + beamline_cam_addr=None, + gonio_cam_addr=None, + gonio_cam_id=None + ) + qtbot.addWidget(win) + + win.enter_compact_automation_view() + assert win.content_stack.currentWidget() is win.compact_automation_page + + win.cleanup() + + assert win.content_stack.currentWidget() is win._standard_main_page \ No newline at end of file From 924fabb7d952e38e006d9b2ea6ce56666346cc52 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 22 Jun 2026 10:39:55 +0200 Subject: [PATCH 60/76] bec_motors differ in some cases between beamline,s only a problem for logging/scilog messages --- src/aare/devices/bec_worker.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/aare/devices/bec_worker.py b/src/aare/devices/bec_worker.py index 93905a7c..4152b382 100644 --- a/src/aare/devices/bec_worker.py +++ b/src/aare/devices/bec_worker.py @@ -377,12 +377,16 @@ class BECClientWorker: mono_pitch_scan(plot) except Exception as e: self._raise_bec_error(e, operation="mono_pitch_scan", tags=["mono_pitch_scan"]) + if self.beamline is MXBeamline.X06DA: + addtional_text = [f"New dcm_pitch position: {self.dev.dcm_pitch.position:5f}"] + else: + addtional_text = [f"New dcm_theta2 position: {self.dev.dccm_theta2.position:5f}"] self.scilog_msg( message="Mono pitch scan completed", bold=True, color="green", tags=["mono_pitch_scan"], - additonal_text=[f"New dcm_pitch position: {self.dev.dcm_pitch.position:5f}"] + additonal_text=addtional_text ) def check_current_energy(self): @@ -407,17 +411,24 @@ class BECClientWorker: if abs(value - self.check_current_energy()) > 1: logger.warning(f"Energy change may have failed, current energy: {self.check_current_energy()} eV") - + if beamline is MXBeamline.X10SA: + additonal_text = [f"New dcm_bragg position: {self.dev.dcm_bragg.position:4g} mrad", + f"New dcm_pitch position: {self.dev.dcm_pitch.position:4g} ", + f"Previous energy: {current_energy:.1f} eV ", + f"Requested energy: {value:.1f} eV ", + f"New current energy: {self.check_current_energy():.1f} eV"] + else: + additonal_text = [f"New dccm_theta1 position: {self.dev.dccm_theta1.position:4g} mrad", + f"New dccm_theta2 position: {self.dev.dccm_theta2.position:4g} mrad", + f"Previous energy: {current_energy:.1f} eV ", + f"Requested energy: {value:.1f} eV ", + f"New current energy: {self.check_current_energy():.1f} eV"] self.scilog_msg( message=f"Moved from {current_energy:.1f} eV to {value:.1f} eV", bold=True, color="green", tags=["energy_change"], - additonal_text=[f"New dcm_bragg position: {self.dev.dcm_bragg.position:4g} mrad", - f"New dcm_pitch position: {self.dev.dcm_pitch.position:4g} ", - f"Previous energy: {current_energy:.1f} eV ", - f"Requested energy: {value:.1f} eV ", - f"New current energy: {self.check_current_energy():.1f} eV"] + additonal_text=additonal_text ) From a5718f3f844fdb0d86cda837c732960f99534e37 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 22 Jun 2026 10:40:23 +0200 Subject: [PATCH 61/76] daq: automation log message split between success and error --- src/aare/daq/daq.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/aare/daq/daq.py b/src/aare/daq/daq.py index 7c6781bd..e5af47a4 100644 --- a/src/aare/daq/daq.py +++ b/src/aare/daq/daq.py @@ -2607,6 +2607,7 @@ class AareDAQ: if error: msg += f"with an error" + logger.error(f"{msg}, time taken {time.perf_counter() - start} seconds.") try: if self.__cfg.state_busy: self.__set_state(BeamlineStateEnum.RobotSampleExchange) @@ -2620,9 +2621,10 @@ class AareDAQ: logger.exception( "Failed to transition to RobotSampleExchange during error recovery" ) + else: - msg += f"successfully" - logger.error(f"{msg}, time taken {time.perf_counter() - start} seconds.") + msg += f" successfully" + logger.info(f"{msg}, time taken {time.perf_counter() - start} seconds.") self.__cfg.state_busy = False end = time.perf_counter() return end - start From b6ab4d1e5ee59ea0868d5f18bbe6c7b2ee7ce620 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 22 Jun 2026 10:44:13 +0200 Subject: [PATCH 62/76] tell_client.py: added validate_mount_start_conditions method --- src/aare/daq/daq.py | 8 +++- src/aare/devices/tell_client.py | 75 ++++++++++++++++++++++++++++++--- 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/src/aare/daq/daq.py b/src/aare/daq/daq.py index e5af47a4..da666f06 100644 --- a/src/aare/daq/daq.py +++ b/src/aare/daq/daq.py @@ -1883,6 +1883,9 @@ class AareDAQ: self.__cfg.state_busy = False raise + def check_tell_mount_start_conditions(self) -> None: + self.__devs.tell.validate_mount_start_conditions() + def _execute_dry(self, park:bool=True, unmount:bool=False): self._create_mounting_service().dry(park=park, unmount=unmount) @@ -2663,7 +2666,8 @@ class AareDAQ: self.__cfg.try_set_busy(timeout=self.AUTOMATION_BUSY_TIMEOUT_S) self._validate_automation_state(context="after acquiring automation busy state") - + logger.info("Cancelling any pending jfjoch operations") + self.__jfjoch.cancel() self._set_progress_context( progress, current_sample_name=getattr(sample, "sample_name", "") or "", @@ -2822,7 +2826,7 @@ class AareDAQ: self._raise_if_critical_jfjoch_detector_error(e, command=e.endpoint or "unknown") raise - except (StateTransitionFailed, MaintenanceStateException) as e: + except (TransformationInvalidException, StateTransitionFailed, MaintenanceStateException) as e: logger.error(f"Critical automation state error: {e}") if progress.current_step is not None: current_kind = next( diff --git a/src/aare/devices/tell_client.py b/src/aare/devices/tell_client.py index 2f8e7e2d..91107449 100755 --- a/src/aare/devices/tell_client.py +++ b/src/aare/devices/tell_client.py @@ -103,10 +103,56 @@ class TellClient: :param value """ self.backend.eval("in_mount_position = " + str(value) + "&") + def _eval_bool(self, expr: str) -> bool: + value = self.backend.eval(expr) + return str(value).strip().lower() == "true" + def is_in_mount_position(self) -> bool: """checks to see if the robot is in the mount position and returns a boolean""" return self.backend.eval("in_mount_position&").lower() == "true" + def is_manual_mode(self) -> bool: + msg = self.backend.eval("is_manual_mode()&") + logger.debug(f"manual mode: {msg}") + return msg + + def is_door_closed(self) -> bool: + msg = self.backend.eval("is_door_closed()&") + logger.debug(f"door closed: {msg}") + return msg + + def is_remote_mode(self) -> bool: + return not self.is_manual_mode() + + def validate_mount_start_conditions(self) -> None: + reasons: list[str] = [] + logger.warning('tell validation not in place') + pass + # try: + system_check = self.get_system_check() + # if system_check not in (None, "", "OK"): + # reasons.append(f"system check failed: {system_check}") + # except Exception as e: + # reasons.append(f"system check failed: {e}") + + # try: + # if not self.is_remote_mode(): + # reasons.append("TELL is not in remote mode") + # except Exception as e: + # reasons.append(f"failed to check remote mode: {e}") + + # try: + # if not self.is_door_closed(): + # reasons.append("TELL doors are open") + # except Exception as e: + # reasons.append(f"failed to check door status: {e}") + + # if reasons: + # raise TellCommunicationError( + # message="Mount can't start: " + "; ".join(reasons), + # operation="mount_precheck", + # ) + def set_samples_info(self, info: List[PuckWithTellPosition]): """sets the samples in the robot dewar based on the given list of PuckWithTellPosition objects and runs set_sample_info in the background""" @@ -366,13 +412,18 @@ class TellClient: return None def get_system_check(self): - return self.backend.eval("system_check_msg()&") + msg = self.backend.eval("system_check()&") + logger.debug(f"SYSTEM CHECK: {msg}") + return msg def get_robot_state(self): - return self.backend.eval("robot.state&") + msg = self.backend.eval("robot.state&") + logger.debug(f"robot state: {msg}") + return msg def get_robot_status(self): status = self.backend.eval("robot.take()&") + logger.debug(f"robot status: {status}") #return eval(status) return ast.literal_eval(status) @@ -487,13 +538,25 @@ def make_tell_client(bl: MXBeamline) -> TellClient: if __name__ == "__main__": from aare.common.beamline import mx_beamline - import time + from datetime import datetime, timezone bl = mx_beamline() tell_client = make_tell_client(bl) #tell_client.toggle_blower() #tell_client.check_enable_motion() - print(tell_client.get_system_check()) - print(tell_client.get_robot_status()) - print(tell_client.get_setting('dry_mount_count')) + print("system check: ", tell_client.get_system_check()) + print("status ", tell_client.get_robot_status()) + print("dry mount count: ", tell_client.get_setting('dry_mount_counter')) + + ts = float(tell_client.get_setting('dry_timestamp')) + print("dry timestape: ", ts) + past = datetime.fromtimestamp(ts, tz=timezone.utc) + now = datetime.now(timezone.utc) + seconds_ago = int((now - past).total_seconds()) + print(seconds_ago) + print("door closer :", tell_client.backend.eval('is_door_closed()&')) + print("manual mode: ", tell_client.backend.eval('is_manual_mode()&')) + print("position :", tell_client.get_robot_status()["pos"]) + #print("release safety: ", tell_client.backend.eval('release_safety()&')) + # time.sleep(5) #tell_client.blower_off() \ No newline at end of file From 316f4d18c115b7ef4abec8f1af140ea814bfed71 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 22 Jun 2026 10:44:56 +0200 Subject: [PATCH 63/76] server: added mount checks to mount and autoamtion --- src/aare/daq/server.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/aare/daq/server.py b/src/aare/daq/server.py index 1c5b610b..91322743 100644 --- a/src/aare/daq/server.py +++ b/src/aare/daq/server.py @@ -1026,6 +1026,8 @@ async def mount(dbid: int, token: str = Depends(oauth2_scheme), reference: bool logger.debug(f"Mount {dbid}") auth.check_jwt_rw(cfg, auth.parse_token(token)) + daq.check_tell_mount_start_conditions() + if reference: st = daq.reference_tools else: @@ -1574,6 +1576,7 @@ async def auto(s: SampleShortInfo, token: str = Depends(oauth2_scheme)): Formatted string of the total runtime. """ auth.check_jwt_rw(cfg, auth.parse_token(token)) + daq.check_tell_mount_start_conditions() try: runtime = daq.measure(s) except Exception as e: From b6cbb5ce26f5ef49e3350cc63929ad9579e729e2 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 22 Jun 2026 10:45:24 +0200 Subject: [PATCH 64/76] portrait_mode: added a loop button to rerun the queue for debugging --- src/aare/gui/main_window.py | 108 ++++++++--- src/aare/gui/panels/LogPanel.py | 225 +++++++++++++++++++++- src/aare/gui/panels/portrait_mode.py | 187 +++++++++++++++++- src/aare/gui/panels/sample_queue_panel.py | 11 ++ 4 files changed, 487 insertions(+), 44 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 1baf7b61..8d523cf8 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -359,7 +359,6 @@ class MainWindow(QMainWindow): self.compact_automation_panel.show_full_view_requested.connect(self._return_from_compact_automation_view) self.compact_automation_panel.annotation_selected.connect(self._handle_compact_annotation) - self.tell_samples_dock = QDockWidget("Sample List", self) self.tell_samples_dock.setObjectName("tell_samples_dock") self.tell_samples_dock.setWidget(self.tell_samples) @@ -374,7 +373,6 @@ class MainWindow(QMainWindow): self.tabifyDockWidget(self.ref_tools_dock, self.tell_samples_dock) if self.__decoded_token.staff: self.ref_tools_dock.show() - self.ref_tools_dock.raise_() else: self.ref_tools_dock.hide() @@ -419,7 +417,6 @@ class MainWindow(QMainWindow): self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.fluor_panel_dock) self.fluor_panel_dock.hide() - # Create and add the dock to your main window self.log_dock = LogDock("Console Log", self) self.log_dock.setObjectName("log_dock") self.addDockWidget(Qt.BottomDockWidgetArea, self.log_dock) @@ -427,6 +424,8 @@ class MainWindow(QMainWindow): self.log_dock.attach_logger("aareGUI") self.log_dock.hide() + self.tabifyDockWidget(self.automation_progress_dock, self.log_dock) + self.tabifyDockWidget(self.manual_sample_dock, self.automation_progress_dock) self.tabifyDockWidget(self.automation_progress_dock, self.log_dock) @@ -933,13 +932,18 @@ class MainWindow(QMainWindow): def _show_samcam_feed_banner(self, message: str) -> None: self.__samcam_feed_banner_message = message - self.alert_banner_secondary.show_message(message, True) + self._show_runtime_notification( + title="Sample camera", + message=message, + level="warning", + sticky=True, + ) self.__samcam_feed_banner_active = True def _clear_samcam_feed_banner(self) -> None: if not self.__samcam_feed_banner_active: return - self.alert_banner_secondary.clear_message() + self._clear_runtime_notification() self.__samcam_feed_banner_active = False def _stop_axis_camera_threads(self) -> None: @@ -1183,19 +1187,31 @@ class MainWindow(QMainWindow): @Slot(str, bool) def _portrait_alert_primary(self, msg: str, is_error: bool) -> None: - """Route primary alert banner — use in-panel toast in portrait mode.""" + """Route primary alerts into the runtime dock.""" if self.content_stack.currentWidget() is self.portrait_mode_page: self.portrait_mode_panel.show_portrait_alert(msg, is_error) else: - self.alert_banner.show_message(msg, is_error) + self._show_runtime_notification( + title="Runtime alert" if is_error else "Runtime update", + message=msg, + level="error" if is_error else "success", + sticky=is_error, + auto_clear_ms=None if is_error else 4000, + ) @Slot(str, bool) def _portrait_alert_secondary(self, msg: str, is_error: bool) -> None: - """Route secondary alert banner — use in-panel toast in portrait mode.""" + """Route secondary alerts into the runtime dock.""" if self.content_stack.currentWidget() is self.portrait_mode_page: self.portrait_mode_panel.show_portrait_alert(msg, is_error) else: - self.alert_banner_secondary.show_message(msg, is_error) + self._show_runtime_notification( + title="Device alert" if is_error else "Device update", + message=msg, + level="warning" if is_error else "info", + sticky=is_error, + auto_clear_ms=None if is_error else 4000, + ) @Slot() def _refresh_portrait_queue_preview(self) -> None: @@ -1546,16 +1562,46 @@ class MainWindow(QMainWindow): self._dev_help_dialog.raise_() self._dev_help_dialog.activateWindow() + def _show_runtime_notification( + self, + *, + title: str, + message: str, + level: str = "error", + sticky: bool = True, + auto_clear_ms: int | None = None, + ) -> None: + self.log_dock.show_notification( + title=title, + message=message, + level=level, + sticky=sticky, + auto_clear_ms=auto_clear_ms, + ) + + def _show_runtime_waiting_notification(self, *, title: str, message: str) -> None: + self.log_dock.show_waiting_notification(title=title, message=message) + + def _clear_runtime_notification(self) -> None: + self.log_dock.clear_notification() + def _clear_automation_critical_banner(self) -> None: if not self._automation_critical_banner_active: return - self.alert_banner.clear_message() + self._clear_runtime_notification() self._automation_critical_banner_active = False @Slot(bool) def _on_automation_running_changed(self, running: bool) -> None: if running: self._clear_automation_critical_banner() + self._show_runtime_notification( + title="Automation running", + message="Automation resumed.", + level="success", + sticky=False, + auto_clear_ms=3500, + ) def _is_detector_critical_failure(self, message: str) -> bool: text = (message or "").lower() @@ -1589,14 +1635,12 @@ class MainWindow(QMainWindow): def _on_manual_collection_critical_failure(self, message: str) -> None: logger.critical(f"Manual collection critical detector failure: {message}") - try: - self.alert_banner_secondary.show_message( - self._detector_error_banner_text(automation=False, message=message), - True, - auto_clear_ms=0, - ) - except Exception: - pass + self._show_runtime_notification( + title="Collection paused", + message=message, + level="error", + sticky=True, + ) try: QMessageBox.critical( @@ -1674,14 +1718,12 @@ class MainWindow(QMainWindow): except Exception as e: logger.error(f"Failed to update automation progress after critical failure: {e}") - # 3. Banner so the operator sees it immediately - try: - self.alert_banner.show_message( - banner_message, True, auto_clear_ms=0 - ) - self._automation_critical_banner_active = True - except Exception: - pass + self._show_runtime_notification( + title="Automation paused", + message=message, + level="error", + sticky=True, + ) # 4. Surface recovery UI try: @@ -1692,13 +1734,19 @@ class MainWindow(QMainWindow): self._detector_error_dialog_title(message), ( "Automation has been stopped because there is an error with the detector.\n\n" - "Please call your local contact.\n\n" - f"Details:\n{message}" + f"Details:\n{message}" ), ) - self.show_local_contact("Detector") else: - self.show_local_contact("Recovery") + QMessageBox.critical( + self, + "Automation halted", + ( + "A critical error occurred during automation and the " + "beamline could not recover automatically:\n\n" + f"{message}\n\n" + ), + ) else: if is_detector_failure: QMessageBox.critical( diff --git a/src/aare/gui/panels/LogPanel.py b/src/aare/gui/panels/LogPanel.py index 9af0e920..8af2f928 100644 --- a/src/aare/gui/panels/LogPanel.py +++ b/src/aare/gui/panels/LogPanel.py @@ -1,32 +1,239 @@ -# Python - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QDockWidget, QPlainTextEdit +from PySide6.QtCore import Qt, QTimer, Signal, Slot +from PySide6.QtWidgets import ( + QDockWidget, + QPlainTextEdit, + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QToolButton, + QFrame, +) from aare.common.logger_config import QtLogEmitter, QtLogHandler, find_existing_formatter, attach_to_logger +class RuntimeNotificationWidget(QFrame): + cleared = Signal() + show_log_requested = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("runtimeNotification") + self.setProperty("noticeLevel", "error") + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setVisible(False) + + self._auto_clear_timer = QTimer(self) + self._auto_clear_timer.setSingleShot(True) + self._auto_clear_timer.timeout.connect(self.clear_notification) + + self._title = QLabel("", self) + self._title.setObjectName("runtimeNotificationTitle") + + self._message = QLabel("", self) + self._message.setObjectName("runtimeNotificationMessage") + self._message.setWordWrap(True) + + self._minimise_button = QToolButton(self) + self._minimise_button.setText("—") + self._minimise_button.setToolTip("Minimise notification") + self._minimise_button.clicked.connect(self._toggle_minimised) + + self._clear_button = QPushButton("Clear", self) + self._clear_button.clicked.connect(self.clear_notification) + + self._show_log_button = QPushButton("Show Log", self) + self._show_log_button.clicked.connect(self.show_log_requested.emit) + + header_layout = QHBoxLayout() + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(self._title, 1) + header_layout.addWidget(self._minimise_button) + + button_layout = QHBoxLayout() + button_layout.setContentsMargins(0, 0, 0, 0) + button_layout.addStretch(1) + button_layout.addWidget(self._show_log_button) + button_layout.addWidget(self._clear_button) + + self._body = QWidget(self) + body_layout = QVBoxLayout(self._body) + body_layout.setContentsMargins(0, 0, 0, 0) + body_layout.setSpacing(8) + body_layout.addWidget(self._message) + body_layout.addLayout(button_layout) + + root_layout = QVBoxLayout(self) + root_layout.setContentsMargins(12, 10, 12, 10) + root_layout.setSpacing(8) + root_layout.addLayout(header_layout) + root_layout.addWidget(self._body) + + self._full_title = "" + self._full_message = "" + self._minimised = False + self._sticky = True + + self.setStyleSheet( + """ + QFrame#runtimeNotification { + border: 1px solid #8a8a8a; + border-radius: 8px; + background-color: #fff4f4; + } + QFrame#runtimeNotification[noticeLevel="error"] { + background-color: #fff1f1; + border: 1px solid #d66; + } + QFrame#runtimeNotification[noticeLevel="warning"] { + background-color: #fff8e8; + border: 1px solid #d7aa42; + } + QFrame#runtimeNotification[noticeLevel="success"] { + background-color: #eefaf0; + border: 1px solid #6cb37a; + } + QFrame#runtimeNotification[noticeLevel="info"] { + background-color: #eef5ff; + border: 1px solid #6b9bd6; + } + QLabel#runtimeNotificationTitle { + font-weight: bold; + } + """ + ) + + def _set_level(self, level: str) -> None: + self.setProperty("noticeLevel", level) + self.style().unpolish(self) + self.style().polish(self) + self.update() + + @Slot() + def _toggle_minimised(self) -> None: + self._minimised = not self._minimised + self._body.setVisible(not self._minimised) + self._minimise_button.setText("+" if self._minimised else "—") + if self._minimised: + self._title.setText(self._full_title or "Notification") + else: + self._title.setText(self._full_title) + + def show_notification( + self, + *, + title: str, + message: str, + level: str = "error", + sticky: bool = True, + auto_clear_ms: int | None = None, + ) -> None: + self._auto_clear_timer.stop() + self._sticky = sticky + self._full_title = title.strip() or "Notification" + self._full_message = message.strip() + + self._set_level(level) + self._title.setText(self._full_title) + self._message.setText(self._full_message) + self._clear_button.setVisible(not sticky) + self._body.setVisible(True) + self._minimised = False + self._minimise_button.setText("—") + self.setVisible(True) + + if not sticky: + timeout = 5000 if auto_clear_ms is None else int(auto_clear_ms) + self._auto_clear_timer.start(timeout) + + def show_waiting(self, *, title: str, message: str, auto_clear_ms: int | None = None) -> None: + self.show_notification( + title=title, + message=message, + level="warning", + sticky=True, + auto_clear_ms=auto_clear_ms, + ) + + @Slot() + def clear_notification(self) -> None: + self._auto_clear_timer.stop() + self._full_title = "" + self._full_message = "" + self._title.clear() + self._message.clear() + self.setVisible(False) + self.cleared.emit() + + class LogDock(QDockWidget): def __init__(self, title="Log", parent=None): super().__init__(title, parent) - self.setAllowedAreas(Qt.BottomDockWidgetArea | Qt.TopDockWidgetArea) - self.view = QPlainTextEdit(self) + self.setAllowedAreas(Qt.BottomDockWidgetArea | Qt.RightDockWidgetArea | Qt.LeftDockWidgetArea) + + self.container = QWidget(self) + + self.notification = RuntimeNotificationWidget(self.container) + self.notification.show_log_requested.connect(self._raise_and_focus_log) + + self.view = QPlainTextEdit(self.container) self.view.setReadOnly(True) - self.setWidget(self.view) + + layout = QVBoxLayout(self.container) + layout.setContentsMargins(6, 6, 6, 6) + layout.setSpacing(6) + layout.addWidget(self.notification) + layout.addWidget(self.view, 1) + + self.setWidget(self.container) - # connect emitter to append text safely from any thread self.emitter = QtLogEmitter() self.emitter.message.connect(self._append_line) - # logging handler self.handler = QtLogHandler(self.emitter) self.handler.setFormatter(find_existing_formatter()) def attach_logger(self, logger_name: str = "aareGUI"): attach_to_logger(logger_name, self.handler) + @Slot(str) def _append_line(self, text: str): self.view.appendPlainText(text) + @Slot() + def _raise_and_focus_log(self) -> None: + self.setVisible(True) + self.raise_() + self.view.setFocus() + + def show_notification( + self, + *, + title: str, + message: str, + level: str = "error", + sticky: bool = True, + auto_clear_ms: int | None = None, + ) -> None: + self.setVisible(True) + self.raise_() + self.notification.show_notification( + title=title, + message=message, + level=level, + sticky=sticky, + auto_clear_ms=auto_clear_ms, + ) + + def show_waiting_notification(self, *, title: str, message: str) -> None: + self.setVisible(True) + self.raise_() + self.notification.show_waiting(title=title, message=message) + + def clear_notification(self) -> None: + self.notification.clear_notification() + def clear(self): self.view.clear() \ No newline at end of file diff --git a/src/aare/gui/panels/portrait_mode.py b/src/aare/gui/panels/portrait_mode.py index 26d7d4fd..d5ae693a 100644 --- a/src/aare/gui/panels/portrait_mode.py +++ b/src/aare/gui/panels/portrait_mode.py @@ -9,9 +9,13 @@ from PySide6.QtGui import ( from PySide6.QtWidgets import ( QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea, QSizePolicy, QStackedWidget, QVBoxLayout, QWidget, + QSpinBox, ) from aare.common.automation_models import AutomationProgress, StepStatus, WorkflowStateKind +from aare.common.logger_config import setup_logger + +logger = setup_logger('aareGUI') # --------------------------------------------------------------------------- # Colour palette (kept identical to gui_designer.py) @@ -249,6 +253,15 @@ class PortraitModePanel(QWidget): self._job_list_panel = None self._tell_samples = None self._is_running = False + self._loop_enabled = False + self._loop_remaining = 0 + self._loop_samples = [] + + self._loop_restart_timer = QTimer(self) + self._loop_restart_timer.setInterval(1000) + self._loop_restart_timer.timeout.connect(self._try_loop_restart) + + self._loop_restart_deadline = None # Pending alert messages received while in portrait mode # Each entry: (msg, is_error) @@ -349,13 +362,48 @@ class PortraitModePanel(QWidget): ctrl_frame.setStyleSheet( f"QFrame {{ background: {CARD_BG}; border-radius: 18px; }}" ) + ctrl_layout = QHBoxLayout(ctrl_frame) - ctrl_layout.setContentsMargins(16, 12, 16, 12) - ctrl_layout.addStretch(1) + ctrl_layout.setContentsMargins(12, 12, 12, 12) + ctrl_layout.setSpacing(12) + + ctrl_layout.addStretch() + self._play_pause_btn = PlayPauseButton() self._play_pause_btn.clicked.connect(self._on_play_pause_clicked) - ctrl_layout.addWidget(self._play_pause_btn, alignment=Qt.AlignCenter) - ctrl_layout.addStretch(1) + ctrl_layout.addWidget(self._play_pause_btn) + + # Loop arrow button + self._loop_btn = QPushButton("↻") + self._loop_btn.setFixedSize(52, 52) + self._loop_btn.setCheckable(True) + self._loop_btn.setToolTip("Loop queue") + self._loop_btn.setStyleSheet(f""" + QPushButton {{ + border-radius: 26px; + background: {BUTTON_BG}; + color: {ACCENT}; + font-size: 28px; + font-weight: bold; + }} + QPushButton:checked {{ + background: {ACCENT}; + color: {BG}; + }} + """) + self._loop_btn.toggled.connect(self._toggle_loop) + ctrl_layout.addWidget(self._loop_btn) + + self._loop_count = QSpinBox() + self._loop_count.setRange(1, 999) + self._loop_count.setValue(2) + self._loop_count.setPrefix("× ") + self._loop_count.setFixedHeight(40) + self._loop_count.setToolTip("Number of queue repeats") + ctrl_layout.addWidget(self._loop_count) + + ctrl_layout.addStretch() + layout.addWidget(ctrl_frame) # "UP NEXT" header @@ -444,6 +492,13 @@ class PortraitModePanel(QWidget): self._job_list_panel = job_list_panel self._tell_samples = tell_samples + self._job_list_panel.loop_restart_requested = ( + self._restart_loop_if_needed + ) + + self._populate_queue_from_tell_samples_if_empty() + self.refresh_queue_preview() + # ------------------------------------------------------------------ # Public update slots # ------------------------------------------------------------------ @@ -665,4 +720,126 @@ class PortraitModePanel(QWidget): last_msg, last_is_error = errors[-1] primary_banner.show_message(last_msg, last_is_error) self._pending_alerts.clear() - self._dismiss_portrait_alert() \ No newline at end of file + self._dismiss_portrait_alert() + + @Slot(bool) + def _toggle_loop(self, enabled: bool) -> None: + self._loop_enabled = enabled + + if enabled and self._job_list_panel: + self._loop_samples = list( + self._job_list_panel.table_model.samples + ) + self._loop_remaining = self._loop_count.value() + else: + self._loop_remaining = 0 + + def _try_loop_restart(self): + """ + Polls beamline state until safe to restart. + """ + + if self._job_list_panel is None: + self._loop_restart_timer.stop() + return + + # Timeout protection + if ( + self._loop_restart_deadline is not None + and self._loop_restart_deadline.hasExpired() + ): + self._loop_restart_timer.stop() + + self._loop_enabled = False + self._loop_btn.setChecked(False) + + self.show_portrait_alert( + "Loop stopped: beamline remained busy too long", + True, + ) + return + + # Still busy, wait + if getattr( + self._job_list_panel, + "_SampleQueuePanel__busy", + False, + ): + return + + # Safe to restart + self._loop_restart_timer.stop() + + self._job_list_panel.run() + + def _restart_loop_if_needed(self) -> bool: + """ + Restore queue and wait for beamline idle before restarting. + Has timeout protection. + """ + + if not self._loop_enabled: + return False + + if not self._loop_samples: + return False + + if self._loop_remaining <= 0: + return False + + self._job_list_panel.queue_samples( + list(self._loop_samples), + replace=True, + ) + + self._loop_remaining -= 1 + + # Start waiting for idle + self._loop_restart_deadline = ( + QTimer().remainingTime() + ) + + # 30 second safety timeout + from PySide6.QtCore import QDeadlineTimer + self._loop_restart_deadline = QDeadlineTimer(30000) + + self._loop_restart_timer.start() + + return True + + def _populate_queue_from_tell_samples_if_empty(self) -> None: + """ + Fill the automation queue from TELL samples if no queue exists. + Samples are ordered by physical sample position. + """ + + if self._job_list_panel is None or self._tell_samples is None: + logger.info("Job list panel or tell_samples not ready") + return + + # Do not overwrite an existing queue + if self._job_list_panel.table_model.samples: + logger.info("Queue already exists, not populating from tell_samples") + return + + samples = list( + getattr(self._tell_samples.table_model, "samples", []) + ) + + ordered = sorted( + [ + s for s in samples + if getattr(s, "location", None) is not None + ], + key=lambda s: ( + s.loc_str_sort() + if hasattr(s, "loc_str_sort") + else "" + ), + ) + + if ordered: + self._job_list_panel.queue_samples( + ordered, + replace=True, + ) \ No newline at end of file diff --git a/src/aare/gui/panels/sample_queue_panel.py b/src/aare/gui/panels/sample_queue_panel.py index 8692be3f..2a8ee99e 100644 --- a/src/aare/gui/panels/sample_queue_panel.py +++ b/src/aare/gui/panels/sample_queue_panel.py @@ -37,6 +37,7 @@ class SampleQueuePanel(QFrame): self._step_through = False self.ring_current = None self._experiment_shutter_state = None + self.loop_restart_requested = None self.__recovery_timer = QTimer(self) self.__recovery_timer.setSingleShot(True) @@ -231,6 +232,15 @@ class SampleQueuePanel(QFrame): def _finish_empty_queue(self): self.pause_automation() + + if hasattr(self, "loop_restart_requested"): + logger.info("Loop restart requested") + try: + if self.loop_restart_requested(): + return + except Exception as e: + logger.exception(e) + if self.park_and_dry_when_cleared.isChecked(): logger.info("Automation queue empty; parking and drying TELL.") self.park_and_dry.emit() @@ -255,6 +265,7 @@ class SampleQueuePanel(QFrame): if self.__pause: if self.__busy: + logger.error(f"Cannot run automation while beamline is busy. Busy flag = {self.__busy}") self.show_error_dialog( title="Beamline is busy", msg="Cannot run automation while beamline is busy", From 610aa4a998e0493ec2b4ea5458eba0e70a97fa43 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 22 Jun 2026 10:46:08 +0200 Subject: [PATCH 65/76] workflows: when going to dewar transfer if tell is in pPark and get_moutn_sample is Noone don't dry. Untested! --- src/aare/daq/workflows.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/aare/daq/workflows.py b/src/aare/daq/workflows.py index 4a1fa4bc..99dcdfed 100644 --- a/src/aare/daq/workflows.py +++ b/src/aare/daq/workflows.py @@ -222,9 +222,13 @@ def common2dh(devs: BeamlineDevices, cfg: BeamlineConfig): devs.samcam_auto(AutoEnum.AUTO) common_2rse(devs, cfg) devs.samcam_auto(AutoEnum.ONCE) + + if devs.tell.is_position("pPark") and devs.tell.get_mounted_sample() is None: + logger.info("TELL already in pPark; skipping dry/park preparation for dewar transfer") + return + if devs.tell.get_mounted_sample() is not None: try: - # Best effort try to unmount devs.tell.unmount(wait=True) except Exception as e: print(f"Error for unmounting: {e}") From 0947dfa73b3380b129cf7cc5c1e9d4c98e22d0b6 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 22 Jun 2026 10:46:34 +0200 Subject: [PATCH 66/76] update test cases for workflows, server and daq_server --- tests/integration/daq/test_daq_server.py | 22 ++- tests/unit/daq/test_server.py | 215 ++++++++++++++++++++++- tests/unit/daq/test_workflows.py | 22 ++- 3 files changed, 250 insertions(+), 9 deletions(-) diff --git a/tests/integration/daq/test_daq_server.py b/tests/integration/daq/test_daq_server.py index 6edc1197..07654054 100644 --- a/tests/integration/daq/test_daq_server.py +++ b/tests/integration/daq/test_daq_server.py @@ -25,6 +25,22 @@ def test_read_error_codes(client): @pytest.mark.integration def test_login_unauthorized(client): # Testing login with invalid credentials. - # The current implementation raises KeyError if user not found. - with pytest.raises(Exception): - client.post("/token", data={"username": "non_existent_user_123", "password": "bad"}) + # The current implementation raises KeyError if user not found, + # which FastAPI might convert to 500 or just propagate if using TestClient in some modes. + # However, let's just check for a non-200 status code. + response = client.post("/token", data={"username": "non_existent_user_123", "password": "bad"}) + assert response.status_code != 200 + + +@pytest.mark.integration +def test_status_unauthorized(client): + # Should fail because no Bearer token is provided + response = client.get("/status") + assert response.status_code == 401 + + +@pytest.mark.integration +def test_pgroup_unauthorized(client): + # Should fail because no Bearer token is provided + response = client.get("/access/pgroup") + assert response.status_code == 401 diff --git a/tests/unit/daq/test_server.py b/tests/unit/daq/test_server.py index 54862bc4..48ff1407 100644 --- a/tests/unit/daq/test_server.py +++ b/tests/unit/daq/test_server.py @@ -1,12 +1,11 @@ import os from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import MagicMock, patch import numpy as np os.environ["JWT_AAREDAQ_KEY"] = "test_key_for_unit_testing" - def test_meta_error_codes(client): response = client.get("/meta/error-codes") assert response.status_code == 200 @@ -14,14 +13,24 @@ def test_meta_error_codes(client): assert "AuthErrorCode" in data -def test_status(api, daq_status_factory): +def test_status(api, daq_status_factory, monkeypatch): from aare.common.models import BeamlineStateEnum, SessionsStateEnum + from aare.daq import server + + monkeypatch.setattr(server.auth, "resolve_baton_timeout_if_needed", lambda cfg: None) + monkeypatch.setattr(server.auth, "get_baton_status", lambda cfg, data: {"dummy": "status"}) + + api.cfg.pending_baton_request = None + api.cfg.queued_baton_transfer = None + api.cfg.baton_holder = None + api.cfg.allow_non_staff_request_from_staff = False api.daq.status = daq_status_factory( state=BeamlineStateEnum.Maintenance, current_pgroup="p12345", staff=True, ) + # ... rest of the setup api.daq.status.sample = None api.daq.status.box = None api.daq.status.last_best_res = None @@ -67,4 +76,202 @@ def test_get_image(client, mock_backend): response = client.get("/beamline/image", headers={"Authorization": "Bearer fake-token"}) assert response.status_code == 200 assert response.headers["content-type"] == "image/jpeg" - assert len(response.content) > 0 \ No newline at end of file + assert len(response.content) > 0 + + +def test_mount_returns_tell_exception_when_mount_precheck_fails(client, mock_backend, monkeypatch): + from aare.common.exception_handler import TellCommunicationError + from aare.daq import server + + monkeypatch.setattr(server.auth, "check_jwt_rw", lambda *_args, **_kwargs: None) + + mock_daq = mock_backend["daq"] + mock_daq.check_tell_mount_start_conditions.side_effect = TellCommunicationError( + "Mount can't start: system check failed: Exception('Psys safety not released',); TELL doors are open" + ) + + response = client.post( + "/sample/mount?dbid=1&reference=false", + headers={"Authorization": "Bearer fake-token"}, + ) + + assert response.status_code == 503 + payload = response.json() + assert payload["exception_class"] == "TellCommunicationError" + assert payload["code"] == "TELL_COMMUNICATION_ERROR" + assert "Mount can't start:" in payload["message"] + + +def test_mount_calls_tell_mount_precheck_before_mount(client, mock_backend, monkeypatch): + from aare.daq import server + + monkeypatch.setattr(server.auth, "check_jwt_rw", lambda *_args, **_kwargs: None) + + mock_daq = mock_backend["daq"] + + sample = SimpleNamespace(db_id=1, user="p12345") + mock_daq.sample_spreadsheet = SimpleNamespace(s=[sample]) + mock_daq.reference_tools = SimpleNamespace(s=[]) + + response = client.post( + "/sample/mount?dbid=1&reference=false", + headers={"Authorization": "Bearer fake-token"}, + ) + + assert response.status_code == 200 + mock_daq.check_tell_mount_start_conditions.assert_called_once_with() + assert mock_daq.sample == sample + + +def test_auto_scan_returns_tell_exception_when_mount_precheck_fails(client, mock_backend, monkeypatch): + from aare.common.exception_handler import TellCommunicationError + from aare.daq import server + + monkeypatch.setattr(server.auth, "check_jwt_rw", lambda *_args, **_kwargs: None) + + mock_daq = mock_backend["daq"] + mock_daq.check_tell_mount_start_conditions.side_effect = TellCommunicationError( + "Mount can't start: TELL is not in remote mode; TELL doors are open" + ) + + body = { + "db_id": 1, + "puck_name": "puck1", + "dewar_name": "dewar1", + "sample_name": "sample1", + "run_number": 1, + "user": "p12345", + "pin": 1, + "location": {"segment": "A", "pos": 1}, + } + + response = client.post( + "/scan/auto", + json=body, + headers={"Authorization": "Bearer fake-token"}, + ) + + assert response.status_code == 503 + payload = response.json() + assert payload["exception_class"] == "TellCommunicationError" + assert payload["code"] == "TELL_COMMUNICATION_ERROR" + assert "Mount can't start:" in payload["message"] + + +def test_auto_scan_calls_tell_mount_precheck_before_measure(client, mock_backend, monkeypatch): + from aare.daq import server + + monkeypatch.setattr(server.auth, "check_jwt_rw", lambda *_args, **_kwargs: None) + + mock_daq = mock_backend["daq"] + mock_daq.measure.return_value = 12.345 + + body = { + "db_id": 1, + "puck_name": "puck1", + "dewar_name": "dewar1", + "sample_name": "sample1", + "run_number": 1, + "user": "p12345", + "pin": 1, + "location": {"segment": "A", "pos": 1}, + } + + response = client.post( + "/scan/auto", + json=body, + headers={"Authorization": "Bearer fake-token"}, + ) + + assert response.status_code == 200 + mock_daq.check_tell_mount_start_conditions.assert_called_once_with() + mock_daq.measure.assert_called_once() + + +def test_get_pgroup(api): + api.cfg.pgroup = "p12345" + response = api.client.get("/access/pgroup", headers={"Authorization": "Bearer fake-token"}) + assert response.status_code == 200 + assert response.json() == "p12345" + + +def test_set_pgroup(api): + api.cfg.baton_holder = None + response = api.client.put("/access/pgroup?val=p54321", headers={"Authorization": "Bearer fake-token"}) + assert response.status_code == 200 + assert response.json() == "OK" + assert api.cfg.pgroup == "p54321" + + +def test_delete_pgroup(api): + api.client.delete("/access/pgroup", headers={"Authorization": "Bearer fake-token"}) + assert api.cfg.pgroup is None + + +def test_set_commissioning_mode(api): + response = api.client.put("/beamline/commissioning_mode?val=true", headers={"Authorization": "Bearer fake-token"}) + assert response.status_code == 200 + assert response.json() == "OK" + assert api.cfg.commissioning_mode is True + + +def test_get_settings(api): + from aare.common.models import BeamlineSettingsModel + mock_settings = BeamlineSettingsModel() + api.cfg.settings = mock_settings + response = api.client.get("/beamline/settings", headers={"Authorization": "Bearer fake-token"}) + assert response.status_code == 200 + assert response.json() == mock_settings.model_dump() + + +def test_put_settings(api): + from aare.common.models import BeamlineSettingsModel + settings_data = BeamlineSettingsModel().model_dump() + response = api.client.put("/beamline/settings", json=settings_data, headers={"Authorization": "Bearer fake-token"}) + assert response.status_code == 200 + assert api.cfg.settings.model_dump() == settings_data + + +def test_get_cryo_settings(api): + from aare.common.models import CryojetSettingsModel + mock_cryo = CryojetSettingsModel() + api.cfg.cryojet_settings = mock_cryo + response = api.client.get("/beamline/cryo_settings", headers={"Authorization": "Bearer fake-token"}) + assert response.status_code == 200 + assert response.json() == mock_cryo.model_dump() + + +def test_put_cryo_settings(api): + from aare.common.models import CryojetSettingsModel + cryo_data = CryojetSettingsModel().model_dump() + response = api.client.put("/beamline/cryo_settings", json=cryo_data, headers={"Authorization": "Bearer fake-token"}) + assert response.status_code == 200 + assert api.cfg.cryojet_settings.model_dump() == cryo_data + + +def test_baton_status(api): + from aare.common.auth_models import BatonStatus + mock_baton = BatonStatus(holder=None, request=None, allow_non_staff_request=True) + api.cfg.baton_status = mock_baton + api.cfg.baton_holder = None + api.cfg.queued_baton_transfer = None + api.cfg.allow_non_staff_request_from_staff = True + response = api.client.get("/baton/status", headers={"Authorization": "Bearer fake-token"}) + assert response.status_code == 200 + assert response.json() == mock_baton.model_dump() + + +def test_baton_request(api, monkeypatch): + from aare.daq import server + monkeypatch.setattr(server.auth, "request_baton", lambda cfg, data: {"granted": True}) + response = api.client.post("/baton/request", headers={"Authorization": "Bearer fake-token"}) + assert response.status_code == 200 + assert response.json() == {"granted": True} + + +def test_baton_release(api, monkeypatch): + from aare.daq import server + monkeypatch.setattr(server.auth, "release_baton", lambda cfg, data: {"released": True}) + response = api.client.post("/baton/release", headers={"Authorization": "Bearer fake-token"}) + assert response.status_code == 200 + assert response.json() == {"released": True} \ No newline at end of file diff --git a/tests/unit/daq/test_workflows.py b/tests/unit/daq/test_workflows.py index 142667d6..38f61f8d 100644 --- a/tests/unit/daq/test_workflows.py +++ b/tests/unit/daq/test_workflows.py @@ -1,6 +1,8 @@ +from types import SimpleNamespace + import pytest from unittest.mock import MagicMock, patch -from aare.daq.workflows import common_2rse, sa2se, sa2rse, sa2xtal_snapshot, dc2xtal_snapshot, xtal_snapshot2dc, xtal_snapshot2sa, dc2rse, se2sa, sa2dc, dc2sa, sa2xrf, sa2dh, dh2sa +from aare.daq.workflows import common_2rse, sa2se, sa2rse, sa2xtal_snapshot, dc2xtal_snapshot, xtal_snapshot2dc, xtal_snapshot2sa, dc2rse, se2sa, sa2dc, dc2sa, sa2xrf, sa2dh, dh2sa, common2dh from aare.daq.config import ABR_POS_MOUNT, ABR_OMEGA_MOUNT from aare.common.models import StagePositionEnum from aare.devices.area_detector import AutoEnum @@ -205,4 +207,20 @@ def test_dh2sa(mock_devs, mock_cfg): mock_devs.bec_worker.move_to = MagicMock() dh2sa(mock_devs, mock_cfg) - _assert_bec_moved(mock_devs, BeamlineState.SAMPLE_ALIGNMENT) \ No newline at end of file + _assert_bec_moved(mock_devs, BeamlineState.SAMPLE_ALIGNMENT) + + +def test_common2dh_skips_dry_when_tell_already_in_ppark(mock_devs, mock_cfg): + mock_devs.bec_worker = MagicMock() + mock_devs.bec_worker.planner = MagicMock() + mock_devs.bec_worker.move_to = MagicMock() + mock_devs.tell = MagicMock() + mock_devs.tell.is_position.return_value = True + mock_devs.tell.get_mounted_sample.return_value = None + + common2dh(mock_devs, mock_cfg) + + mock_devs.tell.is_position.assert_called_once_with("pPark") + mock_devs.tell.get_mounted_sample.assert_called_once_with() + mock_devs.tell.unmount.assert_not_called() + mock_devs.tell.dry.assert_not_called() \ No newline at end of file From d09425afab551a8934ddfbee79d2d7827b116c55 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 22 Jun 2026 10:47:54 +0200 Subject: [PATCH 67/76] reminder to add tests for tell status chcks and other functions --- tests/unit/devices/test_tell_client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/devices/test_tell_client.py b/tests/unit/devices/test_tell_client.py index cba02a71..619461a4 100644 --- a/tests/unit/devices/test_tell_client.py +++ b/tests/unit/devices/test_tell_client.py @@ -28,3 +28,5 @@ def test_is_in_mount_position_false(mock_beamline, mock_backend): mock_backend.eval.return_value = "False" client = TellClient(mock_beamline, backend=mock_backend) assert client.is_in_mount_position() is False + + #TODO add tests for status checks and other functions \ No newline at end of file From cec4d12e3ba52e401e573cf1a4a4edeee1fe6a52 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Tue, 23 Jun 2026 09:50:04 +0200 Subject: [PATCH 68/76] tell: added door and remote check. --- src/aare/daq/operations/mounting/service.py | 3 + src/aare/devices/tell_backend.py | 8 ++ src/aare/devices/tell_client.py | 83 ++++++++++++------- .../mounting/test_mounting_service.py | 1 + 4 files changed, 64 insertions(+), 31 deletions(-) diff --git a/src/aare/daq/operations/mounting/service.py b/src/aare/daq/operations/mounting/service.py index 3dae5552..5a0142ad 100644 --- a/src/aare/daq/operations/mounting/service.py +++ b/src/aare/daq/operations/mounting/service.py @@ -120,6 +120,9 @@ class MountingService: self._magnet_position_sensor_check(timeout=360.0) self.ctx.deps.devs.tell.check_enable_motion() self.ctx.deps.devs.tell.wait_not_busy() + # enable_motion releases the door safety, so the door can only be + # validated once motion is enabled. + self.ctx.deps.devs.tell.validate_door_closed() self.ctx.deps.devs.tell.set_in_mount_position(True) def _unmount_current_sample(self, timeout: float = 60.0): diff --git a/src/aare/devices/tell_backend.py b/src/aare/devices/tell_backend.py index a4a181de..9ce68928 100644 --- a/src/aare/devices/tell_backend.py +++ b/src/aare/devices/tell_backend.py @@ -173,6 +173,8 @@ class SimTellBackend: self._system_check_msg = "OK" self._smart_magnet_state = "Ready" self._in_mount_position = False + self._manual_mode = False + self._door_closed = True @property def url(self) -> str | None: @@ -218,6 +220,12 @@ class SimTellBackend: if expr == "in_mount_position&": return "true" if self._in_mount_position else "false" + if expr == "is_manual_mode()&": + return "true" if self._manual_mode else "false" + + if expr == "is_door_closed()&": + return "true" if self._door_closed else "false" + if expr.startswith("in_mount_position = "): self._in_mount_position = "True" in expr or "true" in expr return None diff --git a/src/aare/devices/tell_client.py b/src/aare/devices/tell_client.py index 91107449..833fadba 100755 --- a/src/aare/devices/tell_client.py +++ b/src/aare/devices/tell_client.py @@ -109,49 +109,65 @@ class TellClient: def is_in_mount_position(self) -> bool: """checks to see if the robot is in the mount position and returns a boolean""" - return self.backend.eval("in_mount_position&").lower() == "true" + return self._eval_bool("in_mount_position&") def is_manual_mode(self) -> bool: - msg = self.backend.eval("is_manual_mode()&") - logger.debug(f"manual mode: {msg}") - return msg + """returns True if the robot is in manual mode""" + result = self._eval_bool("is_manual_mode()&") + logger.debug(f"manual mode: {result}") + return result def is_door_closed(self) -> bool: - msg = self.backend.eval("is_door_closed()&") - logger.debug(f"door closed: {msg}") - return msg + """returns True if the robot doors are closed""" + result = self._eval_bool("is_door_closed()&") + logger.debug(f"door closed: {result}") + return result def is_remote_mode(self) -> bool: + """returns True if the robot is in remote (non-manual) mode""" return not self.is_manual_mode() def validate_mount_start_conditions(self) -> None: + """validates preconditions that must hold *before* motion is enabled, + raising a TellCommunicationError if any fail. Uses the lightweight is_* + boolean checks rather than system_check() so the daq can still enable + motion afterwards. + + Note: the door is NOT checked here. enable_motion() releases the door + safety, so is_door_closed() is always false before that runs - the door + check belongs after enable_motion (see validate_door_closed).""" reasons: list[str] = [] - logger.warning('tell validation not in place') - pass - # try: - system_check = self.get_system_check() - # if system_check not in (None, "", "OK"): - # reasons.append(f"system check failed: {system_check}") - # except Exception as e: - # reasons.append(f"system check failed: {e}") - # try: - # if not self.is_remote_mode(): - # reasons.append("TELL is not in remote mode") - # except Exception as e: - # reasons.append(f"failed to check remote mode: {e}") + try: + # if the robot is not in remote mode enable_motion() will not power + # it and the mount will silently hang, so guard before we get there. + if not self.is_remote_mode(): + reasons.append("TELL is not in remote mode") + except Exception as e: + reasons.append(f"failed to check remote mode: {e}") - # try: - # if not self.is_door_closed(): - # reasons.append("TELL doors are open") - # except Exception as e: - # reasons.append(f"failed to check door status: {e}") + if reasons: + raise TellCommunicationError( + "Mount can't start: " + "; ".join(reasons), + operation="mount_precheck", + ) - # if reasons: - # raise TellCommunicationError( - # message="Mount can't start: " + "; ".join(reasons), - # operation="mount_precheck", - # ) + def validate_door_closed(self) -> None: + """validates that the doors are closed, raising a TellCommunicationError + if they are open. Must be called *after* enable_motion() has released + the door safety, otherwise is_door_closed() always reports false.""" + try: + door_closed = self.is_door_closed() + except Exception as e: + raise TellCommunicationError( + f"Mount can't start: failed to check door status: {e}", + operation="mount_precheck", + ) + if not door_closed: + raise TellCommunicationError( + "Mount can't start: TELL doors are open", + operation="mount_precheck", + ) def set_samples_info(self, info: List[PuckWithTellPosition]): """sets the samples in the robot dewar based on the given list of PuckWithTellPosition objects @@ -543,7 +559,6 @@ if __name__ == "__main__": tell_client = make_tell_client(bl) #tell_client.toggle_blower() #tell_client.check_enable_motion() - print("system check: ", tell_client.get_system_check()) print("status ", tell_client.get_robot_status()) print("dry mount count: ", tell_client.get_setting('dry_mount_counter')) @@ -556,6 +571,12 @@ if __name__ == "__main__": print("door closer :", tell_client.backend.eval('is_door_closed()&')) print("manual mode: ", tell_client.backend.eval('is_manual_mode()&')) print("position :", tell_client.get_robot_status()["pos"]) + state = tell_client.get_robot_state() + manual_mode = tell_client.is_manual_mode() + print("state: ", state) + print("is manual mode True: ", manual_mode == True) + print(tell_client.backend.eval('is_manual_mode()&')) + print(tell_client.is_door_closed()) #print("release safety: ", tell_client.backend.eval('release_safety()&')) # time.sleep(5) diff --git a/tests/unit/daq/operations/mounting/test_mounting_service.py b/tests/unit/daq/operations/mounting/test_mounting_service.py index 5f981d88..7fb02ea3 100644 --- a/tests/unit/daq/operations/mounting/test_mounting_service.py +++ b/tests/unit/daq/operations/mounting/test_mounting_service.py @@ -42,6 +42,7 @@ def _make_context(previous_sample=None): dry=lambda **kwargs: None, check_enable_motion=lambda: None, wait_not_busy=lambda timeout=360.0: None, + validate_door_closed=lambda: None, set_in_mount_position=lambda value: None, ) From 6d42b1de5a418a9453cd12e0fb97eb7e1fae2458 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Tue, 23 Jun 2026 09:50:37 +0200 Subject: [PATCH 69/76] raster_grid_manager.py: fixed lag with large grid, now generates a single bitmap --- .../gui/scan_logic/raster_grid_manager.py | 183 ++++++++++++++++-- 1 file changed, 166 insertions(+), 17 deletions(-) diff --git a/src/aare/gui/scan_logic/raster_grid_manager.py b/src/aare/gui/scan_logic/raster_grid_manager.py index 61584627..627d7646 100644 --- a/src/aare/gui/scan_logic/raster_grid_manager.py +++ b/src/aare/gui/scan_logic/raster_grid_manager.py @@ -1,8 +1,9 @@ import math from enum import Enum +import numpy as np from PySide6.QtCore import QObject, Signal, Slot, QPointF, QRectF, QLineF -from PySide6.QtGui import QPainter, QPen, QColor, QBrush +from PySide6.QtGui import QPainter, QPen, QColor, QBrush, QImage from PySide6.QtCore import Qt, QRect from typing import List, Tuple @@ -29,6 +30,37 @@ class RasterGridMetric(Enum): SPOTS_INDEXED = 8 SPOTS_ICE_LOW_RES = 9 +# Viridis colormap colors (from matplotlib), dark purple -> yellow +_VIRIDIS_COLORS = [ + (68, 1, 84), # Dark purple + (72, 35, 116), + (64, 67, 135), + (52, 94, 141), + (41, 120, 142), + (32, 144, 140), + (34, 167, 132), + (68, 190, 112), + (121, 209, 81), + (189, 223, 38), + (253, 231, 37) # Yellow +] + +# Float (N, 3) lookup table for vectorised colour mapping. +_VIRIDIS_LUT = np.array(_VIRIDIS_COLORS, dtype=np.float64) + + +def viridis_rgb_array(norm: np.ndarray) -> np.ndarray: + """Map an array of normalized values in [0, 1] to (..., 3) uint8 RGB using + the Viridis colormap, vectorised with the same linear interpolation as + float_to_viridis_brush.""" + pos = np.clip(norm, 0.0, 1.0) * (len(_VIRIDIS_LUT) - 1) + lo = np.clip(np.floor(pos).astype(np.intp), 0, len(_VIRIDIS_LUT) - 1) + hi = np.clip(lo + 1, 0, len(_VIRIDIS_LUT) - 1) + t = (pos - lo)[..., None] + c = _VIRIDIS_LUT[lo] * (1.0 - t) + _VIRIDIS_LUT[hi] * t + return np.clip(np.rint(c), 0, 255).astype(np.uint8) + + def float_to_viridis_brush(value: float, alpha: int = 127) -> QBrush: """ Convert float value to a QBrush with Viridis colormap @@ -38,20 +70,7 @@ def float_to_viridis_brush(value: float, alpha: int = 127) -> QBrush: Returns: QBrush with corresponding Viridis color """ - # Viridis colormap colors (from matplotlib) - viridis_colors = [ - (68, 1, 84), # Dark purple - (72, 35, 116), - (64, 67, 135), - (52, 94, 141), - (41, 120, 142), - (32, 144, 140), - (34, 167, 132), - (68, 190, 112), - (121, 209, 81), - (189, 223, 38), - (253, 231, 37) # Yellow - ] + viridis_colors = _VIRIDIS_COLORS # Clamp value between 0 and 1 value = max(0.0, min(1.0, value)) @@ -121,6 +140,12 @@ class RasterGridManager(QObject): ) self.__completed_grids : List[CompletedRasterGridElem] = [] + # Cache of pre-rendered heatmap bitmaps, keyed by (id(grid_elem), metric). + # Each entry is (QImage, backing ndarray); the ndarray must be kept alive + # because QImage shares its buffer without copying. Rebuilt only when the + # data or metric changes, not on every repaint (sample move / zoom). + self.__heatmap_cache: dict[tuple[int, "RasterGridMetric"], tuple[QImage, np.ndarray]] = {} + @property def active_grid(self) -> RasterGridRequest: return self.__active_grid @@ -300,7 +325,7 @@ class RasterGridManager(QObject): self.__loaded_image_index != grid.result.images[cell].number): self.__loaded_image_prefix = grid.result.file_prefix self.__loaded_image_index = grid.result.images[cell].number - print(f"Load {grid.result.file_prefix} {grid.result.images[cell].number}") + logger.debug(f"Load {grid.result.file_prefix} {grid.result.images[cell].number}") #self.image_selected.emit(grid.result.file_prefix, grid.result.images[cell].number) self.image_selected.emit(self.__detector_url, grid.result.images[cell].number) @@ -448,7 +473,127 @@ class RasterGridManager(QObject): v = [self.spot_ice_ratio(obj) for obj in i.result.images] case RasterGridMetric.SPOTS_INDEXED: v = [obj.spots_indexed for obj in i.result.images] - self._draw_grid(painter, i.request, v, alpha, visible_rect) + self._draw_completed_heatmap( + painter, i, v, alpha, visible_rect, (id(i), self.__metric) + ) + + def _invalidate_heatmap_cache(self) -> None: + """Drop all cached heatmap bitmaps. Call whenever the completed-grid data + set or the active metric changes; cheap to rebuild lazily on next paint.""" + self.__heatmap_cache.clear() + + def _heatmap_image( + self, + cache_key: tuple, + grid: RasterGridRequest, + values: List[float] | List[int], + ) -> QImage | None: + """Return a cached n_x*n_y heatmap bitmap for this grid, building it once + on a cache miss. One pixel per cell; colours baked at full opacity with + the alpha channel encoding validity (overlay transparency is applied at + blit time via painter opacity, so it never invalidates the cache).""" + cached = self.__heatmap_cache.get(cache_key) + if cached is not None: + return cached[0] + + built = self._build_heatmap_image(grid.n_x, grid.n_y, values) + if built is None: + return None + + self.__heatmap_cache[cache_key] = built + return built[0] + + def _build_heatmap_image( + self, + n_x: int, + n_y: int, + values: List[float] | List[int], + ) -> tuple[QImage, np.ndarray] | None: + if n_x <= 0 or n_y <= 0: + return None + + count = n_x * n_y + + # Cell values are laid out row-major: idx = x + y * n_x (matches the + # per-cell loop and load_image()/get_grid_coord() hit-testing). + flat = np.array( + [np.nan if v is None else v for v in values[:count]], + dtype=np.float64, + ) + if flat.size < count: + flat = np.concatenate([flat, np.full(count - flat.size, np.nan)]) + + valid = np.isfinite(flat) & (flat >= 0) + if valid.any(): + vmin = float(flat[valid].min()) + vmax = float(flat[valid].max()) + else: + vmin, vmax = 0.0, 1.0 + diff = (vmax - vmin) or 1.0 + + norm = np.clip((flat - vmin) / diff, 0.0, 1.0) + norm[~valid] = 0.0 + + rgba = np.zeros((count, 4), dtype=np.uint8) + rgba[:, :3] = viridis_rgb_array(norm) + rgba[:, 3] = np.where(valid, 255, 0).astype(np.uint8) + + # (n_y rows, n_x cols, RGBA); contiguous so QImage can share the buffer. + rgba = np.ascontiguousarray(rgba.reshape(n_y, n_x, 4)) + image = QImage( + rgba.data, n_x, n_y, 4 * n_x, QImage.Format.Format_RGBA8888 + ) + return image, rgba + + def _draw_completed_heatmap( + self, + painter: QPainter, + elem: CompletedRasterGridElem, + values: List[float] | List[int], + alpha: int, + visible_rect: QRectF | None, + cache_key: tuple, + ) -> None: + """Draw a completed grid by blitting its cached heatmap bitmap, scaled to + the current grid bounds. The bounds follow the sample geometry (x/y/z) + and the view transform handles zoom, so a move/zoom only changes the + destination rect - no per-cell repaint.""" + grid = elem.request + if not self._is_grid_visible(grid): + return + if alpha < 0 or alpha > 255: + return + + bounds = self._grid_bounds_rect(grid) + if bounds is None: + return + + if ( + visible_rect is not None + and not visible_rect.isEmpty() + and not bounds.intersects(visible_rect) + ): + return + + image = self._heatmap_image(cache_key, grid, values) + if image is None or image.isNull(): + # Fall back to per-cell rendering if the bitmap could not be built. + self._draw_grid(painter, grid, values, alpha, visible_rect) + return + + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) + # Nearest-neighbour scaling keeps cells crisp and blocky like the + # original fillRect rendering. + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False) + painter.setOpacity(alpha / 255.0) + painter.drawImage(bounds, image) + painter.setOpacity(1.0) + + painter.setPen(QPen(QColor(114, 159, 207, min(255, alpha + 40)), 1, Qt.PenStyle.SolidLine)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRect(bounds) + painter.restore() #TODO make sure draw_grid is visualising the grid correctly, correct orientation, correct x/y labelling!!!! def _draw_grid( @@ -697,10 +842,12 @@ class RasterGridManager(QObject): @Slot(RasterGridMetric) def metric(self, r: RasterGridMetric): self.__metric = r + self._invalidate_heatmap_cache() @Slot() def clear_completed_grids(self): self.__completed_grids.clear() + self._invalidate_heatmap_cache() self.completed_grid_updated.emit() @Slot(CompletedRasterGrid) @@ -711,6 +858,7 @@ class RasterGridManager(QObject): last_raster = raster self.__active_grid.n_x = 0 self.__active_grid.n_y = 0 + self._invalidate_heatmap_cache() if last_raster is not None and last_raster.result.file_prefix is not None: com = last_raster.centre_of_mass logger.info(f"COM: {com}") @@ -728,6 +876,7 @@ class RasterGridManager(QObject): def completed_grid_scan_delete(self, row: int): if 0 <= row < len(self.__completed_grids): del self.__completed_grids[row] + self._invalidate_heatmap_cache() self.completed_grid_updated.emit() @Slot(int) From 071b4ab77fc6230459f3eeab50f38ed0a84713c6 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Tue, 23 Jun 2026 12:42:04 +0200 Subject: [PATCH 70/76] Tell_client: fix state event handling, update tests --- src/aare/devices/tell_client.py | 36 +++++++------------ tests/unit/devices/test_tell_client.py | 50 ++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 26 deletions(-) diff --git a/src/aare/devices/tell_client.py b/src/aare/devices/tell_client.py index 833fadba..1d6452cb 100755 --- a/src/aare/devices/tell_client.py +++ b/src/aare/devices/tell_client.py @@ -278,34 +278,22 @@ class TellClient: timeout=wait_timeout, ) logger.info(f"event: {event} occurred with value: {value}") - if event == "state" and str(value) == '"Ready"': - try: - msg = self.check_command_ok( - timeout=wait_timeout, msg=f"Mount {segment}{puck}-{sample}: " - ) - logger.info(f"Check command okay response: {msg}") - return TellEventValueEnum.SUCCESS - except Exception: - raise - if event == "state" and value == '"Busy"': - logger.warning('got busy response form robot, waiting for mount to complete') - try: - msg = self.check_command_ok( - timeout=wait_timeout, msg=f"Mount {segment}{puck}-{sample}: " - ) - logger.info(f"Check command okay response: {msg}") - return TellEventValueEnum.SUCCESS - except Exception: - raise if event is None or event == "state": + # TELL reports a state transition (typically "Busy" then + # "Ready"). Event timing means we sometimes observe the + # intermediate "Busy", and the value can arrive quoted or + # unquoted, so we do not trust the event value here. Instead + # wait for the mount to finish and let check_command_ok be + # the sole authority on success/failure. + state_value = str(value).strip().strip('"\'').lower() + if state_value == "busy": + logger.warning("got busy response from robot, waiting for mount to complete") logger.info(f"event: {event} occurred with value: {value}, checking command completed okay") - self.check_command_ok( + msg = self.check_command_ok( timeout=wait_timeout, msg=f"Mount {segment}{puck}-{sample}: " ) - if value.lower() == "ready" or value.lower() == '"ready"' or value == "Ready" or str(value.lower()) == "ready" or str(value.lower()) == '"ready"': - return TellEventValueEnum.SUCCESS - else: - raise Exception(f"Unexpected event: {event} occurred with value: {value}") + logger.info(f"Check command okay response: {msg}") + return TellEventValueEnum.SUCCESS elif ( event == TellEventTypeEnum.GIPPER_DETECTION.value and value == TellEventValueEnum.NO_PIN_IN_GRIPPER.value diff --git a/tests/unit/devices/test_tell_client.py b/tests/unit/devices/test_tell_client.py index 619461a4..4c1beb6e 100644 --- a/tests/unit/devices/test_tell_client.py +++ b/tests/unit/devices/test_tell_client.py @@ -1,6 +1,8 @@ import pytest from unittest.mock import MagicMock -from aare.devices.tell_client import TellClient +from aare.common.exception_handler import TellCommunicationError +from aare.common.models import DewarAddress, SampleDewarAddress +from aare.devices.tell_client import TellClient, TellEventValueEnum from aare.devices.tell_backend import TellBackend @pytest.fixture @@ -29,4 +31,48 @@ def test_is_in_mount_position_false(mock_beamline, mock_backend): client = TellClient(mock_beamline, backend=mock_backend) assert client.is_in_mount_position() is False - #TODO add tests for status checks and other functions \ No newline at end of file + +def _mount_address(): + return SampleDewarAddress(puck=DewarAddress(segment="A", pos=3), pin=10) + + +@pytest.mark.parametrize("state_value", ["Busy", "Ready", '"Busy"', '"Ready"', "busy"]) +def test_mount_state_event_succeeds_when_command_completes( + mock_beamline, mock_backend, state_value +): + """Regression: TELL emits a "state" event (sometimes the intermediate + "Busy", quoted or unquoted) instead of a terminal event. The mount must + rely on check_command_ok rather than string-matching the event value, so a + completed command is reported as SUCCESS rather than raising.""" + mock_backend.wait_events.return_value = ("state", state_value) + mock_backend.get_result.return_value = { + "status": "completed", + "return": "A39", + "exception": None, + "id": 3017907, + } + client = TellClient(mock_beamline, backend=mock_backend) + + result = client.mount(_mount_address(), wait=True) + + assert result == TellEventValueEnum.SUCCESS + mock_backend.get_result.assert_called() # check_command_ok consulted the result + + +def test_mount_state_event_raises_when_command_not_completed( + mock_beamline, mock_backend +): + """A genuine failure still surfaces: if the command did not complete, + check_command_ok raises (MountingFailed) and mount() re-raises it as a + critical TellCommunicationError, even though the event value was "Busy".""" + mock_backend.wait_events.return_value = ("state", "Busy") + mock_backend.get_result.return_value = { + "status": "error", + "return": None, + "exception": "boom", + "id": 3017907, + } + client = TellClient(mock_beamline, backend=mock_backend) + + with pytest.raises(TellCommunicationError): + client.mount(_mount_address(), wait=True) \ No newline at end of file From f2895fb375f26708d8217ad7640d512a2f03a474 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Tue, 23 Jun 2026 12:44:30 +0200 Subject: [PATCH 71/76] daq: fixed dtecetor min and max bug if llm or hlm si missing in epics --- src/aare/daq/daq.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/aare/daq/daq.py b/src/aare/daq/daq.py index da666f06..1ca28bb3 100644 --- a/src/aare/daq/daq.py +++ b/src/aare/daq/daq.py @@ -3195,10 +3195,19 @@ class AareDAQ: dtz_min = self.__cfg.cached_dtz_low dtz_max = self.__cfg.cached_dtz_high - if dtz_min is None or dtz_max is None: + if ( + dtz_min is None or dtz_max is None + or (dtz_min > dtz_max) + or (dtz_min == dtz_max) + or (dtz_min == 0 and dtz_max == 0) + ): logger.warning("DTZ limits missing from cache, using conservative defaults in beamline_status") - dtz_min = 20.0 - dtz_max = 1000.0 + dtz_min = cfg_get('daq.hardware.default_detector_distance_minimum', 100) + dtz_max = cfg_get('daq.hardware.default_detector_distance_maximum', 1000) + logger.warning( + f"using dtz_min {dtz_min} and dtz_max {dtz_max}" + ) + return BeamlineStatus( ring_current_mA=ring_current, front_light=front_light, From 0fcfacd1650039d26eafc7b33d28c086c80b209e Mon Sep 17 00:00:00 2001 From: appleb_m Date: Tue, 23 Jun 2026 14:13:30 +0200 Subject: [PATCH 72/76] data collection settings: panel-level Database/User-values toggle Add DbOverrideLineEdit: a numeric field holding both a database value and the user's value, driven by a per-tab Database/User-values toggle. The user's value persists across samples, can be reverted to the database value, and recovered again - replacing the clunky per-field checkboxes. Fix the dtz<->resolution coupling so editing one updates the other in both Database and User modes (previously it broke whenever a value was overridden). Wire resolution, detector distance, transmission, exposure, rotation total/image angle and the raster grid element size through the new widget; other panels are untouched. Add tests covering persist/revert/recover, the toggle's downstream emit, the dtz/resolution coupling in both modes, and cross-sample persistence. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/aare/gui/panels/raster_data_collection.py | 31 +-- .../gui/panels/rotation_data_collection.py | 26 ++- src/aare/gui/panels/scan_settings_panel.py | 207 +++++++++++++---- src/aare/gui/widgets/number_line_edit.py | 136 +++++++++++ .../unit/gui/test_data_collection_settings.py | 218 ++++++++++++++++++ 5 files changed, 551 insertions(+), 67 deletions(-) create mode 100644 tests/unit/gui/test_data_collection_settings.py diff --git a/src/aare/gui/panels/raster_data_collection.py b/src/aare/gui/panels/raster_data_collection.py index f83b1b4f..ad77223f 100644 --- a/src/aare/gui/panels/raster_data_collection.py +++ b/src/aare/gui/panels/raster_data_collection.py @@ -5,7 +5,7 @@ from aare.common.diffraction_geometry import DiffractionGeometry from aare.common.models import DAQStatusModel, BeamlineStateEnum from aare.gui.panels.scan_settings_panel import ScanSettingsPanel from aare.gui.scan_logic.raster_grid_manager import RasterGridManager, RasterGridMetric -from aare.gui.widgets.number_line_edit import CheckedLineEdit +from aare.gui.widgets.number_line_edit import DbOverrideLineEdit from aare.gui.widgets.raster_grid_table import RasterGridTable from aare.common.logger_config import setup_logger @@ -35,27 +35,29 @@ class RasterDataCollectionPanel(ScanSettingsPanel): self._layout.addWidget(QLabel("Grid element size", parent=self), 3, 0) - self.width_enter = CheckedLineEdit(5, 100, decimals=0, default=self.__size_x, parent=self) - self.width_enter.newValue.connect(self.grid_size) + self.width_enter = DbOverrideLineEdit(5, 100, default=self.__size_x, decimals=0, parent=self) + self.width_enter.valueChanged.connect(self.grid_size) + self._register_override_field(self.width_enter) self._layout.addWidget(self.width_enter, 3, 1) self._layout.addWidget(QLabel(" x ", parent=self), 3, 2) - self.height_enter = CheckedLineEdit(5, 100, decimals=0, default=self.__size_y, parent=self) - self.height_enter.newValue.connect(self.grid_size) - + self.height_enter = DbOverrideLineEdit(5, 100, default=self.__size_y, decimals=0, parent=self) + self.height_enter.valueChanged.connect(self.grid_size) + self._register_override_field(self.height_enter) self._layout.addWidget(self.height_enter, 3, 3) self._layout.addWidget(QLabel("μm", parent=self), 3, 4) self._layout.addWidget(QLabel("Image time", parent=self), 4, 0) - self.image_time_enter = CheckedLineEdit( - 0.0005, 10.0, raster_mgr.active_grid.exp_time_s, decimals=4, check_box_text='', parent=self + self.image_time_enter = DbOverrideLineEdit( + 0.0005, 10.0, default=raster_mgr.active_grid.exp_time_s, decimals=4, parent=self ) self._layout.addWidget(self.image_time_enter, 4, 1, 1, 3) self._layout.addWidget(QLabel("s", parent=self), 4, 4) - self.image_time_enter.newValue.connect(self.exp_time_s) + self.image_time_enter.valueChanged.connect(self.exp_time_s) + self._register_override_field(self.image_time_enter) self._layout.addWidget(QLabel("Grid elements", parent=self), 5, 0) @@ -119,7 +121,6 @@ class RasterDataCollectionPanel(ScanSettingsPanel): self.total_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) self._layout.addWidget(self.total_time, 11, 1, 1, 3) - self.image_time_enter.newValue.connect(self.exp_time_s) self.calculate_total_time() self.start_button = QPushButton("Evaluate grid") @@ -167,16 +168,18 @@ class RasterDataCollectionPanel(ScanSettingsPanel): w.setReadOnly(not can_edit) if s.sample is None and not self._previous_sample_was_none_raster: - for w in (self.width_enter, self.height_enter, self.image_time_enter): - w.reset_to_default() + # The override fields (grid size, image time) are reset by the base + # panel (_reset_to_defaults); nothing extra to reset here. self._previous_sample_was_none_raster = True elif s.sample is not None: self._previous_sample_was_none_raster = False self.__beamline_state = s.state def update_grid_scan_size(self): - self.width_enter.update_value(self.__size_x) - self.height_enter.update_value(self.__size_y) + # The live grid geometry is the "database"/system value for the element + # size; a user override persists and can be reverted via the toggle. + self.width_enter.set_db_value(self.__size_x) + self.height_enter.set_db_value(self.__size_y) self.n_x_label.setText(str(self.__n_x)) self.size_x_label.setText(f"{self.__size_x * self.__n_x:.1f}") self.n_y_label.setText(str(self.__n_y)) diff --git a/src/aare/gui/panels/rotation_data_collection.py b/src/aare/gui/panels/rotation_data_collection.py index 8ee971da..b26b477e 100644 --- a/src/aare/gui/panels/rotation_data_collection.py +++ b/src/aare/gui/panels/rotation_data_collection.py @@ -8,7 +8,7 @@ from aare.common.logger_config import setup_logger from aare.common.models import DAQStatusModel, BeamlineStateEnum from aare.common.rotation_scan import RotationScanRequest from aare.gui.panels.scan_settings_panel import ScanSettingsPanel -from aare.gui.widgets.number_line_edit import NumberLineEdit, CheckedLineEdit +from aare.gui.widgets.number_line_edit import NumberLineEdit, CheckedLineEdit, DbOverrideLineEdit logger = setup_logger("aareGUI") @@ -84,30 +84,33 @@ class RotationDataCollectionPanel(ScanSettingsPanel): self._layout.addWidget(QLabel("
Rotation
", parent=self), 9, 0, 1, 6) self._layout.addWidget(QLabel("Total angle", parent=self), 10, 0) - self.total_angle = CheckedLineEdit(0, 9999.0, 360.0, decimals=3, parent=self) + self.total_angle = DbOverrideLineEdit(0, 9999.0, default=360.0, decimals=3, parent=self) self._layout.addWidget(self.total_angle, 10, 1, 1, 3) self._layout.addWidget(QLabel("°", parent=self), 10, 4) + self._register_override_field(self.total_angle) self._layout.addWidget(QLabel("Image angle", parent=self), 11, 0) - self.image_angle = CheckedLineEdit(0, 10.0, 0.2, decimals=3, parent=self) + self.image_angle = DbOverrideLineEdit(0, 10.0, default=0.2, decimals=3, parent=self) self._layout.addWidget(self.image_angle, 11, 1, 1, 3) self._layout.addWidget(QLabel("°", parent=self), 11, 4) + self._register_override_field(self.image_angle) #TODO add protection on X10SA to prevent too short exposure time/ too high detector rep rate self._layout.addWidget(QLabel("Image time", parent=self), 12, 0) - self.image_time_enter = CheckedLineEdit( - 0.0005, 10.0, 0.01, decimals=4, parent=self + self.image_time_enter = DbOverrideLineEdit( + 0.0005, 10.0, default=0.01, decimals=4, parent=self ) self._layout.addWidget(self.image_time_enter, 12, 1, 1, 3) self._layout.addWidget(QLabel("s", parent=self), 12, 4) + self._register_override_field(self.image_time_enter) self._layout.addWidget(QLabel("Total measurement time", parent=self), 13, 0) self.total_time = QLabel(f"{self.__total_time} min 0 s") self.total_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) self._layout.addWidget(self.total_time, 13, 1, 1, 3) - self.total_angle.newValue.connect(self.calculate_measurement_time) - self.image_angle.newValue.connect(self.calculate_measurement_time) - self.image_time_enter.newValue.connect(self.calculate_measurement_time) + self.total_angle.valueChanged.connect(self.calculate_measurement_time) + self.image_angle.valueChanged.connect(self.calculate_measurement_time) + self.image_time_enter.valueChanged.connect(self.calculate_measurement_time) # Initial compute self.calculate_measurement_time() @@ -238,9 +241,10 @@ class RotationDataCollectionPanel(ScanSettingsPanel): w.setReadOnly(not can_edit) if s.sample is None and not self._previous_sample_was_none_rotation: - # Reset rotation-specific widgets to their defaults - for w in (self.start_angle, self.screening_image_angle, self.screening_image_time_enter, - self.total_angle, self.image_angle, self.image_time_enter): + # The DB-sourced fields (total_angle, image_angle, image_time_enter) + # are reset by the base panel (_reset_to_defaults); only reset the + # screening/start widgets that are not part of the source toggle. + for w in (self.start_angle, self.screening_image_angle, self.screening_image_time_enter): w.reset_to_default() self._previous_sample_was_none_rotation = True elif s.sample is not None: diff --git a/src/aare/gui/panels/scan_settings_panel.py b/src/aare/gui/panels/scan_settings_panel.py index 78bc30f9..1af3e2b7 100644 --- a/src/aare/gui/panels/scan_settings_panel.py +++ b/src/aare/gui/panels/scan_settings_panel.py @@ -1,11 +1,20 @@ from PySide6.QtCore import Slot, Signal -from PySide6.QtWidgets import QWidget, QGridLayout, QLabel, QPushButton +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QGridLayout, + QLabel, + QPushButton, + QRadioButton, + QButtonGroup, +) from aare.common.diffraction_geometry import DiffractionGeometry from aare.common.logger_config import setup_logger from aare.common.models import DAQStatusModel, SessionsStateEnum from aare.gui.widgets.message_box import ring_current_low_check, experiment_hutch_shutter_check -from aare.gui.widgets.number_line_edit import CheckedLineEdit +from aare.gui.widgets.number_line_edit import DbOverrideLineEdit logger = setup_logger("aareGUI") @@ -38,38 +47,114 @@ class ScanSettingsPanel(QWidget): self._ring_current = None self._experiment_shutter_state = None - self._layout = QGridLayout(self) + # Fields whose value can come from the database or be overridden by the + # user; the panel-level toggle drives them all together. + self._override_fields: list[DbOverrideLineEdit] = [] + self._source = DbOverrideLineEdit.SOURCE_DB + + # Outer layout: a Database/My-values toggle above the settings grid. + # Subclasses keep adding their widgets to self._layout (the grid) as + # before, so they are unaffected by the wrapping. + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.addWidget(self._build_source_toggle()) + + grid_host = QWidget(self) + self._layout = QGridLayout(grid_host) + outer.addWidget(grid_host) self._layout.addWidget(QLabel("High resolution", parent=self), 0, 0) - self.high_res_enter = CheckedLineEdit( - 1.0, 10, decimals=2, default=self._high_res, parent=self + self.high_res_enter = DbOverrideLineEdit( + 1.0, 10, default=self._high_res, decimals=2, parent=self ) self._layout.addWidget(self.high_res_enter, 0, 1, 1, 3) self._layout.addWidget(QLabel("Å", parent=self), 0, 4) - self.high_res_enter.newValue.connect(self.set_high_res) - self._layout.addWidget(QLabel("Detector distance", parent=self), 1, 0) - self.dtz_enter = CheckedLineEdit(self.MIN_DTZ, 1000, decimals=2, default=self._dtz, check_box_text="", parent=self) + self.dtz_enter = DbOverrideLineEdit( + self.MIN_DTZ, 1000, default=self._dtz, decimals=2, parent=self + ) self._layout.addWidget(self.dtz_enter, 1, 1, 1, 3) self._layout.addWidget(QLabel("mm", parent=self), 1, 4) - self.dtz_enter.newValue.connect(self.set_dtz) - self.dtz_enter.readOnlyChanged.connect(self.high_res_enter.external_read_only) - self.high_res_enter.readOnlyChanged.connect(self.dtz_enter.external_read_only) - self._layout.addWidget(QLabel("Beam transmission", parent=self), 2, 0) - self.transmission_enter = CheckedLineEdit( - 0, 1.0, decimals=4, default=self._transmission, check_box_text="", parent=self + self.transmission_enter = DbOverrideLineEdit( + 0, 1.0, default=self._transmission, decimals=4, parent=self ) self._layout.addWidget(self.transmission_enter, 2, 1, 1, 3) - self.transmission_enter.newValue.connect(self.set_transmission) + self.transmission_enter.valueChanged.connect(self.set_transmission) + + # dtz and resolution are two views of one quantity. They share the + # source toggle; ``valueChanged`` keeps internal state/downstream in + # sync (toggle, db updates), while ``edited`` (a real user edit) derives + # the partner and switches the panel to "My values". + self.dtz_enter.valueChanged.connect(self._on_dtz_value_changed) + self.high_res_enter.valueChanged.connect(self._on_high_res_value_changed) + self.dtz_enter.edited.connect(self._on_dtz_edited) + self.high_res_enter.edited.connect(self._on_high_res_edited) + self._override_fields.append(self.dtz_enter) + self._override_fields.append(self.high_res_enter) + + self._register_override_field(self.transmission_enter) self.reload_params_button = QPushButton("Reload DB params") self.reload_params_button.setToolTip("Reload data collection parameters from database") self.reload_params_button.clicked.connect(self.reload_parameters) self.reload_params_button.setVisible(False) # Child classes should make it visible + # -- source toggle ----------------------------------------------------- + def _build_source_toggle(self) -> QWidget: + container = QWidget(self) + row = QHBoxLayout(container) + row.setContentsMargins(0, 0, 0, 0) + self._db_radio = QRadioButton("Database values", container) + self._mine_radio = QRadioButton("User values", container) + self._db_radio.setChecked(True) + self._source_group = QButtonGroup(container) + self._source_group.addButton(self._db_radio) + self._source_group.addButton(self._mine_radio) + self._db_radio.toggled.connect(self._on_source_radio_toggled) + row.addWidget(self._db_radio) + row.addWidget(self._mine_radio) + row.addStretch() + return container + + def _register_override_field(self, field: DbOverrideLineEdit): + self._override_fields.append(field) + field.edited.connect(self._on_override_field_edited) + + @Slot(bool) + def _on_source_radio_toggled(self, _checked: bool): + source = ( + DbOverrideLineEdit.SOURCE_DB + if self._db_radio.isChecked() + else DbOverrideLineEdit.SOURCE_MINE + ) + if source != self._source: + self.set_source(source) + + @Slot(float) + def _on_override_field_edited(self, _value: float): + # Editing any field means the user is now driving the panel. + if self._source != DbOverrideLineEdit.SOURCE_MINE: + self.set_source(DbOverrideLineEdit.SOURCE_MINE) + + def set_source(self, source: str): + self._source = source + # Reflect in the toggle without re-triggering the handler. + self._db_radio.blockSignals(True) + self._mine_radio.blockSignals(True) + self._db_radio.setChecked(source == DbOverrideLineEdit.SOURCE_DB) + self._mine_radio.setChecked(source == DbOverrideLineEdit.SOURCE_MINE) + self._db_radio.blockSignals(False) + self._mine_radio.blockSignals(False) + # Two phases: switch every field first, then resync downstream once all + # fields agree (so the dtz<->resolution coupling sees consistent state). + for field in self._override_fields: + field.set_source(source, emit=False) + for field in self._override_fields: + field.emit_value_changed() + @Slot(DAQStatusModel) def update_daq_status(self, s: DAQStatusModel): self.dtz_enter.update_limits(s.bl.dtz_min, s.bl.dtz_max) @@ -80,8 +165,7 @@ class ScanSettingsPanel(QWidget): self._experiment_shutter_state = s.bl.exp_shutter_open can_edit = (not s.busy) and (s.session.session in (SessionsStateEnum.OwnedByYou, SessionsStateEnum.PendingElseToYou)) self._can_edit_params = can_edit - # Lock/unlock base NumberLineEdits - + # Lock/unlock the override fields for w in (self.dtz_enter, self.high_res_enter, self.transmission_enter): w.set_busy(not can_edit) @@ -108,33 +192,60 @@ class ScanSettingsPanel(QWidget): self.reload_params_button.setEnabled(False) def _reset_to_defaults(self): - self._dtz = float(self.dtz_enter.get_default()) - self._transmission = float(self.transmission_enter.get_default()) - self._high_res = float(self.high_res_enter.get_default()) + # Forget user overrides and follow the database/defaults again. + for w in self._override_fields: + w.reset() + self.set_source(DbOverrideLineEdit.SOURCE_DB) + self._dtz = float(self.dtz_enter.value) + self._transmission = float(self.transmission_enter.value) + self._high_res = float(self.high_res_enter.value) + + def _dtz_to_res(self, dtz: float) -> float: + return self.__diffraction.resolution_angstrom(dtz) + + def _res_to_dtz(self, res: float) -> float: + dtz = self.__diffraction.calc_dtz_mm(res) + return self.MIN_DTZ if dtz < self.MIN_DTZ else dtz @Slot(float) - def set_dtz(self, v: float): - #dtz_enter will only be edited in GUI when manual is checked, otherwise it is updated when high_res changes + def _on_dtz_value_changed(self, v: float): + # dtz display changed (user edit, toggle, or db update); keep internal + # state and push the effective dtz downstream. self._dtz = v - self._high_res = self.__diffraction.resolution_angstrom(self._dtz) - self.high_res_enter.force_update_value(self._high_res) + self._high_res = self._dtz_to_res(v) self.dtz_updated.emit(self._dtz) @Slot(float) - def set_high_res(self, v: float): - #if updated from spreadsheet should change dtz external - #if manually updated from GUI should not change dtz internal + def _on_high_res_value_changed(self, v: float): + # Resolution display changed; dtz_updated is emitted by the dtz field + # itself on toggle/db updates, so just track internal state here. self._high_res = v - self._dtz = self.__diffraction.calc_dtz_mm(v) - if self._dtz < self.MIN_DTZ: - self._dtz = self.MIN_DTZ - self.dtz_enter.force_update_value(self._dtz) + + @Slot(float) + def _on_dtz_edited(self, v: float): + # A real user edit of dtz: derive resolution, write both "mine" stores, + # then switch the whole panel to "My values". + self._dtz = v + self._high_res = self._dtz_to_res(v) + self.high_res_enter.set_mine_silently(self._high_res) + if self._source != DbOverrideLineEdit.SOURCE_MINE: + self.set_source(DbOverrideLineEdit.SOURCE_MINE) + self.dtz_updated.emit(self._dtz) + + @Slot(float) + def _on_high_res_edited(self, v: float): + # A real user edit of resolution: derive dtz, write both "mine" stores, + # then switch the whole panel to "My values". + self._high_res = v + self._dtz = self._res_to_dtz(v) + self.dtz_enter.set_mine_silently(self._dtz) + if self._source != DbOverrideLineEdit.SOURCE_MINE: + self.set_source(DbOverrideLineEdit.SOURCE_MINE) self.dtz_updated.emit(self._dtz) @Slot(float) def set_transmission(self, v: float): self._transmission = v - self.transmission_enter.update_value(self._transmission) self.transmission_updated.emit(self._transmission) @Slot() @@ -144,6 +255,8 @@ class ScanSettingsPanel(QWidget): self._last_sample_id = None self.update_data_collection_parameters() self._last_sample_id = temp_id + # Show the freshly-loaded database values. + self.set_source(DbOverrideLineEdit.SOURCE_DB) def update_data_collection_parameters(self): @@ -160,26 +273,28 @@ class ScanSettingsPanel(QWidget): # Get parameter mappings from child class param_mappings = self.get_parameter_mappings() - # Update parameters based on mappings + # Update the database side of each parameter (a user override persists). for param_name, widget, converter in param_mappings: value = getattr(self._params, param_name, None) if value is not None: - if hasattr(widget, "force_update_value"): - widget.force_update_value(converter(value) if converter else value) + converted = converter(value) if converter else value + if hasattr(widget, "set_db_value"): + widget.set_db_value(converted) + elif hasattr(widget, "force_update_value"): + widget.force_update_value(converted) else: - widget.update_value(converter(value) if converter else value) + widget.update_value(converted) # Handle transmission with conversion (common to all panels) if (transmission := getattr(self._params, 'transmission', None)) is not None: transmission_value = transmission / 100.0 if transmission > 1.0 else transmission self._transmission = transmission_value - self.transmission_enter.force_update_value(transmission_value) + self.transmission_enter.set_db_value(transmission_value) - # Handle target resolution (common to all panels) + # Handle target resolution (common to all panels). dtz is derived from + # the resolution so its database value is kept consistent here. if (target_res := getattr(self._params, 'targetresolution', None)) is not None: - self._high_res = target_res - self.high_res_enter.update_value(self._high_res) - self.set_high_res(self._high_res) + self._apply_db_resolution(float(target_res)) # Store metadata (common to all panels) self._sample_space_group = getattr(self._params, 'spacegroupnumber', None) @@ -187,6 +302,14 @@ class ScanSettingsPanel(QWidget): self._sample_pdb_id = getattr(self._params, 'pdbid', None) self._target_dose = getattr(self._params, 'dose', None) + def _apply_db_resolution(self, target_res: float): + """Set the database resolution and the matching database dtz so the + linked pair stays consistent regardless of the active source.""" + self._high_res = target_res + self._dtz = self._res_to_dtz(target_res) + self.high_res_enter.set_db_value(target_res) + self.dtz_enter.set_db_value(self._dtz) + def get_parameter_mappings(self): """Return a list of (param_name, widget, converter) tuples. Child classes should override this to specify their specific parameters. @@ -210,4 +333,4 @@ class ScanSettingsPanel(QWidget): if not reply: logger.warning("Error with file path.") return False - return True \ No newline at end of file + return True diff --git a/src/aare/gui/widgets/number_line_edit.py b/src/aare/gui/widgets/number_line_edit.py index 35883624..ae9f7076 100644 --- a/src/aare/gui/widgets/number_line_edit.py +++ b/src/aare/gui/widgets/number_line_edit.py @@ -225,3 +225,139 @@ class CheckedLineEdit(QWidget): def reset_to_default(self): # Force set to initial default self.force_update_value(self._internal_value) + + +class DbOverrideLineEdit(QWidget): + """A numeric field backed by two sources: a database value and the user's + own value, with a panel-level toggle choosing which is shown. + + Behaviour: + * Until the user edits the field, "mine" mirrors the database value, so a + fresh field just follows the database. + * Editing records the user's value, marks the field as user-edited, flips + the source to "mine", and emits ``edited`` so the owning panel can move + the whole panel to "My values". + * Switching back to "Database values" shows the database value but keeps + the user's value, so it can be recovered by switching to "My values". + * Database updates (new sample, status refresh) only overwrite the + database value; a user-edited value therefore persists across samples. + + The widget has no checkbox of its own - the panel drives ``set_source`` for + all of its fields together. + """ + + # active value changed (user edit, source toggle, or db update while showing db) + valueChanged = Signal(float) + # user finished editing -> the owning panel should switch to "My values" + edited = Signal(float) + + SOURCE_DB = "db" + SOURCE_MINE = "mine" + + def __init__(self, min_val: float, max_val: float, /, default: float = 0.0, + decimals: int = 2, parent=None): + super().__init__(parent) + self._default = float(default) + self._db_value = float(default) + self._mine_value = float(default) + self._user_edited = False + self._source = self.SOURCE_DB + self._busy = False + + self.editor = NumberLineEdit(min_val, max_val, default, decimals, self) + self.editor.newValue.connect(self._on_editor_value) + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.editor) + self._apply_editable() + + # -- internal helpers -------------------------------------------------- + def _active_value(self) -> float: + return self._mine_value if self._source == self.SOURCE_MINE else self._db_value + + def _refresh_display(self): + self.editor.force_update_value(self._active_value()) + + def _apply_editable(self): + # Editable whenever the panel is not busy; editing implies "mine". + self.editor.setReadOnly(self._busy) + + def _on_editor_value(self, value: float): + self._user_edited = True + self._mine_value = value + self._source = self.SOURCE_MINE + self.valueChanged.emit(value) + self.edited.emit(value) + + # -- panel-facing API -------------------------------------------------- + def set_source(self, source: str, emit: bool = True): + """Show the database value or the user's value. The panel switches all + of its fields with emit=False, then calls emit_value_changed() once each + so downstream consumers resync after every field is in the new source.""" + if source not in (self.SOURCE_DB, self.SOURCE_MINE): + return + self._source = source + self._refresh_display() + if emit: + self.valueChanged.emit(self._active_value()) + + def emit_value_changed(self): + self.valueChanged.emit(self._active_value()) + + def source(self) -> str: + return self._source + + def set_db_value(self, value: float): + """Update the database value. Mirrors into the user's value while the + field has not been user-edited, so untouched fields follow the database; + a user-edited value is preserved (only the db side changes), which is + what lets a user's value persist across samples.""" + value = float(value) + changed = value != self._db_value + self._db_value = value + if not self._user_edited: + self._mine_value = value + # Only refresh/emit when the shown value actually changes, so repeated + # database pushes (e.g. live grid geometry) cannot feed back into a loop. + if self._source == self.SOURCE_DB and changed: + self._refresh_display() + self.valueChanged.emit(value) + + def set_mine_silently(self, value: float): + """Set the user's value from a linked field (e.g. the dtz<->resolution + pair) without emitting. Marks the field user-edited and refreshes the + display if the user's value is currently shown. No signal is emitted, so + the linked field (which drives the physics downstream) does not feed back + into a loop.""" + value = float(value) + self._mine_value = value + self._user_edited = True + if self._source == self.SOURCE_MINE: + self._refresh_display() + + def has_user_value(self) -> bool: + return self._user_edited + + def set_busy(self, busy: bool): + self._busy = busy + self._apply_editable() + + def update_limits(self, min_val: float, max_val: float): + self.editor.update_limits(min_val, max_val) + + def reset(self): + """Forget the user's value and return to the constructor default, + following the database again (used when the sample is cleared).""" + self._user_edited = False + self._db_value = self._default + self._mine_value = self._default + self._source = self.SOURCE_DB + self._refresh_display() + + @property + def value(self) -> float: + return self.editor.value + + def isReadOnly(self) -> bool: + return self.editor.isReadOnly() diff --git a/tests/unit/gui/test_data_collection_settings.py b/tests/unit/gui/test_data_collection_settings.py new file mode 100644 index 00000000..4f3b17b1 --- /dev/null +++ b/tests/unit/gui/test_data_collection_settings.py @@ -0,0 +1,218 @@ +"""Tests for the data-collection settings override model. + +Covers DbOverrideLineEdit (database value vs user's value, persistence across +samples, go-back-to-DB, recover-my-value) and the panel-level Database/My-values +toggle with the dtz<->resolution coupling that must hold in both modes. +""" + +import types + +import pytest + +from aare.common.coordinate import Coordinate, SmargonCoordinate +from aare.common.diffraction_geometry import DiffractionGeometry +from aare.common.models import SampleGeometryModel +from aare.gui.panels.raster_data_collection import RasterDataCollectionPanel +from aare.gui.panels.rotation_data_collection import RotationDataCollectionPanel +from aare.gui.scan_logic.raster_grid_manager import RasterGridManager +from aare.gui.widgets.number_line_edit import DbOverrideLineEdit + + +def _edit(field: DbOverrideLineEdit, text: str): + """Simulate a user typing into and committing a field.""" + field.editor.setText(text) + field.editor.on_editing_finished() + + +# --------------------------------------------------------------------------- +# Widget: DbOverrideLineEdit +# --------------------------------------------------------------------------- + + +def test_db_override_follows_database_until_edited(qapp): + w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2) + assert w.source() == DbOverrideLineEdit.SOURCE_DB + assert not w.has_user_value() + + w.set_db_value(150.0) + assert w.value == 150.0 # untouched field tracks the database + + +def test_db_override_user_value_persists_and_recovers(qapp): + w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2) + + _edit(w, "300.00") + assert w.has_user_value() + assert w.source() == DbOverrideLineEdit.SOURCE_MINE + assert w.value == 300.0 + + # A new database value (e.g. next sample) must not clobber the user's value. + w.set_db_value(120.0) + assert w.value == 300.0 + + # Go back to the database value, then recover the user's value. + w.set_source(DbOverrideLineEdit.SOURCE_DB, emit=False) + assert w.value == 120.0 + w.set_source(DbOverrideLineEdit.SOURCE_MINE, emit=False) + assert w.value == 300.0 + + +def test_db_override_reset_forgets_user_value(qapp): + w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2) + _edit(w, "300.00") + w.reset() + assert w.value == 200.0 + assert w.source() == DbOverrideLineEdit.SOURCE_DB + assert not w.has_user_value() + + +def test_db_override_emits_value_changed_on_toggle(qapp): + w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2) + _edit(w, "300.00") # mine = 300, db = 200 + seen = [] + w.valueChanged.connect(lambda v: seen.append(v)) + w.set_source(DbOverrideLineEdit.SOURCE_DB) + assert seen[-1] == 200.0 # toggling pushes the now-active value downstream + w.set_source(DbOverrideLineEdit.SOURCE_MINE) + assert seen[-1] == 300.0 + + +# --------------------------------------------------------------------------- +# Panel: toggle + dtz/resolution coupling +# --------------------------------------------------------------------------- + + +@pytest.fixture +def diffraction(): + return DiffractionGeometry( + energy_keV=12.0, + dtz_mm=150.0, + pixel_size_mm=0.075, + beam_center_pxl=(1000.0, 1000.0), + detector_size_pxl=(2000, 2000), + detector_description="Eiger 16M", + detector_serial_number="123", + poni_rot1_rad=0.0, + poni_rot2_rad=0.0, + ) + + +@pytest.fixture +def panel(qapp, diffraction): + return RotationDataCollectionPanel(diffraction=diffraction) + + +def test_editing_dtz_updates_resolution_and_switches_to_mine(panel, diffraction): + _edit(panel.dtz_enter, "250.00") + assert panel._source == DbOverrideLineEdit.SOURCE_MINE + assert abs(panel.high_res_enter.value - diffraction.resolution_angstrom(250.0)) < 0.01 + + +def test_editing_resolution_updates_dtz(panel, diffraction): + _edit(panel.high_res_enter, "2.00") + expected = max(panel.MIN_DTZ, diffraction.calc_dtz_mm(2.0)) + assert abs(panel.dtz_enter.value - expected) < 0.01 + + +def test_coupling_holds_in_database_mode(panel, diffraction): + # Set a database resolution; dtz database value must stay consistent. + panel._apply_db_resolution(2.5) + assert panel._source == DbOverrideLineEdit.SOURCE_DB + expected_dtz = max(panel.MIN_DTZ, diffraction.calc_dtz_mm(2.5)) + assert abs(panel.dtz_enter.value - expected_dtz) < 0.01 + assert abs(panel.high_res_enter.value - 2.5) < 0.01 + + +def test_toggle_pushes_active_value_downstream(panel): + emitted = [] + panel.dtz_updated.connect(lambda v: emitted.append(round(v, 2))) + + _edit(panel.dtz_enter, "250.00") # mine dtz = 250 + mine_dtz = panel.dtz_enter.value + + panel.set_source(DbOverrideLineEdit.SOURCE_DB) + db_dtz = panel.dtz_enter.value + assert emitted[-1] == round(db_dtz, 2) # downstream got the db value + + panel.set_source(DbOverrideLineEdit.SOURCE_MINE) + assert panel.dtz_enter.value == mine_dtz # user value recovered + assert emitted[-1] == round(mine_dtz, 2) # downstream got the user value + + +def test_user_override_persists_across_samples(panel, diffraction): + # Sample 1 loads a database resolution. + panel._sample = types.SimpleNamespace(db_id=1) + panel._params = types.SimpleNamespace( + targetresolution=2.5, transmission=0.5, + totalrange=180.0, oscillation=0.1, exposure=0.02, + ) + panel.update_data_collection_parameters() + + # User overrides the exposure time. + _edit(panel.image_time_enter, "0.0500") + assert panel.image_time_enter.value == 0.05 + + # Sample 2 arrives with a different database exposure. + panel._sample = types.SimpleNamespace(db_id=2) + panel._params = types.SimpleNamespace( + targetresolution=1.8, transmission=1.0, + totalrange=360.0, oscillation=0.2, exposure=0.01, + ) + panel.update_data_collection_parameters() + + # In My-values mode the user's exposure persists across the sample change. + assert panel.image_time_enter.value == 0.05 + # Going back to Database shows the new sample's database value. + panel.set_source(DbOverrideLineEdit.SOURCE_DB) + assert panel.image_time_enter.value == 0.01 + + +# --------------------------------------------------------------------------- +# Raster grid element size follows the same override model +# --------------------------------------------------------------------------- + + +@pytest.fixture +def raster_panel(qapp, diffraction): + geom = SampleGeometryModel( + beam_location_pxl=Coordinate(x=500, y=500), + pixel_in_mm=0.001, + aerotech=Coordinate(x=0, y=0, z=0), + aerotech_meas=Coordinate(x=0, y=0, z=0), + smargon=SmargonCoordinate( + sh_mm=Coordinate(x=0, y=0, z=0), phi_deg=0.0, chi_deg=0.0 + ), + omega_deg=0.0, + beam_size_mm=Coordinate(x=0.01, y=0.01), + ) + mgr = RasterGridManager(geom) + return RasterDataCollectionPanel(raster_mgr=mgr, diffraction=diffraction) + + +def test_grid_element_size_user_value_persists_over_geometry(raster_panel): + emitted = [] + raster_panel.grid_size_updated.connect(lambda x, y: emitted.append((x, y))) + + _edit(raster_panel.width_enter, "20") + assert raster_panel._source == DbOverrideLineEdit.SOURCE_MINE + assert raster_panel.width_enter.value == 20.0 + assert emitted # editing pushed the new element size downstream + + # The live grid geometry keeps reporting a different element size; the + # user's value must persist (only the database side changes). + raster_panel.width_enter.set_db_value(35.0) + assert raster_panel.width_enter.value == 20.0 + + # Revert to the geometry value, then recover the user's value. + raster_panel.set_source(DbOverrideLineEdit.SOURCE_DB) + assert raster_panel.width_enter.value == 35.0 + raster_panel.set_source(DbOverrideLineEdit.SOURCE_MINE) + assert raster_panel.width_enter.value == 20.0 + + +def test_grid_size_field_shares_panel_toggle(raster_panel): + # Editing the resolution flips the whole panel to "My values", which the + # grid-size field shares. + _edit(raster_panel.high_res_enter, "2.50") + assert raster_panel._source == DbOverrideLineEdit.SOURCE_MINE + assert raster_panel.width_enter.source() == DbOverrideLineEdit.SOURCE_MINE From c3dd2aa38ae3d66ccadbc1b2415c10d3f9c71e44 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Wed, 24 Jun 2026 10:01:42 +0200 Subject: [PATCH 73/76] BEC: changed the initialisation function in bec_worker.py and how certain PVS are handled: including ring_current backlight_brightness frontlight_brightness and zoom --- src/aare/devices/bec_worker.py | 90 +++++++++++++++------------------- 1 file changed, 39 insertions(+), 51 deletions(-) diff --git a/src/aare/devices/bec_worker.py b/src/aare/devices/bec_worker.py index 4152b382..7611b50c 100644 --- a/src/aare/devices/bec_worker.py +++ b/src/aare/devices/bec_worker.py @@ -57,6 +57,17 @@ class BECClientWorker: def __init__(self, beamline:MXBeamline, name:str = "default"): BEAMLINE = beamline.value.lower() self.beamline = beamline + if self.beamline is MXBeamline.X06DA: + self._beamline_name = "px_iii" + elif self.beamline is MXBeamline.X10SA: + self._beamline_name = "px_ii" + elif self.beamline is MXBeamline.X06DA: + self._beamline_name = "px_i" + elif self.beamline is MXBeamline.SIMULATED: + self._beamline_name = "SIMULATED" + else: + raise ValueError(f"Unknown beamline: {beamline}") + if self.beamline is MXBeamline.SIMULATED: self.simulated = True @@ -80,23 +91,34 @@ class BECClientWorker: self.helper = FrontendProcedureHelper(self.client.connector) self.__set_scilog_tags() try: - self.position_devices, self.planner = self.__init_beamline_environment() - self.__backlight_brightness = self.position_devices['bl_bright'] - self.__frontlight_brightness = self.position_devices['fl_bright'] + self.__init_beamline_environment() except Exception as e: logger.error(f"Error initialising BEC devices: {e}") #raise self._raise_bec_error(e, operation="create planner") - self.planner = None self.simulated = True - self.__zoom = self.dev.scam_zoom - self.ring_current = self.dev.sls_current.get() logger.debug(f"simulated is {self.simulated}") def __init_beamline_environment(self): try: - return init_beamline_environment() + self.position_devices, self.planner = init_beamline_environment() + self.__backlight_brightness = self.position_devices['bl_bright'] + self.__frontlight_brightness = self.position_devices['fl_bright'] + self.__zoom = self.dev.scam_zoom + self._ring_current = self.dev.sls_current except Exception as e: + logger.error(f"Error initialising planar and position devices: {e}") + self.position_devices = None + self.planner = None + self.__backlight_brightness = None + self.__frontlight_brightness = None + try: + self.__zoom = self.dev.scam_zoom + self._ring_current = self.dev.sls_current + except Exception as e: + logger.error(f"Error initialising zoom and ring_current: {e}") + self.__zoom = None + self.ring_current = None raise Exception(f"Error initialising BEC devices: {e}") def _raise_bec_error(self, exc: Exception, *, operation: str, tags:Optional[List[str]] = None) -> None: @@ -140,30 +162,6 @@ class BECClientWorker: exception=exc, ) from exc - def __get_states(self): - """Get beamline states and modifiers from beamlien_states.yaml""" - if self.simulated: - logger.debug("Simulating initialise_devices") - return None - try: - states, allow_modifiers = get_states() - return states, allow_modifiers - except Exception as e: - self._raise_bec_error(e, operation="initialise beamline states") - - def __planner(self): - if self.simulated is None: - logger.debug("Simulating planner") - return None - self.states, self.allow_modifiers = self.__get_states() - self.deps = planner_deps() - return StateManager( - states=self.states, - deps=self.deps, - devices=self.position_devices, - allow_modifiers=self.allow_modifiers - ) - def __set_scilog_tags(self, tags:Optional[List[str]]=None): try: if tags: @@ -341,26 +339,10 @@ class BECClientWorker: if self.simulated: logger.debug(f"Simulating reinitialise_planner_and_position_devices(method={method})") return [] - - method = str(method or "auto").strip().lower() - try: - if method == "auto": - if self.beamline is MXBeamline.X06DA: - method = "beamline" - else: - method = "sample" - - if method in {"beamline", "sample", "sample_environment"}: - self.position_devices, self.planner = self.__init_beamline_environment() - else: - raise ValueError( - "Invalid BEC reinitialisation method. " - "Expected one of: auto, beamline, sample." - ) - #TODO move these into Init beamline env - self.__backlight_brightness = self.position_devices['bl_bright'] - self.__frontlight_brightness = self.position_devices['fl_bright'] + # self.client.config.update_session_with_file( + # f"/sls/{self.beamline}/config/bec/production/") + self.position_devices, self.planner = self.__init_beamline_environment() logger.info(f"Reinitialised BEC planner and position devices using method={method}") return self.list_position_devices() except Exception as e: @@ -519,6 +501,9 @@ class BECClientWorker: def zoom(self, value:float): self.scans.umv(self.__zoom, value, relative=False) + @property + def ring_current(self) -> float: + return self._ring_current.get() if __name__ == "__main__": import time @@ -542,6 +527,9 @@ if __name__ == "__main__": # print(client.client.show_last_alarm()) # print(client._raise_bec_error(exc=Exception("test"), operation="test", tags=["test"])) print(client.ring_current) + for i in range(10): + print(client._ring_current.get()) + time.sleep(1) # print('setting to 5') # client.backlight_brightness = 5 # print(client.get_backlight_pos()) @@ -572,7 +560,7 @@ if __name__ == "__main__": except Exception as e: #client._raise_bec_error(e, operation="send message") client.shutdown_client() - #print(f"Error: {e}") + print(f"Error: {e}") # try: # det_value = 980 From 9850daed71bcec2ec59f989a5dac882dfe570902 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Wed, 24 Jun 2026 10:14:26 +0200 Subject: [PATCH 74/76] DAQ: added bbackup default_detector dsitances --- src/aare/common/config/x06da.yaml | 4 +++- src/aare/common/config/x10sa.yaml | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/aare/common/config/x06da.yaml b/src/aare/common/config/x06da.yaml index 795079f6..2bf17cd8 100644 --- a/src/aare/common/config/x06da.yaml +++ b/src/aare/common/config/x06da.yaml @@ -28,6 +28,8 @@ daq: aarelc_url: "http://sls-gpu-003:9094" dtz_safe_position: null lens_magnification: 10 #or 5 currently + default_detector_distance_minimum: 86 + default_detector_distance_maximum: 900 db: aaredb_url: "https://mx-aaredb-dmz-01.psi.ch/dispatcher" @@ -47,4 +49,4 @@ daq: steps: 1800 detector_limit_modifier: 2.0 - maximum_flux: 4e11 \ No newline at end of file + maximum_flux: 4e11 diff --git a/src/aare/common/config/x10sa.yaml b/src/aare/common/config/x10sa.yaml index 161abf53..60e5c983 100644 --- a/src/aare/common/config/x10sa.yaml +++ b/src/aare/common/config/x10sa.yaml @@ -27,6 +27,8 @@ daq: aarelc_url: "http://sls-gpu-003:9090" dtz_safe_position: 300 lens_magnification: 10 #or 5 currently + default_detector_distance_minimum: 150 + default_detector_distance_maximum: 900 db: aaredb_url: "https://mx-aaredb-dmz-01.psi.ch/dispatcher" @@ -46,4 +48,4 @@ daq: steps: 1800 detector_distance_limit_modifier: 2.0 - maximum_flux: 1e12 \ No newline at end of file + maximum_flux: 1e12 From 7cedc84e38b0512b415e560a39af332c198aec93 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Wed, 24 Jun 2026 13:02:21 +0200 Subject: [PATCH 75/76] GUI/DAQ: hutch-safety gating, error pop-ups, and automation pause-and-wait Fix the GUI pop-up path and add personnel-safety-system (PSS) gating so door-open / beam-down / shutter-closed conditions are surfaced and acted on. - exception pop-ups: connect the previously-orphaned http_error signal; failed user operations now raise a modal dialog, background/polling errors a non-modal banner. - PSS device (devices/pss_state.py) reading EH1-PSYS PROHIBITED-STATE / ALARM-STATE; new critical DoorSafetyError + DOOR_SAFETY_ERROR code. - mounting service blocks mount/unmount when the hutch is not prohibited or an alarm is active; /status now publishes pss_prohibited / pss_alarm. - GUI blocks manual mount/unmount and the automation Run button immediately (pop-up) on door-open, and shows a warning banner while an alarm is active. - centralise per-action precondition checks (ring current, safety shutter, hutch door) into one combined "continue?" dialog with a session-global "don't ask again for 1 hour" snooze, applied to all data-collection buttons. - live automation pauses and auto-resumes on bad conditions (beam, shutter, door, robot) with continue-now / stop overrides, gated by a default-on "Pause on bad conditions" checkbox replacing the dead CHECK_ENABLED constant. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 32 ++++ src/aare/common/error_codes.py | 1 + src/aare/common/exception_handler.py | 19 +++ src/aare/common/models.py | 7 + src/aare/daq/daq.py | 4 + src/aare/daq/devices.py | 3 + src/aare/daq/operations/mounting/service.py | 25 +++ src/aare/devices/pss_state.py | 67 ++++++++ src/aare/gui/main_window.py | 147 +++++++++++++++++- src/aare/gui/panels/sample_queue_panel.py | 79 ++++++---- src/aare/gui/panels/scan_settings_panel.py | 16 +- src/aare/gui/threads/daq_worker.py | 31 +++- src/aare/gui/widgets/message_box.py | 85 +++++++++- .../mounting/test_mounting_service.py | 53 ++++++- 14 files changed, 523 insertions(+), 46 deletions(-) create mode 100644 src/aare/devices/pss_state.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3650736c..04cc747a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,36 @@ # Changelog +## 0.3.2 (unreleased) +- **Authentication & Sessions**: + - Migrated DAQ server URLs to HTTPS with per-beamline configurable certificate paths (cacert) and mTLS; GUI now validates certificates and handles self-signed certs / SSL errors in `DAQWorker` and `SSEClient`. + - Reworked authentication around Kerberos/SPNEGO token negotiation (via `curl`) behind an Apache/HTTPS proxy; removed the direct `gssapi` dependency and simplified proxy-header handling. + - GUI session management: track active GUIs in Redis, show the current baton holder, let staff force-close erroneous sessions, and auto-close idle GUIs after a grace period. +- **Operations refactor (major)**: + - Standardised every multi-step procedure under `aare/daq/operations//` as a `*Service` with constructor-injected context (`deps`, `runtime` providers, `services` protocols, `settings`); added the `rotation` operation and `screenshot`/common helpers. + - Major refactor of the mounting service, plus loop-centering, face-detection and raster services, with runtime-state providers/protocols and updated unit tests. +- **Exception model (major refactor)**: + - Reworked the exception hierarchy into `AareException` with a load-bearing `critical` flag, routing roots (`AutomationError`/`AareUserError`/`AareAuthError`), empty device-family bases for `isinstance` grouping, and centralised `AareErrorCode`s served through FastAPI handlers; the GUI reads `body.critical` to pause/halt automation. + - Added `StateTransitionFailed`, `MaintenanceStateException`, `BECCommunicationError`, `BeamlineBusyTimeoutException`, `AutoRasterSampleSkipped` and improved `TransformationInvalidException` handling; moved excessive logging out of the exception classes themselves. +- **DAQ/Server**: + - Converted the in-use EPICS devices to BEC (ring current, energy, front/back light, zoom, transmission, fast shutter, detector Z), and cached BEC device handles as variables for a large speed-up (~6 µs vs ~200 ms per read). + - Store detector metadata and TELL events in Redis so `/status` polling stays fast; moved detector-Z soft-limit checks into the device with a `detector_limit_modifier` to avoid hitting limits. + - Added a TELL state handler that derives activity/phase from the TELL SSE stream and surfaces the current robot action in the GUI BUSY label, status bar and recovery panel. + - Critical detector faults now halt automation (and surface during manual collection); added blower-on for every mount, anneal in/out, `change_energy` and `mono_pitch_scan` controls. + - Scaling/skip handling for over-large raster grids (Jungfrau buffer limit), with a longer busy timeout and busy-timeout exceptions. +- **GUI**: + - Added portrait mode, a compact automation view with mode-switching buttons, a Redis-backed automation progress panel, a beamline-state panel, a shared busy-overlay widget, and a splash screen / app banner / icon. + - Added a local-contact panel that restarts backend device connections (JFJoch, TELL, Aerotech, Smargon), reinitialises BEC macros/devices and opens beamline web helpers. + - Performance: raster grid manager now renders a single bitmap to remove lag on large grids; added a Database/User-values toggle to data-collection settings. +- **Common, Devices & Config**: + - Moved all hardware URLs (GUI, JFJoch, Aerotech, BEC, Smargon, TELL) into per-beamline YAML read via `cfg_get`; added `lens_magnification`, `maximum_flux`, `detector_distance_minimum`, `detector_limit_modifier`, beamline-specific transmission PVs and cryojet temperature PVs; removed unused EPICS devices. +- **Workflows**: + - Added `maintenance2sa`/`maintenance2se` transitions, a safe detector-Z position check, explicit per-transition error messages, and switched to `ABR_POS`/`ABR_OMEGA` mount positions; removed the unused `ws` mode. +- **Hutch safety & error pop-ups** (in progress): + - Re-wired the GUI pop-up path: failed user operations (mount/unmount) now raise a modal dialog (e.g. *Mounting Failed: No Pin in Gripper*) and background/polling errors a non-modal banner — previously the `http_error` signal was emitted but never connected, so these were silently dropped. + - Added a `PssState` device reading the hutch personnel-safety system (`…-EH1-PSYS:PROHIBITED-STATE` / `:ALARM-STATE`); mounts are blocked (critical `DoorSafetyError`, automation queue paused) when the hutch is not in the prohibited state or a safety alarm is active, and an active alarm also raises a non-modal warning banner via `/status`. + - `/status` now publishes `pss_prohibited`/`pss_alarm`; the GUI blocks manual mount/unmount and the automation Run button immediately (with a pop-up) when the hutch door is open, instead of letting the robot fail to move server-side. + - Centralised the per-action precondition checks (ring current, safety shutter, hutch door) into one combined "continue?" dialog used by all data-collection buttons (Evaluate grid, X-ray centering, Run screening/rotation/fluorescence, simple rotation), with a session-global "Don't ask me again for 1 hour" snooze. + - Live automation now pauses and auto-resumes when the beam, experiment shutter, hutch door or robot (TELL) are not ready (with Continue-now / Stop overrides), gated by a "Pause on bad conditions" checkbox (default on) that replaces the dead `CHECK_ENABLED` constant. + ## 0.3.1 (2026-04-29) - **DAQ/Server**: - Improved baton exchange logic with automatic timeout and Redis-backed state. diff --git a/src/aare/common/error_codes.py b/src/aare/common/error_codes.py index 801cb4f3..cc103a42 100644 --- a/src/aare/common/error_codes.py +++ b/src/aare/common/error_codes.py @@ -77,6 +77,7 @@ class AareErrorCode(StrEnum): TRANSFORMATION_INVALID_EXCEPTION = "TRANSFORMATION_INVALID_EXCEPTION" MAGNET_POSITION_SENSOR_ERORR = "MAGNET_POSITION_SENSOR_ERORR" # NOTE: class name "Erorr" has a typo; preserved SMART_MAGNET_FAULT_EXCEPTION = "SMART_MAGNET_FAULT_EXCEPTION" + DOOR_SAFETY_ERROR = "DOOR_SAFETY_ERROR" # User errors MANUAL_MOUNT_EXCEPTION = "MANUAL_MOUNT_EXCEPTION" diff --git a/src/aare/common/exception_handler.py b/src/aare/common/exception_handler.py index fbc5028c..d8c2e9ba 100644 --- a/src/aare/common/exception_handler.py +++ b/src/aare/common/exception_handler.py @@ -185,6 +185,25 @@ class SmartMagnetFaultException(AutomationError): return self.message +class DoorSafetyError(AutomationError): + """Hutch personnel-safety system does not permit robot motion. + + Raised before a mount/unmount when ``…-EH1-PSYS:PROHIBITED-STATE`` is not in + the prohibited state (door safety could not be activated, so the robot will + not move) or when ``…-EH1-PSYS:ALARM-STATE`` reports an active alarm. Always + critical so automation halts and the GUI shows a pop-up. + """ + + critical: ClassVar[bool] = True + + def __init__(self, message: str = "Door safety could not be activated", *, critical: bool | None = None): + super().__init__(message, critical=critical) + self.message = message + + def __str__(self) -> str: + return self.message + + class TellCommandWhileBusyException(TellException): """Custom exception for trying to move Tell when it is busy""" diff --git a/src/aare/common/models.py b/src/aare/common/models.py index 746eb9c7..40c8a5f4 100644 --- a/src/aare/common/models.py +++ b/src/aare/common/models.py @@ -553,6 +553,13 @@ class BeamlineStatus(BaseModel): commissioning_mode: bool dtz_min: float dtz_max: float + # Hutch personnel-safety system state. ``pss_prohibited`` is True when the + # hutch is interlocked so the robot may move (PROHIBITED-STATE); the GUI + # blocks a mount when it is False. ``pss_alarm`` is True when ALARM-STATE + # != 0 (warning). Defaults keep older payloads/constructors valid and avoid + # the GUI false-blocking when an old server omits the field. + pss_prohibited: bool = True + pss_alarm: bool = False class SessionStatus(BaseModel): diff --git a/src/aare/daq/daq.py b/src/aare/daq/daq.py index 1ca28bb3..9f8b697b 100644 --- a/src/aare/daq/daq.py +++ b/src/aare/daq/daq.py @@ -3185,6 +3185,8 @@ class AareDAQ: cryojet_temp = self.__devs.cryojet_temp shutter_open = self.__devs.shutter exp_shutter_open = self.__devs.exp_shutter.state() + pss_prohibited = self.__devs.pss.is_prohibited() + pss_alarm = self.__devs.pss.alarm_active() flux = self.__devs.full_flux samcam_settings = self.__devs.samcam_settings bl = self.__bl @@ -3223,6 +3225,8 @@ class AareDAQ: commissioning_mode=commisioning_mode, dtz_min=dtz_min, dtz_max=dtz_max, + pss_prohibited=pss_prohibited, + pss_alarm=pss_alarm, ) except Exception as e: diff --git a/src/aare/daq/devices.py b/src/aare/daq/devices.py index c7c5da6c..11113198 100644 --- a/src/aare/daq/devices.py +++ b/src/aare/daq/devices.py @@ -19,6 +19,7 @@ from aare.devices.area_detector import epicsAD, AutoEnum from aare.devices.enum_pv import EnumPV from aare.devices.experimental_hutch_shutter import ExperimentalHutchShutter from aare.devices.my_motor import MyMotor +from aare.devices.pss_state import PssState from aare.devices.set_get_pv import SetGetPV, PredefinedPV from aare.devices.tell_client import make_tell_client @@ -39,6 +40,8 @@ class BeamlineDevices: logger.debug("initialising BEC worker done") self.__smargon = smargon.Smargon(beamline) self.exp_shutter = ExperimentalHutchShutter(beamline=self._beamline) + # Personnel Safety System: gates whether the robot is allowed to move. + self.pss = PssState(beamline=self._beamline) #faster to define the dtz object here than in functions and then use self.__dtz = self.bec_worker.dev.det_z diff --git a/src/aare/daq/operations/mounting/service.py b/src/aare/daq/operations/mounting/service.py index 5a0142ad..505afffd 100644 --- a/src/aare/daq/operations/mounting/service.py +++ b/src/aare/daq/operations/mounting/service.py @@ -2,6 +2,7 @@ import time from aare.common.exception_handler import ( CriticalTellException, + DoorSafetyError, MountingFailed, TellCommunicationError, UnmountingFailed, @@ -114,7 +115,29 @@ class MountingService: self.logger.error(message) raise CriticalTellException(f"Critical error in TELL mount: {message}") + def _check_hutch_safety(self) -> None: + """Block robot motion unless the hutch PSS permits it. + + The robot only moves when the hutch is in the *prohibited* state; if it + is not, the door safety could not be activated. A non-zero alarm state + is also blocked. Always raised critical so automation halts and the GUI + shows a pop-up. + """ + pss = self.ctx.deps.devs.pss + if not pss.is_prohibited(): + self.logger.error( + "Hutch is not in prohibited state; robot motion not permitted" + ) + raise DoorSafetyError( + "Door safety could not be activated: close the hutch doors and " + "complete the safety search before mounting." + ) + if pss.alarm_active(): + self.logger.error("Hutch safety alarm is active; blocking robot motion") + raise DoorSafetyError("Hutch safety alarm is active. Mounting blocked.") + def _prepare_mount_hardware(self) -> None: + self._check_hutch_safety() self.ctx.deps.devs.smargon_move_home() self.ctx.deps.devs.aerotech_pos = self.ctx.settings.mount_position self._magnet_position_sensor_check(timeout=360.0) @@ -161,6 +184,8 @@ class MountingService: previous_sample=unmounted_sample, did_unmount_previous=unmounted_sample is not None, ) + except DoorSafetyError: + raise except Exception as e: raise UnmountingFailed(f"Failed to unmount: {e}") from e diff --git a/src/aare/devices/pss_state.py b/src/aare/devices/pss_state.py new file mode 100644 index 00000000..500f4e7f --- /dev/null +++ b/src/aare/devices/pss_state.py @@ -0,0 +1,67 @@ +"""Personnel Safety System (PSS) state for the experimental hutch. + +The sample-changer robot may only move when the hutch is in the *prohibited* +state (doors closed/searched, no personnel access). When +``…-EH1-PSYS:PROHIBITED-STATE`` reads ``0`` the robot will not move, so a +mount/unmount must be blocked. A non-zero ``…-EH1-PSYS:ALARM-STATE`` signals a +safety alarm. + +This mirrors the simple PV-wrapper pattern used by +:class:`aare.devices.experimental_hutch_shutter.ExperimentalHutchShutter` +(same ``EH1-PSYS`` PV family). In ``SIMULATED`` the device reports a safe state +(prohibited, no alarm) so the simulated mount path runs without hardware. +""" + +from aare.common.beamline import MXBeamline +from aare.common.logger_config import setup_logger +from epics import PV + +logger = setup_logger("aareDAQ") + + +class PssState: + def __init__(self, beamline: MXBeamline): + self._simulated = beamline == MXBeamline.SIMULATED + BEAMLINE = beamline.value.upper() + if self._simulated: + self.__prohibited = None + self.__alarm = None + else: + self.__prohibited = PV(f"{BEAMLINE}-EH1-PSYS:PROHIBITED-STATE") + self.__alarm = PV(f"{BEAMLINE}-EH1-PSYS:ALARM-STATE") + + def is_prohibited(self) -> bool: + """True when the hutch is interlocked so the robot is allowed to move. + + ``PROHIBITED-STATE`` reports ``"PROHIBITED"`` (robot may move) or ``0`` + (robot will not move). A disconnected/unknown PV is treated as *not* + prohibited, so the mount is blocked fail-safe. + """ + if self._simulated: + return True + value = self.__prohibited.get() + if value is None: + logger.warning( + "PROHIBITED-STATE PV returned no value; treating hutch as not prohibited" + ) + return False + if isinstance(value, str): + prohibited = value.strip().upper() == "PROHIBITED" + else: + prohibited = value != 0 + if not prohibited: + # Log the raw value when we block, so the PV semantics can be + # confirmed against the hardware without spamming the happy path. + logger.info(f"Hutch not in prohibited state; PROHIBITED-STATE raw value={value!r}") + return prohibited + + def alarm_active(self) -> bool: + """True when ``ALARM-STATE`` is non-zero (a safety alarm is active).""" + if self._simulated: + return False + value = self.__alarm.get() + if value is None: + return False + if isinstance(value, str): + return value.strip().upper() not in ("", "0", "OK", "NONE", "NO ALARM") + return value != 0 diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 8d523cf8..74454d36 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -78,6 +78,7 @@ from aare.gui.widgets.no_wheel_scroll_area import NoWheelScrollArea from aare.gui.widgets.status_bar import StatusBar from aare.gui.widgets.video_image import VideoGraphicsView from aare.gui.widgets.busy_overlay import build_busy_overlay_style +from aare.gui.widgets.message_box import precondition_check logger = setup_logger("aareGUI") @@ -685,10 +686,12 @@ class MainWindow(QMainWindow): self.job_list_panel.unmount.connect(self.daq.unmount) self.job_list_panel.park_and_dry.connect(self.daq.park_and_dry) - self.tell_samples.mount.connect(self.daq.mount) - self.tell_samples.unmount.connect(self.daq.unmount) - self.ref_tools_panel.mount.connect(self.daq.mount) - self.ref_tools_panel.unmount.connect(self.daq.unmount) + # Gate manual mounts/unmounts on the hutch PSS state so the user gets an + # immediate pop-up instead of the robot failing to move server-side. + self.tell_samples.mount.connect(self._on_manual_mount_requested) + self.tell_samples.unmount.connect(self._on_manual_unmount_requested) + self.ref_tools_panel.mount.connect(self._on_manual_mount_requested) + self.ref_tools_panel.unmount.connect(self._on_manual_unmount_requested) self.data_collection.raster.grid_size_updated.connect(self.raster.update_grid_size) self.data_collection.raster.exp_time_updated.connect(self.raster.update_exposure_time) @@ -701,7 +704,7 @@ class MainWindow(QMainWindow): self.raster.grid_scan.connect(self.daq.raster_scan) self.raster.grid_scan_auto.connect(self.daq.raster_scan_auto) self.data_collection.screening.rotation_scan.connect(self.daq.standard_scan) - self.data_collection.simple.rotation_scan.connect(self.daq.standard_scan) + self.data_collection.simple.rotation_scan.connect(self._on_simple_rotation_requested) self.data_collection.simple.parameters_changed.connect(self.daq.smart_params) self.raster.grid_scan_size_changed.connect(self.data_collection.raster.grid_scan_size_change) @@ -780,6 +783,11 @@ class MainWindow(QMainWindow): self.daq.update.connect(self.update_daq_status) self.daq.sample_missing.connect(self.show_sample_missing_dialog) + # Generic/background HTTP errors -> non-modal runtime alert; failed + # user operations (mount/unmount/...) -> modal pop-up. + self.daq.http_error.connect(self._on_http_error) + self.daq.operation_failed.connect(self._on_operation_failed) + self.daq.pss_alarm_changed.connect(self._on_pss_alarm_changed) self.daq.standard_scan_completed.connect(self.rotation.scan_completed) self.daq.raster_scan_completed.connect(self.raster.grid_scan_completed) @@ -796,7 +804,7 @@ class MainWindow(QMainWindow): self.status_bar.get_all_pgroups.connect(self.daq.get_all_pgroups) self.daq.staff_pgroups_loaded.connect(self.status_bar.staff_pgroups_loaded) - self.data_collection.fluo.fluo_scan.connect(self.daq.fluorimeter_spectrum) + self.data_collection.fluo.fluo_scan.connect(self._on_fluo_scan_requested) self.daq.fluorimeter_spectrum_update.connect(self.fluor_panel.update_plot) self.daq.fluorimeter_spectrum_update.connect(lambda: self.fluor_panel_dock.setVisible(True)) @@ -1632,6 +1640,133 @@ class MainWindow(QMainWindow): ) @Slot(str) + @Slot(str) + def _on_http_error(self, message: str) -> None: + """Surface generic/background HTTP errors as a non-modal runtime alert. + + These come from polling, resync and other background requests; a modal + pop-up would be too intrusive, so they go to the runtime dock/banner. + """ + self._portrait_alert_primary(message, True) + + @Slot(bool) + def _on_pss_alarm_changed(self, active: bool) -> None: + """Non-modal warning banner for the hutch PSS alarm (ALARM-STATE != 0). + + Edge-triggered from /status: shows a sticky warning while active and a + brief confirmation when it clears. + """ + if active: + self._show_runtime_notification( + title="Hutch safety alarm", + message=( + "The hutch personnel-safety system reports an active alarm " + "(ALARM-STATE != 0). Mounting is blocked until it clears — " + "check the hutch / call your local contact." + ), + level="warning", + sticky=True, + ) + else: + self._show_runtime_notification( + title="Hutch safety alarm cleared", + message="The hutch safety alarm has cleared.", + level="success", + sticky=False, + auto_clear_ms=4000, + ) + + @Slot(str, str, bool) + def _on_operation_failed(self, title: str, message: str, critical: bool) -> None: + """Modal pop-up for a failed user-triggered operation (mount, unmount, ...). + + Critical failures (e.g. door safety could not be activated) also pause + the automation queue defensively, so we never keep dispatching samples. + """ + logger.error(f"Operation failed [{title}] (critical={critical}): {message}") + + if critical: + try: + if self.job_list_panel is not None and self.job_list_panel.is_running(): + self.job_list_panel.pause_automation() + except Exception as e: + logger.error(f"Failed to pause automation after operation failure: {e}") + + try: + show = QMessageBox.critical if critical else QMessageBox.warning + show(self, title, message) + except Exception as e: + logger.error(f"Failed to show operation failure popup: {e}") + + def _hutch_blocks_mount(self) -> str | None: + """Reason the hutch PSS currently blocks a mount, or None if OK. + + Uses the latest ``/status``; if no status has arrived yet we defer to + the authoritative server-side gate rather than guess. + """ + bl = getattr(self._latest_daq_status, "bl", None) + if bl is None: + return None + if getattr(bl, "pss_prohibited", True) is False: + return ( + "Door safety could not be activated: close the hutch doors and " + "complete the safety search before mounting." + ) + if getattr(bl, "pss_alarm", False): + return "The hutch safety alarm is active. Mounting is blocked until it clears." + return None + + def _on_manual_mount_requested(self, sample, reference: bool = False) -> None: + """Pre-check the hutch before sending a manual mount to the server. + + Gives the user an immediate pop-up if the door is open / alarm active, + instead of the robot silently failing to move server-side. + """ + reason = self._hutch_blocks_mount() + if reason is not None: + logger.warning(f"Manual mount blocked by hutch PSS: {reason}") + try: + QMessageBox.critical(self, "Mounting Failed", reason) + except Exception as e: + logger.error(f"Failed to show mount-blocked popup: {e}") + return + self.daq.mount(sample, reference) + + def _on_manual_unmount_requested(self) -> None: + """Block a manual unmount if the hutch isn't ready (robot can't move).""" + reason = self._hutch_blocks_mount() + if reason is not None: + logger.warning(f"Manual unmount blocked by hutch PSS: {reason}") + try: + QMessageBox.critical(self, "Unmounting Failed", reason) + except Exception as e: + logger.error(f"Failed to show unmount-blocked popup: {e}") + return + self.daq.unmount() + + def _precondition_ok(self) -> bool: + """Run the shared ring-current / shutter / door 'continue?' check. + + Uses the latest ``/status``; defers (returns True) if no status yet. + """ + bl = getattr(self._latest_daq_status, "bl", None) + if bl is None: + return True + return precondition_check( + self, + ring_current=getattr(bl, "ring_current_mA", None), + shutter_open=getattr(bl, "exp_shutter_open", None), + door_prohibited=getattr(bl, "pss_prohibited", None), + ) + + def _on_fluo_scan_requested(self, params) -> None: + if self._precondition_ok(): + self.daq.fluorimeter_spectrum(params) + + def _on_simple_rotation_requested(self, request) -> None: + if self._precondition_ok(): + self.daq.standard_scan(request) + def _on_manual_collection_critical_failure(self, message: str) -> None: logger.critical(f"Manual collection critical detector failure: {message}") diff --git a/src/aare/gui/panels/sample_queue_panel.py b/src/aare/gui/panels/sample_queue_panel.py index 2a8ee99e..ce46a850 100644 --- a/src/aare/gui/panels/sample_queue_panel.py +++ b/src/aare/gui/panels/sample_queue_panel.py @@ -5,8 +5,7 @@ from PySide6.QtGui import QKeySequence, QShortcut from aare.common.models import SampleShortInfoList, SampleShortInfo, BeamlineStateEnum, SessionsStateEnum from aare.gui.models.sample_queue_model import SampleQueueSpreadsheet -from aare.gui.widgets.message_box import ring_current_low_check, experiment_hutch_shutter_check, LOW_CURRENT_THRESHOLD, \ - ring_current_auto_check +from aare.gui.widgets.message_box import LOW_CURRENT_THRESHOLD, conditions_auto_check from aare.gui.widgets.title_label import TitleLabel from aare.common.models import DAQStatusModel @@ -14,8 +13,6 @@ from aare.common.logger_config import setup_logger logger = setup_logger("aareGUI") -CHECK_ENABLED = False - class SampleQueuePanel(QFrame): auto_scan = Signal(SampleShortInfo) unmount = Signal() @@ -37,6 +34,8 @@ class SampleQueuePanel(QFrame): self._step_through = False self.ring_current = None self._experiment_shutter_state = None + self._door_prohibited = None + self._tell_connected = True self.loop_restart_requested = None self.__recovery_timer = QTimer(self) @@ -86,10 +85,19 @@ class SampleQueuePanel(QFrame): self.park_and_dry_when_cleared = QCheckBox("Park and dry when automation finishes", self) self.park_and_dry_when_cleared.setChecked(True) + self.pause_on_conditions_cb = QCheckBox("Pause on bad conditions (beam/shutter/door/robot)", self) + self.pause_on_conditions_cb.setChecked(True) + self.pause_on_conditions_cb.setToolTip( + "When on, automation refuses to start and pauses between samples if the beam, " + "experiment shutter, hutch doors or robot are not ready, auto-resuming when they " + "recover. Uncheck for testing to run straight through." + ) + button_layout.addWidget(self.play_button) button_layout.addWidget(self.remove_button) button_layout.addWidget(self.clear_button) button_layout.addWidget(self.park_and_dry_when_cleared) + button_layout.addWidget(self.pause_on_conditions_cb) layout.addLayout(button_layout) self._emit_samples_in_queue_changed() @@ -179,15 +187,25 @@ class SampleQueuePanel(QFrame): self._emit_samples_in_queue_changed() - def ring_current_check(self): - if not ring_current_low_check(self, self.ring_current): - self.table_model.set_running(False) - self.__set_to_pause = True - self.__pause = True - self.play_button.setText("▶ Run") - self._emit_samples_in_queue_changed() - return False - return True + def _checks_enabled(self) -> bool: + return self.pause_on_conditions_cb.isChecked() + + def _bad_conditions(self) -> list[str]: + """Beamline conditions that currently block/should pause automation.""" + problems: list[str] = [] + if self.ring_current is None or self.ring_current < LOW_CURRENT_THRESHOLD: + rc = "unknown" if self.ring_current is None else f"{round(self.ring_current, 2)} mA" + problems.append(f"beam (ring current {rc})") + if not self._experiment_shutter_state: + problems.append("experiment shutter closed") + if self._door_prohibited is False: + problems.append("hutch doors open / not searched") + if not self._tell_connected: + problems.append("robot (TELL) not connected") + return problems + + def _conditions_ok(self) -> bool: + return len(self._bad_conditions()) == 0 def resume_automation(self): if self.__warning_msg_box: @@ -250,7 +268,8 @@ class SampleQueuePanel(QFrame): def run(self): self.__recovery_timer.stop() - if CHECK_ENABLED: + checks_enabled = self._checks_enabled() + if checks_enabled: if self.__warning_msg_box: self.__warning_msg_box.done(0) self.__warning_msg_box = None @@ -303,14 +322,17 @@ class SampleQueuePanel(QFrame): return if len(self.table_model.samples) > 0: - if CHECK_ENABLED: - if not self.ring_current_check(): - logger.debug("low ring current, skipping") + if checks_enabled: + bad = self._bad_conditions() + if bad: + logger.warning(f"Cannot start automation; beamline not ready: {bad}") + self.show_error_dialog( + title="Beamline not ready", + msg="Cannot start automation:\n- " + "\n- ".join(bad), + info="Fix the above, or untick 'Pause on bad conditions' for testing.", + ) return - elif not experiment_hutch_shutter_check(parent=self, shutter_state=self._experiment_shutter_state): - logger.debug("experiment shutter closed, user chose to skip") - self.table_model.set_running(True) self.__set_to_pause = False self.__pause = False @@ -346,6 +368,8 @@ class SampleQueuePanel(QFrame): def update_daq_status(self, s: DAQStatusModel): self.ring_current = s.bl.ring_current_mA self._experiment_shutter_state = s.bl.exp_shutter_open + self._door_prohibited = getattr(s.bl, "pss_prohibited", None) + self._tell_connected = getattr(s, "tell_connected", True) self.__beamline_state = s.state self.__busy = s.busy self.__baton_holder = s.session.session @@ -364,21 +388,20 @@ class SampleQueuePanel(QFrame): if self._current_db_id is not None and db_id != self._current_db_id: return - if CHECK_ENABLED and (self.ring_current is None or (self.ring_current < LOW_CURRENT_THRESHOLD)): + if self._checks_enabled() and not self._conditions_ok(): self.pause_automation(set_id_to_None=False) + bad = self._bad_conditions() + logger.warning(f"Automation paused; beamline not ready: {bad}") + msg = "Automation paused — beamline not ready:\n- " + "\n- ".join(bad) - def current_ok() -> bool: - return (self.ring_current is not None) and (self.ring_current >= LOW_CURRENT_THRESHOLD) - - if ring_current_auto_check(self, self.ring_current, current_ok): - logger.debug("Ring current recovered or user chose to continue," - "resuming automation") + if conditions_auto_check(self, msg, self._conditions_ok): + logger.debug("Conditions recovered or user chose to continue; resuming automation") self.table_model.set_running(True) self.__pause = False self.play_button.setText("⏸ Pause") self._emit_samples_in_queue_changed() else: - logger.debug("No beam, or user quit, ending automation") + logger.debug("Conditions not met or user stopped; ending automation") self.unmount.emit() self._emit_samples_in_queue_changed() return diff --git a/src/aare/gui/panels/scan_settings_panel.py b/src/aare/gui/panels/scan_settings_panel.py index 1af3e2b7..0c6a1ceb 100644 --- a/src/aare/gui/panels/scan_settings_panel.py +++ b/src/aare/gui/panels/scan_settings_panel.py @@ -13,7 +13,7 @@ from PySide6.QtWidgets import ( from aare.common.diffraction_geometry import DiffractionGeometry from aare.common.logger_config import setup_logger from aare.common.models import DAQStatusModel, SessionsStateEnum -from aare.gui.widgets.message_box import ring_current_low_check, experiment_hutch_shutter_check +from aare.gui.widgets.message_box import precondition_check from aare.gui.widgets.number_line_edit import DbOverrideLineEdit logger = setup_logger("aareGUI") @@ -46,6 +46,7 @@ class ScanSettingsPanel(QWidget): self._target_dose = None self._ring_current = None self._experiment_shutter_state = None + self._door_prohibited = None # Fields whose value can come from the database or be overridden by the # user; the panel-level toggle drives them all together. @@ -163,6 +164,7 @@ class ScanSettingsPanel(QWidget): self.__diffraction = s.diffraction self._ring_current = s.bl.ring_current_mA self._experiment_shutter_state = s.bl.exp_shutter_open + self._door_prohibited = getattr(s.bl, "pss_prohibited", None) can_edit = (not s.busy) and (s.session.session in (SessionsStateEnum.OwnedByYou, SessionsStateEnum.PendingElseToYou)) self._can_edit_params = can_edit # Lock/unlock the override fields @@ -320,11 +322,13 @@ class ScanSettingsPanel(QWidget): return [] def check_before_run (self, scan_kind: str): - if not ring_current_low_check(self, self._ring_current): - logger.warning("Low ring current, User chose to not to continue scan") - return False - if not experiment_hutch_shutter_check(self, self._experiment_shutter_state): - logger.warning("experiment shutter closed, user chose to not to continue scan") + if not precondition_check( + self, + ring_current=self._ring_current, + shutter_open=self._experiment_shutter_state, + door_prohibited=self._door_prohibited, + ): + logger.warning("Beamline not ready; user chose not to continue scan") return False p = self.parent() if hasattr(p, "file_path_panel"): diff --git a/src/aare/gui/threads/daq_worker.py b/src/aare/gui/threads/daq_worker.py index c37dbab0..e81d23cf 100644 --- a/src/aare/gui/threads/daq_worker.py +++ b/src/aare/gui/threads/daq_worker.py @@ -1,6 +1,7 @@ import copy import os import random +import re import time import json import logging @@ -82,6 +83,12 @@ class DAQWorker(QObject): detector_error = Signal(str, bool) # (message, is_error) auth_error = Signal() sample_missing = Signal(str) + # Failure of a user-triggered operation POST (mount, unmount, ...). Carries + # (title, message, critical) and is surfaced as a modal pop-up by the GUI. + operation_failed = Signal(str, str, bool) + # Hutch PSS alarm (ALARM-STATE != 0) became active/inactive. Edge-triggered + # from /status; surfaced as a non-modal warning banner. + pss_alarm_changed = Signal(bool) automated_scan_done = Signal(int, bool, str) # sample ID, success run_number_incremented = Signal() @@ -157,6 +164,9 @@ class DAQWorker(QObject): self._last_smargon_error: str | None = None self._last_aerotech_connected: bool | None = None self._last_aerotech_error: str | None = None + # Edge-trigger the hutch PSS alarm banner; start False so a clear hutch + # at startup doesn't emit a spurious "cleared" notification. + self._last_pss_alarm: bool = False self._server_connected: bool | None = None self._last_server_error: str | None = None @@ -434,6 +444,11 @@ class DAQWorker(QObject): parsed_response = DAQStatusModel.model_validate_json(response_data) self.update.emit(parsed_response) + pss_alarm = bool(getattr(getattr(parsed_response, "bl", None), "pss_alarm", False)) + if pss_alarm != self._last_pss_alarm: + self._last_pss_alarm = pss_alarm + self.pss_alarm_changed.emit(pss_alarm) + self._last_status_can_read = True self._restart_blocked_sse_streams_if_access_restored() @@ -621,10 +636,24 @@ class DAQWorker(QObject): self.sample_missing.emit(error_info.message) else: logger.error(f"{error_info.message}") - self.http_error.emit(error_info.message) + title = self._operation_error_title(error_info.exception_class) + self.operation_failed.emit(title, error_info.message, error_info.critical) reply.deleteLater() + @staticmethod + def _operation_error_title(exception_class: str | None) -> str: + """Human-friendly dialog title from an exception class name. + + ``MountingFailed`` -> ``"Mounting Failed"``; falls back to + ``"Operation Failed"`` when the class is unknown. + """ + if not exception_class: + return "Operation Failed" + spaced = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", exception_class) + spaced = spaced.replace("Exception", "Error").strip() + return spaced or "Operation Failed" + def _handle_sample_resync_response(self, reply: QNetworkReply): try: response_data = self.handle_response(reply) diff --git a/src/aare/gui/widgets/message_box.py b/src/aare/gui/widgets/message_box.py index 34ef3db6..461ba1ad 100644 --- a/src/aare/gui/widgets/message_box.py +++ b/src/aare/gui/widgets/message_box.py @@ -1,10 +1,80 @@ +import time + from PySide6.QtCore import QTimer, QEventLoop -from PySide6.QtWidgets import QMessageBox +from PySide6.QtWidgets import QMessageBox, QCheckBox from aare.common.logger_config import setup_logger logger = setup_logger("aareGUI") LOW_CURRENT_THRESHOLD = 100.0 +SNOOZE_SECONDS = 3600.0 # "Don't ask again for 1 hour" + + +class _PreconditionSnooze: + """Session-global 'don't ask me again for 1 hour' for precondition prompts. + + In-memory only, so it auto-clears when the GUI restarts. + """ + + def __init__(self): + self._until = 0.0 + + def active(self) -> bool: + return time.monotonic() < self._until + + def snooze(self) -> None: + self._until = time.monotonic() + SNOOZE_SECONDS + logger.info("Precondition prompts snoozed for 1 hour") + + def clear(self) -> None: + self._until = 0.0 + + +# Shared across all panels for the session. +precondition_snooze = _PreconditionSnooze() + + +def precondition_problems(ring_current, shutter_open, door_prohibited) -> list[str]: + """List the beamline conditions that are currently not satisfied.""" + problems: list[str] = [] + if ring_current is None: + problems.append("Ring current is unknown.") + elif ring_current < LOW_CURRENT_THRESHOLD: + problems.append(f"Ring current is low: {round(ring_current, 2)} mA.") + if not shutter_open: + problems.append("Experiment safety shutter is closed.") + if door_prohibited is False: + problems.append("Hutch is not in the prohibited state (doors open / not searched).") + return problems + + +def precondition_check(parent, *, ring_current, shutter_open, door_prohibited) -> bool: + """Combined ring current / safety shutter / hutch door pre-action check. + + Shows ONE 'Do you wish to continue?' dialog listing every failing + condition, with a 'Don't ask me again for 1 hour' checkbox. Returns True to + proceed. Honours the session-global snooze and never blocks outright. + """ + if precondition_snooze.active(): + return True + + problems = precondition_problems(ring_current, shutter_open, door_prohibited) + if not problems: + return True + + box = QMessageBox(parent) + box.setIcon(QMessageBox.Icon.Warning) + box.setWindowTitle("Beamline not ready") + box.setText("\n".join(problems) + "\n\nDo you wish to continue?") + box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + box.setDefaultButton(QMessageBox.StandardButton.No) + snooze_cb = QCheckBox("Don't ask me again for 1 hour") + box.setCheckBox(snooze_cb) + + proceed = box.exec() == QMessageBox.StandardButton.Yes + if proceed and snooze_cb.isChecked(): + precondition_snooze.snooze() + return proceed def reply_box(parent, title: str = "Warning", msg: str = "Warning." ): return QMessageBox.question( @@ -82,7 +152,18 @@ def experiment_hutch_shutter_check(parent, shutter_state) -> bool: def ring_current_auto_check(parent, ring_current, check_func) -> bool: msg = "Ring current: unknown." if ring_current is None else f"Ring current is low {round(ring_current, 2)} mA." - box = timer_box(parent, title="Ring current too low", msg=msg, condition_func=check_func) + return conditions_auto_check(parent, msg, check_func, title="Ring current too low") + + +def conditions_auto_check(parent, msg: str, check_func, title: str = "Beamline not ready") -> bool: + """Pause-and-wait dialog that auto-resumes when ``check_func()`` becomes True. + + Used to hold automation between samples until the beamline recovers. The + box auto-accepts (resume) as soon as the condition is satisfied; the user + can also click 'Yes' to continue now (override) or 'No' to stop automation. + Returns True to resume, False to stop. + """ + box = timer_box(parent, title=title, msg=msg, condition_func=check_func) loop = QEventLoop() def finish(_=None): if loop.isRunning(): diff --git a/tests/unit/daq/operations/mounting/test_mounting_service.py b/tests/unit/daq/operations/mounting/test_mounting_service.py index 7fb02ea3..ca0d417d 100644 --- a/tests/unit/daq/operations/mounting/test_mounting_service.py +++ b/tests/unit/daq/operations/mounting/test_mounting_service.py @@ -2,7 +2,7 @@ import types import sys from aare.common.coordinate import AerotechCoordinate, Coordinate -from aare.common.exception_handler import CriticalTellException, MountingFailed +from aare.common.exception_handler import CriticalTellException, DoorSafetyError, MountingFailed from aare.common.models import DewarAddress, SampleShortInfo from aare.devices.tell_client import TellEventValueEnum from aare.daq.operations.mounting.models import MountingContext, MountingResult, MountingDependencies, MountingSettings @@ -35,7 +35,7 @@ def _make_sample(sample_id: int, name: str) -> SampleShortInfo: ) -def _make_context(previous_sample=None): +def _make_context(previous_sample=None, *, prohibited=True, alarm=False): tell = types.SimpleNamespace( mount=lambda **kwargs: TellEventValueEnum.SUCCESS, unmount=lambda **kwargs: None, @@ -51,6 +51,10 @@ def _make_context(previous_sample=None): aerotech_pos=None, tell=tell, magnet_position_sensor=types.SimpleNamespace(value=0), + pss=types.SimpleNamespace( + is_prohibited=lambda: prohibited, + alarm_active=lambda: alarm, + ), ) streak = {"count": 0} @@ -225,4 +229,47 @@ def test_execute_mount_critical_tell_error_does_not_increment_failure_streak(moc assert result.success is False assert isinstance(result.error, CriticalTellException) - assert ctx.deps.cfg.get_mount_failure_streak() == 0 \ No newline at end of file + assert ctx.deps.cfg.get_mount_failure_streak() == 0 + +def test_mount_blocked_when_not_prohibited(mock_logger): + """Doors open / hutch not in prohibited state -> critical DoorSafetyError, + and it is not counted as a mount-failure streak.""" + target_sample = _make_sample(2, "new") + ctx = _make_context(prohibited=False) + + service = MountingService(context=ctx, logger=mock_logger) + + result = service.execute(target=target_sample) + + assert result.success is False + assert isinstance(result.error, DoorSafetyError) + assert result.error.critical is True + # Door safety is a pre-flight gate, not a mount attempt: streak stays clear. + assert ctx.deps.cfg.get_mount_failure_streak() == 0 + + +def test_mount_blocked_when_alarm_active(mock_logger): + target_sample = _make_sample(3, "new") + ctx = _make_context(alarm=True) + + service = MountingService(context=ctx, logger=mock_logger) + + result = service.execute(target=target_sample) + + assert result.success is False + assert isinstance(result.error, DoorSafetyError) + assert result.error.critical is True + + +def test_unmount_blocked_when_not_prohibited_keeps_door_error(mock_logger): + """Unmount path must not swallow DoorSafetyError into UnmountingFailed.""" + previous_sample = _make_sample(1, "old") + ctx = _make_context(previous_sample=previous_sample, prohibited=False) + + service = MountingService(context=ctx, logger=mock_logger) + + result = service.execute(target=None) + + assert result.success is False + assert isinstance(result.error, DoorSafetyError) + assert result.error.critical is True From b6e1edea3d627ae43344271c701e5ac9efa4ec97 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Wed, 24 Jun 2026 14:36:27 +0200 Subject: [PATCH 76/76] tell_client: restore "No Pin in Gripper" detection on empty-gripper mount Commit 071b4ab made mount() return SUCCESS as soon as it saw the intermediate TELL "state"/Busy event, before the later "Gripper detection" event is read, so an empty-gripper mount completed silently with no MountingFailed/pop-up. check_command_ok blocks (wait_not_busy) until the mount sequence finishes, so after it returns the robot's own record is authoritative: if get_mounted_sample() is None the gripper was empty -> return NO_PIN_IN_GRIPPER, which the mounting service maps to MountingFailed("No Pin in Gripper"). Covers manual and automation. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + src/aare/devices/tell_client.py | 9 +++++++++ tests/unit/devices/test_tell_client.py | 23 +++++++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04cc747a..a1744da0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ - `/status` now publishes `pss_prohibited`/`pss_alarm`; the GUI blocks manual mount/unmount and the automation Run button immediately (with a pop-up) when the hutch door is open, instead of letting the robot fail to move server-side. - Centralised the per-action precondition checks (ring current, safety shutter, hutch door) into one combined "continue?" dialog used by all data-collection buttons (Evaluate grid, X-ray centering, Run screening/rotation/fluorescence, simple rotation), with a session-global "Don't ask me again for 1 hour" snooze. - Live automation now pauses and auto-resumes when the beam, experiment shutter, hutch door or robot (TELL) are not ready (with Continue-now / Stop overrides), gated by a "Pause on bad conditions" checkbox (default on) that replaces the dead `CHECK_ENABLED` constant. + - Restored "No Pin in Gripper" detection: a mount that returns on the intermediate TELL "Busy" state event now verifies the robot's `get_mounted_sample()` after the command completes, so an empty-gripper mount surfaces `MountingFailed` (and the GUI pop-up) instead of silently reporting success. ## 0.3.1 (2026-04-29) - **DAQ/Server**: diff --git a/src/aare/devices/tell_client.py b/src/aare/devices/tell_client.py index 1d6452cb..85743544 100755 --- a/src/aare/devices/tell_client.py +++ b/src/aare/devices/tell_client.py @@ -293,6 +293,15 @@ class TellClient: timeout=wait_timeout, msg=f"Mount {segment}{puck}-{sample}: " ) logger.info(f"Check command okay response: {msg}") + # check_command_ok blocks (wait_not_busy) until the whole + # mount sequence has finished, so the gripper-detection event + # we observed as an intermediate "state"/Busy event is now + # resolved. The robot's own record is authoritative: if it + # reports nothing mounted, the gripper was empty (no pin), + # which we must surface rather than reporting success. + if self.get_mounted_sample() is None: + logger.warning("Mount command completed but robot reports no sample mounted") + return TellEventValueEnum.NO_PIN_IN_GRIPPER return TellEventValueEnum.SUCCESS elif ( event == TellEventTypeEnum.GIPPER_DETECTION.value diff --git a/tests/unit/devices/test_tell_client.py b/tests/unit/devices/test_tell_client.py index 4c1beb6e..29aa002d 100644 --- a/tests/unit/devices/test_tell_client.py +++ b/tests/unit/devices/test_tell_client.py @@ -52,6 +52,8 @@ def test_mount_state_event_succeeds_when_command_completes( "id": 3017907, } client = TellClient(mock_beamline, backend=mock_backend) + # Robot reports the requested sample as mounted -> genuine success. + client.get_mounted_sample = lambda: _mount_address() result = client.mount(_mount_address(), wait=True) @@ -59,6 +61,27 @@ def test_mount_state_event_succeeds_when_command_completes( mock_backend.get_result.assert_called() # check_command_ok consulted the result +def test_mount_state_event_reports_no_pin_when_nothing_mounted( + mock_beamline, mock_backend +): + """If the command completes but the robot reports nothing mounted, the + gripper was empty: mount() must return NO_PIN_IN_GRIPPER (so the service + raises MountingFailed) rather than reporting success.""" + mock_backend.wait_events.return_value = ("state", "Busy") + mock_backend.get_result.return_value = { + "status": "completed", + "return": "A39", + "exception": None, + "id": 3017907, + } + client = TellClient(mock_beamline, backend=mock_backend) + client.get_mounted_sample = lambda: None + + result = client.mount(_mount_address(), wait=True) + + assert result == TellEventValueEnum.NO_PIN_IN_GRIPPER + + def test_mount_state_event_raises_when_command_not_completed( mock_beamline, mock_backend ):