diff --git a/superxas_bec/device_configs/laser_delay_sim.yaml b/superxas_bec/device_configs/laser_delay_sim.yaml new file mode 100644 index 0000000..fb24026 --- /dev/null +++ b/superxas_bec/device_configs/laser_delay_sim.yaml @@ -0,0 +1,27 @@ +################################### +## Laser delay, simulated ## +################################### +# SimLaserDelay: same device as LaserDelay but with soft signals instead of the five EPICS PVs, for a +# local demo BEC. Add it to a running session: +# bec.config.add_to_session("/superxas_bec/device_configs/laser_delay_sim.yaml") +# Then: dev.laser_delay.calibrate(dev.laser_delay.sim_scope.get()) +# scans.line_scan(dev.laser_delay, -5, 5, steps=21, exp_time=0.1, relative=False) +# sim_scope is a simulated oscilloscope (X-ray minus laser), sim_response a pump-probe step at 0 ns. +# step_sleep is shortened so that demo moves take seconds instead of tens of seconds; set it to 0.1 +# to see the beamline timing. The calibration file is separate from the beamline one. + +laser_delay: + readoutPriority: baseline + description: Simulated laser delay in ns (no EPICS) + deviceClass: superxas_bec.devices.laser_delay_sim.SimLaserDelay + deviceConfig: + step_sleep: 0.01 + calibration_file: ~/.superxas_bec/laser_delay_sim_calibration.json + deviceTags: + - laser + - timing + - simulation + onFailure: retry + enabled: true + readOnly: false + softwareTrigger: false diff --git a/superxas_bec/device_configs/x10da_experimental_hutch.yaml b/superxas_bec/device_configs/x10da_experimental_hutch.yaml index 71a085d..915ab12 100644 --- a/superxas_bec/device_configs/x10da_experimental_hutch.yaml +++ b/superxas_bec/device_configs/x10da_experimental_hutch.yaml @@ -136,4 +136,11 @@ es2_try: prefix: X10DA-ES2-ET2:TRY onFailure: retry enabled: true - softwareTrigger: false \ No newline at end of file + softwareTrigger: false + +################################### +## Laser delay (pump-probe) ## +################################### + +laser_delay_config: + - !include ./x10da_laser_delay.yaml diff --git a/superxas_bec/device_configs/x10da_laser_delay.yaml b/superxas_bec/device_configs/x10da_laser_delay.yaml new file mode 100644 index 0000000..c1d9ec5 --- /dev/null +++ b/superxas_bec/device_configs/x10da_laser_delay.yaml @@ -0,0 +1,42 @@ +################################### +## Laser delay (pump-probe) ## +################################### +# Delay card X10DA-ES2-TIM:SET1 + RF phase shifter 2 (ES4) for the Duetto laser, external trigger. +# Usage and calibration: the LaserDelay docstrings in superxas_bec/devices/laser_delay.py; the user +# guide LASER_DELAY.md lives on the branch docs/laser-delay. +# +# readoutPriority baseline: every scan records the delay and the calibration once; a scan over +# laser_delay elevates it to monitored. +# onFailure buffer: if the timing or ES4 IOC is down, baseline readings fall back to the last value +# instead of aborting unrelated scans. If the PVs are unreachable at startup, BEC disables only this +# device (no I/O in __init__), the rest of the session loads. +# Calibration values are NOT config keys: run dev.laser_delay.calibrate(...) after the laser restart +# or the weekly scope check. They are kept across BEC restarts in a JSON file on the device server +# host (calibration_file). dev.laser_delay.get_calibration_info() shows where, when and from which +# scope value, and whether the restored calibration was verified. + +laser_delay: + readoutPriority: baseline + description: Laser delay in ns (delay card + phase shifter 2, Duetto); calibrate after laser restart + deviceClass: superxas_bec.devices.laser_delay.LaserDelay + deviceConfig: + delay_coarse_pv: X10DA-ES2-TIM:SET1-DELAY + delay_fine_pv: X10DA-ES2-TIM:SET1-FINE + evr_period_pv: X10DA-ES2-TIM:EVR-FPS2 + phase_v1_pv: X10DA-ES4:AO-02 + phase_v2_pv: X10DA-ES4:AO-01 + rf_period: 2.0 + cavity_period: 12.01 + vmax: 1.0 + steps_per_rf_period: 36 + step_sleep: 0.1 + phase_path: script # "shortest" is opt-in, see the module docstring + fine_rollover: true # FINE 200 is written as the next coarse unit and 0 (open question) + calibration_file: ~/.superxas_bec/laser_delay_calibration.json + deviceTags: + - laser + - timing + onFailure: buffer + enabled: true + readOnly: false + softwareTrigger: false diff --git a/superxas_bec/device_configs/x10da_standard_config.yaml b/superxas_bec/device_configs/x10da_standard_config.yaml index 04808aa..8588c7d 100644 --- a/superxas_bec/device_configs/x10da_standard_config.yaml +++ b/superxas_bec/device_configs/x10da_standard_config.yaml @@ -67,6 +67,33 @@ nidaq: enabled: true softwareTrigger: false +### Timepix Detector +timepix: + readoutPriority: async + description: ASI Serval Timepix Detector + deviceClass: superxas_bec.devices.timepix.timepix.Timepix + deviceConfig: + prefix: "X10DA-ES-TPX1:" + backend_rest_url: "P6-0008.psi.ch:8452" + hostname: "x10da-bec-001.psi.ch" + enable_xes: false + onFailure: retry + enabled: true + readOnly: false + softwareTrigger: true + +### Falcon +falcon: + readoutPriority: monitored + description: Falcon Sitoro detector + deviceClass: superxas_bec.devices.falcon.FalconSuperXAS + deviceConfig: + prefix: 'X10DA-SITORO:' + onFailure: raise + enabled: True + softwareTrigger: False + + ## Remaining experimental hutch es_config: - - !include ./x10da_experimental_hutch.yaml \ No newline at end of file + - !include ./x10da_experimental_hutch.yaml diff --git a/superxas_bec/devices/laser_delay.py b/superxas_bec/devices/laser_delay.py new file mode 100644 index 0000000..9b78289 --- /dev/null +++ b/superxas_bec/devices/laser_delay.py @@ -0,0 +1,1540 @@ +""" +Laser delay positioner for SuperXAS (X10DA): delay card plus RF phase shifter. + +Port of the beamline scientist's ``DelayLaser.py`` (Duetto laser, timing mode ``DelayAndPhase``, +external trigger from the event receiver, phase shifter 2 in ES4). All times are in ns. + +How a delay ``t`` (X-ray minus laser, as read on the oscilloscope) is set: + +* **Delay card (coarse + fine).** ``t1 = (-offset_t - t) mod laser_period``; + ``DELAY = int(t1 / 2)`` (units of 2 ns) and ``FINE = int((t1 mod 2) / 0.009999)`` + (units of 0.01 ns). The divisor 0.009999 instead of 0.01 is taken over from the script on + purpose: it stops values such as ``0.5 / 0.01 = 49.99999...`` from being truncated one unit low. + It can give ``FINE = 200``; by default (``fine_rollover``) that is written as ``DELAY + 1`` and + ``FINE = 0``, the same delay. +* **Phase shifter (two voltages V1 = vmax cos(phi), V2 = vmax sin(phi)).** Target phase + ``P = (-t mod cavity_period) - offset_phase_t``. The move is relative: ``dP = P - phase_t`` + where ``phase_t`` is the phase stored by the previous move (the script's behaviour). The ramp + always starts at the phase read back from V1/V2 and walks in steps of + ``sign(dP) * rf_period / steps_per_rf_period`` with ``step_sleep`` seconds between steps; the + last step lands exactly on ``readback + dP``. Afterwards ``P`` is stored as the new ``phase_t``. +* **Phase path.** ``phase_path="script"`` (default) ramps ``dP`` as above, up to about two cavity + periods. ``phase_path="shortest"`` (opt-in) first folds ``dP`` by whole cavity periods into + ``(-cavity_period/2, +cavity_period/2]`` and stores ``P - k * cavity_period``. That is only the + same physical delay if the laser timing repeats every cavity period *and* the cavity period is a + whole number of RF periods: with ``rf_period=2.0`` and ``cavity_period=12.01`` each folded period + leaves the phase shifter 0.01 ns away from where the script would put it. + +The stored phase is "unwrapped": the voltages only encode the phase modulo one RF period, while the +stored value keeps track of how many RF periods the shifter has been turned. That is why the script +(and this device) computes ``dP`` from the stored value. If somebody changes V1/V2 outside this +device, stored and read-back phase no longer agree modulo the RF period; the ``phase_mismatch`` +signal shows the difference, and a move is refused when it exceeds one ramp step. + +Calibration (the script's ``CurrentSituation``, run after every laser restart) reads the EVR laser +period, the delay card and the phase shifter and combines them with the oscilloscope reading +``s`` = X-ray minus laser: ``offset_t = -(2 DELAY + 0.01 FINE) - s`` and +``offset_phase_t = -phase_t - (s mod cavity_period)``. The script kept these numbers in a file +``Timing.offset``; here they are config signals on the device, kept across BEC restarts in a JSON +file (``calibration_file``) and checked against the hardware when the device connects. + +The readback of the positioner is the last commanded delay. After ``calibrate(s)`` it is ``s``, +because the model maps the current settings to ``t = s``. It is NaN before calibration, while a move +runs and after an interrupted move. +""" + +from __future__ import annotations + +import contextlib +import datetime +import functools +import json +import math +import os +import threading +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Callable + +import numpy as np +from bec_lib.logger import bec_logger +from ophyd import Component as Cpt +from ophyd import EpicsSignal, EpicsSignalRO +from ophyd import FormattedComponent as FCpt +from ophyd import Kind, Signal +from ophyd.positioner import PositionerBase +from ophyd.signal import InternalSignal, SignalRO +from ophyd.utils import InvalidState, LimitError, ReadOnlyError +from ophyd_devices import DeviceStatus, PSIDeviceBase, StatusBase +from ophyd_devices.interfaces.base_classes.psi_device_base import DeviceStoppedError +from ophyd_devices.utils.psi_device_base_utils import TaskKilledError + +if TYPE_CHECKING: # pragma: no cover + from bec_lib.devicemanager import DeviceManagerBase, ScanInfo + +logger = bec_logger.logger + +#: Delay card coarse unit in ns (``X10DA-ES2-TIM:SET1-DELAY``). +DELAY_CARD_COARSE_UNIT = 2.0 +#: Delay card fine unit in ns (``X10DA-ES2-TIM:SET1-FINE``), used when reading the card. +DELAY_CARD_FINE_UNIT = 0.01 +#: Divisor used when *writing* the fine value. Kept from the script (see module docstring). +DELAY_CARD_FINE_DIVISOR = 0.009999 +#: Fine units per coarse unit. The script can write FINE = 200 (see ``delay_card_setting``). +DELAY_CARD_FINE_PER_COARSE = 200 +#: Hard limit of the phase shifter voltages in V (ES4 analog outputs). +PHASE_VOLTAGE_LIMIT = 5.0 +#: A restored calibration older than this is reported with a warning (it is redone about weekly). +CALIBRATION_MAX_AGE_S = 7 * 24 * 3600 + +#: Fewest phase ramp steps per RF period (the script's 36: a step of 10 degrees). +MIN_STEPS_PER_RF_PERIOD = 36 + +PHASE_PATHS = ("script", "shortest") + + +class LaserDelayNotCalibratedError(RuntimeError): + """Raised when a move is requested before the device has been calibrated.""" + + +class LaserDelayBusyError(RuntimeError): + """Raised when an action needs the device idle while a move is still running.""" + + +class LaserDelayHardwareError(RuntimeError): + """Raised when the hardware is not in the state a move or calibration needs.""" + + +class LaserDelayPersistenceError(RuntimeError): + """Raised by calibrate/set_calibration when the calibration could not be saved.""" + + +# --------------------------------------------------------------------------------------------- # +# Maths, kept as plain functions so that they can be checked against the reference script. # +# --------------------------------------------------------------------------------------------- # + + +def delay_card_setting( + total_t: float, laser_period: float, fine_rollover: bool = False +) -> tuple[int, int]: + """Coarse and fine delay card values for a total delay, as the script's MoveDelayCard. + + The script computes ``FINE = int((t1 mod 2) / 0.009999)``, which is 200 (2.00 ns) when + ``t1 mod 2 >= 1.9998``. With ``fine_rollover`` such a value is written as the next coarse unit + and ``FINE = 0`` instead (the same delay, and FINE stays within 0..199). Without it the values + are the script's, bit for bit. + + Args: + total_t (float): ``-offset_t - t`` in ns. + laser_period (float): laser period in ns. + fine_rollover (bool): carry ``FINE = 200`` into the coarse value. + + Returns: + tuple[int, int]: ``(DELAY, FINE)``, in units of 2 ns and 0.01 ns. + """ + t1 = total_t % laser_period + coarse = int(t1 / DELAY_CARD_COARSE_UNIT) + fine = int((t1 % DELAY_CARD_COARSE_UNIT) / DELAY_CARD_FINE_DIVISOR) + if fine_rollover and fine >= DELAY_CARD_FINE_PER_COARSE: + coarse, fine = coarse + 1, fine - DELAY_CARD_FINE_PER_COARSE + if (coarse * DELAY_CARD_COARSE_UNIT) >= laser_period: + coarse = 0 # one full laser period later is the same delay + return coarse, fine + + +def delay_card_time(coarse: float, fine: float) -> float: + """Delay in ns currently set on the delay card (script: ``DelayCardT``).""" + return DELAY_CARD_COARSE_UNIT * coarse + DELAY_CARD_FINE_UNIT * fine + + +def phase_from_voltages(v1: float, v2: float, rf_period: float) -> float: + """Phase shifter delay in ns from the two voltages (script: ``GetPhaseT``), in [-rf/2, rf/2].""" + phi = np.arctan2(v2, v1) + return rf_period * phi / (2 * np.pi) + + +def voltages_for_phase(phase_t: float, rf_period: float, vmax: float) -> tuple[float, float]: + """V1 and V2 for a phase shifter delay in ns (script: ``SetVoltages``).""" + phi = 2 * np.pi * phase_t / rf_period + return vmax * np.cos(phi), vmax * np.sin(phi) + + +def phase_ramp_offsets(delta: float, rf_period: float, steps_per_rf_period: int) -> np.ndarray: + """Intermediate phase offsets (relative to the ramp start) of a phase shifter move. + + Same values as the script's ``arange(0, RelativeDelay, DelayStep)``. The final step to exactly + ``delta`` is not included. The script fails on ``delta == 0`` (numpy cannot size the range); + here the ramp is simply empty and only the final step is written. + """ + if delta == 0: + return np.empty(0) + step = np.sign(delta) * rf_period / steps_per_rf_period + return np.arange(0, delta, step) + + +def wrap_phase(value: float, rf_period: float) -> float: + """Wrap a phase difference in ns into [-rf/2, rf/2).""" + return (value + rf_period / 2) % rf_period - rf_period / 2 + + +def fold_phase_delta(delta: float, cavity_period: float) -> tuple[float, int]: + """Fold a relative phase move by whole cavity periods into (-cavity/2, +cavity/2]. + + Returns: + tuple[float, int]: ``(delta - k * cavity_period, k)``. A move that is already inside the + interval is returned unchanged (``k == 0``, bit for bit). + """ + half = cavity_period / 2 + if -half < delta <= half: + return delta, 0 + wraps = math.ceil((delta - half) / cavity_period) + return delta - wraps * cavity_period, int(wraps) + + +@dataclass(frozen=True) +class LaserDelayCalibration: + """Calibration of the delay, the four numbers of the script's ``Timing.offset`` file.""" + + laser_period: float + offset_t: float + offset_phase_t: float + phase_t: float + + def as_dict(self) -> dict[str, float]: + """Plain dictionary (RPC friendly).""" + return {key: float(value) for key, value in asdict(self).items()} + + def is_valid(self) -> bool: + """All four numbers finite and a positive laser period.""" + values = self.as_dict().values() + return all(math.isfinite(v) for v in values) and self.laser_period > 0 + + +_EMPTY_CALIBRATION = LaserDelayCalibration(math.nan, math.nan, math.nan, math.nan) + + +def _utc_now() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") + + +@dataclass(frozen=True) +class _Constants: + rf_period: float + cavity_period: float + vmax: float + steps_per_rf_period: int + step_sleep: float + phase_path: str + fine_rollover: bool + + @property + def step_ns(self) -> float: + """Size of one phase ramp step in ns.""" + return self.rf_period / self.steps_per_rf_period + + +@dataclass(frozen=True) +class PhasePlan: + """Phase shifter part of a move: ramp from ``start`` by ``delta``, then store ``stored_after``.""" + + target: float # the script's ForPhaseShifter, P + start: float # read back from V1/V2 + reference: float # what delta is measured from: the stored phase phase_t + delta: float # relative ramp, folded for phase_path "shortest" + cavity_wraps: int # k: stored_after = target - k * cavity_period (always 0 for "script") + offsets: np.ndarray # intermediate ramp offsets, without the final step to delta + stored_after: float # new phase_t: P, or P - cavity_wraps * cavity_period + + @property + def n_puts(self) -> int: + """V1/V2 pairs written, including the final one.""" + return len(self.offsets) + 1 + + +def plan_phase_move( + target_t: float, calibration: LaserDelayCalibration, constants: _Constants, start: float +) -> PhasePlan: + """Phase shifter ramp for a move to delay ``target_t``, starting at read-back phase ``start``. + + The relative move is computed from the stored (unwrapped) phase, as in the script. + """ + phase_target = (-target_t) % constants.cavity_period - calibration.offset_phase_t + reference = calibration.phase_t + delta = phase_target - reference + wraps = 0 + if constants.phase_path == "shortest": + delta, wraps = fold_phase_delta(delta, constants.cavity_period) + offsets = phase_ramp_offsets(delta, constants.rf_period, constants.steps_per_rf_period) + stored_after = phase_target - wraps * constants.cavity_period if wraps else phase_target + return PhasePlan( + target=phase_target, + start=start, + reference=reference, + delta=delta, + cavity_wraps=wraps, + offsets=offsets, + stored_after=stored_after, + ) + + +# --------------------------------------------------------------------------------------------- # +# Signals # +# --------------------------------------------------------------------------------------------- # + + +class CheckedSignal(Signal): + """Soft signal that validates every write, used for tunable constants and the soft limits. + + Writes come from the constructor, from runtime changes in the client + (``dev.laser_delay.step_sleep.set(0.15).wait()``, ``dev.laser_delay.limits = [-20, 20]``) and + from runtime ``deviceConfig`` updates, so a bad value is rejected instead of being used in the + next move. + + ``validator`` is a callable taking the value, or the name of a method of the parent device + taking the value (for checks that depend on the device class). + """ + + def __init__(self, *args, validator: Callable[[object], None] | str | None = None, **kwargs): + self._validator = validator + super().__init__(*args, **kwargs) + + def check_value(self, value): + super().check_value(value) + if isinstance(self._validator, str): + getattr(self.parent, self._validator)(value) + elif self._validator is not None: + self._validator(value) + + +class _DeviceWritesOnly: # pylint: disable=no-member + """Mixin for the hardware signals: only the parent device's own code may write them. + + A move ramps the phase shifter and keeps the stored phase in step with it; a direct write + (``dev.laser_delay.phase_v1.set(...)`` from a client) would bypass the ramp, the voltage limit + and the bookkeeping. The parent allows writes per thread (``LaserDelay._hardware_writes``). + Values are also range-checked (``value_range``) in every case. + """ + + def __init__(self, *args, value_range: tuple[float, float] | None = None, **kwargs): + self._value_limits = value_range + super().__init__(*args, **kwargs) + + def check_value(self, value): + super().check_value(value) + if self._value_limits is not None: + low, high = self._value_limits + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{self.name}: {value!r} is not a number") from exc + if not low <= number <= high: + raise ValueError(f"{self.name}: {number} is outside [{low}, {high}]") + + def put(self, value, *args, **kwargs): + parent = self.parent + if parent is not None and not parent.hardware_writes_allowed(): + raise ReadOnlyError( + f"{self.name} is written only by {parent.name} itself; move with " + f"umv(dev.{parent.name}, ) or a scan instead of writing it directly." + ) + self.check_value(value) + super().put(value, *args, **kwargs) + + +class _HardwareEpicsSignal(_DeviceWritesOnly, EpicsSignal): + """EPICS signal of the delay card or the phase shifter (written only by the device).""" + + +class ComputedPhaseSignal(SignalRO): + """Read-only signal whose value is computed by the parent device on every read. + + ``compute`` names a method of the parent that takes no arguments and returns a float. + """ + + def __init__(self, *args, compute: str, **kwargs): + self._compute = compute + super().__init__(*args, value=math.nan, **kwargs) + + def get(self, **kwargs): + value = float(getattr(self.parent, self._compute)()) + self._readback = value + self._metadata["timestamp"] = time.time() + return value + + +class _ConstructorOnly: + """Read-only attribute for a constructor-only ``deviceConfig`` key (PV names, storage). + + When a runtime ``deviceConfig`` update fails, BEC's device server re-applies the whole previous + ``deviceConfig`` with ``setattr``/``Signal.set`` per key. Without an attribute of that name the + rollback itself fails ("Unknown config parameter delay_coarse_pv") and hides the real error. + Writing the current value is a no-op; a new value needs a config reload. + """ + + def __init__(self): + self._key = "" + self._attr = "" + + def __set_name__(self, owner, name): + self._key = name + self._attr = f"_{name}" + + def __get__(self, obj, objtype=None): + if obj is None: + return self + return getattr(obj, self._attr) + + def __set__(self, obj, value): + if value != getattr(obj, self._attr): + raise ValueError( + f"{obj.name}: {self._key} can only be changed by reloading the device config " + f"(current {getattr(obj, self._attr)!r}, requested {value!r})" + ) + + +def _finite_number(name: str) -> Callable[[object], None]: + def check(value): + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be a number, got {value!r}") from exc + if not math.isfinite(number): + raise ValueError(f"{name} must be a finite number, got {value!r}") + + return check + + +def _positive_number(name: str) -> Callable[[object], None]: + def check(value): + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be a number, got {value!r}") from exc + if not math.isfinite(number) or number <= 0: + raise ValueError(f"{name} must be a positive finite number, got {value!r}") + + return check + + +def _phase_voltage_amplitude(value): + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"vmax must be a number, got {value!r}") from exc + if not math.isfinite(number) or not 0 < number <= PHASE_VOLTAGE_LIMIT: + raise ValueError( + f"vmax must be above 0 and at most the phase shifter hard limit of " + f"{PHASE_VOLTAGE_LIMIT} V, got {value!r}" + ) + + +def _steps_per_rf_period(value): + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"steps_per_rf_period must be an integer, got {value!r}") from exc + if isinstance(value, bool) or not number.is_integer() or number < MIN_STEPS_PER_RF_PERIOD: + raise ValueError( + f"steps_per_rf_period must be an integer of at least {MIN_STEPS_PER_RF_PERIOD} (a " + f"phase step of at most 1/{MIN_STEPS_PER_RF_PERIOD} of the RF period), got {value!r}" + ) + + +def _boolean(name: str) -> Callable[[object], None]: + def check(value): + if not isinstance(value, (bool, np.bool_)): + raise ValueError(f"{name} must be True or False, got {value!r}") + + return check + + +def _phase_path(value): + if value not in PHASE_PATHS: + raise ValueError(f"phase_path must be one of {PHASE_PATHS}, got {value!r}") + + +# --------------------------------------------------------------------------------------------- # +# Calibration storage (kept across BEC restarts) # +# --------------------------------------------------------------------------------------------- # + + +class _FileCalibrationStore: + """Calibration kept in a JSON file on the device server host (like ``Timing.offset``).""" + + def __init__(self, path: str): + self.path = Path(os.path.expanduser(path)) + + def __str__(self) -> str: + return str(self.path) + + def load(self) -> dict | None: + """Saved content, or None if there is no file.""" + if not self.path.exists(): + return None + return json.loads(self.path.read_text(encoding="utf-8")) + + def save(self, content: dict) -> None: + """Write atomically (temporary file in the same directory, fsync, rename).""" + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_name(f".{self.path.name}.{os.getpid()}.{threading.get_ident()}.tmp") + try: + with open(tmp, "w", encoding="utf-8") as handle: + handle.write(json.dumps(content, indent=2)) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, self.path) + finally: + if tmp.exists(): + tmp.unlink() + + def destroy(self) -> None: + """Nothing to release.""" + + +# --------------------------------------------------------------------------------------------- # +# Device # +# --------------------------------------------------------------------------------------------- # + + +class LaserDelay(PSIDeviceBase, PositionerBase): + """Scannable laser delay (ns) at SuperXAS: delay card plus slowly ramped RF phase shifter. + + Usage (BEC client):: + + dev.laser_delay.calibrate(546.04 - 546.92) # scope: X-ray minus laser, after laser restart + scans.mv(dev.laser_delay, 1.2, relative=False) + scans.line_scan(dev.laser_delay, -5, 5, steps=21, exp_time=1, relative=False) + + A move writes the delay card, then ramps the phase shifter voltages (up to a few hundred steps + of ``step_sleep`` seconds). It runs in a background task; ``stop()`` interrupts the ramp between + two steps and leaves the voltages at the last written step. + + Safety checks: the delay card and the phase shifter are written only by the device; voltages + above 5 V are never written; a move is refused when the phase shifter amplitude or phase does + not match the device's bookkeeping, or when a restored calibration could not be verified; every + write is read back. + + Args: + name (str): device name. + prefix (str): unused, kept for the common device signature. PV names are given in full. + delay_coarse_pv (str): delay card coarse PV (units of 2 ns). + delay_fine_pv (str): delay card fine PV (units of 0.01 ns). + evr_period_pv (str): event receiver laser trigger period PV (µs). + phase_v1_pv (str): phase shifter voltage V1 (cos) PV. + phase_v2_pv (str): phase shifter voltage V2 (sin) PV. + rf_period (float): RF period in ns (script ``RFPeriod``). + cavity_period (float): laser cavity period in ns (script ``CavityPeriod``, Duetto). + vmax (float): phase shifter voltage amplitude (script ``Vmax``), at most 5 V. + steps_per_rf_period (int): phase ramp steps per RF period (script ``NumStepsPerPeriod``), + at least 36. + step_sleep (float): seconds between two phase ramp steps, at least ``MIN_STEP_SLEEP``. + phase_path (str): ``"script"`` (default, script behaviour) or ``"shortest"``: fold the + relative phase move into half a cavity period either way. Opt-in, see module docstring. + fine_rollover (bool): write a fine value of 200 as the next coarse unit and 0 (default). + False writes 200 like the script. + calibration_file (str | None): JSON file on the device server host that keeps the + calibration across BEC restarts. None: the calibration is lost at every restart. + limits (list[float] | None): optional soft limits ``[low, high]`` in ns. + """ + + USER_ACCESS = [ + "calibrate", + "set_calibration", + "get_calibration", + "get_calibration_info", + "plan_move", + ] + + #: Shortest allowed time between two phase ramp steps in s: the value the scientist tested. A + #: faster ramp needs their OK and a change here (the simulation overrides it). + MIN_STEP_SLEEP = 0.1 + #: A move or calibration is refused when hypot(V1, V2) differs from vmax by more than this. + AMPLITUDE_TOLERANCE = 0.1 + #: Read-back tolerance for the written phase shifter voltages, in V. + VOLTAGE_READBACK_TOLERANCE = 1e-3 + + # Readback: last commanded delay (named like the device, hinted) + readback = Cpt( + InternalSignal, value=math.nan, kind=Kind.hinted, doc="Last commanded delay in ns" + ) + motor_is_moving = Cpt(InternalSignal, value=0, kind=Kind.omitted, doc="1 while a move runs") + + # Hardware (written only by the device itself) + delay_coarse = FCpt( + _HardwareEpicsSignal, + "{self._delay_coarse_pv}", + kind=Kind.normal, + value_range=(0, 65535), + doc="Delay card, units of 2 ns", + ) + delay_fine = FCpt( + _HardwareEpicsSignal, + "{self._delay_fine_pv}", + kind=Kind.normal, + value_range=(0, DELAY_CARD_FINE_PER_COARSE), + doc="Delay card, units of 0.01 ns", + ) + evr_period = FCpt( + EpicsSignalRO, "{self._evr_period_pv}", kind=Kind.config, doc="EVR laser period in µs" + ) + phase_v1 = FCpt( + _HardwareEpicsSignal, + "{self._phase_v1_pv}", + kind=Kind.normal, + value_range=(-PHASE_VOLTAGE_LIMIT, PHASE_VOLTAGE_LIMIT), + doc="Phase shifter V1", + ) + phase_v2 = FCpt( + _HardwareEpicsSignal, + "{self._phase_v2_pv}", + kind=Kind.normal, + value_range=(-PHASE_VOLTAGE_LIMIT, PHASE_VOLTAGE_LIMIT), + doc="Phase shifter V2", + ) + + # Derived from the hardware on every read + phase_readback = Cpt( + ComputedPhaseSignal, + compute="read_phase", + kind=Kind.normal, + doc="Phase shifter delay in ns from V1/V2, wrapped to one RF period", + ) + phase_mismatch = Cpt( + ComputedPhaseSignal, + compute="read_phase_mismatch", + kind=Kind.normal, + doc="Stored minus read-back phase in ns, modulo the RF period (0 when consistent)", + ) + + # Calibration (script: Timing.offset). Written by calibrate()/set_calibration() and by moves. + laser_period = Cpt(InternalSignal, value=math.nan, kind=Kind.config, doc="Laser period in ns") + offset_t = Cpt(InternalSignal, value=math.nan, kind=Kind.config, doc="Delay card offset in ns") + offset_phase_t = Cpt( + InternalSignal, value=math.nan, kind=Kind.config, doc="Phase shifter offset in ns" + ) + phase_t = Cpt( + InternalSignal, + value=math.nan, + kind=Kind.config, + doc="Stored (unwrapped) phase shifter delay in ns", + ) + + # Tunable constants (deviceConfig). Runtime changes (dev.laser_delay.step_sleep.set(0.15)) apply + # to the next move. + rf_period = Cpt( + CheckedSignal, value=2.0, kind=Kind.config, validator=_positive_number("rf_period") + ) + cavity_period = Cpt( + CheckedSignal, value=12.01, kind=Kind.config, validator=_positive_number("cavity_period") + ) + vmax = Cpt(CheckedSignal, value=1.0, kind=Kind.config, validator=_phase_voltage_amplitude) + steps_per_rf_period = Cpt( + CheckedSignal, value=36, kind=Kind.config, validator=_steps_per_rf_period + ) + step_sleep = Cpt(CheckedSignal, value=0.1, kind=Kind.config, validator="_check_step_sleep") + phase_path = Cpt(CheckedSignal, value="script", kind=Kind.config, validator=_phase_path) + fine_rollover = Cpt( + CheckedSignal, value=True, kind=Kind.config, validator=_boolean("fine_rollover") + ) + + # Soft limits (BEC publishes them as the device limits). Equal values mean no limits. + low_limit_travel = Cpt( + CheckedSignal, value=0.0, kind=Kind.omitted, validator=_finite_number("low limit") + ) + high_limit_travel = Cpt( + CheckedSignal, value=0.0, kind=Kind.omitted, validator=_finite_number("high limit") + ) + + # Constructor-only deviceConfig keys, see _ConstructorOnly + delay_coarse_pv = _ConstructorOnly() + delay_fine_pv = _ConstructorOnly() + evr_period_pv = _ConstructorOnly() + phase_v1_pv = _ConstructorOnly() + phase_v2_pv = _ConstructorOnly() + calibration_file = _ConstructorOnly() + + # pylint: disable=too-many-arguments,too-many-locals + def __init__( + self, + prefix: str = "", + *, + name: str, + delay_coarse_pv: str = "X10DA-ES2-TIM:SET1-DELAY", + delay_fine_pv: str = "X10DA-ES2-TIM:SET1-FINE", + evr_period_pv: str = "X10DA-ES2-TIM:EVR-FPS2", + phase_v1_pv: str = "X10DA-ES4:AO-02", + phase_v2_pv: str = "X10DA-ES4:AO-01", + rf_period: float = 2.0, + cavity_period: float = 12.01, + vmax: float = 1.0, + steps_per_rf_period: int = 36, + step_sleep: float = 0.1, + phase_path: str = "script", + fine_rollover: bool = True, + calibration_file: str | None = None, + limits: list[float] | tuple[float, float] | None = None, + scan_info: ScanInfo | None = None, + device_manager: DeviceManagerBase | None = None, + **kwargs, + ): + self._delay_coarse_pv = delay_coarse_pv + self._delay_fine_pv = delay_fine_pv + self._evr_period_pv = evr_period_pv + self._phase_v1_pv = phase_v1_pv + self._phase_v2_pv = phase_v2_pv + self._calibration_file = calibration_file + self._move_lock = threading.RLock() # guards _move_status and _move_stop + self._hw_lock = threading.Lock() # held by the move task and by calibrate/set_calibration + self._hw_permit = threading.local() # per-thread permission to write the hardware signals + self._move_stop = threading.Event() # one event per move, set to interrupt that move + self._move_status: StatusBase | None = None + self._calibration_meta: dict = {} # calibrated_at, scope value of the last calibration + self._unverified: list[str] = [] # reasons the calibration may not match the hardware + super().__init__( + prefix=prefix, name=name, scan_info=scan_info, device_manager=device_manager, **kwargs + ) + self.readback.name = self.name + self._position = math.nan + self._store: _FileCalibrationStore | None = None + if calibration_file: + self._store = _FileCalibrationStore(calibration_file) + # Constants: validated by CheckedSignal.check_value, a bad value fails here. + self.rf_period.put(float(rf_period)) + self.cavity_period.put(float(cavity_period)) + self.vmax.put(float(vmax)) + self.steps_per_rf_period.put(steps_per_rf_period) + self.step_sleep.put(float(step_sleep)) + self.phase_path.put(phase_path) + self.fine_rollover.put(fine_rollover) + if limits is not None: + if len(limits) != 2: + raise ValueError(f"limits must be [low, high], got {limits!r}") + low, high = float(limits[0]), float(limits[1]) + if low > high: + raise ValueError(f"limits must be [low, high] with low <= high, got {limits!r}") + self.low_limit_travel.put(low) + self.high_limit_travel.put(high) + + # ----------------------------------------------------------------------------------------- # + # Positioner interface # + # ----------------------------------------------------------------------------------------- # + + @property + def egu(self) -> str: + """Engineering unit of the position.""" + return "ns" + + @property + def limits(self) -> tuple[float, float]: + """Soft limits (low, high). Equal values mean no limits.""" + return (float(self.low_limit_travel.get()), float(self.high_limit_travel.get())) + + @property + def moving(self) -> bool: + """True while a move runs (also while a stopped move finishes its current step).""" + with self._move_lock: + pending = self._move_status is not None and not self._move_status.done + return pending or self._hw_lock.locked() + + def check_value(self, value) -> None: + """Check that the requested delay is a finite number within the soft limits.""" + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{self.name}: delay must be a number, got {value!r}") from exc + if not math.isfinite(number): + raise ValueError(f"{self.name}: delay must be finite, got {value!r}") + low, high = self.limits + if low > high: + raise LimitError( + f"{self.name}: the limits [{low}, {high}] are inverted; set them again with " + f"dev.{self.name}.limits = [low, high] (equal values mean no limits)" + ) + if low < high and not low <= number <= high: + raise LimitError(f"{self.name}: {number} ns is outside the limits [{low}, {high}]") + + # pylint: disable=arguments-differ + def move(self, position, moved_cb=None, timeout: float | None = None, wait: bool = False): + """Move to a delay ``position`` (ns). Returns a status that finishes when the ramp is done. + + Raises: + LaserDelayNotCalibratedError: if the device is not calibrated, or a restored + calibration could not be verified. + + A move requested while another one runs is not raised: the returned status is already + failed with ``LaserDelayBusyError`` and the running move goes on. BEC's device server turns + an exception from ``set()`` into a status whose failure calls ``stop()`` (ophyd + ``DeviceStatus._handle_failure``), which would end the move that is running. + """ + with self._move_lock: + if self._move_status is not None and not self._move_status.done: + status = self._refused( + LaserDelayBusyError( + f"{self.name} is still moving; wait for the move to finish or stop it first." + ), + position, + ) + if wait: + status.wait(timeout=timeout) + return status + self.check_value(position) + target = float(position) + self._require_calibration() # refuse early; the task takes its own snapshot later + stop_event = threading.Event() + status = DeviceStatus( + self, timeout=timeout, description=f"{self.name}: move to {target} ns" + ) + # Any failure of the status (stop, timeout) also ends the ramp. + status.add_callback(functools.partial(self._on_move_status_done, stop_event)) + if moved_cb is not None: + status.add_callback(functools.partial(moved_cb, obj=self)) + self._move_status = status + self._move_stop = stop_event + self.motor_is_moving.put(1, internal=True) + self.task_handler.submit_task(self._move_task, task_args=(target, status, stop_event)) + if wait: + status.wait(timeout=timeout) + return status + + ######################################## + # Beamline Specific Implementations # + ######################################## + + def on_init(self) -> None: + """ + Called when the device is initialized. + + No signals are connected at this point. If you like to + set default values on signals, please use on_connected instead. + """ + + def on_connected(self) -> None: + """ + Called after the device is connected and its signals are connected. + Default values for signals should be set here. + """ + # Restore the calibration from the store (the file), see _load_calibration. Never + # raises: the device connects uncalibrated if the restore fails. + try: + self._load_calibration() + except Exception as exc: # pylint: disable=broad-except + logger.error( + f"{self.name}: could not restore the calibration from {self._store}: {exc}. " + f"Calibrate again with dev.{self.name}.calibrate()." + ) + + def on_stage(self) -> DeviceStatus | StatusBase | None: + """ + Called while staging the device. + + Information about the upcoming scan can be accessed from the scan_info (self.scan_info.msg) object. + """ + + def on_unstage(self) -> DeviceStatus | StatusBase | None: + """Called while unstaging the device.""" + + def on_pre_scan(self) -> DeviceStatus | StatusBase | None: + """Called right before the scan starts on all devices automatically.""" + + def on_trigger(self) -> DeviceStatus | StatusBase | None: + """Called when the device is triggered.""" + + def on_complete(self) -> DeviceStatus | StatusBase | None: + """Called to inquire if a device has completed a scans.""" + + def on_kickoff(self) -> DeviceStatus | StatusBase | None: + """Called to kickoff a device for a fly scan. Has to be called explicitly.""" + + def on_stop(self) -> None: + """Called when the device is stopped.""" + # Interrupt a running phase ramp between two steps and fail its status. + self._interrupt_move(DeviceStoppedError(f"Device {self.name} has been stopped")) + + def on_destroy(self) -> None: + """Called when the device is destroyed. Cleanup resources here.""" + # Fail the move status before the base class kills the task: the kill can land anywhere + # in the task, including after the ramp but before the status is resolved, so whoever + # waits on it (a scan, umv) is released here. + self._interrupt_move(DeviceStoppedError(f"Device {self.name} has been destroyed")) + if self._store is not None: + self._store.destroy() + + ######################################## + # Helper Methods # + ######################################## + + # ----------------------------------------------------------------------------------------- # + # User access # + # ----------------------------------------------------------------------------------------- # + + def calibrate(self, scope_xray_minus_laser: float) -> dict[str, float]: + """Calibrate from an oscilloscope reading (the script's ``CurrentSituation``). + + Run after every laser restart, with the laser and X-ray signals on the scope: + ``dev.laser_delay.calibrate(546.04 - 546.92)``. + + Args: + scope_xray_minus_laser (float): X-ray minus laser time on the scope, in ns. + + Returns: + dict: the new calibration (``laser_period``, ``offset_t``, ``offset_phase_t``, + ``phase_t``). + + Raises: + LaserDelayPersistenceError: the calibration was applied but could not be saved. + """ + s = float(scope_xray_minus_laser) + if not math.isfinite(s): + raise ValueError( + f"{self.name}: scope reading must be finite, got {scope_xray_minus_laser!r}" + ) + with self._exclusive_hardware("calibrate"): + result = self._calibrate(s) + self._save_calibration(raise_on_error=True) + return result + + def _calibrate(self, s: float) -> dict[str, float]: + constants = self._constants() + # Same reading order as the script: EVR, DELAY, FINE, V1, V2. + laser_period = 1000 * self.evr_period.get() + if not math.isfinite(laser_period) or laser_period <= 0: + raise ValueError( + f"{self.name}: laser period from {self._evr_period_pv} is {laser_period} ns; " + "is the laser triggered by the event receiver?" + ) + dt1 = self.delay_coarse.get() + dt2 = self.delay_fine.get() + delay_card_t = delay_card_time(dt1, dt2) + v1 = self.phase_v1.get() + v2 = self.phase_v2.get() + problem = self._amplitude_problem(v1, v2, constants) + if problem: + raise LaserDelayHardwareError(f"{self.name}: cannot calibrate: {problem}") + phase_t = phase_from_voltages(v1, v2, constants.rf_period) + ph = s % constants.cavity_period + offset_phase_t = -phase_t - ph + offset_t = -delay_card_t - s + calibration = LaserDelayCalibration( + laser_period=float(laser_period), + offset_t=float(offset_t), + offset_phase_t=float(offset_phase_t), + phase_t=float(phase_t), + ) + self._calibration_meta = {"calibrated_at": _utc_now(), "scope_xray_minus_laser": s} + self._unverified = [] + self._store_calibration(calibration, current_delay=s) + logger.info( + f"{self.name}: calibrated with scope X-ray minus laser = {s} ns: " + f"{calibration.as_dict()}" + ) + return calibration.as_dict() + + def set_calibration( + self, + laser_period: float, + offset_t: float, + offset_phase_t: float, + phase_t: float, + current_delay: float | None = None, + ) -> dict[str, float]: + """Set the calibration directly, e.g. from the script's ``Timing.offset``. + + The argument order is the order of the script's ``Timing.offset`` file, so + ``set_calibration(*[float(x) for x in numpy.loadtxt("Timing.offset")])`` takes over the + script's state. ``phase_t`` must be the stored phase of the last move (``get_calibration()`` + after that move), not a fresh read-back: the voltages only know the phase modulo one RF + period. The next move still checks that the phase shifter matches ``phase_t``. + + Args: + laser_period (float): laser period in ns. + offset_t (float): delay card offset in ns. + offset_phase_t (float): phase shifter offset in ns. + phase_t (float): stored phase shifter delay in ns. + current_delay (float | None): the delay the hardware is at, shown as readback. + NaN if None. + + Raises: + LaserDelayPersistenceError: the calibration was applied but could not be saved. + """ + values = { + "laser_period": laser_period, + "offset_t": offset_t, + "offset_phase_t": offset_phase_t, + "phase_t": phase_t, + } + for key, value in values.items(): + if not math.isfinite(float(value)): + raise ValueError(f"{self.name}: {key} must be finite, got {value!r}") + if float(laser_period) <= 0: + raise ValueError(f"{self.name}: laser_period must be positive, got {laser_period!r}") + calibration = LaserDelayCalibration(**{key: float(val) for key, val in values.items()}) + delay = math.nan if current_delay is None else float(current_delay) + with self._exclusive_hardware("set_calibration"): + self._calibration_meta = {"calibrated_at": _utc_now(), "scope_xray_minus_laser": None} + self._unverified = [] + self._store_calibration(calibration, current_delay=delay) + self._save_calibration(raise_on_error=True) + logger.info(f"{self.name}: calibration set to {calibration.as_dict()}") + return calibration.as_dict() + + def get_calibration(self) -> dict[str, float]: + """Current calibration (NaN values when not calibrated).""" + return self._calibration().as_dict() + + def get_calibration_info(self) -> dict: + """Calibration with its origin and state. + + Returns: + dict: ``calibration`` (as ``get_calibration``), ``current_delay``, ``calibrated_at`` + (UTC, ISO 8601), ``age_days``, ``scope_xray_minus_laser`` (None after + ``set_calibration``), ``verified`` (False after a restore that could not be checked + against the hardware: moves are refused), ``unverified_reasons`` and ``storage`` (the + file that keeps it across restarts, None if not kept). + """ + calibrated_at = self._calibration_meta.get("calibrated_at") + age_days = None + if calibrated_at: + try: + age = time.time() - datetime.datetime.fromisoformat(calibrated_at).timestamp() + age_days = round(age / 86400, 2) + except ValueError: + age_days = None + return { + "calibration": self.get_calibration(), + "current_delay": float(self.readback.get()), + "calibrated_at": calibrated_at, + "age_days": age_days, + "scope_xray_minus_laser": self._calibration_meta.get("scope_xray_minus_laser"), + "verified": self._calibration().is_valid() and not self._unverified, + "unverified_reasons": list(self._unverified), + "storage": str(self._store) if self._store is not None else None, + } + + def plan_move(self, position: float) -> dict: + """Dry run of a move: what would be written, without writing anything. + + Reads V1/V2 for the ramp start. Useful to compare with the script before the first move, + and to see how long a move takes with the current ``phase_path``. + + Returns: + dict: ``delay_coarse``, ``delay_fine``, ``phase_target`` (the script's target ``P``), + ``phase_start``, ``phase_delta`` (the ramp actually done), ``phase_path``, + ``cavity_wraps`` (``k``: the stored phase after the move is ``P - k * cavity_period``; + always 0 for ``phase_path="script"``, counts the folds since the calibration for + ``"shortest"``), ``wrap_offset_ns`` (``-k * cavity_period`` modulo the RF period: how + far the phase shifter ends from where the script would put it; 0.01 ns per fold with + the script's 2.0/12.01 ns), ``phase_steps`` (puts of V1/V2 incl. the last one), + ``estimated_duration_s`` and ``refused`` (why the move would be refused, or None). + """ + target = float(position) + self.check_value(target) + calibration = self._require_calibration() + constants = self._constants() + coarse, fine = delay_card_setting( + -calibration.offset_t - target, calibration.laser_period, constants.fine_rollover + ) + v1, v2 = self.phase_v1.get(), self.phase_v2.get() + start = float(phase_from_voltages(v1, v2, constants.rf_period)) + plan = plan_phase_move(target, calibration, constants, start=start) + refused = self._amplitude_problem(v1, v2, constants) or self._phase_problem( + calibration, start, constants + ) + return { + "target": target, + "delay_coarse": coarse, + "delay_fine": fine, + "phase_target": float(plan.target), + "phase_start": float(plan.start), + "phase_delta": float(plan.delta), + "phase_path": constants.phase_path, + "cavity_wraps": plan.cavity_wraps, + "wrap_offset_ns": self._wrap_offset(plan.cavity_wraps, constants), + "phase_steps": plan.n_puts, + "estimated_duration_s": plan.n_puts * constants.step_sleep, + "refused": refused, + } + + @staticmethod + def _wrap_offset(cavity_wraps: int, constants: _Constants) -> float: + """Phase shifter offset in ns, modulo the RF period, left by folding whole cavity periods.""" + if cavity_wraps == 0: + return 0.0 + return float(wrap_phase(-cavity_wraps * constants.cavity_period, constants.rf_period)) + + # ----------------------------------------------------------------------------------------- # + # Readbacks used by the computed signals # + # ----------------------------------------------------------------------------------------- # + + def read_phase(self) -> float: + """Phase shifter delay in ns read back from V1/V2 (wrapped to one RF period).""" + return self._read_phase(float(self.rf_period.get())) + + def _read_phase(self, rf_period: float) -> float: + v1 = self.phase_v1.get() + v2 = self.phase_v2.get() + return float(phase_from_voltages(v1, v2, rf_period)) + + def read_phase_mismatch(self) -> float: + """Stored minus read-back phase in ns, wrapped to [-rf/2, rf/2). NaN if uncalibrated.""" + stored = float(self.phase_t.get()) + if not math.isfinite(stored): + return math.nan + rf_period = float(self.rf_period.get()) + return float(wrap_phase(stored - self.read_phase(), rf_period)) + + # ----------------------------------------------------------------------------------------- # + # Hardware write permission # + # ----------------------------------------------------------------------------------------- # + + def hardware_writes_allowed(self) -> bool: + """True in a thread that is inside ``_hardware_writes`` (the device's own code).""" + return getattr(self._hw_permit, "depth", 0) > 0 + + @contextlib.contextmanager + def _hardware_writes(self): + """Allow this thread to write the delay card and phase shifter signals.""" + self._hw_permit.depth = getattr(self._hw_permit, "depth", 0) + 1 + try: + yield + finally: + self._hw_permit.depth -= 1 + + # ----------------------------------------------------------------------------------------- # + # Validators # + # ----------------------------------------------------------------------------------------- # + + def _check_step_sleep(self, value) -> None: + minimum = type(self).MIN_STEP_SLEEP + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"step_sleep must be a number, got {value!r}") from exc + if not math.isfinite(number) or number < minimum: + raise ValueError(f"step_sleep must be at least {minimum} s, got {value!r}") + + def _amplitude_problem(self, v1: float, v2: float, constants: _Constants) -> str | None: + """Why the phase shifter amplitude does not allow a ramp, or None.""" + amplitude = math.hypot(v1, v2) + if abs(amplitude - constants.vmax) > self.AMPLITUDE_TOLERANCE * constants.vmax: + return ( + f"phase shifter amplitude hypot(V1, V2) = {amplitude:.3f} V but vmax is " + f"{constants.vmax} V (ES4 IOC rebooted, voltages changed by hand, or vmax " + f"changed?). The first ramp step would jump the voltages. Restore V1/V2 to " + f"amplitude {constants.vmax} V outside BEC, or set vmax to the amplitude in use, " + f"then calibrate again." + ) + return None + + @staticmethod + def _phase_problem( + calibration: LaserDelayCalibration, start: float, constants: _Constants + ) -> str | None: + """Why the phase shifter does not match the stored phase, or None.""" + mismatch = wrap_phase(calibration.phase_t - start, constants.rf_period) + if abs(mismatch) > constants.step_ns: + return ( + f"the phase shifter is {mismatch:+.4f} ns (modulo the RF period) away from the " + f"stored phase, more than one ramp step ({constants.step_ns:.4f} ns). It was " + f"turned outside BEC, or a move was interrupted without bookkeeping. Calibrate " + f"again with the scope." + ) + return None + + # ----------------------------------------------------------------------------------------- # + # Internals # + # ----------------------------------------------------------------------------------------- # + + def _interrupt_move(self, exc: Exception) -> None: + with self._move_lock: + self._move_stop.set() + status = self._move_status + if status is not None: + self._finish_status(status, exc) + + def _constants(self) -> _Constants: + return _Constants( + rf_period=float(self.rf_period.get()), + cavity_period=float(self.cavity_period.get()), + vmax=float(self.vmax.get()), + steps_per_rf_period=int(self.steps_per_rf_period.get()), + step_sleep=float(self.step_sleep.get()), + phase_path=str(self.phase_path.get()), + fine_rollover=bool(self.fine_rollover.get()), + ) + + def _calibration(self) -> LaserDelayCalibration: + return LaserDelayCalibration( + laser_period=float(self.laser_period.get()), + offset_t=float(self.offset_t.get()), + offset_phase_t=float(self.offset_phase_t.get()), + phase_t=float(self.phase_t.get()), + ) + + def _require_calibration(self) -> LaserDelayCalibration: + calibration = self._calibration() + if not calibration.is_valid(): + raise LaserDelayNotCalibratedError( + f"{self.name} is not calibrated. Run dev.{self.name}.calibrate() after the laser is on, or restore a previous calibration with " + f"dev.{self.name}.set_calibration(...). Current calibration: " + f"{calibration.as_dict()}" + ) + if self._unverified: + raise LaserDelayNotCalibratedError( + f"{self.name}: the calibration does not match the hardware: " + f"{'; '.join(self._unverified)}. Moves are refused until " + f"dev.{self.name}.calibrate()." + ) + return calibration + + @contextlib.contextmanager + def _exclusive_hardware(self, action: str): + """Hold the hardware lock for a calibration, refusing if a move runs or is queued. + + BEC's device server handles instructions on a thread pool, so a ``set`` can arrive while + ``calibrate`` reads the PVs. Holding the lock keeps a move from writing the PVs that are + being read, and makes a move that arrives meanwhile run after the calibration, with it. + """ + if not self._hw_lock.acquire(blocking=False): + raise LaserDelayBusyError(f"{self.name}: cannot {action} while a move is running.") + try: + with self._move_lock: + pending = self._move_status is not None and not self._move_status.done + if pending: + raise LaserDelayBusyError(f"{self.name}: cannot {action} while a move is running.") + yield + finally: + self._hw_lock.release() + + def _store_calibration(self, calibration: LaserDelayCalibration, current_delay: float) -> None: + self.laser_period.put(calibration.laser_period, internal=True) + self.offset_t.put(calibration.offset_t, internal=True) + self.offset_phase_t.put(calibration.offset_phase_t, internal=True) + self.phase_t.put(calibration.phase_t, internal=True) + self._update_readback(current_delay) + + def _update_readback(self, value: float) -> None: + self.readback.put(float(value), internal=True) + self._set_position(float(value)) # runs the "readback" subscription (BEC update) + + def _mark_unverified(self, reason: str) -> None: + if reason not in self._unverified: + self._unverified.append(reason) + + # ----------------------------------------------------------------------------------------- # + # Calibration storage # + # ----------------------------------------------------------------------------------------- # + + def _extra_saved_state(self) -> dict: + """More content for the calibration file (used by the simulation).""" + return {} + + def _save_calibration( + self, move_in_progress: bool = False, raise_on_error: bool = False + ) -> bool: + """Write calibration, readback and the move marker to the store. + + Returns False (and logs an error) if the store could not be written; with + ``raise_on_error`` a ``LaserDelayPersistenceError`` is raised instead. + """ + if self._store is None: + return True + calibration = self._calibration() + if not calibration.is_valid(): + return True + readback = float(self.readback.get()) + content = { + "device": self.name, + "saved_at": _utc_now(), + **self._calibration_meta, + "calibration": calibration.as_dict(), + "constants": { + "rf_period": float(self.rf_period.get()), + "cavity_period": float(self.cavity_period.get()), + "vmax": float(self.vmax.get()), + }, + "current_delay": readback if math.isfinite(readback) else None, + "move_in_progress": move_in_progress, + **self._extra_saved_state(), + } + try: + self._store.save(content) + except Exception as exc: # pylint: disable=broad-except + message = ( + f"{self.name}: could not save the calibration to {self._store}: {exc}. It is " + f"active, but will be lost at the next restart." + ) + logger.error(message) + if raise_on_error: + raise LaserDelayPersistenceError(message) from exc + return False + return True + + def _read_saved_calibration(self) -> dict | None: + if self._store is None: + return None + return self._store.load() + + def _load_calibration(self) -> None: + """Restore the calibration from the store when the device connects. + + The four numbers are restored as they were saved. The calibration is *verified* only if the + last move finished, the laser period still matches the EVR, the phase shifter amplitude is + right, its phase matches the stored phase (modulo the RF period, within one step), the delay + card still shows the saved delay and ``rf_period``/``cavity_period``/``vmax`` are unchanged. + Otherwise moves are refused until the next ``calibrate``. The card check matters because + the phase check cannot see a change by whole RF periods; only a BEC move changes the card, + and that saves the marker first. The readback (the delay the hardware is at) is restored + only with a verified calibration. + """ + content = self._read_saved_calibration() + if content is None: + return + calibration = LaserDelayCalibration( + **{key: float(content["calibration"][key]) for key in asdict(_EMPTY_CALIBRATION)} + ) + if not calibration.is_valid(): + raise ValueError(f"not a valid calibration: {calibration.as_dict()}") + constants = self._constants() + reasons = [] + saved_constants = content.get("constants") or {} + for key in ("rf_period", "cavity_period", "vmax"): + if key in saved_constants and not math.isclose( + float(saved_constants[key]), getattr(constants, key), rel_tol=1e-9, abs_tol=1e-9 + ): + reasons.append( + f"{key} was {saved_constants[key]} at the calibration and is " + f"{getattr(constants, key)} now" + ) + if content.get("move_in_progress"): + reasons.append("the device server stopped during a move (the phase is unknown)") + evr_period = 1000 * float(self.evr_period.get()) + if not math.isclose(evr_period, calibration.laser_period, rel_tol=1e-9, abs_tol=1e-6): + reasons.append( + f"the laser period changed from {calibration.laser_period} to {evr_period} ns" + ) + v1, v2 = float(self.phase_v1.get()), float(self.phase_v2.get()) + problem = self._amplitude_problem(v1, v2, constants) + if problem is None: + start = float(phase_from_voltages(v1, v2, constants.rf_period)) + problem = self._phase_problem(calibration, start, constants) + if problem: + reasons.append(problem) + saved_delay = content.get("current_delay") + delay = math.nan if saved_delay is None else float(saved_delay) + if math.isfinite(delay): + expected = delay_card_setting( + -calibration.offset_t - delay, calibration.laser_period, constants.fine_rollover + ) + actual = (self.delay_coarse.get(), self.delay_fine.get()) + if not math.isclose( + delay_card_time(*expected), delay_card_time(*actual), abs_tol=0.005 + ): + reasons.append( + f"the delay card reads DELAY={actual[0]}, FINE={actual[1]} instead of " + f"DELAY={expected[0]}, FINE={expected[1]} for the saved delay {delay} ns (the " + f"delay was changed outside BEC?)" + ) + if reasons: + delay = math.nan + with self._exclusive_hardware("restore the calibration"): + self._calibration_meta = { + "calibrated_at": content.get("calibrated_at"), + "scope_xray_minus_laser": content.get("scope_xray_minus_laser"), + } + self._unverified = list(reasons) + self._store_calibration(calibration, current_delay=delay) + info = self.get_calibration_info() + summary = ( + f"{self.name}: restored the calibration from {self._store} (calibrated " + f"{info['calibrated_at']}): {calibration.as_dict()}, current delay {delay} ns" + ) + if reasons: + logger.warning( + f"{summary}. NOT VERIFIED, moves are refused until " + f"dev.{self.name}.calibrate(): {'; '.join(reasons)}" + ) + else: + logger.info(summary) + if info["age_days"] is not None and info["age_days"] * 86400 > CALIBRATION_MAX_AGE_S: + logger.warning( + f"{self.name}: the restored calibration is {info['age_days']} days old; it is " + "usually redone about once a week with the oscilloscope." + ) + + # ----------------------------------------------------------------------------------------- # + # Move # + # ----------------------------------------------------------------------------------------- # + + def _refused(self, exc: Exception, position) -> StatusBase: + """An already failed move status; ``call_stop_on_failure=False`` as nothing was started.""" + status = DeviceStatus( + self, + call_stop_on_failure=False, + description=f"{self.name}: move to {position!r} ns refused", + ) + status.set_exception(exc) + return status + + @staticmethod + def _on_move_status_done(stop_event: threading.Event, status: StatusBase) -> None: + if not status.success: + stop_event.set() + + @staticmethod + def _finish_status(status: StatusBase, exc: Exception | None = None) -> None: + if status.done: + return + try: + if exc is None: + status.set_finished() + else: + status.set_exception(exc) + except InvalidState: + pass # resolved concurrently (stop vs. end of move) + + def _write_phase(self, phase_t: float, constants: _Constants) -> None: + v1, v2 = voltages_for_phase(phase_t, constants.rf_period, constants.vmax) + if max(abs(v1), abs(v2)) > PHASE_VOLTAGE_LIMIT: + raise ValueError( + f"{self.name}: refusing to write V1={v1}, V2={v2}: above the phase shifter hard " + f"limit of {PHASE_VOLTAGE_LIMIT} V" + ) + with self._hardware_writes(): + self.phase_v1.put(float(v1)) + self.phase_v2.put(float(v2)) + + def _write_delay_card(self, coarse: int, fine: int) -> None: + with self._hardware_writes(): + self.delay_coarse.put(coarse) + self.delay_fine.put(fine) + actual = (self.delay_coarse.get(use_monitor=False), self.delay_fine.get(use_monitor=False)) + if actual != (coarse, fine): + raise LaserDelayHardwareError( + f"{self.name}: wrote DELAY={coarse}, FINE={fine} but the delay card reads " + f"DELAY={actual[0]}, FINE={actual[1]} (value clamped by the IOC?). The phase " + f"shifter was not touched." + ) + + def _check_phase_written(self, phase_t: float, constants: _Constants) -> None: + expected = voltages_for_phase(phase_t, constants.rf_period, constants.vmax) + actual = (self.phase_v1.get(use_monitor=False), self.phase_v2.get(use_monitor=False)) + if any(abs(a - e) > self.VOLTAGE_READBACK_TOLERANCE for a, e in zip(actual, expected)): + problem = ( + f"wrote V1={expected[0]:.4f}, V2={expected[1]:.4f} V but the phase shifter reads " + f"V1={actual[0]:.4f}, V2={actual[1]:.4f} V (value clamped by the IOC?)" + ) + self._mark_unverified(problem) + raise LaserDelayHardwareError(f"{self.name}: {problem}") + + def _move_task(self, target: float, status: StatusBase, stop_event: threading.Event) -> None: + """Body of the move, run by the task handler. Never raises; resolves ``status``. + + Readback, ``motor_is_moving``, the store and the hardware lock are all settled before the + status resolves, so whoever waits on the status sees the final state. Calibration and + constants are read only once the hardware lock is held: a move queued right behind a + stopped one must see the phase bookkeeping of that stopped move. + """ + error = None + with self._hw_lock: + wrote = False + try: + calibration = self._require_calibration() + constants = self._constants() + wrote = self._execute_move(target, calibration, constants, stop_event) + except BaseException as exc: # pylint: disable=broad-except + error = exc + wrote = wrote or getattr(exc, "hardware_written", False) + if not isinstance(exc, (DeviceStoppedError, TaskKilledError)): + logger.error(f"{self.name}: move to {target} ns failed: {exc}") + if wrote: + # Clears the move marker; the stored phase changes with every move. + self._save_calibration() + with self._move_lock: + if self._move_status is status: # no newer move queued behind this one + self.motor_is_moving.put(0, internal=True) + if isinstance(error, TaskKilledError): + error = DeviceStoppedError(f"Device {self.name} has been destroyed") + self._finish_status(status, error) + + def _execute_move( + self, + target: float, + calibration: LaserDelayCalibration, + constants: _Constants, + stop_event: threading.Event, + ) -> bool: + """The script's SetDelay: checks, delay card, phase shifter ramp, bookkeeping. + + Returns True if hardware was written. Nothing is written before all checks passed. An + exception raised after the first write carries ``hardware_written = True``. + """ + if stop_event.is_set(): + raise DeviceStoppedError(f"Device {self.name} has been stopped") + v1, v2 = float(self.phase_v1.get()), float(self.phase_v2.get()) + problem = self._amplitude_problem(v1, v2, constants) + if problem: + raise LaserDelayHardwareError(f"{self.name}: move to {target} ns refused: {problem}") + start = float(phase_from_voltages(v1, v2, constants.rf_period)) + problem = self._phase_problem(calibration, start, constants) + if problem: + self._mark_unverified(problem) + raise LaserDelayHardwareError(f"{self.name}: move to {target} ns refused: {problem}") + coarse, fine = delay_card_setting( + -calibration.offset_t - target, calibration.laser_period, constants.fine_rollover + ) + plan = plan_phase_move(target, calibration, constants, start=start) + wraps = "" + if plan.cavity_wraps: + wraps = ( + f"; shortest path, {plan.cavity_wraps:+d} cavity period(s) from the script's " + f"phase: {self._wrap_offset(plan.cavity_wraps, constants):+.4f} ns (mod RF)" + ) + logger.info( + f"{self.name}: move to {target} ns: DELAY={coarse}, FINE={fine}, phase " + f"{plan.start:.4f} {plan.delta:+.4f} ns in {plan.n_puts} steps " + f"(~{plan.n_puts * constants.step_sleep:.1f} s){wraps}" + ) + + # Mark the move before the first write: a crash from here on leaves the marker set. Without + # the marker a later restore could trust a phase that is whole RF periods stale (the phase + # check cannot see those), so the move is refused when the marker cannot be saved. + previous_readback = float(self.readback.get()) + self._update_readback(math.nan) + if not self._save_calibration(move_in_progress=True): + self._update_readback(previous_readback) + raise LaserDelayPersistenceError( + f"{self.name}: move to {target} ns refused: the move marker could not be saved to " + f"{self._store} (see the log). The hardware was not touched; fix the store and move " + f"again." + ) + done_offset = None + try: + self._write_delay_card(coarse, fine) + for offset in plan.offsets: + if stop_event.is_set(): + raise DeviceStoppedError(f"Device {self.name} has been stopped") + self._write_phase(plan.start + offset, constants) + done_offset = offset + if stop_event.wait(constants.step_sleep): + raise DeviceStoppedError(f"Device {self.name} has been stopped") + if stop_event.is_set(): + raise DeviceStoppedError(f"Device {self.name} has been stopped") + self._write_phase(plan.start + plan.delta, constants) + done_offset = None + self.phase_t.put(float(plan.stored_after), internal=True) + self._check_phase_written(plan.start + plan.delta, constants) + # The script also sleeps after the last step; a stop now no longer matters. + stop_event.wait(constants.step_sleep) + self._update_readback(target) + except BaseException as exc: + if done_offset is not None: + # Keep stored and real (unwrapped) phase consistent for the next move. + self.phase_t.put(float(plan.reference + done_offset), internal=True) + try: + exc.hardware_written = True + except AttributeError: + pass + raise + return True diff --git a/superxas_bec/devices/laser_delay_sim.py b/superxas_bec/devices/laser_delay_sim.py new file mode 100644 index 0000000..d0cf2be --- /dev/null +++ b/superxas_bec/devices/laser_delay_sim.py @@ -0,0 +1,252 @@ +""" +Simulated laser delay for SuperXAS: ``LaserDelay`` without EPICS, with a simulated laser and +oscilloscope. For a local demo BEC and tests only; the beamline device is +``superxas_bec.devices.laser_delay.LaserDelay``. +""" + +from __future__ import annotations + +import inspect +import math +import threading +from typing import Callable + +import numpy as np +from bec_lib.logger import bec_logger +from ophyd import Component as Cpt +from ophyd import Kind, Signal + +from superxas_bec.devices.laser_delay import ( + DELAY_CARD_FINE_PER_COARSE, + PHASE_VOLTAGE_LIMIT, + CheckedSignal, + ComputedPhaseSignal, + LaserDelay, + _DeviceWritesOnly, + delay_card_time, +) + +logger = bec_logger.logger + + +class _HardwareSoftSignal(_DeviceWritesOnly, CheckedSignal): + """Soft stand-in for a hardware PV in the simulation (written only by the device).""" + + +def _sim_range(name: str, low: float, high: float) -> Callable[[object], None]: + def check(value): + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be a number, got {value!r}") from exc + if not low <= number <= high: + raise ValueError(f"{name} must be within [{low}, {high}], got {value!r}") + + return check + + +class SimLaserDelay(LaserDelay): + """LaserDelay without EPICS, with a simulated oscilloscope (local demo BEC, tests). + + Soft signals replace the five PVs; the ``*_pv`` arguments are accepted and ignored. They refuse + what the IOCs are assumed to refuse: |V1|, |V2| above 5 V and FINE outside 0..199. Start values: + laser period 2.0002 µs, delay card 150 / 37 (300.37 ns), V1 = 1, V2 = 0. + + The simulated laser emits a pulse train with the cavity period, locked to the RF: turning the + phase shifter by ``x`` ns (tracked continuously, also across whole RF periods) shifts the train + by ``x`` ns. The delay card triggers the laser, which fires the first pulse of the train at or + after the trigger. ``sim_scope`` is the X-ray minus laser time an oscilloscope would show; it + starts at -0.88 ns, so ``calibrate(-0.88)`` (or + ``calibrate(dev.laser_delay.sim_scope.get())``) is a correct calibration. After any move + ``sim_scope`` should equal the readback within 0.01 ns. ``sim_response`` is a pump-probe signal: + a step of width ``sim_rise`` ns at ``sim_time_zero`` (scope time), plus noise. + + ``sim_set_hardware(...)`` changes the simulated PVs as somebody outside BEC would (caput, an IOC + reboot), to try the safety checks. + + Args: + sim_cavity_period (float | None): cavity period of the simulated laser in ns. None: the + device's ``cavity_period``. A different value shows what a wrong ``cavity_period`` + does. + sim_time_zero (float): scope delay of the pump-probe step in ns. + sim_rise (float): width of the step in ns. + sim_noise (float): standard deviation of the noise on ``sim_response``. + """ + + USER_ACCESS = LaserDelay.USER_ACCESS + ["sim_set_hardware"] + #: The simulation may ramp faster than the beamline (demo, tests). + MIN_STEP_SLEEP = 0.0 + + delay_coarse = Cpt( + _HardwareSoftSignal, + value=150, + kind=Kind.normal, + validator=_sim_range("delay_coarse", 0, 65535), + doc="Delay card, units of 2 ns", + ) + delay_fine = Cpt( + _HardwareSoftSignal, + value=37, + kind=Kind.normal, + validator=_sim_range("delay_fine", 0, DELAY_CARD_FINE_PER_COARSE - 1), + doc="Delay card, units of 0.01 ns", + ) + evr_period = Cpt(Signal, value=2.0002, kind=Kind.config, doc="EVR laser period in µs") + phase_v1 = Cpt( + _HardwareSoftSignal, + value=1.0, + kind=Kind.normal, + validator=_sim_range("phase_v1", -PHASE_VOLTAGE_LIMIT, PHASE_VOLTAGE_LIMIT), + doc="Phase shifter V1", + ) + phase_v2 = Cpt( + _HardwareSoftSignal, + value=0.0, + kind=Kind.normal, + validator=_sim_range("phase_v2", -PHASE_VOLTAGE_LIMIT, PHASE_VOLTAGE_LIMIT), + doc="Phase shifter V2", + ) + sim_scope = Cpt( + ComputedPhaseSignal, + compute="read_sim_scope", + kind=Kind.normal, + doc="Simulated oscilloscope: X-ray minus laser in ns", + ) + sim_response = Cpt( + ComputedPhaseSignal, + compute="read_sim_response", + kind=Kind.hinted, + doc="Simulated pump-probe signal", + ) + + #: Simulated X-ray arrival and laser pulse train (ns, delay card time scale). With the start + #: values a pulse sits half a cavity period after the trigger, and the scope shows -0.88 ns. + _SIM_XRAY_TIME = 305.495 + _SIM_TRAIN_OFFSET = 6.125 + + def __init__( + self, + prefix: str = "", + *, + name: str, + sim_cavity_period: float | None = None, + sim_time_zero: float = 0.0, + sim_rise: float = 0.3, + sim_noise: float = 0.01, + **kwargs, + ): + super().__init__(prefix=prefix, name=name, **kwargs) + self._sim_cavity_period = sim_cavity_period + self._sim_time_zero = float(sim_time_zero) + self._sim_rise = float(sim_rise) + self._sim_noise = float(sim_noise) + self._sim_rng = np.random.default_rng() + self._sim_lock = threading.Lock() + self._sim_angle = math.atan2(self.phase_v2.get(), self.phase_v1.get()) + self._sim_phase_turned = 0.0 # unwrapped phase shift in ns since the start + self.phase_v1.subscribe(self._sim_track_phase, run=False) + self.phase_v2.subscribe(self._sim_track_phase, run=False) + + def sim_set_hardware( + self, + delay_coarse: int | None = None, + delay_fine: int | None = None, + phase_v1: float | None = None, + phase_v2: float | None = None, + ) -> None: + """Change the simulated PVs as somebody outside BEC would (caput, IOC reboot). + + A voltage jump is followed by the simulated laser like any other phase change. + """ + with self._hardware_writes(): + for key, value in ( + ("delay_coarse", delay_coarse), + ("delay_fine", delay_fine), + ("phase_v1", phase_v1), + ("phase_v2", phase_v2), + ): + if value is not None: + getattr(self, key).put(value) + + def _sim_track_phase(self, *_, **__) -> None: + """Follow the phase shifter continuously, counting whole RF periods (the laser lock).""" + with self._sim_lock: + v1, v2 = self.phase_v1.get(), self.phase_v2.get() + if math.hypot(v1, v2) == 0: + return # no phase defined; the lock keeps the last one + angle = math.atan2(v2, v1) + turned = (angle - self._sim_angle + math.pi) % (2 * math.pi) - math.pi + self._sim_angle = angle + self._sim_phase_turned += float(self.rf_period.get()) * turned / (2 * math.pi) + + def on_connected(self) -> None: + """ + Called after the device is connected and its signals are connected. + Default values for signals should be set here. + """ + # Real IOCs and the laser keep their state across a BEC restart; soft signals do not. The + # calibration file therefore also keeps the simulated state; bring it back first, then + # restore the calibration like the real device. + try: + content = self._read_saved_calibration() + sim = (content or {}).get("sim") + if sim: + self.sim_set_hardware(**sim.get("hardware", {})) + with ( + self._sim_lock + ): # the puts above were tracked as turns; reset to the saved state + self._sim_angle = math.atan2(self.phase_v2.get(), self.phase_v1.get()) + self._sim_phase_turned = float(sim["phase_turned"]) + except Exception as exc: # pylint: disable=broad-except + logger.error(f"{self.name}: could not restore the simulated hardware: {exc}") + super().on_connected() + + def _extra_saved_state(self) -> dict: + hardware = { + "delay_coarse": int(self.delay_coarse.get()), + "delay_fine": int(self.delay_fine.get()), + "phase_v1": float(self.phase_v1.get()), + "phase_v2": float(self.phase_v2.get()), + } + with self._sim_lock: + return {"sim": {"phase_turned": self._sim_phase_turned, "hardware": hardware}} + + def read_sim_scope(self) -> float: + """X-ray minus laser in ns, as the oscilloscope would show it.""" + cavity = self._sim_cavity_period or float(self.cavity_period.get()) + trigger = delay_card_time(self.delay_coarse.get(), self.delay_fine.get()) + with self._sim_lock: + train = self._SIM_TRAIN_OFFSET + self._sim_phase_turned + laser = train + math.ceil((trigger - train) / cavity) * cavity + return self._SIM_XRAY_TIME - laser + + def read_sim_response(self) -> float: + """Pump-probe signal: step at ``sim_time_zero`` (scope time) plus noise.""" + x = (self.read_sim_scope() - self._sim_time_zero) / (math.sqrt(2) * self._sim_rise) + noise = self._sim_rng.normal(0, self._sim_noise) if self._sim_noise > 0 else 0.0 + return 0.5 * (1 + math.erf(x)) + noise + + +def _merged_signature(init: Callable, base_init: Callable) -> inspect.Signature: + """``init``'s own parameters plus the keyword parameters of ``base_init``. + + BEC's device server only passes ``deviceConfig`` keys (and ``device_manager``/``scan_info``) + that appear in the signature of the device class itself, so a subclass ``__init__`` that + forwards ``**kwargs`` has to show the base class parameters. + """ + own = [p for p in inspect.signature(init).parameters.values() if p.kind != p.VAR_KEYWORD] + names = {p.name for p in own} + base = [ + p + for p in inspect.signature(base_init).parameters.values() + if p.kind == p.KEYWORD_ONLY and p.name not in names + ] + var_kw = [ + p for p in inspect.signature(base_init).parameters.values() if p.kind == p.VAR_KEYWORD + ] + return inspect.signature(init).replace(parameters=own + base + var_kw) + + +SimLaserDelay.__init__.__signature__ = _merged_signature( + SimLaserDelay.__init__, LaserDelay.__init__ +) diff --git a/superxas_bec/devices/timepix/timepix.py b/superxas_bec/devices/timepix/timepix.py index 5ebe319..d328c39 100644 --- a/superxas_bec/devices/timepix/timepix.py +++ b/superxas_bec/devices/timepix/timepix.py @@ -12,14 +12,20 @@ import os import threading import time import traceback +from collections import deque +from dataclasses import dataclass +from functools import partial from typing import TYPE_CHECKING, Any, Literal import numpy as np from bec_lib.file_utils import get_full_path from bec_lib.logger import bec_logger +from bec_server.scan_server.scans.scan_base import ScanInfo as ScanServerScanInfo from ophyd import ADBase from ophyd import Component as Cpt -from ophyd import Signal +from ophyd import Staged +from ophyd.status import StatusTimeoutError, WaitTimeoutError +from ophyd.utils import InvalidState from ophyd_devices import ( AsyncSignal, CompareStatus, @@ -44,6 +50,7 @@ from superxas_bec.devices.timepix.timepix_fly_client.timepix_fly_interface impor OtherConfigModel, PixelMap, ) +from superxas_bec.devices.utils.utils import fetch_scan_info if TYPE_CHECKING: from bec_lib.messages import ScanStatusMessage @@ -165,6 +172,27 @@ def load_pixel_map_from_json(file_path: str) -> PixelMap: return pixel_map +def window_fits(window: slice, length: int) -> bool: + """True if [start, stop) is a non-empty index window inside range(length).""" + return 0 <= window.start < window.stop <= length + + +def window_mean(data: np.ndarray, window: slice) -> float: + """ + Mean of data over the half-open index window [start, stop). + + Returns NaN if the window is empty or does not fit into data. numpy would instead average + over fewer points (stop past the end) or return NaN with a RuntimeWarning (empty window). + + Args: + data (np.ndarray): 1D data, e.g. one time spectrum. + window (slice): window with integer start and stop. + """ + if not window_fits(window, len(data)): + return float("nan") + return float(np.mean(data[window])) + + class ImagePlugin_Timepix(ImagePlugin_V35): """Custom Image Plugin for TimePix detector.""" @@ -192,6 +220,34 @@ DETECTOR_SHAPE = (512, 1024) # Shape of the TimePix detector TIMEPIX_FLY_BACKEND_DEFAULT_REDIS_TOPIC = "public/timepix_fly_backend/raw_data" +# scan_name of superxas_bec.scans.timepix_scan.TimepixScan +TIMEPIX_SCAN_NAME = "timepix_scan" + +# How long a repeated acquisition waits for the images of the lost one before it starts. +LOST_ACQUISITION_IMAGE_TIMEOUT = 2.0 + + +@dataclass +class XesAcquisition: + """ + One camera acquisition for which TimePixFly was armed. TimePixFly sends exactly one + StartFrame ... EndFrame sequence per armed acquisition, in order, so msg_buffer_callback + matches each EndFrame to the oldest pending acquisition. + + Attributes: + exposure (float): seconds the detector needs for the images of this trigger. + repeat_allowed (bool): the acquisition may be repeated if its raw data are lost. + acquire_time (float | None): time.monotonic() when Acquire was set, None before. + lost (bool): TimePixFly closed its frame before the exposure could end. + done (bool): the trigger status of this acquisition has completed. + """ + + exposure: float + repeat_allowed: bool + acquire_time: float | None = None + lost: bool = False + done: bool = False + class Timepix(PSIDeviceBase, TimePixControl): """ @@ -221,9 +277,18 @@ class Timepix(PSIDeviceBase, TimePixControl): static_spectra = Cpt(AsyncSignal, name="static_spectra", ndim=1, max_size=1000, acquisition_group="monitored", async_update={"type": "add", "max_shape": [None, DETECTOR_SHAPE[0]]}, doc="Spectra signal of the TimePix detector.") - # xes_data_accumulated_1 = Cpt(AsyncSignal, name="xes_accumulated_energy_1", ndim=1, max_size=1000, doc="1D time spectra for energy bin 2.") - # xes_data_accumulated_2 = Cpt(AsyncSignal, name="xes_accumulated_energy_2", ndim=1, max_size=1000, doc="1D time spectra for energy bin 2.") + xes_data_accumulated_1 = Cpt(AsyncSignal, name="xes_accumulated_energy_1", ndim=1, max_size=1000, doc="1D time spectra for energy bin 2.") + xes_data_accumulated_2 = Cpt(AsyncSignal, name="xes_accumulated_energy_2", ndim=1, max_size=1000, doc="1D time spectra for energy bin 2.") file_event = Cpt(FileEventSignal, name="file_event", doc="File event signal for TimePix detector.") + + ### Additional timepix scan signals + xes_level_1_off = Cpt(AsyncSignal, name="xes_level_1_off", ndim=0, async_update={"type": "add", "max_shape": [None]}, max_size=1000, doc="XES Level Off for energy bin 1.") + xes_level_1_on = Cpt(AsyncSignal, name="xes_level_1_on", ndim=0, async_update={"type": "add", "max_shape": [None]}, max_size=1000, doc="XES Level On for energy bin 1.") + xes_diff_1_on_off = Cpt(AsyncSignal, name="xes_diff_1_on_off", ndim=0, async_update={"type": "add", "max_shape": [None]}, max_size=1000, doc="XES Level Difference for energy bin 1.") + xes_level_2_off = Cpt(AsyncSignal, name="xes_level_2_off", ndim=0, async_update={"type": "add", "max_shape": [None]}, max_size=1000, doc="XES Level Off for energy bin 2.") + xes_level_2_on = Cpt(AsyncSignal, name="xes_level_2_on", ndim=0, async_update={"type": "add", "max_shape": [None]}, max_size=1000, doc="XES Level On for energy bin 2.") + xes_diff_2_on_off = Cpt(AsyncSignal, name="xes_diff_2_on_off", ndim=0, async_update={"type": "add", "max_shape": [None]}, max_size=1000, doc="XES Level Difference for energy bin 2.") + # fmt: on def __init__( @@ -259,6 +324,7 @@ class Timepix(PSIDeviceBase, TimePixControl): super().__init__( name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs ) + self.scan_parameters: ScanServerScanInfo | None = None self._redis_raw_data_topic = TIMEPIX_FLY_BACKEND_DEFAULT_REDIS_TOPIC self.backend = TimepixFlyBackend( @@ -269,8 +335,8 @@ class Timepix(PSIDeviceBase, TimePixControl): redis_connector=self.device_manager.connector, ) self._pixel_map = None - self._troistep = 1 - self._troin = 5000 + self._troistep = 1 # Will be changed if timepix scan is used, see on_stage call + self._troin = 5000 # Will be changed if timepix scan is used, see on_stage call self._poll_thread = threading.Thread( target=self._poll_array_data, daemon=True, name=f"{self.name}_poll_thread" @@ -288,13 +354,43 @@ class Timepix(PSIDeviceBase, TimePixControl): self.accumulated_data_e1 = None self.accumulated_data_e2 = None + # Settings that timepix_scan overrides in on_stage, saved for the restore in + # on_unstage / on_stop. None while no timepix_scan overrides are active. + self._settings_before_timepix_scan: dict[str, Any] | None = None + # Log the "on/off window does not fit" warning only once per scan. + self._on_off_window_warned = False + # Acquisitions armed in TimePixFly whose EndFrame has not arrived yet, oldest first. + self._xes_acquisitions: deque[XesAcquisition] = deque() + self._xes_acquisitions_lock = threading.Lock() + # Images of a lost acquisition still to arrive; they are not published. + self._images_to_skip = 0 + self._exposure_per_trigger = 0.0 + self._images_per_trigger = 1 def stage(self) -> list[object] | StatusBase: # type: ignore """Stage the device. - Super stage not safe to call..""" + Super stage not safe to call: ophyd's Device.stage would also stage the areaDetector + plugins and apply their stage_sigs. The device is still marked as staged, because the + BEC device server only calls unstage() (and with it on_unstage) on devices that report + Staged.yes. + """ self.stopped = False - status = self.on_stage() # pylint: disable=assignment-from-no-return + try: + status = self.on_stage() # pylint: disable=assignment-from-no-return + except Exception: + # Do not leave timepix_scan overrides behind if staging fails half-way. If the + # restore fails too, only log it so that the stage error is the one raised; the + # saved settings are then put back by the next stage, unstage or stop. + try: + self._restore_settings_after_timepix_scan() + except Exception: # pylint: disable=broad-except + content = traceback.format_exc() + logger.error( + f"Failed to restore settings of {self.name} after a failed stage: {content}" + ) + raise + self._staged = Staged.yes if isinstance(status, StatusBase): return status return [] @@ -355,6 +451,8 @@ class Timepix(PSIDeviceBase, TimePixControl): end_frame (dict): The EndFrame. Dictionary representation of detailed structure described in model .timepix_fly_client.timepix_fly_interface.TimepixEndFrame """ + if self._frame_of_lost_acquisition(end_frame): + return # on_trigger repeats the acquisition; publish nothing for this one n_energy_points = start_frame.get("NumEnergyPoints", None) troin = start_frame["TRoiN"] if troin != self._troin: @@ -407,27 +505,135 @@ class Timepix(PSIDeviceBase, TimePixControl): data_2 = np.sum(xes_data[:, 4:8], axis=1) self.xes_energy_1.put(data_1, async_update={"type": "add", "max_shape": [None, troin]}) self.xes_energy_2.put(data_2, async_update={"type": "add", "max_shape": [None, troin]}) - # if self.accumulated_data_e1 is None: - # self.accumulated_data_e1 = data_1 - # else: - # self.accumulated_data_e1 += data_1 - # if self.accumulated_data_e2 is None: - # self.accumulated_data_e2 = data_2 - # else: - # self.accumulated_data_e2 += data_2 - # self.xes_data_accumulated_1.put( - # self.accumulated_data_e1, - # async_update={"type": "replace", "max_shape": [None, troin]}, - # ) - # self.xes_data_accumulated_2.put( - # self.accumulated_data_e2, - # async_update={"type": "replace", "max_shape": [None, troin]}, - # ) + if self.accumulated_data_e1 is None: + self.accumulated_data_e1 = data_1 + else: + self.accumulated_data_e1 += data_1 + if self.accumulated_data_e2 is None: + self.accumulated_data_e2 = data_2 + else: + self.accumulated_data_e2 += data_2 + self.xes_data_accumulated_1.put( + self.accumulated_data_e1, + async_update={"type": "replace", "max_shape": [None, troin]}, + ) + self.xes_data_accumulated_2.put( + self.accumulated_data_e2, + async_update={"type": "replace", "max_shape": [None, troin]}, + ) + + ### Additional signals for timepix scan + # Frames can arrive without a staged timepix_scan (e.g. before the first stage after + # a restart), so scan_parameters may still be None here. + if self._is_timepix_scan(): + self._put_on_off_levels(data_1, data_2) + self.xes_spectra.put( xes_data.sum(axis=1), async_update={"type": "add", "max_shape": [None, troin]} ) logger.debug(f"Device data set for Timepix with {tds_period}, {tds_total_events}") + def _frame_of_lost_acquisition(self, end_frame: dict) -> bool: + """ + Match the EndFrame to its acquisition and check whether that acquisition was lost. + + A frame without any TDC period holds no XES data. If it arrives before the exposure of + its acquisition can have ended, TimePixFly's raw-data connection closed before the + detector measured, and the raw data of that acquisition never reached TimePixFly + (scan S00437, point 1: the frame closed 1.2 s before the detector delivered the image). + Such an acquisition is marked lost and on_trigger repeats it once, if allowed. A frame + without TDC periods after the exposure means the detector recorded no TDC pulse; it is + published as it is. + + Args: + end_frame (dict): EndFrame received from TimePixFly. + + Returns: + bool: True if the frame belongs to a lost acquisition that will be repeated. + """ + with self._xes_acquisitions_lock: + if not self._xes_acquisitions: + return False # no acquisition armed by this device, e.g. after a stop + acquisition = self._xes_acquisitions.popleft() + if end_frame.get("periods", None) != 0: + return False + if acquisition.acquire_time is None: + timing = "before Acquire was set" + else: + elapsed = time.monotonic() - acquisition.acquire_time + timing = f"{elapsed:.3f} s after Acquire was set" + if elapsed >= acquisition.exposure: + logger.error( + f"{self.name}: TimePixFly frame without any TDC period, {timing} " + f"({acquisition.exposure:.3f} s exposure). The detector recorded no TDC " + "pulse: check the TDC input signal. This point has no XES data." + ) + return False + if not acquisition.repeat_allowed or acquisition.done: + logger.error( + f"{self.name}: TimePixFly closed the frame {timing}, before the " + f"{acquisition.exposure:.3f} s exposure could end, without any TDC period. " + "The raw data of this point did not reach TimePixFly and the acquisition " + "cannot be repeated. This point has no XES data." + ) + return False + acquisition.lost = True + self._images_to_skip += self._images_per_trigger + logger.warning( + f"{self.name}: TimePixFly closed the frame {timing}, before the " + f"{acquisition.exposure:.3f} s exposure could end, without any TDC period. The raw " + "data of this acquisition did not reach TimePixFly; repeating the acquisition." + ) + return True + + def _put_on_off_levels(self, data_1: np.ndarray, data_2: np.ndarray) -> None: + """ + Publish the laser on/off levels of a timepix_scan for both energy groups. + + The off/on windows are the half-open time-bin ranges [idx_*_start, idx_*_stop) given as + timepix_scan arguments. A level is the mean of xes_energy_1/2 over its window, the + difference is on minus off. If a window is empty or does not fit into the received + time bins (TimePixFly sent fewer bins than time_roi_n), its levels and the differences + are published as NaN, so every frame still adds one value per signal and the signals stay + aligned with the scan points; a warning is logged once per scan. + + Args: + data_1 (np.ndarray): xes_energy_1 of this frame, one value per time bin. + data_2 (np.ndarray): xes_energy_2 of this frame, one value per time bin. + """ + params = self.scan_parameters.additional_scan_parameters + off = slice(params["idx_off_start"], params["idx_off_stop"]) + on = slice(params["idx_on_start"], params["idx_on_stop"]) + + bad_windows = [ + f"{name} [{window.start}, {window.stop})" + for name, window in (("off", off), ("on", on)) + if not window_fits(window, len(data_1)) + ] + if bad_windows and not self._on_off_window_warned: + self._on_off_window_warned = True + logger.warning( + f"{self.name}: laser {' and '.join(bad_windows)} window does not fit into the " + f"{len(data_1)} time bins received; publishing NaN for the affected levels." + ) + + level_1_off = window_mean(data_1, off) + level_1_on = window_mean(data_1, on) + level_2_off = window_mean(data_2, off) + level_2_on = window_mean(data_2, on) + self.xes_level_1_off.put(level_1_off) + self.xes_level_1_on.put(level_1_on) + self.xes_level_2_off.put(level_2_off) + self.xes_level_2_on.put(level_2_on) + self.xes_diff_1_on_off.put(level_1_on - level_1_off) + self.xes_diff_2_on_off.put(level_2_on - level_2_off) + + def _is_timepix_scan(self) -> bool: + """True if the device is staged for a timepix_scan (False before the first stage).""" + return ( + self.scan_parameters is not None and self.scan_parameters.scan_name == TIMEPIX_SCAN_NAME + ) + ### User ACCESS methods def get_pixel_map(self) -> dict: @@ -603,6 +809,11 @@ class Timepix(PSIDeviceBase, TimePixControl): """Callback for image unique ID updates to trigger preview update.""" if value == old_value: return # No new image, or counter reset + with self._xes_acquisitions_lock: + if self._images_to_skip > 0: + # Image of an acquisition whose XES data were lost; its repetition replaces it. + self._images_to_skip -= 1 + return try: # Get new image data width = self.image.array_size.width.get() @@ -621,9 +832,17 @@ class Timepix(PSIDeviceBase, TimePixControl): def on_stage(self) -> StatusBase | None: """Called while staging the device.""" - ### + self.scan_parameters = fetch_scan_info(self.scan_info) + + if self._is_timepix_scan(): + self._apply_timepix_scan_settings(self.scan_parameters.additional_scan_parameters) + else: + # Overrides of an earlier timepix_scan whose restore failed (no-op otherwise) + self._restore_settings_after_timepix_scan() + self.accumulated_data_e1 = None self.accumulated_data_e2 = None + self._on_off_window_warned = False scan_msg: ScanStatusMessage = self.scan_info.msg # type: ignore exp_time = scan_msg.scan_parameters.get("exp_time", 0) if exp_time - self._readout_time <= 0: @@ -632,6 +851,9 @@ class Timepix(PSIDeviceBase, TimePixControl): ) burst_images = scan_msg.scan_parameters.get("frames_per_trigger", 1) self._n_images = scan_msg.num_points * burst_images + self._images_per_trigger = burst_images + self._exposure_per_trigger = burst_images * exp_time - self._readout_time + self._clear_xes_acquisitions() # Camera has to be set to burst_images, each step will get an individual trigger self.cam.acquire_time.set(exp_time - self._readout_time).wait(timeout=self._pv_timeout) self.cam.acquire_period.set(exp_time).wait(timeout=self._pv_timeout) @@ -680,12 +902,53 @@ class Timepix(PSIDeviceBase, TimePixControl): timeout=self._pv_timeout ) + def _apply_timepix_scan_settings(self, params: dict) -> None: + """ + Apply the timepix_scan overrides: time binning from the scan arguments, HDF5 image + writing off and XES on. The previous values are saved and put back by + _restore_settings_after_timepix_scan (on_unstage, or on_stop for an aborted scan). + + Args: + params (dict): additional_scan_parameters of the timepix_scan. + """ + if self._settings_before_timepix_scan is None: + # If an earlier restore failed, keep the values saved before that scan. + self._settings_before_timepix_scan = { + "troistep": self._troistep, + "troin": self._troin, + "hdf_enable": self.hdf.enable.get(), + "enable_xes": self.enable_xes, + } + self._troistep = params["time_roi_step"] + self._troin = params["time_roi_n"] + # Wait for the readback: the rest of on_stage checks hdf.enable to decide on capture. + self.set_enable_image_writing(False) + self.set_enable_xes(True) + + def _restore_settings_after_timepix_scan(self) -> None: + """ + Put back the settings saved by _apply_timepix_scan_settings. Does nothing if no + timepix_scan overrides are active, so on_stop, on_unstage and on_stage can all call it. + """ + settings = self._settings_before_timepix_scan + if settings is None: + return + self._troistep = settings["troistep"] + self._troin = settings["troin"] + self.hdf.enable.set(settings["hdf_enable"]).wait(timeout=self._pv_timeout) + self.set_enable_xes(settings["enable_xes"]) + # Clear only after success, so a failed restore in on_stop is retried in on_unstage. + self._settings_before_timepix_scan = None + def on_unstage(self) -> None: """Called while unstaging the device.""" - if self.cam.acquire_busy.get() == ACQUIRESTATUS.ACQUIRING: - self.cam.acquire.put(0) - status_camera = CompareStatus(self.cam.acquire_busy, ACQUIRESTATUS.DONE) - status_camera.wait(timeout=self._pv_timeout) + try: + if self.cam.acquire_busy.get() == ACQUIRESTATUS.ACQUIRING: + self.cam.acquire.put(0) + status_camera = CompareStatus(self.cam.acquire_busy, ACQUIRESTATUS.DONE) + status_camera.wait(timeout=self._pv_timeout) + finally: + self._restore_settings_after_timepix_scan() def on_pre_scan(self) -> StatusBase: """Called right before the scan starts on all devices automatically.""" @@ -705,51 +968,155 @@ class Timepix(PSIDeviceBase, TimePixControl): return status def on_trigger(self) -> DeviceStatus | StatusBase | None: - """Called when the device is triggered.""" + """ + Called when the device is triggered. - status_camera = TransitionStatus( + With XES enabled, TimePixFly is armed before the camera starts. If TimePixFly closes the + frame of the acquisition before its exposure could end (see _frame_of_lost_acquisition), + the acquisition is repeated once and the returned status resolves after the repetition. + This needs the HDF5 plugin to be off (as in timepix_scan): with image writing on, a + repetition would add an image to the file, so the point is kept without XES data. + """ + if self.enable_xes is not True: + status_camera = self._camera_acquisition_status() + self.cancel_on_stop(status_camera) + self.cam.acquire.put(1) + return status_camera + + point_status = DeviceStatus(self) + self.cancel_on_stop(point_status) + repeat_allowed = self.hdf.enable.get() != "Enable" + self._start_xes_acquisition(point_status, repeat_allowed=repeat_allowed) + return point_status + + def _camera_acquisition_status(self) -> TransitionStatus: + """Status that resolves once the camera has acquired (AcquireBusy DONE -> ACQUIRING -> DONE).""" + return TransitionStatus( self.cam.acquire_busy, [ACQUIRESTATUS.DONE, ACQUIRESTATUS.ACQUIRING, ACQUIRESTATUS.DONE] ) - status_backend = None + + def _start_xes_acquisition(self, point_status: DeviceStatus, repeat_allowed: bool) -> None: + """ + Arm TimePixFly and start one camera acquisition for the trigger status point_status. + _on_xes_acquisition_done resolves point_status, or repeats a lost acquisition. + + Args: + point_status (DeviceStatus): trigger status returned by on_trigger. + repeat_allowed (bool): the acquisition may be repeated if its raw data are lost. + + Raises: + TimeoutError: if TimePixFly does not reach the 'config' or 'await_connection' state. + """ + status_camera = self._camera_acquisition_status() # First we make sure that the backend reach 'config' state. This needs to happend before each trigger. - if self.enable_xes is True: - status_backend_config = DeviceStatus(self) - self.cancel_on_stop(status_backend_config) - self.backend.timepix_fly_client.add_status_callback( - status=status_backend_config, - success=[TimePixFlyStatus.CONFIG], - error=[TimePixFlyStatus.EXCEPT, TimePixFlyStatus.SHUTDOWN], - ) - try: - status_backend_config.wait(timeout=5) - except TimeoutError: - # pylint:disable=raise-missing-from - raise TimeoutError( - f"TimePixFly backend of device {self.name} failed to reach 'config' state in trigger" - ) - - # Prepare backend to be ready to receive trigger - status = self.backend.on_trigger() - try: - status.wait(timeout=5) # Wait until backend trigger is done + status_backend_config = DeviceStatus(self) + self.cancel_on_stop(status_backend_config) + self.backend.timepix_fly_client.add_status_callback( + status=status_backend_config, + success=[TimePixFlyStatus.CONFIG], + error=[TimePixFlyStatus.EXCEPT, TimePixFlyStatus.SHUTDOWN], + ) + try: + status_backend_config.wait(timeout=5) + except TimeoutError: # pylint:disable=raise-missing-from - except Exception: - logger.error( - f"TimePixFly backend of device {self.name} failed to prepare for trigger" - ) - raise TimeoutError( - f"TimePixFly backend of device {self.name} failed to reach 'trigger' state in trigger" - ) - # Status that resolves once the trigger is done - status_backend = self.backend.on_trigger_finished() + raise TimeoutError( + f"TimePixFly backend of device {self.name} failed to reach 'config' state in trigger" + ) - if status_backend is not None: - return_status = status_camera & status_backend - else: - return_status = status_camera - self.cancel_on_stop(return_status) + # Registered before arming: the EndFrame of a lost acquisition can arrive at once. + acquisition = XesAcquisition( + exposure=self._exposure_per_trigger, repeat_allowed=repeat_allowed + ) + with self._xes_acquisitions_lock: + self._xes_acquisitions.append(acquisition) + # Prepare backend to be ready to receive trigger + status = self.backend.on_trigger() + try: + status.wait(timeout=5) # Wait until backend trigger is done + # pylint:disable=raise-missing-from + except Exception: + with self._xes_acquisitions_lock: + if acquisition in self._xes_acquisitions: + self._xes_acquisitions.remove(acquisition) + logger.error(f"TimePixFly backend of device {self.name} failed to prepare for trigger") + raise TimeoutError( + f"TimePixFly backend of device {self.name} failed to reach 'trigger' state in trigger" + ) + # Status that resolves once the trigger is done + status_backend = self.backend.on_trigger_finished() + acquisition_status = status_camera & status_backend + self.cancel_on_stop(acquisition_status) + acquisition_status.add_callback( + partial(self._on_xes_acquisition_done, point_status, acquisition) + ) + acquisition.acquire_time = time.monotonic() self.cam.acquire.put(1) - return return_status + + def _on_xes_acquisition_done( + self, point_status: DeviceStatus, acquisition: XesAcquisition, status: StatusBase + ) -> None: + """Resolve the trigger status, or repeat the acquisition if TimePixFly lost its data.""" + with self._xes_acquisitions_lock: + acquisition.done = True + lost = acquisition.lost + if point_status.done: + return # cancelled, e.g. by a stop + if not status.success: + self._resolve_point_status(point_status, status.exception()) + return + if lost: + threading.Thread( + target=self._repeat_xes_acquisition, + args=(point_status,), + daemon=True, + name=f"{self.name}_repeat_acquisition", + ).start() + return + self._resolve_point_status(point_status) + + def _repeat_xes_acquisition(self, point_status: DeviceStatus) -> None: + """Repeat a lost acquisition once, after the images of the lost one have arrived.""" + try: + deadline = time.monotonic() + LOST_ACQUISITION_IMAGE_TIMEOUT + while True: + with self._xes_acquisitions_lock: + if self._images_to_skip == 0: + break + if time.monotonic() > deadline: + logger.warning( + f"{self.name}: {self._images_to_skip} image(s) of the lost " + "acquisition did not arrive; repeating without waiting for them." + ) + self._images_to_skip = 0 + break + time.sleep(0.05) + if point_status.done or self.stopped: + return + self._start_xes_acquisition(point_status, repeat_allowed=False) + except Exception as exc: # pylint: disable=broad-except + content = traceback.format_exc() + logger.error(f"{self.name}: repeating the lost acquisition failed: {content}") + self._resolve_point_status(point_status, exc) + + @staticmethod + def _resolve_point_status(point_status: DeviceStatus, exc: Exception | None = None) -> None: + """Finish point_status (failed if exc is given), unless it is already done.""" + if isinstance(exc, (StatusTimeoutError, WaitTimeoutError)): + exc = TimeoutError(str(exc)) # ophyd reserves these for its own timeouts + try: + if exc is None: + point_status.set_finished() + else: + point_status.set_exception(exc) + except InvalidState: + pass # resolved meanwhile, e.g. cancelled by a stop + + def _clear_xes_acquisitions(self) -> None: + """Forget pending acquisitions and images to skip (new scan, or stop).""" + with self._xes_acquisitions_lock: + self._xes_acquisitions.clear() + self._images_to_skip = 0 def on_complete(self) -> DeviceStatus | StatusBase | None: """Called to inquire if a device has completed a scans.""" @@ -813,6 +1180,16 @@ class Timepix(PSIDeviceBase, TimePixControl): # Backend if self.enable_xes is True: self.backend.on_stop() + self._clear_xes_acquisitions() + # BEC does not unstage the devices of an aborted scan, so undo the timepix_scan + # overrides here. Runs after the backend stop, which depends on enable_xes. + try: + self._restore_settings_after_timepix_scan() + except Exception: # pylint: disable=broad-except + content = traceback.format_exc() + logger.error( + f"Failed to restore settings of {self.name} after timepix_scan, will retry on unstage: {content}" + ) def on_destroy(self): """Cleanup method to stop the device and clean up resources.""" diff --git a/superxas_bec/scans/__init__.py b/superxas_bec/scans/__init__.py index 5f4a83b..078ccff 100644 --- a/superxas_bec/scans/__init__.py +++ b/superxas_bec/scans/__init__.py @@ -3,3 +3,4 @@ from .mono_bragg_scans import ( # XASAdvancedScanWithXRD,; XASSimpleScanWithXRD XASSimpleScan, ) from .nidaq_cont_scan import NIDAQContinuousScan +from .timepix_scan import TimepixScan diff --git a/superxas_bec/scans/timepix_scan.py b/superxas_bec/scans/timepix_scan.py new file mode 100644 index 0000000..f2bcced --- /dev/null +++ b/superxas_bec/scans/timepix_scan.py @@ -0,0 +1,95 @@ +""" +Timepix Scan (extension of line scan). + +Scan procedure: + - prepare_scan + - open_scan + - stage + - pre_scan + - scan_core + - at_each_point (optionally called by scan_core) + - post_scan + - unstage + - close_scan + - on_exception (called if any exception is raised during the scan) +""" + +from __future__ import annotations + +from typing import Annotated + +from bec_lib.scan_args import DefaultArgType, ScanArgument +from bec_server.scan_server.scans.line_scan import LineScan + + +class TimepixScan(LineScan): + """ + Timepix Scan + Extends Line Scan with additional parameters for the timepix detector. + """ + + scan_name = "timepix_scan" + + gui_config = { + "Movement Parameters": ["steps", "relative"], + "Acquisition Parameters": ["exp_time", "burst_at_each_point"], + "Binning Parameters": ["time_roi_step", "time_roi_n"], + "Laser On/Off Parameters": ["idx_off_start", "idx_off_stop", "idx_on_start", "idx_on_stop"], + } + + def __init__( + self, + *args, + steps: Annotated[int, ScanArgument(display_name="Number of Steps", gt=0)], + relative: DefaultArgType.Relative, + exp_time: DefaultArgType.ExposureTime = 0, + burst_at_each_point: DefaultArgType.BurstAtEachPoint = 1, + # fmt: off + time_roi_step: Annotated[int, ScanArgument(display_name="Time ROI step", description="Bin size", gt=0)], + time_roi_n: Annotated[int, ScanArgument(display_name="Time ROI n", description="Number of time points", gt=0)], + idx_off_start: Annotated[int, ScanArgument(display_name="Index Off Start", description="Start Index for Off Level (first time bin)", ge=0)], + idx_off_stop: Annotated[int, ScanArgument(display_name="Index Off Stop", description="Stop Index for Off Level (exclusive)", ge=0)], + idx_on_start: Annotated[int, ScanArgument(display_name="Index On Start", description="Start Index for On Level (first time bin)", ge=0)], + idx_on_stop: Annotated[int, ScanArgument(display_name="Index On Stop", description="Stop Index for On Level (exclusive)", ge=0)], + # fmt: on + **kwargs, + ): + # The on/off windows are half-open index ranges [start, stop) over the time_roi_n time + # bins; the Timepix device averages each window into xes_level_*. An empty window + # (start == stop) would only produce NaN, so it is rejected here as well. + if idx_off_start >= idx_off_stop: + raise ValueError( + f"idx_off_start ({idx_off_start}) must be smaller than idx_off_stop ({idx_off_stop})!" + ) + if idx_on_start >= idx_on_stop: + raise ValueError( + f"idx_on_start ({idx_on_start}) must be smaller than idx_on_stop ({idx_on_stop})!" + ) + for name, idx in ( + ("idx_off_start", idx_off_start), + ("idx_off_stop", idx_off_stop), + ("idx_on_start", idx_on_start), + ("idx_on_stop", idx_on_stop), + ): + if idx > time_roi_n: + raise ValueError( + f"{name} with value {idx} exceeds length of data array of time_roi_n of {time_roi_n}" + ) + + super().__init__( + *args, + steps=steps, + relative=relative, + exp_time=exp_time, + burst_at_each_point=burst_at_each_point, + **kwargs, + ) + + self.update_scan_info( + time_roi_step=time_roi_step, + time_roi_n=time_roi_n, + idx_off_start=idx_off_start, + idx_off_stop=idx_off_stop, + idx_on_start=idx_on_start, + idx_on_stop=idx_on_stop, + ) diff --git a/tests/tests_devices/reference/DelayLaser.py.txt b/tests/tests_devices/reference/DelayLaser.py.txt new file mode 100644 index 0000000..49362df --- /dev/null +++ b/tests/tests_devices/reference/DelayLaser.py.txt @@ -0,0 +1,234 @@ +#!/usr/bin/python +# Reference only: the beamline scientist's laser-delay script, attached to request 2. +# Kept verbatim (Python 2 era, CaChannel/epicsPV) as the behavioural spec for the BEC +# LaserDelay device. Do not import or run it from BEC code. + +from CaChannel import ca, CaChannel +from epicsPV import epicsPV +from math import * +from numpy import * +import time + +#Possible values of Laser: Duetto, KML, XITON, PI +Laser="Duetto" +Beamline="SuperXAS" +#LaserTriggerMode: "internal" or "external" External means from Event receiver. + + +LaserTriggerMode="external" + +# Units of time are nanoseconds + +#If internal triggering is used, set LaserPeriod (time between two laser pulses) as it is set at the laser +LaserPeriod=0 +if (LaserTriggerMode=="internal"): LaserPeriod=2000.2 + + +#Timing mode "DelayAndPhase" - use both Delay card and phase shifter, "Delay" - use only delay card, "Phase" - use only phase shifter +if (Laser=="Duetto"): + TimingMode="DelayAndPhase" + +#Index Of delay channel (as in EPICS name) used for laser +DelayChIndex=1 +EVRChIndex=2 + +#For SuperXAS 1 corresponds to the old (KML) phase shifter +#For SuperXAS 2 corresponds to ES4 phase shifter +PhaseShifterIndex=2 + + + +#change only for another synchrotron +RFPeriod=2.0 + +DelayCh=CaChannel() +DelayChFine=CaChannel() +EVRCh=CaChannel() +PhaseV1=CaChannel() +PhaseV2=CaChannel() + + +def ConnectEpics(): + + global CavityPeriod, Laser, Beamline + global DelayCh, DelayChFine, EVRCh, PhaseV1, PhaseV2 + + if (Laser=="Duetto"): + CavityPeriod=12.01 + + if (Beamline=="SuperXAS"): + DN="X10DA-ES2-TIM:SET"+str(DelayChIndex) + EVRChName="X10DA-ES2-TIM:EVR-FPS"+str(EVRChIndex) + if (PhaseShifterIndex==1): + PhaseV1Name="X10DA-LAS-PHSH:V1" + PhaseV2Name="X10DA-LAS-PHSH:V2" + if (PhaseShifterIndex==2): + PhaseV1Name="X10DA-ES4:AO-02" + PhaseV2Name="X10DA-ES4:AO-01" + + if (Beamline=="Phoenix"): + DN="X07MB-ES-TIM:SET"+str(DelayChIndex) + EVRChName="X07MB-ES-TIM:EVR-FPS"+str(EVRChIndex) + PhaseV1Name="X07MB-ES1-PP2:VO2" + PhaseV2Name="X07MB-ES1-PP2:VO1" + + DelayChName=DN+"-DELAY" + DelayChFineName=DN+"-FINE" + + + + + + + + + if ((TimingMode=="Delay") or (TimingMode=="DelayAndPhase")): + DelayCh.searchw(DelayChName) + DelayChFine.searchw(DelayChFineName) + + if ((TimingMode=="Phase") or (TimingMode=="DelayAndPhase")): + PhaseV1.searchw(PhaseV1Name) + PhaseV2.searchw(PhaseV2Name) + + if (LaserTriggerMode=="external"): + EVRCh.searchw(EVRChName) + +#Maybe will be useful to automatically set pulse width for different lasers +#def SetPulseWidth(): + + +def CurrentSituation(FromScopeXrayMinusLaser): + + global LaserPeriod, OffsetT + if (TimingMode=="Delay"): + PhaseT=0 + if (TimingMode=="Phase"): + DelayCardT=0 + + if (LaserTriggerMode=="external"): + LaserPeriod=1000*EVRCh.getw() + print ("LaserPeriod=", LaserPeriod) + + if ((TimingMode=="Delay") or (TimingMode=="DelayAndPhase")): + dt1=DelayCh.getw() + dt2=DelayChFine.getw() + DelayCardT=2.0*dt1+0.01*dt2 + + + + if ((TimingMode=="Phase") or (TimingMode=="DelayAndPhase")): + PhaseT=GetPhaseT() + Ph=FromScopeXrayMinusLaser%CavityPeriod + OffsetPhaseT=-PhaseT-Ph + OffsetT=-DelayCardT-FromScopeXrayMinusLaser + + print ("LaserPeriod=", LaserPeriod) + print ("OffsetT=", OffsetT) + print ("Current PhaseT", PhaseT) + print ("Offset Phse shifter T", OffsetPhaseT) + qwe=(LaserPeriod, OffsetT, OffsetPhaseT, PhaseT) +# print ("qwe=",qwe) + savetxt("Timing.offset", qwe) + + + +def GetPhaseT(): + v1=PhaseV1.getw() + v2=PhaseV2.getw() + phi=atan2(v2,v1) + T=RFPeriod*phi/(2*pi) + return T + + + +def SetVoltages(phi): + + #Vmax may be different for different lasers or phase shifters + #if (PhaseShifterIndex==1): + Vmax=1.0 + + V1=Vmax*cos(phi) + V2=Vmax*sin(phi) + PhaseV1.putw(V1) + PhaseV2.putw(V2) + #set values via epics + print (V1, V2) + time.sleep(0.1) + + + + +def MovePhaseShifter(RelativeDelay): + + + + NumStepsPerPeriod=36 + DelayStep=sign(RelativeDelay)*RFPeriod/NumStepsPerPeriod + + #read current values of + + CurT=GetPhaseT() + for t1 in arange(0,RelativeDelay, DelayStep): + phi1=2*pi*(CurT+t1)/RFPeriod + SetVoltages(phi1) + + phi1=2*pi*(CurT+RelativeDelay)/RFPeriod + SetVoltages(phi1) + + + + +def MoveDelayCard(NewDelayTime): + print ("ndt,lp", NewDelayTime, LaserPeriod) + t1=NewDelayTime%LaserPeriod + t2=int(t1/2.0) + t3=int((t1%2.0)/0.009999) + print ("t1 t2 t3 ",t1,t2,t3) + DelayCh.putw(t2) + DelayChFine.putw(t3) + + + +def SetDelay(t=0): + global LaserPeriod, OffsetT + a=loadtxt("Timing.offset") + LaserPeriod=a[0] + OffsetT=a[1] + print ("a1=",a[1]) + OffsetPhaseT=a[2] + print ("OffsetPhaseT from File", OffsetPhaseT) + CurrentPhaseT=a[3] + print ("Current value of PhaseT", CurrentPhaseT) + + TotalT=-OffsetT-t + ForPhaseShifter=-t%CavityPeriod-OffsetPhaseT + print ("-t/Cavity", -t%CavityPeriod, "-OffsetPhaseT", OffsetPhaseT, "-CurrentPhaseT", CurrentPhaseT) + MoveDelayCard(TotalT) + print ("Relative Phase shifter delay", ForPhaseShifter-CurrentPhaseT) + MovePhaseShifter(ForPhaseShifter-CurrentPhaseT) + print ("OffsetT before save=", OffsetT) + savetxt("Timing.offset",(LaserPeriod,OffsetT,OffsetPhaseT,ForPhaseShifter)) + + +def main(): + + + ConnectEpics() +# CurSit=-20.015+15.46 +# #CurSit=-14.58+15.46 +# CurSit=546.04-546.92 + #CurSit=0.1 + #CurrentSituation(FromScopeXrayMinusLaser=CurSit) + + print ("------------------") + + SetDelay(-4.9) + +# SetDelay(0.5) +# time.sleep(5) +# SetDelay(.5) +if __name__=="__main__": + main() +#MovePhaseShifter(2.0) +#MoveDelayCard(25.) +#time.sleep(10) diff --git a/tests/tests_devices/test_laser_delay.py b/tests/tests_devices/test_laser_delay.py new file mode 100644 index 0000000..0c98d8e --- /dev/null +++ b/tests/tests_devices/test_laser_delay.py @@ -0,0 +1,1487 @@ +"""Unit tests for the LaserDelay positioner (mocked EPICS).""" + +# pylint: disable=redefined-outer-name,protected-access + +from __future__ import annotations + +import functools +import json +import math +import threading +import time +from pathlib import Path +from unittest import mock + +import numpy as np +import ophyd +import pytest +from bec_lib import messages +from bec_lib.bec_yaml_loader import yaml_load +from bec_lib.devicemanager import DeviceConfigError +from bec_server.device_server.devices.config_update_handler import ConfigUpdateHandler +from bec_server.device_server.devices.device_serializer import get_device_base_class +from bec_server.device_server.devices.devicemanager import DeviceManagerDS +from ophyd.signal import ConnectionTimeoutError, InternalSignalError +from ophyd.utils import LimitError, ReadOnlyError +from ophyd_devices.interfaces.base_classes.psi_device_base import DeviceStoppedError +from ophyd_devices.interfaces.protocols.bec_protocols import ( + BECDeviceProtocol, + BECPositionerProtocol, +) +from ophyd_devices.tests.utils import MockPV, patched_device + +from superxas_bec.devices.laser_delay import ( + PHASE_VOLTAGE_LIMIT, + LaserDelay, + LaserDelayBusyError, + LaserDelayHardwareError, + LaserDelayNotCalibratedError, + LaserDelayPersistenceError, + delay_card_setting, + fold_phase_delta, + phase_from_voltages, + phase_ramp_offsets, + voltages_for_phase, + wrap_phase, +) +from superxas_bec.devices.laser_delay_sim import SimLaserDelay + +CONFIG_DIR = Path(__file__).resolve().parents[2] / "superxas_bec" / "device_configs" +SCOPE = 546.04 - 546.92 + + +@pytest.fixture(autouse=True) +def fast_ramp(monkeypatch): + """Tests ramp without sleeping; the beamline minimum is tested on its own.""" + assert LaserDelay.MIN_STEP_SLEEP == 0.1 # the value the scientist tested + monkeypatch.setattr(LaserDelay, "MIN_STEP_SLEEP", 0.0) + + +def record_puts(device: LaserDelay) -> list: + """Record every put to the four written PVs, in order (thread safe enough for list.append).""" + log: list[tuple[str, object]] = [] + for signal in (device.delay_coarse, device.delay_fine, device.phase_v1, device.phase_v2): + pv = signal._write_pv + original = pv.put + + def recorded(value, *args, _pv=pv, _original=original, **kwargs): + log.append((_pv.pvname, value)) + return _original(value, *args, **kwargs) + + pv.put = recorded + return log + + +def set_hardware(device: LaserDelay, evr=2.0002, coarse=150, fine=37, v1=0.6, v2=-0.8) -> None: + """Set the mocked PV values.""" + device.evr_period._read_pv.mock_data = evr + device.delay_coarse._read_pv.mock_data = coarse + device.delay_fine._read_pv.mock_data = fine + device.phase_v1._read_pv.mock_data = v1 + device.phase_v2._read_pv.mock_data = v2 + + +@pytest.fixture +def laser_delay(): + """Uncalibrated device with mocked PVs and no sleep between ramp steps.""" + with patched_device(LaserDelay, name="laser_delay", step_sleep=0.0) as device: + set_hardware(device) + yield device + device.stop() + + +@pytest.fixture +def calibrated(laser_delay): + """Calibrated device.""" + laser_delay.calibrate(SCOPE) + return laser_delay + + +def wait_until(condition, timeout=5.0): + """Poll ``condition`` until it is true.""" + start = time.monotonic() + while time.monotonic() - start < timeout: + if condition(): + return True + time.sleep(0.005) + return False + + +# --------------------------------------------------------------------------------------------- # +# Construction and configuration # +# --------------------------------------------------------------------------------------------- # + + +def test_default_pvs_constants_and_positioner_type(laser_delay): + assert laser_delay.delay_coarse.pvname == "X10DA-ES2-TIM:SET1-DELAY" + assert laser_delay.delay_fine.pvname == "X10DA-ES2-TIM:SET1-FINE" + assert laser_delay.evr_period.pvname == "X10DA-ES2-TIM:EVR-FPS2" + assert laser_delay.phase_v1.pvname == "X10DA-ES4:AO-02" + assert laser_delay.phase_v2.pvname == "X10DA-ES4:AO-01" + config = laser_delay.read_configuration() + assert config["laser_delay_rf_period"]["value"] == 2.0 + assert config["laser_delay_cavity_period"]["value"] == 12.01 + assert config["laser_delay_vmax"]["value"] == 1.0 + assert config["laser_delay_steps_per_rf_period"]["value"] == 36 + assert config["laser_delay_step_sleep"]["value"] == 0.0 + assert config["laser_delay_phase_path"]["value"] == "script" + assert get_device_base_class(laser_delay) == "positioner" + assert laser_delay.hints == {"fields": ["laser_delay"]} + assert laser_delay.egu == "ns" + assert laser_delay.limits == (0.0, 0.0) + assert math.isnan(laser_delay.read()["laser_delay"]["value"]) + assert set(LaserDelay.USER_ACCESS) == { + "calibrate", + "set_calibration", + "get_calibration", + "get_calibration_info", + "plan_move", + } + assert config["laser_delay_fine_rollover"]["value"] is True + assert laser_delay.calibration_file is None + assert isinstance(laser_delay, BECPositionerProtocol) + assert isinstance(laser_delay, BECDeviceProtocol) + + +def test_config_overrides_pvs_and_constants(): + with patched_device( + LaserDelay, + name="ld", + delay_coarse_pv="X99:DELAY", + delay_fine_pv="X99:FINE", + evr_period_pv="X99:EVR", + phase_v1_pv="X99:V1", + phase_v2_pv="X99:V2", + rf_period=2.0014, + cavity_period=12.0084, + vmax=0.8, + steps_per_rf_period=72, + step_sleep=0.0, + limits=[-20, 20], + ) as device: + assert [s.pvname for s in (device.delay_coarse, device.delay_fine, device.evr_period)] == [ + "X99:DELAY", + "X99:FINE", + "X99:EVR", + ] + assert (device.phase_v1.pvname, device.phase_v2.pvname) == ("X99:V1", "X99:V2") + assert device.limits == (-20.0, 20.0) + set_hardware(device, v1=0.8, v2=0.0) + log = record_puts(device) + device.calibrate(1.5) + plan = device.plan_move(3.0) + device.set(3.0).wait(timeout=10) + phase_puts = log[2:] + # first move after calibrate: the stored phase is the read-back, so the ramp goes from the + # read-back phase straight to the target + assert plan["phase_delta"] == pytest.approx(plan["phase_target"] - plan["phase_start"]) + expected_steps = len(phase_ramp_offsets(plan["phase_delta"], 2.0014, 72)) + 1 + assert len(phase_puts) == 2 * expected_steps == 2 * plan["phase_steps"] + for (_, v1), (_, v2) in zip(phase_puts[::2], phase_puts[1::2]): + assert math.hypot(v1, v2) == pytest.approx(0.8) + # step size is rf/72 + phases = [ + phase_from_voltages(v1, v2, 2.0014) for (_, v1), (_, v2) in zip(log[2::2], log[3::2]) + ] + steps = np.abs(np.diff(np.unwrap(np.asarray(phases) * 2 * np.pi / 2.0014))) + assert np.max(steps[:-1]) == pytest.approx(2 * np.pi / 72) + with pytest.raises(LimitError): + device.set(25.0) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"steps_per_rf_period": 35}, + {"vmax": 0.0}, + {"phase_path": "fast"}, + {"steps_per_rf_period": 0}, + {"steps_per_rf_period": 2.5}, + {"rf_period": -2.0}, + {"cavity_period": 0}, + {"vmax": float("nan")}, + {"vmax": 5.5}, + {"fine_rollover": "yes"}, + {"step_sleep": -0.1}, + {"limits": [1.0]}, + {"limits": [5.0, -5.0]}, + {"limits": [-5.0, float("inf")]}, + ], +) +def test_invalid_config_is_rejected(kwargs): + with pytest.raises(ValueError): + with patched_device(LaserDelay, name="laser_delay", **kwargs): + pass + + +def test_runtime_constant_update_is_validated(calibrated): + """deviceConfig updates at runtime (BEC calls .set on the signal) are validated.""" + calibrated.step_sleep.set(0.2).wait(timeout=2) + assert calibrated._constants().step_sleep == 0.2 + with pytest.raises(ValueError): + calibrated.step_sleep.set(-1).wait(timeout=2) + with pytest.raises(ValueError): + calibrated.steps_per_rf_period.set(18).wait(timeout=2) + assert calibrated._constants().steps_per_rf_period == 36 + with pytest.raises(ValueError): + calibrated.phase_path.set("fastest").wait(timeout=2) + calibrated.phase_path.set("shortest").wait(timeout=2) + assert calibrated._constants().phase_path == "shortest" + + +def test_runtime_limits_are_validated(calibrated): + """BEC writes runtime limits (dev.laser_delay.limits = [...]) one signal at a time.""" + with pytest.raises(ValueError): + calibrated.low_limit_travel.set(float("nan")).wait(timeout=2) + calibrated.low_limit_travel.set(5.0).wait(timeout=2) + calibrated.high_limit_travel.set(-5.0).wait(timeout=2) + with pytest.raises(LimitError, match="inverted"): + calibrated.set(0.0) + calibrated.high_limit_travel.set(10.0).wait(timeout=2) + with pytest.raises(LimitError, match="outside"): + calibrated.set(0.0) + calibrated.set(7.0).wait(timeout=10) + assert calibrated.read()["laser_delay"]["value"] == 7.0 + + +def config_update_handler(device: LaserDelay, device_config: dict) -> ConfigUpdateHandler: + """BEC's ConfigUpdateHandler with just enough device manager for runtime deviceConfig updates.""" + entry = mock.MagicMock() + entry.name, entry.enabled, entry.obj = device.name, True, device + entry._config = {"deviceConfig": dict(device_config)} + device_manager = mock.MagicMock() + device_manager.devices = {device.name: entry} + device_manager.update_config = functools.partial(DeviceManagerDS.update_config, device_manager) + handler = ConfigUpdateHandler.__new__(ConfigUpdateHandler) + handler.device_manager = device_manager + return handler + + +def test_runtime_config_update_errors_name_the_real_problem(): + """A failed runtime deviceConfig update makes BEC re-apply the whole previous deviceConfig, + including the PV names. That rollback must succeed so the user sees the actual error.""" + entry = yaml_load(str(CONFIG_DIR / "x10da_laser_delay.yaml"))["laser_delay"] + with patched_device(LaserDelay, name="laser_delay", **entry["deviceConfig"]) as device: + handler = config_update_handler(device, entry["deviceConfig"]) + + def update(config): + msg = messages.DeviceConfigMessage( + action="update", config={"laser_delay": {"deviceConfig": config}} + ) + handler._update_config(msg, threading.Event()) + + with pytest.raises(DeviceConfigError, match="phase_path must be one of"): + update({"phase_path": "fastest"}) + assert device.phase_path.get() == "script" + with pytest.raises(DeviceConfigError, match="reloading the device config"): + update({"phase_v1_pv": "X10DA-ES4:AO-03"}) + assert device.phase_v1.pvname == "X10DA-ES4:AO-02" + with pytest.raises(DeviceConfigError, match="finite"): + update({"limits": [float("nan"), 1.0]}) + update({"step_sleep": 0.05, "phase_v1_pv": "X10DA-ES4:AO-02", "limits": [-20, 20]}) + assert device.step_sleep.get() == 0.05 + assert device.limits == (-20.0, 20.0) + + +def test_calibration_signals_are_internal(calibrated): + """Calibration can only change through calibrate()/set_calibration()/moves.""" + with pytest.raises(InternalSignalError): + calibrated.offset_t.put(1.0) + with pytest.raises(InternalSignalError): + calibrated.readback.put(1.0) + + +def test_yaml_entries_map_onto_constructor_arguments(): + """Every deviceConfig key of both YAML files is a constructor argument (no leftovers), and the + standard config pulls laser_delay in through the experimental hutch include.""" + standard = yaml_load(str(CONFIG_DIR / "x10da_standard_config.yaml")) + hutch = yaml_load(str(CONFIG_DIR / "x10da_experimental_hutch.yaml")) + sim = yaml_load(str(CONFIG_DIR / "laser_delay_sim.yaml")) + assert standard["laser_delay"] == hutch["laser_delay"] + entry = standard["laser_delay"] + assert entry["deviceClass"] == "superxas_bec.devices.laser_delay.LaserDelay" + assert entry["onFailure"] == "buffer" + assert sim["laser_delay"]["deviceClass"] == "superxas_bec.devices.laser_delay_sim.SimLaserDelay" + for config in (entry, sim["laser_delay"]): + with mock.patch.object(ophyd, "cl") as mock_cl: + mock_cl.get_pv = MockPV + mock_cl.thread_class = threading.Thread + obj, leftover = DeviceManagerDS.construct_device_obj( + {"name": "laser_delay", **config}, device_manager=None + ) + assert leftover == {} + assert isinstance(obj, LaserDelay) + obj.destroy() + + +# --------------------------------------------------------------------------------------------- # +# Calibration # +# --------------------------------------------------------------------------------------------- # + + +def test_refuses_to_move_before_calibration(laser_delay): + log = record_puts(laser_delay) + with pytest.raises(LaserDelayNotCalibratedError, match="calibrate"): + laser_delay.set(1.0) + with pytest.raises(LaserDelayNotCalibratedError): + laser_delay.plan_move(1.0) + assert log == [] + assert not laser_delay.moving + + +def test_calibrate_stores_config_signals_and_sets_readback(laser_delay): + result = laser_delay.calibrate(SCOPE) + delay_card_t = 2.0 * 150 + 0.01 * 37 + phase_t = 2.0 * math.atan2(-0.8, 0.6) / (2 * math.pi) + assert result["laser_period"] == pytest.approx(2000.2) + assert result["offset_t"] == pytest.approx(-delay_card_t - SCOPE) + assert result["phase_t"] == pytest.approx(phase_t) + assert result["offset_phase_t"] == pytest.approx(-phase_t - SCOPE % 12.01) + config = laser_delay.read_configuration() + for key, value in result.items(): + assert config[f"laser_delay_{key}"]["value"] == value + assert laser_delay.get_calibration() == result + reading = laser_delay.read() + assert reading["laser_delay"]["value"] == SCOPE + assert reading["laser_delay_phase_mismatch"]["value"] == pytest.approx(0.0, abs=1e-12) + assert laser_delay.position == SCOPE + + +def test_calibrate_rejects_bad_input(laser_delay): + with pytest.raises(ValueError): + laser_delay.calibrate(float("nan")) + laser_delay.evr_period._read_pv.mock_data = 0.0 + with pytest.raises(ValueError, match="laser period"): + laser_delay.calibrate(SCOPE) + assert math.isnan(laser_delay.get_calibration()["offset_t"]) + + +def test_calibration_publishes_readback_subscription(laser_delay): + """BEC listens to the 'readback' event; calibrate() and moves must fire it.""" + values = [] + laser_delay.subscribe(lambda value, **_: values.append(value), event_type="readback", run=False) + laser_delay.calibrate(SCOPE) + laser_delay.set(1.0).wait(timeout=10) + # NaN while the move runs: the delay is undefined until the ramp has finished + assert values[0] == SCOPE and math.isnan(values[1]) and values[2:] == [1.0] + + +def test_set_calibration_restores_previous_state(laser_delay): + laser_delay.calibrate(SCOPE) + laser_delay.set(2.5).wait(timeout=10) + saved = laser_delay.get_calibration() + v1, v2 = laser_delay.phase_v1.get(), laser_delay.phase_v2.get() + + # BEC restart: a fresh device, hardware untouched + with patched_device(LaserDelay, name="laser_delay", step_sleep=0.0) as fresh: + set_hardware( + fresh, + coarse=laser_delay.delay_coarse.get(), + fine=laser_delay.delay_fine.get(), + v1=v1, + v2=v2, + ) + with pytest.raises(LaserDelayNotCalibratedError): + fresh.set(1.0) + # Timing.offset order: laser_period, offset_t, offset_phase_t, phase_t + fresh.set_calibration(*saved.values(), current_delay=2.5) + assert fresh.get_calibration() == saved + assert fresh.read()["laser_delay"]["value"] == 2.5 + log_fresh = record_puts(fresh) + fresh.set(-1.0).wait(timeout=10) + log = record_puts(laser_delay) + laser_delay.set(-1.0).wait(timeout=10) + assert log_fresh == log + + +@pytest.mark.parametrize( + "values", [(0.0, 1.0, 1.0, 1.0), (-5.0, 1.0, 1.0, 1.0), (2000.0, float("nan"), 1.0, 1.0)] +) +def test_set_calibration_validates(laser_delay, values): + with pytest.raises(ValueError): + laser_delay.set_calibration(*values) + + +# --------------------------------------------------------------------------------------------- # +# Moves # +# --------------------------------------------------------------------------------------------- # + + +def test_move_sets_delay_card_and_ramps_phase(calibrated): + log = record_puts(calibrated) + plan = calibrated.plan_move(-4.9) + status = calibrated.set(-4.9) + status.wait(timeout=10) + assert status.success + assert log[0] == ("X10DA-ES2-TIM:SET1-DELAY", plan["delay_coarse"]) + assert log[1] == ("X10DA-ES2-TIM:SET1-FINE", plan["delay_fine"]) + assert len(log) == 2 + 2 * plan["phase_steps"] + v1, v2 = voltages_for_phase(plan["phase_start"] + plan["phase_delta"], 2.0, 1.0) + assert log[-2][1] == v1 and log[-1][1] == v2 + assert calibrated.phase_t.get() == plan["phase_target"] + reading = calibrated.read() + assert reading["laser_delay"]["value"] == -4.9 + assert reading["laser_delay_delay_coarse"]["value"] == plan["delay_coarse"] + assert reading["laser_delay_phase_mismatch"]["value"] == pytest.approx(0.0, abs=1e-9) + assert calibrated.motor_is_moving.get() == 0 + assert not calibrated.moving + + +def test_move_does_not_block_and_reports_moving(calibrated): + calibrated.step_sleep.put(0.01) + moving = [] + calibrated.motor_is_moving.subscribe(lambda value, **_: moving.append(value), run=False) + start = time.monotonic() + status = calibrated.set(-4.9) # 290 steps, ~3 s + assert time.monotonic() - start < 0.5 + assert not status.done + assert calibrated.moving + assert calibrated.motor_is_moving.get() == 1 + refused = calibrated.set(0.0) # not raised: a failed status, the running move goes on + assert refused.done and not refused.success + with pytest.raises(LaserDelayBusyError): + refused.wait(timeout=1) + assert calibrated.moving and not status.done + with pytest.raises(LaserDelayBusyError): + calibrated.calibrate(SCOPE) + calibrated.stop() + with pytest.raises(DeviceStoppedError): + status.wait(timeout=2) + assert wait_until(lambda: calibrated.motor_is_moving.get() == 0) + assert moving[:1] == [1] and moving[-1] == 0 + + +def test_move_requested_during_calibration_runs_after_it(calibrated): + """BEC's device server runs instructions on a thread pool, so a set can arrive while calibrate() + reads the PVs. The move must not write while the calibration reads, and must then use the new + calibration.""" + reading, release = threading.Event(), threading.Event() + original_get = calibrated.delay_fine.get + + def slow_get(*args, **kwargs): + reading.set() + release.wait(timeout=5) + return original_get(*args, **kwargs) + + set_hardware(calibrated, coarse=160, fine=12) # the hardware was moved by hand since + log = record_puts(calibrated) + result = {} + with mock.patch.object(calibrated.delay_fine, "get", side_effect=slow_get): + thread = threading.Thread(target=lambda: result.update(cal=calibrated.calibrate(2.5))) + thread.start() + assert reading.wait(timeout=5) + status = calibrated.set(1.0) + time.sleep(0.1) + assert log == [], "the move wrote PVs while calibrate() was reading them" + release.set() + thread.join(timeout=5) + status.wait(timeout=10) + assert status.success + cal = result["cal"] + assert cal["offset_t"] == pytest.approx(-(2.0 * 160 + 0.01 * 12) - 2.5) + assert (log[0][1], log[1][1]) == delay_card_setting(-cal["offset_t"] - 1.0, cal["laser_period"]) + assert calibrated.read()["laser_delay"]["value"] == 1.0 + + +def test_calibration_refused_while_a_stopped_move_finishes_its_step(calibrated): + calibrated.step_sleep.put(0.05) + blocker = threading.Event() + original_put = calibrated.phase_v1._write_pv.put + + def blocking_put(value, *args, **kwargs): + blocker.wait(timeout=5) # a slow CA put + return original_put(value, *args, **kwargs) + + calibrated.phase_v1._write_pv.put = blocking_put + status = calibrated.set(-4.9) + assert wait_until(calibrated._hw_lock.locked) + calibrated.stop() + assert status.done and not status.success + with pytest.raises(LaserDelayBusyError): + calibrated.calibrate(SCOPE) + with pytest.raises(LaserDelayBusyError): + calibrated.set_calibration(2000.2, 1.0, 1.0, 1.0) + blocker.set() + assert wait_until(lambda: not calibrated.moving) + calibrated.calibrate(SCOPE) + + +def test_stop_mid_ramp_keeps_last_step_and_fails_status(calibrated): + calibrated.step_sleep.put(0.05) + log = record_puts(calibrated) + old_phase_t = calibrated.phase_t.get() + plan = calibrated.plan_move(-4.9) + status = calibrated.set(-4.9) + assert wait_until(lambda: len(log) >= 2 + 2 * 5) + start = time.monotonic() + calibrated.stop() + with pytest.raises(DeviceStoppedError): + status.wait(timeout=1) + assert time.monotonic() - start < 0.5 + assert wait_until(lambda: not calibrated._hw_lock.locked()) + n_puts = len(log) + time.sleep(0.2) + assert len(log) == n_puts, "puts continued after stop" + assert n_puts % 2 == 0, "a step was left half written" + steps_written = (n_puts - 2) // 2 + assert 5 <= steps_written < plan["phase_steps"] + # voltages stay at the last written step + assert calibrated.phase_v1.get() == log[-2][1] + assert calibrated.phase_v2.get() == log[-1][1] + # bookkeeping: stored phase advanced by what was actually ramped + last_offset = phase_ramp_offsets(plan["phase_delta"], 2.0, 36)[steps_written - 1] + assert calibrated.phase_t.get() == pytest.approx(old_phase_t + last_offset) + assert calibrated.read()["laser_delay_phase_mismatch"]["value"] == pytest.approx(0, abs=1e-9) + assert math.isnan(calibrated.read()["laser_delay"]["value"]) + assert calibrated.motor_is_moving.get() == 0 + + # the next move works and ends where an uninterrupted move would have ended + calibrated.step_sleep.put(0.0) + calibrated.set(-4.9).wait(timeout=10) + assert calibrated.phase_t.get() == plan["phase_target"] + v1, v2 = voltages_for_phase(plan["phase_target"], 2.0, 1.0) + assert calibrated.phase_v1.get() == pytest.approx(v1, abs=1e-12) + assert calibrated.phase_v2.get() == pytest.approx(v2, abs=1e-12) + assert calibrated.read()["laser_delay"]["value"] == -4.9 + + +def test_new_move_right_after_stop_does_not_revive_the_old_ramp(calibrated): + """The scan server may send the next move as soon as the stopped status fails. The old ramp must + not continue, and the new move must start only after the old task has let go of the PVs.""" + calibrated.step_sleep.put(0.02) + log = record_puts(calibrated) + old = calibrated.set(-4.9) + assert wait_until(lambda: len(log) >= 2 + 2 * 5) + calibrated.stop() + with pytest.raises(DeviceStoppedError): + old.wait(timeout=1) + calibrated.step_sleep.put(0.0) + new = calibrated.set(0.0) + new.wait(timeout=10) + assert new.success + delay_puts = [i for i, (pv, _) in enumerate(log) if pv == "X10DA-ES2-TIM:SET1-DELAY"] + assert len(delay_puts) == 2 + assert delay_puts[1] % 2 == 0 # the old move ended on a complete V1/V2 pair + expected_phase = (-0.0) % 12.01 - calibrated.offset_phase_t.get() + assert calibrated.phase_t.get() == expected_phase + assert calibrated.read()["laser_delay_phase_mismatch"]["value"] == pytest.approx(0, abs=1e-9) + assert calibrated.read()["laser_delay"]["value"] == 0.0 + + +def test_stop_is_idempotent_without_move(calibrated): + calibrated.stop() + calibrated.stop() + calibrated.set(0.5).wait(timeout=10) + assert calibrated.read()["laser_delay"]["value"] == 0.5 + + +def test_status_timeout_also_stops_the_ramp(calibrated): + calibrated.step_sleep.put(0.05) + log = record_puts(calibrated) + status = calibrated.move(-4.9, timeout=0.3) + with pytest.raises(Exception): + status.wait(timeout=2) + assert wait_until(lambda: not calibrated._hw_lock.locked()) + n_puts = len(log) + time.sleep(0.2) + assert len(log) == n_puts < 2 + 2 * 290 + + +def test_destroy_ends_a_running_move(): + with patched_device(LaserDelay, name="laser_delay", step_sleep=0.05) as device: + set_hardware(device) + device.calibrate(SCOPE) + status = device.set(-4.9) + device.destroy() + assert wait_until(lambda: status.done) + assert not status.success + + +def test_destroy_fails_the_move_status_even_if_the_task_hangs(): + """destroy() (config reload, device server shutdown) must release whoever waits on the move, + also when the move task is stuck in a slow CA put and the thread kill does not reach it.""" + with patched_device(LaserDelay, name="laser_delay", step_sleep=0.0) as device: + set_hardware(device) + device.calibrate(SCOPE) + blocker = threading.Event() + original_put = device.phase_v1._write_pv.put + + def hung_put(value, *args, **kwargs): + blocker.wait(timeout=5) + return original_put(value, *args, **kwargs) + + device.phase_v1._write_pv.put = hung_put + status = device.set(-4.9) + assert wait_until(device._hw_lock.locked) + with mock.patch.object(device.task_handler, "shutdown"): + device.destroy() + assert status.done and not status.success + with pytest.raises(DeviceStoppedError, match="destroyed"): + status.wait(timeout=0) + blocker.set() + assert wait_until(lambda: not device._hw_lock.locked()) + + +def fail_on_put(signal, call: int, exc: Exception) -> None: + """Make the ``call``-th put to ``signal`` raise ``exc`` (the IOC went away).""" + pv = signal._write_pv + original, count = pv.put, {"n": 0} + + def put(value, *args, **kwargs): + count["n"] += 1 + if count["n"] == call: + raise exc + return original(value, *args, **kwargs) + + pv.put = put + + +def test_pv_lost_mid_ramp_fails_the_move_and_keeps_the_phase_bookkeeping(calibrated): + """V2 stops answering in the middle of the ramp: the move fails with the EPICS error, the device + is idle again, the readback is NaN, the stored phase follows the last complete V1/V2 pair and + the next move (IOC back) works.""" + old_phase_t = calibrated.phase_t.get() + plan = calibrated.plan_move(-4.9) + log = record_puts(calibrated) + error = ConnectionTimeoutError("X10DA-ES4:AO-01 could not connect within 1.0 s") + fail_on_put(calibrated.phase_v2, 6, error) + status = calibrated.set(-4.9) + with pytest.raises(ConnectionTimeoutError): + status.wait(timeout=5) + assert wait_until(lambda: not calibrated.moving) + assert calibrated.motor_is_moving.get() == 0 + assert math.isnan(calibrated.readback.get()) + # DELAY, FINE, 5 complete pairs, then V1 of the 6th step (V2 failed) + assert [pv for pv, _ in log[:2]] == ["X10DA-ES2-TIM:SET1-DELAY", "X10DA-ES2-TIM:SET1-FINE"] + assert len(log) == 2 + 2 * 5 + 1 + offsets = phase_ramp_offsets(plan["phase_delta"], 2.0, 36) + assert calibrated.phase_t.get() == pytest.approx(old_phase_t + offsets[4]) + # the half-written pair leaves less than one ramp step of mismatch; phase_mismatch shows it + assert abs(calibrated.read_phase_mismatch()) < 2.0 / 36 + + calibrated.set(-4.9).wait(timeout=10) + assert calibrated.read()["laser_delay"]["value"] == -4.9 + assert calibrated.phase_t.get() == plan["phase_target"] + + +def test_delay_card_write_failure_leaves_the_phase_shifter_alone(calibrated): + old_phase_t = calibrated.phase_t.get() + log = record_puts(calibrated) + fail_on_put(calibrated.delay_fine, 1, ConnectionTimeoutError("SET1-FINE not connected")) + status = calibrated.set(-4.9) + with pytest.raises(ConnectionTimeoutError): + status.wait(timeout=5) + assert [pv for pv, _ in log] == ["X10DA-ES2-TIM:SET1-DELAY"] + assert calibrated.phase_t.get() == old_phase_t + assert math.isnan(calibrated.readback.get()) + assert calibrated.motor_is_moving.get() == 0 + + +def test_phase_read_failure_refuses_the_move_before_any_write(calibrated): + old_phase_t = calibrated.phase_t.get() + log = record_puts(calibrated) + with mock.patch.object( + calibrated.phase_v1, "get", side_effect=ConnectionTimeoutError("AO-02 not connected") + ): + status = calibrated.set(-4.9) + with pytest.raises(ConnectionTimeoutError): + status.wait(timeout=5) + assert log == [] + assert calibrated.phase_t.get() == old_phase_t + assert calibrated.readback.get() == pytest.approx(SCOPE) # nothing moved + assert not calibrated.moving + + +def test_move_refused_when_voltages_were_touched(calibrated): + """Somebody turned the phase shifter outside BEC: the next move is refused before any write, + and every later move too, until a new calibration.""" + calibrated.set(1.0).wait(timeout=10) + calibrated.phase_v1._read_pv.mock_data = 0.0 + calibrated.phase_v2._read_pv.mock_data = 1.0 + assert calibrated.read()["laser_delay_phase_mismatch"]["value"] != pytest.approx(0, abs=1e-3) + assert "away from the stored phase" in calibrated.plan_move(2.0)["refused"] + log = record_puts(calibrated) + status = calibrated.set(2.0) + with pytest.raises(LaserDelayHardwareError, match="away from the stored phase"): + status.wait(timeout=10) + assert log == [] + assert calibrated.readback.get() == 1.0 # nothing was written + assert not calibrated.get_calibration_info()["verified"] + with pytest.raises(LaserDelayNotCalibratedError, match="does not match the hardware"): + calibrated.set(2.0) + calibrated.calibrate(SCOPE) + calibrated.set(2.0).wait(timeout=10) + assert calibrated.readback.get() == 2.0 + + +def test_mismatch_below_one_step_is_accepted(calibrated): + """A V1 written without its V2 (IOC failure mid-pair) leaves less than one step: allowed.""" + calibrated.set(1.0).wait(timeout=10) + phase = calibrated.read_phase() + v1, v2 = voltages_for_phase(phase + 0.04, 2.0, 1.0) + calibrated.phase_v1._read_pv.mock_data = v1 + calibrated.phase_v2._read_pv.mock_data = v2 + calibrated.set(2.0).wait(timeout=10) + assert calibrated.readback.get() == 2.0 + + +def test_plan_move_writes_nothing(calibrated): + log = record_puts(calibrated) + plan = calibrated.plan_move(5.3) + assert log == [] + assert plan["estimated_duration_s"] == 0.0 # step_sleep 0 in the fixture + assert plan["phase_steps"] == len(phase_ramp_offsets(plan["phase_delta"], 2.0, 36)) + 1 + + +def test_first_move_after_calibration_can_ramp_two_cavity_periods(laser_delay): + """Module docstring: dP = (-t mod cav) + (s mod cav) right after calibration.""" + laser_delay.calibrate(SCOPE) + plan = laser_delay.plan_move(-4.9) + assert plan["phase_delta"] == pytest.approx(4.9 + SCOPE % 12.01) + assert plan["phase_delta"] > 12.01 + laser_delay.set(-4.9).wait(timeout=10) + # later moves stay within one cavity period + for target in (0.0, 5.3, -4.9, 11.9, 12.2): + assert abs(laser_delay.plan_move(target)["phase_delta"]) < 12.01 + laser_delay.set(target).wait(timeout=10) + + +# --------------------------------------------------------------------------------------------- # +# phase_path "shortest" # +# --------------------------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + "delta, expected, wraps", + [ + (0.0, 0.0, 0), + (6.005, 6.005, 0), # +cavity/2 is inside + (-6.005, 6.005, -1), # -cavity/2 is not + (-6.0, -6.0, 0), + (6.006, 6.006 - 12.01, 1), + (16.13, 16.13 - 12.01, 1), + (11.51, 11.51 - 12.01, 1), + (-24.03, -24.03 + 2 * 12.01, -2), + (30.0, 30.0 - 2 * 12.01, 2), + ], +) +def test_fold_phase_delta(delta, expected, wraps): + folded, k = fold_phase_delta(delta, 12.01) + assert k == wraps and isinstance(k, int) + assert folded == pytest.approx(expected, abs=1e-12) + + +def test_fold_phase_delta_properties(): + rng = np.random.default_rng(7) + for delta in rng.uniform(-60, 60, 2000): + folded, k = fold_phase_delta(float(delta), 12.01) + assert -6.005 - 1e-9 < folded <= 6.005 + 1e-9 + assert folded == pytest.approx(delta - k * 12.01, abs=1e-12) + if -6.005 < delta <= 6.005: + assert folded == delta and k == 0 # unchanged, bit for bit + + +SHORTEST_TARGETS = [-5.0, 5.0, -5.0, 0.0, 0.5, 11.9, 12.2, -24.0, 37.25, -0.88, 400.0, -1500.3] + + +def run_targets(device: LaserDelay, targets: list) -> list[dict]: + """Plan and move to each target; return the plans with the final voltages and stored phase.""" + results = [] + for target in targets: + plan = device.plan_move(target) + device.set(target).wait(timeout=30) + plan["v1"], plan["v2"] = device.phase_v1.get(), device.phase_v2.get() + plan["stored"] = device.phase_t.get() + plan["mismatch"] = device.read_phase_mismatch() + plan["readback"] = device.readback.get() + results.append(plan) + return results + + +@pytest.mark.parametrize("rf_period, cavity_period", [(2.0, 12.01), (2.0014, 12.0084)]) +def test_shortest_path_ramps_at_most_half_a_cavity_period(rf_period, cavity_period): + """Same delay card as the script path, at most cavity/2 of phase per move (also the first move + after calibration), consistent bookkeeping, and the phase shifter ends where the script path + puts it, up to the documented wrap offset (0 when the cavity is a whole number of RF periods). + """ + kwargs = {"step_sleep": 0.0, "rf_period": rf_period, "cavity_period": cavity_period} + runs = {} + for path in ("script", "shortest"): + with patched_device(LaserDelay, name="laser_delay", phase_path=path, **kwargs) as device: + set_hardware(device) + device.calibrate(SCOPE) + runs[path] = run_targets(device, SHORTEST_TARGETS) + max_steps = math.ceil((cavity_period / 2) / (rf_period / 36)) + 1 + for script, short in zip(runs["script"], runs["shortest"]): + assert (short["delay_coarse"], short["delay_fine"]) == ( + script["delay_coarse"], + script["delay_fine"], + ) + assert short["phase_path"] == "shortest" and script["cavity_wraps"] == 0 + assert abs(short["phase_delta"]) <= cavity_period / 2 + 1e-9 + assert short["phase_steps"] <= max_steps + assert short["readback"] == short["target"] + assert short["mismatch"] == pytest.approx(0, abs=1e-9) + k = short["cavity_wraps"] + assert short["stored"] == pytest.approx(script["stored"] - k * cavity_period, abs=1e-9) + end_short = phase_from_voltages(short["v1"], short["v2"], rf_period) + end_script = phase_from_voltages(script["v1"], script["v2"], rf_period) + offset = wrap_phase(-k * cavity_period, rf_period) + assert short["wrap_offset_ns"] == pytest.approx(offset, abs=1e-12) + assert wrap_phase(end_short - end_script - offset, rf_period) == pytest.approx(0, abs=1e-9) + if cavity_period == 12.0084: + assert offset == pytest.approx(0, abs=1e-9) + else: + assert offset == pytest.approx(wrap_phase(-0.01 * k, 2.0), abs=1e-9) + first = runs["shortest"][0] + assert runs["script"][0]["phase_delta"] > cavity_period # script: long first move + assert first["phase_delta"] == pytest.approx( + runs["script"][0]["phase_delta"] - cavity_period, abs=1e-9 + ) + + +def test_shortest_path_is_the_script_path_when_no_fold_is_needed(): + """Moves that stay within half a cavity period write exactly what the script path writes.""" + targets = [1.5, 2.0, 3.0, 2.2, 7.0] + with patched_device(LaserDelay, name="laser_delay", step_sleep=0.0) as device: + set_hardware(device) + device.calibrate(SCOPE) + device.set(1.0).wait(timeout=10) + state = device.get_calibration() + hardware = { + "coarse": device.delay_coarse.get(), + "fine": device.delay_fine.get(), + "v1": device.phase_v1.get(), + "v2": device.phase_v2.get(), + } + log_script = record_puts(device) + for target in targets: + device.set(target).wait(timeout=10) + with patched_device( + LaserDelay, name="laser_delay", step_sleep=0.0, phase_path="shortest" + ) as device: + set_hardware(device, **hardware) + device.set_calibration(**state, current_delay=1.0) + log_short = record_puts(device) + for target in targets: + assert device.plan_move(target)["cavity_wraps"] == 0 + device.set(target).wait(timeout=10) + assert log_short == log_script + + +def test_stop_during_a_shortest_path_move_keeps_the_bookkeeping(laser_delay): + laser_delay.phase_path.put("shortest") + laser_delay.calibrate(SCOPE) + laser_delay.step_sleep.put(0.02) + log = record_puts(laser_delay) + plan = laser_delay.plan_move(-5.0) + assert plan["cavity_wraps"] == 1 and 0 < plan["phase_delta"] < 6.005 + status = laser_delay.set(-5.0) + assert wait_until(lambda: len(log) >= 2 + 2 * 5) + laser_delay.stop() + with pytest.raises(DeviceStoppedError): + status.wait(timeout=1) + assert wait_until(lambda: not laser_delay.moving) + assert laser_delay.read_phase_mismatch() == pytest.approx(0, abs=1e-9) + laser_delay.step_sleep.put(0.0) + laser_delay.set(-5.0).wait(timeout=10) + end = phase_from_voltages(laser_delay.phase_v1.get(), laser_delay.phase_v2.get(), 2.0) + expected = plan["phase_start"] + plan["phase_delta"] + assert wrap_phase(end - expected, 2.0) == pytest.approx(0, abs=1e-9) + assert laser_delay.phase_t.get() == pytest.approx(plan["phase_target"] - 12.01, abs=1e-9) + + +@pytest.mark.parametrize("value", [float("nan"), float("inf"), "abc"]) +def test_rejects_non_finite_targets(calibrated, value): + with pytest.raises(ValueError): + calibrated.set(value) + + +# --------------------------------------------------------------------------------------------- # +# Simulation # +# --------------------------------------------------------------------------------------------- # + + +def test_sim_device_moves_without_epics(): + device = SimLaserDelay(name="laser_delay", step_sleep=0.0) + try: + device.wait_for_connection() + assert not any(hasattr(sig.item, "pvname") for sig in device.walk_signals()) + result = device.calibrate(-0.88) + assert result["laser_period"] == pytest.approx(2000.2) + assert result["offset_t"] == pytest.approx(-300.37 + 0.88) + device.set(0.5).wait(timeout=30) + reading = device.read() + assert reading["laser_delay"]["value"] == 0.5 + assert reading["laser_delay_phase_mismatch"]["value"] == pytest.approx(0, abs=1e-9) + coarse, fine = ( + reading["laser_delay_delay_coarse"]["value"], + reading["laser_delay_delay_fine"]["value"], + ) + assert 2 * coarse + 0.01 * fine == pytest.approx( + (-result["offset_t"] - 0.5) % 2000.2, abs=0.011 + ) + finally: + device.destroy() + + +def test_sim_scope_follows_every_move_on_both_phase_paths(): + """The simulated oscilloscope shows the commanded delay after every move.""" + device = SimLaserDelay(name="laser_delay", step_sleep=0.0, sim_noise=0) + try: + assert device.sim_scope.get() == pytest.approx(-0.88) + device.calibrate(device.sim_scope.get()) + rng = np.random.default_rng(7) + for path in ("script", "shortest"): + device.phase_path.set(path).wait(timeout=2) + for target in rng.uniform(-40, 40, 25): + device.set(float(target)).wait(timeout=30) + assert device.sim_scope.get() == pytest.approx(target, abs=1e-6), path + device.set(-5.0).wait(timeout=30) + assert device.sim_response.get() == pytest.approx(0, abs=1e-6) + device.set(5.0).wait(timeout=30) + assert device.sim_response.get() == pytest.approx(1, abs=1e-6) + finally: + device.destroy() + + +def test_sim_scope_shows_a_wrong_calibration(): + device = SimLaserDelay(name="laser_delay", step_sleep=0.0, sim_noise=0) + try: + device.calibrate(-0.5) # scope misread by 0.38 ns + device.set(2.0).wait(timeout=30) + assert device.sim_scope.get() == pytest.approx(2.0 - 0.38) + finally: + device.destroy() + + +def test_sim_rejects_what_the_iocs_refuse(): + device = SimLaserDelay(name="laser_delay", step_sleep=0.0) + try: + with pytest.raises(ValueError): + device.sim_set_hardware(phase_v1=PHASE_VOLTAGE_LIMIT + 0.1) + with pytest.raises(ValueError): + device.sim_set_hardware(delay_fine=200) + finally: + device.destroy() + + +# --------------------------------------------------------------------------------------------- # +# Delay card fine value 200 # +# --------------------------------------------------------------------------------------------- # + + +def test_delay_card_fine_rollover(): + # t1 mod 2 = 1.99985 -> the script writes FINE 200 + assert delay_card_setting(301.99985, 2000.2) == (150, 200) + assert delay_card_setting(301.99985, 2000.2, fine_rollover=True) == (151, 0) + # the next coarse unit would be a full laser period: wrap to 0 + assert delay_card_setting(1999.99985, 2000.0, fine_rollover=True) == (0, 0) + rng = np.random.default_rng(3) + for total in rng.uniform(-5000, 5000, 5000): + script = delay_card_setting(total, 2000.2) + coarse, fine = delay_card_setting(total, 2000.2, fine_rollover=True) + assert 0 <= fine <= 199 + assert 2 * coarse + 0.01 * fine == pytest.approx(2 * script[0] + 0.01 * script[1]) + + +@pytest.mark.parametrize("rollover, expected", [(True, (151, 0)), (False, (150, 200))]) +def test_move_writes_rolled_over_fine_value(calibrated, rollover, expected): + calibrated.fine_rollover.set(rollover).wait(timeout=2) + offset_t = calibrated.get_calibration()["offset_t"] + target = -offset_t - 301.99985 # delay card total with FINE 200 + plan = calibrated.plan_move(target) + assert (plan["delay_coarse"], plan["delay_fine"]) == expected + log = record_puts(calibrated) + calibrated.set(target).wait(timeout=10) + assert [value for _, value in log[:2]] == list(expected) + + +# --------------------------------------------------------------------------------------------- # +# Phase shifter voltage limit # +# --------------------------------------------------------------------------------------------- # + + +def test_vmax_is_capped_at_the_hard_limit(calibrated): + calibrated.vmax.set(PHASE_VOLTAGE_LIMIT).wait(timeout=2) + with pytest.raises(ValueError, match="hard limit"): + calibrated.vmax.set(PHASE_VOLTAGE_LIMIT + 0.01).wait(timeout=2) + assert calibrated.vmax.get() == PHASE_VOLTAGE_LIMIT + + +# --------------------------------------------------------------------------------------------- # +# Calibration kept across BEC restarts # +# --------------------------------------------------------------------------------------------- # + + +@pytest.fixture +def caplog(): + """Messages logged by the device module (bec_logger is loguru, not std logging).""" + + class Log: + def __init__(self): + self.patcher = mock.patch("superxas_bec.devices.laser_delay.logger") + + @property + def text(self): + return "\n".join(str(c.args[0]) for c in self.mock.method_calls if c.args) + + log = Log() + log.mock = log.patcher.start() + yield log + log.patcher.stop() + + +@pytest.fixture +def calibration_file(tmp_path): + return tmp_path / "sub" / "laser_delay_calibration.json" + + +def make_device(calibration_file, **hardware): + """A fresh device (as after a BEC restart) on the given mocked hardware.""" + context = patched_device( + LaserDelay, name="laser_delay", step_sleep=0.0, calibration_file=str(calibration_file) + ) + device = context.__enter__() + set_hardware(device, **hardware) + return context, device + + +def hardware_of(device): + return { + "coarse": device.delay_coarse.get(), + "fine": device.delay_fine.get(), + "v1": device.phase_v1.get(), + "v2": device.phase_v2.get(), + } + + +def test_calibration_is_saved_after_calibrate_and_every_move(calibration_file): + context, device = make_device(calibration_file) + try: + device.calibrate(SCOPE) + saved = json.loads(calibration_file.read_text()) + assert saved["calibration"] == device.get_calibration() + assert saved["current_delay"] == pytest.approx(SCOPE) + assert saved["scope_xray_minus_laser"] == pytest.approx(SCOPE) + device.set(1.5).wait(timeout=10) + saved = json.loads(calibration_file.read_text()) + assert saved["calibration"]["phase_t"] == device.get_calibration()["phase_t"] + assert saved["current_delay"] == 1.5 + assert saved["move_in_progress"] is False + assert saved["constants"] == {"rf_period": 2.0, "cavity_period": 12.01, "vmax": 1.0} + info = device.get_calibration_info() + assert info["calibrated_at"] == saved["calibrated_at"] + assert info["age_days"] == pytest.approx(0, abs=0.01) + assert info["storage"] == str(calibration_file) + assert info["verified"] is True + finally: + context.__exit__(None, None, None) + + +def test_calibration_is_restored_after_a_restart(calibration_file): + context, device = make_device(calibration_file) + try: + device.calibrate(SCOPE) + device.set(1.5).wait(timeout=10) + before, hardware = device.get_calibration(), hardware_of(device) + plan_before = device.plan_move(-2.0) + finally: + context.__exit__(None, None, None) + context, device = make_device(calibration_file, **hardware) + try: + assert math.isnan(device.get_calibration()["phase_t"]) + device.on_connected() + assert device.get_calibration() == before + assert device.read()["laser_delay"]["value"] == 1.5 + assert device.plan_move(-2.0) == plan_before + finally: + context.__exit__(None, None, None) + + +def test_restore_with_turned_phase_shifter_refuses_moves(calibration_file, caplog): + context, device = make_device(calibration_file) + try: + device.calibrate(SCOPE) + device.set(1.5).wait(timeout=10) + before, hardware = device.get_calibration(), hardware_of(device) + finally: + context.__exit__(None, None, None) + hardware["v1"], hardware["v2"] = hardware["v2"], -hardware["v1"] # phase turned by hand + context, device = make_device(calibration_file, **hardware) + try: + device.on_connected() + assert device.get_calibration() == before + assert math.isnan(device.read()["laser_delay"]["value"]) + assert "NOT VERIFIED" in caplog.text + info = device.get_calibration_info() + assert ( + not info["verified"] and "away from the stored phase" in info["unverified_reasons"][0] + ) + with pytest.raises(LaserDelayNotCalibratedError, match="does not match the hardware"): + device.set(1.0) + finally: + context.__exit__(None, None, None) + + +def test_restore_after_a_crash_during_a_move_refuses_moves(calibration_file): + """The marker written before the first hardware write survives a crashed device server.""" + context, device = make_device(calibration_file) + try: + device.calibrate(SCOPE) + with mock.patch.object(device, "_write_phase", side_effect=SystemExit("crash")): + status = device.set(3.0) + with pytest.raises(Exception): + status.wait(timeout=10) + # simulate the crash: the end-of-move save never happened + content = json.loads(calibration_file.read_text()) + content["move_in_progress"] = True + calibration_file.write_text(json.dumps(content)) + hardware = hardware_of(device) + finally: + context.__exit__(None, None, None) + context, device = make_device(calibration_file, **hardware) + try: + device.on_connected() + assert "stopped during a move" in device.get_calibration_info()["unverified_reasons"][0] + with pytest.raises(LaserDelayNotCalibratedError): + device.set(1.0) + finally: + context.__exit__(None, None, None) + + +def test_move_marker_is_set_before_the_first_write(calibration_file): + context, device = make_device(calibration_file) + try: + device.calibrate(SCOPE) + seen = [] + original = device.delay_coarse._write_pv.put + + def check_marker(value, *args, **kwargs): + seen.append(json.loads(calibration_file.read_text())["move_in_progress"]) + return original(value, *args, **kwargs) + + device.delay_coarse._write_pv.put = check_marker + device.set(2.0).wait(timeout=10) + assert seen == [True] + assert json.loads(calibration_file.read_text())["move_in_progress"] is False + finally: + context.__exit__(None, None, None) + + +def test_restore_with_changed_laser_period_refuses_moves(calibration_file): + context, device = make_device(calibration_file) + try: + device.calibrate(SCOPE) + hardware = hardware_of(device) + finally: + context.__exit__(None, None, None) + context, device = make_device(calibration_file, evr=1.0, **hardware) + try: + device.on_connected() + assert "laser period changed" in device.get_calibration_info()["unverified_reasons"][0] + finally: + context.__exit__(None, None, None) + + +def test_restore_with_changed_delay_card_refuses_moves(calibration_file): + """A delay card that no longer shows the saved delay means the delay was changed outside BEC + (script, caput). The phase part of such a change can be whole RF periods, invisible to the phase + check, so the calibration is not verified until the next calibrate.""" + context, device = make_device(calibration_file) + try: + device.calibrate(SCOPE) + device.set(1.5).wait(timeout=10) + hardware = hardware_of(device) + finally: + context.__exit__(None, None, None) + hardware["fine"] = (hardware["fine"] + 50) % 200 + context, device = make_device(calibration_file, **hardware) + try: + device.on_connected() + info = device.get_calibration_info() + assert not info["verified"] and "delay card reads" in info["unverified_reasons"][0] + assert math.isnan(device.read()["laser_delay"]["value"]) + with pytest.raises(LaserDelayNotCalibratedError, match="does not match the hardware"): + device.set(1.0) + device.calibrate(SCOPE) + device.set(1.0).wait(timeout=10) + assert device.readback.get() == 1.0 + finally: + context.__exit__(None, None, None) + + +def test_restore_after_an_external_move_by_whole_rf_periods_refuses_moves(calibration_file): + """While BEC was down the script moved the delay by 2.0 ns (one RF period): the voltages are + exactly where the saved phase expects them, only the delay card tells. Without the card check the + next move would be 2.0 ns off while the readback claims the target.""" + context, device = make_device(calibration_file) + try: + device.calibrate(SCOPE) + device.set(5.0).wait(timeout=10) + saved = calibration_file.read_text() # BEC's last state: 5.0 ns + at_5 = hardware_of(device) + device.set(3.0).wait(timeout=10) # what the script does meanwhile: one RF period + hardware = hardware_of(device) + assert (hardware["v1"], hardware["v2"]) == pytest.approx((at_5["v1"], at_5["v2"]), abs=1e-9) + assert (hardware["coarse"], hardware["fine"]) != (at_5["coarse"], at_5["fine"]) + finally: + context.__exit__(None, None, None) + calibration_file.write_text(saved) # BEC never saw the script's move + context, device = make_device(calibration_file, **hardware) + try: + device.on_connected() + assert device.read_phase_mismatch() == pytest.approx(0, abs=1e-9) # phase check is blind + info = device.get_calibration_info() + assert not info["verified"] and "delay card reads" in info["unverified_reasons"][0] + with pytest.raises(LaserDelayNotCalibratedError): + device.set(4.0) + finally: + context.__exit__(None, None, None) + + +def test_restore_with_changed_constants_refuses_moves(calibration_file): + context, device = make_device(calibration_file) + try: + device.calibrate(SCOPE) + hardware = hardware_of(device) + finally: + context.__exit__(None, None, None) + context = patched_device( + LaserDelay, + name="laser_delay", + step_sleep=0.0, + cavity_period=12.0084, + calibration_file=str(calibration_file), + ) + device = context.__enter__() + set_hardware(device, **hardware) + try: + device.on_connected() + info = device.get_calibration_info() + assert not info["verified"] and "cavity_period was 12.01" in info["unverified_reasons"][0] + finally: + context.__exit__(None, None, None) + + +def test_old_calibration_is_restored_with_a_warning(calibration_file, caplog): + context, device = make_device(calibration_file) + try: + device.calibrate(SCOPE) + finally: + context.__exit__(None, None, None) + content = json.loads(calibration_file.read_text()) + content["calibrated_at"] = "2026-01-01T00:00:00+00:00" + calibration_file.write_text(json.dumps(content)) + context, device = make_device(calibration_file) + try: + device.on_connected() + assert device.read()["laser_delay"]["value"] == pytest.approx(SCOPE) + assert "days old" in caplog.text + finally: + context.__exit__(None, None, None) + + +def test_broken_calibration_file_leaves_the_device_uncalibrated(calibration_file, caplog): + calibration_file.parent.mkdir(parents=True) + calibration_file.write_text("{not json") + context, device = make_device(calibration_file) + try: + device.on_connected() # must not raise: the device still connects + assert "could not restore" in caplog.text + with pytest.raises(LaserDelayNotCalibratedError): + device.set(1.0) + finally: + context.__exit__(None, None, None) + + +def test_no_calibration_file_configured_writes_nothing(laser_delay, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + laser_delay.on_connected() + laser_delay.calibrate(SCOPE) + assert laser_delay.get_calibration_info()["storage"] is None + assert not list(tmp_path.iterdir()) + + +def test_unwritable_calibration_store_refuses_the_move(tmp_path, caplog): + """Without the move marker a later restore could trust a stale phase: fail safe.""" + blocker = tmp_path / "file" + blocker.write_text("") + context, device = make_device(blocker / "cal.json") # parent is a file + try: + with pytest.raises(LaserDelayPersistenceError, match="will be lost at the next restart"): + device.calibrate(SCOPE) + assert device.get_calibration_info()["verified"] # applied anyway + log = record_puts(device) + status = device.set(1.0) + with pytest.raises(LaserDelayPersistenceError, match="move marker could not be saved"): + status.wait(timeout=10) + assert log == [] + assert device.readback.get() == pytest.approx(SCOPE) # nothing moved + assert device.motor_is_moving.get() == 0 and not device.moving + assert "could not save" in caplog.text + finally: + context.__exit__(None, None, None) + + +def test_sim_restart_restores_hardware_and_calibration(tmp_path): + path = tmp_path / "cal.json" + device = SimLaserDelay(name="laser_delay", step_sleep=0.0, sim_noise=0, calibration_file=path) + try: + device.on_connected() + device.calibrate(device.sim_scope.get()) + for target in (3.3, -7.1, 14.2): + device.set(target).wait(timeout=30) + finally: + device.destroy() + device = SimLaserDelay(name="laser_delay", step_sleep=0.0, sim_noise=0, calibration_file=path) + try: + device.on_connected() + assert device.read()["laser_delay"]["value"] == 14.2 + assert device.sim_scope.get() == pytest.approx(14.2) + device.set(-2.0).wait(timeout=30) + assert device.sim_scope.get() == pytest.approx(-2.0) + finally: + device.destroy() + + +# --------------------------------------------------------------------------------------------- # +# Safety checks (deployment audit 2026-10-08) # +# --------------------------------------------------------------------------------------------- # + + +def test_hardware_signals_refuse_direct_writes(calibrated): + """dev.laser_delay.phase_v1.set(...) from a client would bypass the ramp and the bookkeeping.""" + log = record_puts(calibrated) + for signal, value in ( + (calibrated.phase_v1, 0.5), + (calibrated.phase_v2, 7.5), + (calibrated.delay_coarse, 10), + (calibrated.delay_fine, 10), + ): + with pytest.raises(ReadOnlyError, match="written only by laser_delay"): + signal.put(value) + with pytest.raises(ReadOnlyError): + signal.set(value).wait(timeout=2) + assert log == [] + + +def test_hardware_writes_are_range_checked_even_for_the_device(calibrated): + with calibrated._hardware_writes(): + with pytest.raises(ValueError, match="outside"): + calibrated.phase_v1.put(PHASE_VOLTAGE_LIMIT + 0.01) + with pytest.raises(ValueError, match="outside"): + calibrated.delay_fine.put(201) + + +def test_direct_write_from_another_thread_during_a_move_is_refused(laser_delay): + """The permission is per thread: a client write while the move task writes is still refused.""" + laser_delay.step_sleep.put(0.02) + laser_delay.calibrate(SCOPE) + status = laser_delay.set(0.5) # crosses t = 0: a long ramp + assert wait_until(lambda: laser_delay.phase_v1._write_pv.mock_data != 0.6) + with pytest.raises(ReadOnlyError): + laser_delay.phase_v1.put(0.0) + laser_delay.stop() + with pytest.raises(DeviceStoppedError): + status.wait(timeout=5) + + +def test_beamline_minimum_step_sleep(monkeypatch): + monkeypatch.setattr(LaserDelay, "MIN_STEP_SLEEP", 0.1) # the real value, see fast_ramp + with pytest.raises(ValueError, match="at least 0.1"): + with patched_device(LaserDelay, name="laser_delay", step_sleep=0.05): + pass + with patched_device(LaserDelay, name="laser_delay") as device: + assert device.step_sleep.get() == 0.1 + with pytest.raises(ValueError, match="at least 0.1"): + device.step_sleep.set(0.05).wait(timeout=2) + device.step_sleep.set(0.15).wait(timeout=2) + assert SimLaserDelay.MIN_STEP_SLEEP == 0.0 + + +def test_busy_refusal_does_not_stop_the_running_move(laser_delay): + """BEC v4's mv ends without waiting; the next queued scan's set arrives while the ramp runs. The + refusal must not end that ramp: the device server turns an exception from set() into a status + whose failure calls stop(), so the refusal is an already failed status instead.""" + laser_delay.step_sleep.put(0.02) + laser_delay.calibrate(SCOPE) + log = record_puts(laser_delay) + first = laser_delay.set(0.5) # crosses t = 0: a long ramp + assert wait_until(lambda: len(log) >= 2 + 2 * 3) + refused = laser_delay.set(1.0) + assert refused.done and not refused.success + assert isinstance(refused.exception(), LaserDelayBusyError) + assert not refused._call_stop_on_failure # pylint: disable=protected-access + n_puts = len(log) + time.sleep(0.2) + assert len(log) > n_puts, "the running move was stopped by the refusal" + assert not first.done and laser_delay.moving + laser_delay.step_sleep.put(0.0) + first.wait(timeout=30) + assert laser_delay.readback.get() == 0.5 + + +def test_move_refused_when_the_amplitude_is_wrong(calibrated): + """ES4 IOC reboot (V1 = V2 = 0): the first ramp step would jump to vmax.""" + calibrated.phase_v1._read_pv.mock_data = 0.0 + calibrated.phase_v2._read_pv.mock_data = 0.0 + log = record_puts(calibrated) + status = calibrated.set(1.0) + with pytest.raises(LaserDelayHardwareError, match="amplitude"): + status.wait(timeout=5) + assert log == [] + with pytest.raises(LaserDelayHardwareError, match="amplitude"): + calibrated.calibrate(SCOPE) + + +def test_raising_vmax_at_runtime_does_not_jump_the_voltages(calibrated): + calibrated.vmax.set(5.0).wait(timeout=2) + log = record_puts(calibrated) + status = calibrated.set(1.0) + with pytest.raises(LaserDelayHardwareError, match="amplitude"): + status.wait(timeout=5) + assert log == [] + + +def test_readback_is_nan_while_moving(laser_delay): + laser_delay.step_sleep.put(0.02) + laser_delay.calibrate(SCOPE) + status = laser_delay.set(0.5) # crosses t = 0: about 200 steps of 0.02 s + assert wait_until(lambda: math.isnan(laser_delay.read()["laser_delay"]["value"])) + assert laser_delay.moving + status.wait(timeout=30) + assert laser_delay.read()["laser_delay"]["value"] == 0.5 + + +def test_clamped_delay_card_fails_the_move_before_the_phase_shifter(calibrated): + log = record_puts(calibrated) + original = calibrated.delay_fine.get + + def clamped(*args, **kwargs): + value = original(*args, **kwargs) + return min(value, 10) if kwargs.get("use_monitor") is False else value + + with mock.patch.object(calibrated.delay_fine, "get", side_effect=clamped): + status = calibrated.set(-0.5) # FINE above 10 + with pytest.raises(LaserDelayHardwareError, match="delay card reads"): + status.wait(timeout=5) + assert [pv for pv, _ in log] == ["X10DA-ES2-TIM:SET1-DELAY", "X10DA-ES2-TIM:SET1-FINE"] + assert math.isnan(calibrated.readback.get()) + assert calibrated.get_calibration_info()["verified"] # phase untouched: next move is fine + + +def test_clamped_phase_voltage_fails_the_move_and_refuses_the_next(calibrated): + original = calibrated.phase_v2.get + + def clamped(*args, **kwargs): + value = original(*args, **kwargs) + return value + 0.2 if kwargs.get("use_monitor") is False else value + + with mock.patch.object(calibrated.phase_v2, "get", side_effect=clamped): + status = calibrated.set(1.0) + with pytest.raises(LaserDelayHardwareError, match="phase shifter reads"): + status.wait(timeout=5) + assert not calibrated.get_calibration_info()["verified"] + with pytest.raises(LaserDelayNotCalibratedError): + calibrated.set(2.0) diff --git a/tests/tests_devices/test_laser_delay_golden.py b/tests/tests_devices/test_laser_delay_golden.py new file mode 100644 index 0000000..1e8dac4 --- /dev/null +++ b/tests/tests_devices/test_laser_delay_golden.py @@ -0,0 +1,457 @@ +"""Golden tests: the LaserDelay device must write exactly what the scientist's script writes. + +The reference is the beamline scientist's ``DelayLaser.py`` (CaChannel, Python 2 era), kept +verbatim in ``reference/DelayLaser.py.txt``. It is executed here as a module with fake +``CaChannel``/``epicsPV`` modules that record every put. Its ``Timing.offset`` state file goes to +``tmp_path``. The device runs on mocked PVs (``patched_device``) and must produce the same PV write +sequence (delay card, then every V1/V2 pair of the phase ramp) and the same calibration state for +every case. +""" + +# pylint: disable=redefined-outer-name,protected-access,no-member + +from __future__ import annotations + +import math +import sys +import types +from dataclasses import dataclass, field +from pathlib import Path + +import numpy as np +import pytest +from ophyd_devices.tests.utils import patched_device + +from superxas_bec.devices.laser_delay import ( + LaserDelay, + LaserDelayHardwareError, + delay_card_setting, + phase_from_voltages, + phase_ramp_offsets, + voltages_for_phase, + wrap_phase, +) + +REFERENCE_SCRIPT = Path(__file__).parent / "reference" / "DelayLaser.py.txt" + +DELAY = "X10DA-ES2-TIM:SET1-DELAY" +FINE = "X10DA-ES2-TIM:SET1-FINE" +EVR = "X10DA-ES2-TIM:EVR-FPS2" +V1 = "X10DA-ES4:AO-02" +V2 = "X10DA-ES4:AO-01" + +FLOAT_TOL = 1e-12 + + +@pytest.fixture(autouse=True) +def fast_ramp(monkeypatch): + """Tests ramp without sleeping; the beamline minimum is tested on its own.""" + monkeypatch.setattr(LaserDelay, "MIN_STEP_SLEEP", 0.0) + + +# Two hardware states to start from (values as the IOCs would report them). +STATE_A = {EVR: 2.0002, DELAY: 150, FINE: 37, V1: 0.6, V2: -0.8} +STATE_B = {EVR: 1.0, DELAY: 10, FINE: 150, V1: -0.3, V2: 0.95} + + +@dataclass +class Scenario: + """Calibrate with ``scope`` from ``initial``, then run ``steps``. + + A step is a float (move to that delay) or a dict of PV values changed outside both programs + (somebody turning the phase shifter knobs). + """ + + initial: dict + scope: float + steps: list = field(default_factory=list) + + +SCENARIOS = { + "scope_cal_and_basic_moves": Scenario(STATE_A, 546.04 - 546.92, [-4.9, 0.0, 0.5, 5.3]), + "cross_cavity_period": Scenario(STATE_A, 546.04 - 546.92, [11.9, 12.2, 11.9]), + "large_negative_delays": Scenario(STATE_A, 546.04 - 546.92, [-1500.3, -250.37, -24.02]), + "laser_period_wrap": Scenario(STATE_A, 546.04 - 546.92, [400.0, -1800.0, 299.49, 299.5]), + "large_phase_moves_both_directions": Scenario( + STATE_A, 546.04 - 546.92, [0.1, 6.0, 0.1, 11.95, 0.05] + ), + "other_state_positive_scope": Scenario(STATE_B, 37.25, [37.25, 30.0, -3.33, 1000.0]), + "other_state_wrap_short_period": Scenario(STATE_B, -14.58 + 15.46, [-990.0, 2.5, 999.99]), +} + + +# --------------------------------------------------------------------------------------------- # +# Reference script with fake EPICS # +# --------------------------------------------------------------------------------------------- # + + +class FakeIOC: + """PV values shared by all fake channels; records puts in order.""" + + def __init__(self, initial: dict): + self.values = dict(initial) + self.puts: list[tuple[str, object]] = [] + + +def load_reference_script(ioc: FakeIOC, monkeypatch, tmp_path, sleeps: list) -> types.ModuleType: + """Execute the reference script as a fresh module talking to ``ioc``.""" + + class CaChannel: # pylint: disable=invalid-name + """Fake of CaChannel.CaChannel: searchw/getw/putw.""" + + def __init__(self): + self.pvname = None + + def searchw(self, pvname): + self.pvname = pvname + + def getw(self): + return ioc.values[self.pvname] + + def putw(self, value): + ioc.puts.append((self.pvname, value)) + ioc.values[self.pvname] = value + + ca_module = types.ModuleType("CaChannel") + ca_module.CaChannel = CaChannel + ca_module.ca = types.SimpleNamespace() + epics_module = types.ModuleType("epicsPV") + epics_module.epicsPV = object + monkeypatch.setitem(sys.modules, "CaChannel", ca_module) + monkeypatch.setitem(sys.modules, "epicsPV", epics_module) + monkeypatch.chdir(tmp_path) # Timing.offset is written to the working directory + + module = types.ModuleType("delay_laser_reference") + module.__file__ = str(REFERENCE_SCRIPT) + source = REFERENCE_SCRIPT.read_text(encoding="utf-8") + code = compile(source, str(REFERENCE_SCRIPT), "exec") + exec(code, module.__dict__) # pylint: disable=exec-used + module.time = types.SimpleNamespace(sleep=sleeps.append) + module.ConnectEpics() + return module + + +def run_reference(scenario: Scenario, monkeypatch, tmp_path) -> dict: + """Run a scenario through the script. Returns calibration, puts and state per move.""" + ioc = FakeIOC(scenario.initial) + sleeps: list = [] + script = load_reference_script(ioc, monkeypatch, tmp_path, sleeps) + script.CurrentSituation(scenario.scope) + result = {"calibration": list(np.loadtxt("Timing.offset")), "moves": []} + for step in scenario.steps: + if isinstance(step, dict): + ioc.values.update(step) + continue + ioc.puts.clear() + sleeps.clear() + script.SetDelay(step) + result["moves"].append( + { + "target": step, + "puts": list(ioc.puts), + "sleeps": len(sleeps), + "state": list(np.loadtxt("Timing.offset")), + } + ) + return result + + +# --------------------------------------------------------------------------------------------- # +# Device with recorded puts # +# --------------------------------------------------------------------------------------------- # + + +def record_puts(device: LaserDelay) -> list: + """Record every put to the four written PVs of ``device``, in order.""" + log: list[tuple[str, object]] = [] + for signal in (device.delay_coarse, device.delay_fine, device.phase_v1, device.phase_v2): + pv = signal._write_pv + original = pv.put + + def recorded(value, *args, _pv=pv, _original=original, **kwargs): + log.append((_pv.pvname, value)) + return _original(value, *args, **kwargs) + + pv.put = recorded + return log + + +def set_pv_values(device: LaserDelay, values: dict) -> None: + """Set mocked PV values without recording them as puts.""" + signals = { + DELAY: device.delay_coarse, + FINE: device.delay_fine, + EVR: device.evr_period, + V1: device.phase_v1, + V2: device.phase_v2, + } + for pvname, value in values.items(): + signals[pvname]._read_pv.mock_data = value + + +def run_device(scenario: Scenario, **device_kwargs) -> dict: + """Run a scenario through the device (default configuration unless ``device_kwargs``).""" + device_kwargs.setdefault("fine_rollover", False) # the script writes FINE 200 + with patched_device(LaserDelay, name="laser_delay", step_sleep=0.0, **device_kwargs) as device: + set_pv_values(device, scenario.initial) + log = record_puts(device) + calibration = device.calibrate(scenario.scope) + result = { + "calibration": [ + calibration[key] + for key in ("laser_period", "offset_t", "offset_phase_t", "phase_t") + ], + "moves": [], + } + for step in scenario.steps: + if isinstance(step, dict): + set_pv_values(device, step) + continue + log.clear() + status = device.set(step) + status.wait(timeout=30) + assert status.success + state = device.get_calibration() + result["moves"].append( + { + "target": step, + "puts": list(log), + "state": [ + state[key] + for key in ("laser_period", "offset_t", "offset_phase_t", "phase_t") + ], + "readback": device.read()["laser_delay"]["value"], + } + ) + return result + + +# --------------------------------------------------------------------------------------------- # +# Comparison helpers # +# --------------------------------------------------------------------------------------------- # + + +def assert_same_value(expected, actual, context: str) -> None: + """Integers must match exactly (and be integers); floats within FLOAT_TOL.""" + if isinstance(expected, (int, np.integer)) and not isinstance(expected, bool): + assert isinstance(actual, (int, np.integer)), f"{context}: {actual!r} is not an integer" + assert int(actual) == int(expected), f"{context}: {actual} != {expected}" + return + assert math.isclose( + float(actual), float(expected), rel_tol=FLOAT_TOL, abs_tol=FLOAT_TOL + ), f"{context}: {actual!r} != {expected!r} (diff {float(actual) - float(expected):.3e})" + + +def assert_same_puts(expected: list, actual: list, context: str) -> None: + """Same PVs in the same order, same values.""" + assert [pv for pv, _ in actual] == [ + pv for pv, _ in expected + ], f"{context}: PV sequence differs (expected {len(expected)} puts, got {len(actual)})" + for index, ((pv, expected_value), (_, actual_value)) in enumerate(zip(expected, actual)): + assert_same_value(expected_value, actual_value, f"{context}, put #{index} to {pv}") + + +# --------------------------------------------------------------------------------------------- # +# Tests # +# --------------------------------------------------------------------------------------------- # + + +@pytest.mark.parametrize("scenario_id", list(SCENARIOS)) +def test_device_matches_reference_script(scenario_id, monkeypatch, tmp_path): + """Calibration and every move write the same PV sequence as the script.""" + scenario = SCENARIOS[scenario_id] + expected = run_reference(scenario, monkeypatch, tmp_path) + actual = run_device(scenario) + + for key, exp, act in zip( + ("laser_period", "offset_t", "offset_phase_t", "phase_t"), + expected["calibration"], + actual["calibration"], + ): + assert_same_value(exp, act, f"{scenario_id}: calibration {key}") + + assert len(actual["moves"]) == len(expected["moves"]) + for exp_move, act_move in zip(expected["moves"], actual["moves"]): + context = f"{scenario_id}: move to {exp_move['target']}" + assert_same_puts(exp_move["puts"], act_move["puts"], context) + # delay card first, then pairs of V1/V2; one script sleep per pair + pvs = [pv for pv, _ in exp_move["puts"]] + assert pvs[:2] == [DELAY, FINE] + assert pvs[2::2] == [V1] * exp_move["sleeps"] and pvs[3::2] == [V2] * exp_move["sleeps"] + for key, exp, act in zip( + ("laser_period", "offset_t", "offset_phase_t", "phase_t"), + exp_move["state"], + act_move["state"], + ): + assert_same_value(exp, act, f"{context}: stored {key}") + assert act_move["readback"] == exp_move["target"] + + +@pytest.mark.parametrize("scenario_id", list(SCENARIOS)) +def test_shortest_phase_path_against_reference_script(scenario_id, monkeypatch, tmp_path): + """Opt-in phase_path="shortest" on the golden scenarios. + + The delay card writes are the script's. Each ramp is at most half a cavity period and turns the + phase shifter by exactly the change of the stored phase, which stays the script's phase minus + whole cavity periods (k). Without hand-made voltage changes, every move therefore ends where + the script's move ends up to k x 12.01 ns of phase, i.e. -k x 0.01 ns modulo the 2 ns RF + period: the documented caveat of folding with RFPeriod 2.0 and CavityPeriod 12.01. + """ + scenario = SCENARIOS[scenario_id] + expected = run_reference(scenario, monkeypatch, tmp_path) + actual = run_device(scenario, phase_path="shortest") + for key, exp, act in zip( + ("laser_period", "offset_t", "offset_phase_t", "phase_t"), + expected["calibration"], + actual["calibration"], + ): + assert_same_value(exp, act, f"{scenario_id}: calibration {key}") + + max_pairs = math.ceil(6.005 / (2.0 / 36)) + 1 + touched = False + voltages = {"exp": (scenario.initial[V1], scenario.initial[V2])} + voltages["act"] = voltages["exp"] + stored = {"exp": expected["calibration"][3], "act": actual["calibration"][3]} + moves = iter(zip(expected["moves"], actual["moves"], strict=True)) + for step in scenario.steps: + if isinstance(step, dict): # both programs see the same hand-made change + voltages = { + key: (step.get(V1, v[0]), step.get(V2, v[1])) for key, v in voltages.items() + } + touched = True + continue + exp_move, act_move = next(moves) + context = f"{scenario_id}: move to {exp_move['target']}" + assert_same_puts(exp_move["puts"][:2], act_move["puts"][:2], f"{context}: delay card") + assert 1 <= (len(act_move["puts"]) - 2) // 2 <= max_pairs, context + for index in range(3): # laser_period, offset_t, offset_phase_t + assert_same_value(exp_move["state"][index], act_move["state"][index], context) + assert act_move["readback"] == exp_move["target"] + + wraps = (exp_move["state"][3] - act_move["state"][3]) / 12.01 + assert wraps == pytest.approx(round(wraps), abs=1e-9), context + turned = act_move["state"][3] - stored["act"] + assert abs(turned) <= 6.005 + 1e-9, context + start = {key: phase_from_voltages(*v, 2.0) for key, v in voltages.items()} + voltages = { + "exp": (exp_move["puts"][-2][1], exp_move["puts"][-1][1]), + "act": (act_move["puts"][-2][1], act_move["puts"][-1][1]), + } + end = {key: phase_from_voltages(*v, 2.0) for key, v in voltages.items()} + assert wrap_phase(end["act"] - start["act"] - turned, 2.0) == pytest.approx( + 0, abs=1e-9 + ), context + if not touched: + assert wrap_phase(end["act"] - end["exp"], 2.0) == pytest.approx( + wrap_phase(-0.01 * round(wraps), 2.0), abs=1e-9 + ), context + stored = {"exp": exp_move["state"][3], "act": act_move["state"][3]} + + +def test_scenario_table_covers_the_edge_cases(monkeypatch, tmp_path): + """Guard the table itself: both laser-period wraps, a cavity-period crossing and long phase + ramps in both directions really occur.""" + totals, phase_deltas, crossings = [], [], 0 + for scenario in SCENARIOS.values(): + expected = run_reference(scenario, monkeypatch, tmp_path) + laser_period, offset_t, _, phase_t = expected["calibration"] + targets = [move["target"] for move in expected["moves"]] + for previous, current in zip(targets, targets[1:]): + if math.floor(previous / 12.01) != math.floor(current / 12.01): + crossings += 1 + for move in expected["moves"]: + totals.append((-offset_t - move["target"], laser_period)) + phase_deltas.append(move["state"][3] - phase_t) + phase_t = move["state"][3] + assert any(total < 0 for total, _ in totals), "no case wraps below zero" + assert any(total >= period for total, period in totals), "no case wraps above the laser period" + assert crossings > 0, "no move crosses a cavity period" + assert min(phase_deltas) < -10 and max(phase_deltas) > 10, "no long ramps in both directions" + + +def test_zero_phase_move_script_fails_device_writes_final_step(monkeypatch, tmp_path): + """Moving to the same delay twice: the script crashes in numpy.arange (step 0), the device + writes the same delay card values and only the final (unchanged) phase step.""" + scenario = Scenario(STATE_A, 546.04 - 546.92, [0.5]) + ioc = FakeIOC(scenario.initial) + script = load_reference_script(ioc, monkeypatch, tmp_path, []) + script.CurrentSituation(scenario.scope) + script.SetDelay(0.5) + ioc.puts.clear() + with pytest.raises((ValueError, ZeroDivisionError)): + script.SetDelay(0.5) + script_card_puts = list(ioc.puts) + assert [pv for pv, _ in script_card_puts] == [DELAY, FINE] + + with patched_device(LaserDelay, name="laser_delay", step_sleep=0.0) as device: + set_pv_values(device, scenario.initial) + device.calibrate(scenario.scope) + device.set(0.5).wait(timeout=30) + log = record_puts(device) + v1_before, v2_before = device.phase_v1.get(), device.phase_v2.get() + status = device.set(0.5) + status.wait(timeout=5) + assert status.success + assert_same_puts(script_card_puts, log[:2], "repeat move: delay card") + assert [pv for pv, _ in log[2:]] == [V1, V2] + assert_same_value(v1_before, log[2][1], "repeat move: V1") + assert_same_value(v2_before, log[3][1], "repeat move: V2") + + +def test_delay_card_conversion_matches_script_for_many_values(monkeypatch, tmp_path): + """int() truncations of MoveDelayCard, checked on many delays incl. near-integer boundaries.""" + ioc = FakeIOC(STATE_A) + script = load_reference_script(ioc, monkeypatch, tmp_path, []) + rng = np.random.default_rng(20261013) + totals = list(rng.uniform(-5000.0, 5000.0, 3000)) + # values whose fine part sits on a 0.01 ns boundary, where truncation is fragile + totals += [round(k * 0.01, 2) for k in range(0, 2001)] + totals += [k * 0.01 for k in range(-400, 400)] + for laser_period in (2000.2, 1000.0, 1000 * 2.0002, 12.01 * 83.0): + script.LaserPeriod = laser_period + for total in totals: + ioc.puts.clear() + script.MoveDelayCard(total) + (_, coarse), (_, fine) = ioc.puts # pylint: disable=unbalanced-tuple-unpacking + assert delay_card_setting(total, laser_period) == (coarse, fine), (total, laser_period) + + +def test_phase_ramp_matches_script_for_many_moves(monkeypatch, tmp_path): + """Ramp values (numpy.arange steps plus final step) of MovePhaseShifter for random moves.""" + ioc = FakeIOC(STATE_A) + script = load_reference_script(ioc, monkeypatch, tmp_path, []) + rng = np.random.default_rng(42) + for _ in range(200): + angle = rng.uniform(-np.pi, np.pi) + ioc.values[V1], ioc.values[V2] = float(np.cos(angle)), float(np.sin(angle)) + start = script.GetPhaseT() + relative = float(rng.uniform(-24.0, 24.0)) + ioc.puts.clear() + script.MovePhaseShifter(relative) + expected = [value for _, value in ioc.puts] + actual = [] + for offset in list(phase_ramp_offsets(relative, 2.0, 36)) + [relative]: + actual.extend(voltages_for_phase(start + offset, 2.0, 1.0)) + assert len(actual) == len(expected) + for exp, act in zip(expected, actual): + assert_same_value(exp, act, f"phase ramp {relative}") + + +def test_device_refuses_where_the_script_follows_touched_voltages(monkeypatch, tmp_path): + """Voltages turned by hand between moves: the script ramps from the touched phase and ends + off by that amount; the device refuses the move and writes nothing (calibrate again).""" + scenario = Scenario(STATE_A, 546.04 - 546.92, [1.0, {V1: 0.0, V2: 1.0}, 2.0]) + expected = run_reference(scenario, monkeypatch, tmp_path) + assert len(expected["moves"]) == 2 # the script moves twice + with patched_device( + LaserDelay, name="laser_delay", step_sleep=0.0, fine_rollover=False + ) as device: + set_pv_values(device, scenario.initial) + device.calibrate(scenario.scope) + device.set(1.0).wait(timeout=30) + set_pv_values(device, {V1: 0.0, V2: 1.0}) + log = record_puts(device) + status = device.set(2.0) + with pytest.raises(LaserDelayHardwareError): + status.wait(timeout=30) + assert log == [] diff --git a/tests/tests_devices/test_timepix.py b/tests/tests_devices/test_timepix.py index 0010025..c7afa1f 100644 --- a/tests/tests_devices/test_timepix.py +++ b/tests/tests_devices/test_timepix.py @@ -3,14 +3,19 @@ from __future__ import annotations import threading -from types import SimpleNamespace +import time +from contextlib import ExitStack from unittest import mock import numpy as np import ophyd import pytest +from bec_lib.devicemanager import ScanInfo +from bec_lib.messages import ScanStatusMessage from bec_server.device_server.tests.utils import DMMock -from ophyd import DeviceStatus, StatusBase +from bec_server.scan_server.scans.scan_base import ScanInfo as ScanServerScanInfo +from ophyd import DeviceStatus, Staged, StatusBase +from ophyd_devices.interfaces.base_classes.psi_device_base import DeviceStoppedError from ophyd_devices.tests.utils import MockPV, patch_dual_pvs from superxas_bec.devices.timepix.timepix import ( @@ -23,12 +28,14 @@ from superxas_bec.devices.timepix.timepix import ( TDCEdge, TDCOuput, Timepix, + window_mean, ) from superxas_bec.devices.timepix.timepix_fly_client.timepix_fly_client import TimePixFlyStatus from superxas_bec.devices.timepix.timepix_fly_client.timepix_fly_interface import ( NetAddresses, PixelMap, ) +from superxas_bec.devices.utils.utils import fetch_scan_info # pylint: disable=protected-access # pylint: disable=redefined-outer-name @@ -124,21 +131,108 @@ def _message_value(signal): return msg.signals[signal.name]["value"] +def _scan_status_msg( + scan_name: str = "line_scan", + additional_scan_parameters: dict | None = None, + exp_time: float = 0.1, + frames_per_trigger: int = 2, + num_points: int = 3, +) -> ScanStatusMessage: + """Scan status message shaped like the one the v4 scan server publishes.""" + v4_info = ScanServerScanInfo( + scan_name=scan_name, + scan_id="test", + scan_type="software_triggered", + num_points=num_points, + exp_time=exp_time, + frames_per_trigger=frames_per_trigger, + additional_scan_parameters=additional_scan_parameters or {}, + ) + scan_parameters = {"exp_time": exp_time, "frames_per_trigger": frames_per_trigger} + info = v4_info.model_dump(mode="python") + info["scan_parameters"] = scan_parameters + return ScanStatusMessage( + scan_id="test", + status="open", + scan_name=scan_name, + scan_type="software_triggered", + num_points=num_points, + scan_parameters=scan_parameters, + info=info, + ) + + +TIMEPIX_SCAN_PARAMETERS = { + "time_roi_step": 1, + "time_roi_n": 4, + "idx_off_start": 0, + "idx_off_stop": 2, + "idx_on_start": 2, + "idx_on_stop": 4, +} + +LEVEL_SIGNALS = ( + "xes_level_1_off", + "xes_level_1_on", + "xes_level_2_off", + "xes_level_2_on", + "xes_diff_1_on_off", + "xes_diff_2_on_off", +) + + +def _set_scan_parameters(dev, scan_name: str, additional_scan_parameters: dict | None = None): + """Set the normalized scan parameters the way on_stage does, without staging.""" + dev.scan_info.msg = _scan_status_msg(scan_name, additional_scan_parameters) + dev.scan_parameters = fetch_scan_info(dev.scan_info) + + +def _xes_frames(spectra: np.ndarray): + """StartFrame, one XesData frame and EndFrame for a (TRoiN, NumEnergyPoints) array.""" + troin, n_energy_points = spectra.shape + start_frame = { + "type": "StartFrame", + "Mode": "TOA", + "TRoiStart": 0, + "TRoiStep": 1, + "TRoiN": troin, + "NumEnergyPoints": n_energy_points, + "save_interval": 1, + } + # TDSpectra is the flattened (time bin, energy point) array, energy points varying fastest + data_frames = [ + { + "type": "XesData", + "period": 131000, + "totalEvents": int(spectra.sum()), + "TDSpectra": spectra.ravel().tolist(), + "beforeROI": 0, + "afterROI": 0, + } + ] + end_frame = {"type": "EndFrame", "error": "", "periods": 4} + return start_frame, data_frames, end_frame + + +def _level_test_spectra() -> np.ndarray: + """4 time bins x 8 energy points; energy group 1 sums to [4, 8, 12, 16], group 2 to [40, 80, 120, 160].""" + time_bin = np.arange(1, 5, dtype=np.float32)[:, None] + return np.hstack([np.repeat(time_bin, 4, axis=1), np.repeat(10 * time_bin, 4, axis=1)]) + + @pytest.fixture(scope="function") def timepix(): """Timepix device with mocked EPICS signals and a fully mocked backend.""" backend = FakeBackend() - scan_info = SimpleNamespace( - msg=SimpleNamespace( - scan_name="step_scan", - scan_parameters={"exp_time": 0.1, "frames_per_trigger": 2}, - num_points=3, - scan_id="test", - ) - ) + scan_info = ScanInfo(msg=_scan_status_msg()) with ( mock.patch.object(ophyd, "cl") as mock_cl, mock.patch("superxas_bec.devices.timepix.timepix.TimepixFlyBackend", return_value=backend), + # The full default pixel map takes seconds to build; no test here depends on it. + mock.patch( + "superxas_bec.devices.timepix.timepix.create_single_energy_per_chip_pixel_map", + return_value=PixelMap(chips=[[{"i": 0, "p": 0, "f": [1.0]}]]), + ), ): mock_cl.get_pv = MockPV mock_cl.thread_class = threading.Thread @@ -379,6 +473,75 @@ def test_timepix_msg_buffer_callback_updates_xes_signals(timepix): assert _message_value(timepix.total_events) == 36 +def test_timepix_msg_buffer_callback_accumulates_xes_energy_per_scan(timepix): + """xes_accumulated_energy_1/2 sum the frames of one scan; staging starts a new sum.""" + timepix.troin = 4 + # one frame: xes_energy_1 = [4, 8, 12, 16], xes_energy_2 = [40, 80, 120, 160] + frames = _xes_frames(_level_test_spectra()) + + timepix.msg_buffer_callback(*frames) + timepix.msg_buffer_callback(*frames) + + np.testing.assert_array_equal( + _message_value(timepix.xes_data_accumulated_1), np.array([8, 16, 24, 32]) + ) + np.testing.assert_array_equal( + _message_value(timepix.xes_data_accumulated_2), np.array([80, 160, 240, 320]) + ) + assert timepix.xes_data_accumulated_1.get().metadata["async_update"]["type"] == "replace" + # the per-frame signal still carries only the last frame + np.testing.assert_array_equal(_message_value(timepix.xes_energy_1), np.array([4, 8, 12, 16])) + + _stage_for(timepix, "line_scan") + timepix.msg_buffer_callback(*frames) + + np.testing.assert_array_equal( + _message_value(timepix.xes_data_accumulated_1), np.array([4, 8, 12, 16]) + ) + + +@pytest.mark.parametrize("scan_name", [None, "line_scan"]) +def test_timepix_msg_buffer_callback_skips_levels_outside_timepix_scan(timepix, scan_name): + """Frames before the first stage or during other scans must not touch the on/off levels.""" + if scan_name is not None: + _set_scan_parameters(timepix, scan_name) + assert timepix._is_timepix_scan() is False + timepix.troin = 4 + with ExitStack() as stack: + level_puts = [ + stack.enter_context(mock.patch.object(getattr(timepix, name), "put")) + for name in LEVEL_SIGNALS + ] + timepix.msg_buffer_callback(*_xes_frames(_level_test_spectra())) + + for put in level_puts: + put.assert_not_called() + np.testing.assert_array_equal( + _message_value(timepix.xes_energy_1), np.array([4, 8, 12, 16], dtype=np.float32) + ) + + +def test_timepix_msg_buffer_callback_publishes_on_off_levels(timepix): + """During a timepix_scan the six level/diff signals are the means over the index windows.""" + _set_scan_parameters(timepix, "timepix_scan", TIMEPIX_SCAN_PARAMETERS) + timepix.troin = 4 + + timepix.msg_buffer_callback(*_xes_frames(_level_test_spectra())) + + # xes_energy_1 = [4, 8, 12, 16], xes_energy_2 = [40, 80, 120, 160] + # off window [0, 2), on window [2, 4) + expected = { + "xes_level_1_off": 6.0, + "xes_level_1_on": 14.0, + "xes_diff_1_on_off": 8.0, + "xes_level_2_off": 60.0, + "xes_level_2_on": 140.0, + "xes_diff_2_on_off": 80.0, + } + for name, value in expected.items(): + assert _message_value(getattr(timepix, name)) == pytest.approx(value), name + + def test_timepix_on_stop_stops_camera_writer_and_backend(timepix): """Stop should stop camera acquisition and delegate backend stop.""" timepix.cam.acquire.put(1) @@ -391,6 +554,182 @@ def test_timepix_on_stop_stops_camera_writer_and_backend(timepix): timepix.backend.on_stop.assert_called_once() +@pytest.mark.parametrize( + "window, expected", + [ + (slice(0, 2), 1.5), + (slice(1, 4), 3.0), + (slice(2, 2), np.nan), # empty + (slice(3, 2), np.nan), # start after stop + (slice(2, 5), np.nan), # stop past the end + (slice(4, 5), np.nan), # start past the end + (slice(-1, 2), np.nan), # negative start + ], +) +def test_window_mean(window, expected): + data = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float32) + np.testing.assert_equal(window_mean(data, window), expected) + + +def test_timepix_on_off_levels_are_nan_for_window_outside_received_bins(timepix): + """If TimePixFly sends fewer time bins than requested, the affected levels become NaN.""" + _set_scan_parameters(timepix, "timepix_scan", TIMEPIX_SCAN_PARAMETERS) + timepix.troin = 4 + # only 3 time bins: xes_energy_1 = [4, 8, 12], xes_energy_2 = [40, 80, 120] + frames = _xes_frames(_level_test_spectra()[:3]) + + with mock.patch("superxas_bec.devices.timepix.timepix.logger") as logger: + timepix.msg_buffer_callback(*frames) + timepix.msg_buffer_callback(*frames) + + assert _message_value(timepix.xes_level_1_off) == pytest.approx(6.0) + assert _message_value(timepix.xes_level_2_off) == pytest.approx(60.0) + for name in ("xes_level_1_on", "xes_level_2_on", "xes_diff_1_on_off", "xes_diff_2_on_off"): + assert np.isnan(_message_value(getattr(timepix, name))), name + # one warning per scan, not one per frame + assert logger.warning.call_count == 1 + assert "on [2, 4)" in logger.warning.call_args.args[0] + + +def _stage_for(dev, scan_name: str, additional_scan_parameters: dict | None = None, **kwargs): + """Stage the device for a scan, as the device server does (stage(), not on_stage()).""" + dev.scan_info.msg = _scan_status_msg(scan_name, additional_scan_parameters, **kwargs) + with mock.patch( + "superxas_bec.devices.timepix.timepix.get_full_path", + return_value="/tmp/timepix/test_scan.h5", + ): + return dev.stage() + + +def _prepare_settings_before_timepix_scan(dev): + """Typical beamline state: image writing on, XES off, default time binning.""" + _force_signal_value(dev.hdf.enable, "Enable") + dev.enable_xes = False + dev.troistep = 1 + dev.troin = 5000 + + +def _assert_settings_before_timepix_scan(dev): + assert dev.hdf.enable.get() == "Enable" + assert dev.enable_xes is False + assert dev.cam.tdc1_enable.get() == 0 + assert dev.troistep == 1 + assert dev.troin == 5000 + assert dev._settings_before_timepix_scan is None + + +def test_timepix_scan_stage_overrides_settings_and_unstage_restores_them(timepix, pixel_map): + """timepix_scan switches image writing off and XES on for the scan only.""" + timepix._pixel_map = pixel_map + _prepare_settings_before_timepix_scan(timepix) + params = {**TIMEPIX_SCAN_PARAMETERS, "time_roi_step": 2} + + with mock.patch.object(timepix.file_event, "put") as file_event_put: + _stage_for(timepix, "timepix_scan", params) + file_event_put.assert_not_called() + + # The device server only unstages devices that report Staged.yes + assert timepix._staged == Staged.yes + assert timepix.hdf.enable.get() != "Enable" + assert timepix.hdf.capture.get() != 1 + assert timepix.enable_xes is True + assert timepix.cam.tdc1_enable.get() == 1 + other_config = timepix.backend.on_stage.call_args.kwargs["other_config"] + assert (other_config.TRoiStep, other_config.TRoiN) == (2, 4) + + timepix.unstage() + + assert timepix._staged == Staged.no + _assert_settings_before_timepix_scan(timepix) + + +def test_timepix_unstage_after_other_scan_keeps_settings(timepix): + """Only timepix_scan touches image writing and XES.""" + _prepare_settings_before_timepix_scan(timepix) + + _stage_for(timepix, "line_scan") + timepix.unstage() + + _assert_settings_before_timepix_scan(timepix) + + +def test_timepix_stop_restores_settings_of_aborted_timepix_scan(timepix, pixel_map): + """An aborted scan is stopped but not unstaged; a later unstage must not restore again.""" + timepix._pixel_map = pixel_map + _prepare_settings_before_timepix_scan(timepix) + _stage_for(timepix, "timepix_scan", TIMEPIX_SCAN_PARAMETERS) + + timepix.stop() + + # XES was on during the scan, so the backend collection is stopped as well + timepix.backend.on_stop.assert_called_once() + _assert_settings_before_timepix_scan(timepix) + + # A user change after the abort survives the unstage the device server does before the + # next stage (no second restore). + timepix.set_enable_xes(True) + timepix.unstage() + assert timepix.enable_xes is True + + +def test_timepix_failed_restore_on_stop_is_retried_on_unstage(timepix, pixel_map): + timepix._pixel_map = pixel_map + _prepare_settings_before_timepix_scan(timepix) + _stage_for(timepix, "timepix_scan", TIMEPIX_SCAN_PARAMETERS) + working_set = timepix.hdf.enable.set + timepix.hdf.enable.set = mock.Mock(side_effect=TimeoutError("IOC did not answer")) + + timepix.stop() # logs the error instead of raising + + assert timepix._settings_before_timepix_scan is not None + timepix.hdf.enable.set = working_set + timepix.unstage() + _assert_settings_before_timepix_scan(timepix) + + +def test_timepix_failed_stage_restores_timepix_scan_settings(timepix, pixel_map): + """If on_stage raises after the overrides were applied, they are undone right away.""" + timepix._pixel_map = pixel_map + _prepare_settings_before_timepix_scan(timepix) + + with pytest.raises(ValueError, match="Exposure time"): + _stage_for(timepix, "timepix_scan", TIMEPIX_SCAN_PARAMETERS, exp_time=0.001) + + assert timepix._staged == Staged.no + _assert_settings_before_timepix_scan(timepix) + + +def test_timepix_failed_stage_and_failed_restore_keep_stage_error(timepix, pixel_map): + """A failing restore must not hide why staging failed; the next stage restores instead.""" + timepix._pixel_map = pixel_map + _prepare_settings_before_timepix_scan(timepix) + working_set = timepix.hdf.enable.set + + def set_or_time_out(value, **kwargs): + if value == "Enable": # only the restore writes the saved readback value + raise TimeoutError("IOC did not answer") + return working_set(value, **kwargs) + + timepix.hdf.enable.set = mock.Mock(side_effect=set_or_time_out) + + with mock.patch("superxas_bec.devices.timepix.timepix.logger") as logger: + with pytest.raises(ValueError, match="Exposure time"): + _stage_for(timepix, "timepix_scan", TIMEPIX_SCAN_PARAMETERS, exp_time=0.001) + + assert timepix._staged == Staged.no + assert timepix._settings_before_timepix_scan is not None + assert "IOC did not answer" in logger.error.call_args.args[0] + + # Not staged, so the device server will not unstage it: the next stage puts the settings + # back before staging for a scan that is not a timepix_scan. + timepix.hdf.enable.set = working_set + _stage_for(timepix, "line_scan") + + _assert_settings_before_timepix_scan(timepix) + # XES is off again, so TimePixFly is not configured for the line_scan + timepix.backend.on_stage.assert_not_called() + + def test_timepix_on_destroy_cleans_up_backend(timepix): """Destroy should stop polling and forward cleanup to the backend.""" timepix.on_destroy() @@ -425,3 +764,155 @@ def test_timepix_on_prescan_returns_correct_status(timepix): status.wait(timeout=1) assert status.done is True assert status.success is True + + +# --- Lost acquisitions: TimePixFly closes the frame before the exposure ends (S00437 point 1) --- + + +def _wait_for(condition, timeout: float = 2.0): + """Poll until condition() is true (the repetition runs in its own thread).""" + deadline = time.monotonic() + timeout + while not condition(): + assert time.monotonic() < deadline, "condition not met in time" + time.sleep(0.01) + + +def _camera_acquires(dev): + """The camera runs one acquisition: AcquireBusy DONE -> ACQUIRING -> DONE.""" + _force_signal_value(dev.cam.acquire_busy, ACQUIRESTATUS.ACQUIRING) + _force_signal_value(dev.cam.acquire_busy, ACQUIRESTATUS.DONE) + + +def _empty_frames(): + """Frames of an acquisition in which TimePixFly saw no TDC pulse (as S00437, point 1).""" + start_frame, data_frames, end_frame = _xes_frames(np.zeros((4, 8), dtype=np.float32)) + data_frames[0]["period"] = 0 + return start_frame, data_frames, {**end_frame, "periods": 0} + + +@pytest.fixture +def xes_timepix(timepix): + """Timepix armed for XES as in a timepix_scan: image writing off, 1 s exposure, 1 image.""" + _force_signal_value(timepix.hdf.enable, "Disable") + timepix.troin = 4 + timepix._exposure_per_trigger = 1.0 + timepix._images_per_trigger = 1 + backend_done = [StatusBase(), StatusBase()] + timepix.backend.on_trigger_finished = mock.Mock(side_effect=backend_done) + timepix.backend_done = backend_done + return timepix + + +def test_timepix_lost_acquisition_is_repeated_once(xes_timepix): + """A frame closed before the exposure ends is dropped and the acquisition repeated.""" + timepix = xes_timepix + with ( + mock.patch.object(timepix.xes_data, "put") as xes_put, + mock.patch.object(timepix.static_spectra, "put") as spectra_put, + # a 2 x 2 detector image + mock.patch.object(timepix.image.array_size.height, "get", return_value=2), + mock.patch.object(timepix.image.array_size.width, "get", return_value=2), + mock.patch.object(timepix.image.array_data, "get", return_value=np.arange(4)), + ): + status = timepix.on_trigger() + assert timepix.cam.acquire.get() == 1 + + # TimePixFly closes the frame at once, without TDC periods: nothing is published + timepix.msg_buffer_callback(*_empty_frames()) + xes_put.assert_not_called() + # the image of the lost acquisition is not published either + timepix._on_new_image_received(value=1, old_value=0) + spectra_put.assert_not_called() + + _camera_acquires(timepix) + timepix.backend_done[0].set_finished() + assert status.done is False + + # the repetition arms TimePixFly again and restarts the camera + _wait_for(lambda: timepix.backend.on_trigger_finished.call_count == 2) + timepix.msg_buffer_callback(*_xes_frames(_level_test_spectra())) + timepix._on_new_image_received(value=2, old_value=1) + _camera_acquires(timepix) + timepix.backend_done[1].set_finished() + status.wait(timeout=2) + + assert status.success is True + xes_put.assert_called_once() + np.testing.assert_array_equal(xes_put.call_args.args[0], _level_test_spectra()) + spectra_put.assert_called_once() + assert not timepix._xes_acquisitions + + +def test_timepix_lost_repetition_is_published(xes_timepix): + """If the repetition is lost too, its empty frame is published: the point keeps one row.""" + timepix = xes_timepix + with mock.patch.object(timepix.xes_data, "put") as xes_put: + status = timepix.on_trigger() + timepix.msg_buffer_callback(*_empty_frames()) + timepix._on_new_image_received(value=1, old_value=0) + _camera_acquires(timepix) + timepix.backend_done[0].set_finished() + _wait_for(lambda: timepix.backend.on_trigger_finished.call_count == 2) + + with mock.patch("superxas_bec.devices.timepix.timepix.logger") as logger: + timepix.msg_buffer_callback(*_empty_frames()) + _camera_acquires(timepix) + timepix.backend_done[1].set_finished() + status.wait(timeout=2) + + assert status.success is True + xes_put.assert_called_once() + assert "cannot be repeated" in logger.error.call_args.args[0] + + +def test_timepix_frame_without_tdc_after_exposure_is_published(xes_timepix): + """No TDC pulse during a whole exposure is a detector/TDC problem: publish, do not repeat.""" + timepix = xes_timepix + timepix._exposure_per_trigger = 0.0 # the exposure has ended when the frame arrives + with ( + mock.patch.object(timepix.xes_data, "put") as xes_put, + mock.patch("superxas_bec.devices.timepix.timepix.logger") as logger, + ): + status = timepix.on_trigger() + timepix.msg_buffer_callback(*_empty_frames()) + _camera_acquires(timepix) + timepix.backend_done[0].set_finished() + status.wait(timeout=1) + + assert status.success is True + xes_put.assert_called_once() + assert timepix.backend.on_trigger_finished.call_count == 1 + assert "no TDC pulse" in logger.error.call_args.args[0] + + +def test_timepix_lost_acquisition_not_repeated_while_writing_images(xes_timepix): + """With HDF5 image writing on, a repetition would add an image: keep the point as it is.""" + timepix = xes_timepix + _force_signal_value(timepix.hdf.enable, "Enable") + with mock.patch.object(timepix.xes_data, "put") as xes_put: + status = timepix.on_trigger() + timepix.msg_buffer_callback(*_empty_frames()) + _camera_acquires(timepix) + timepix.backend_done[0].set_finished() + status.wait(timeout=1) + + assert status.success is True + xes_put.assert_called_once() + assert timepix.backend.on_trigger_finished.call_count == 1 + + +def test_timepix_stop_cancels_repetition_of_lost_acquisition(xes_timepix): + """A stop while the lost acquisition is still running cancels the trigger, no repetition.""" + timepix = xes_timepix + status = timepix.on_trigger() + timepix.msg_buffer_callback(*_empty_frames()) + + timepix.stop() + + with pytest.raises(DeviceStoppedError): + status.wait(timeout=1) + _camera_acquires(timepix) + time.sleep(0.2) + assert timepix.backend.on_trigger_finished.call_count == 1 + assert not timepix._xes_acquisitions + assert timepix._images_to_skip == 0 diff --git a/tests/tests_scans/test_timepix_scan.py b/tests/tests_scans/test_timepix_scan.py new file mode 100644 index 0000000..87c4dce --- /dev/null +++ b/tests/tests_scans/test_timepix_scan.py @@ -0,0 +1,75 @@ +# pylint: skip-file +import pytest +from bec_lib.bec_errors import ScanInputValidationError +from bec_server.scan_server.tests.scan_fixtures import * + +from superxas_bec.scans.timepix_scan import TimepixScan + +TIMEPIX_PARAMETERS = { + "time_roi_step": 1, + "time_roi_n": 10, + "idx_off_start": 0, + "idx_off_stop": 4, + "idx_on_start": 5, + "idx_on_stop": 10, +} + + +def _assemble_timepix_scan(scan_assembler, **overrides): + params = {**TIMEPIX_PARAMETERS, **overrides} + return scan_assembler( + "timepix_scan", "samx", -1.0, 1.0, steps=3, relative=False, exp_time=0.1, **params + ) + + +def test_timepix_scan_forwards_parameters_to_scan_info(scan_assembler): + """The Timepix device reads the parameters from additional_scan_parameters.""" + scan = _assemble_timepix_scan(scan_assembler) + + assert isinstance(scan, TimepixScan) + assert scan.scan_info.scan_name == "timepix_scan" + for name, value in TIMEPIX_PARAMETERS.items(): + assert scan.scan_info.additional_scan_parameters[name] == value + + +@pytest.mark.parametrize( + "overrides, match", + [ + ({"idx_off_start": 5, "idx_off_stop": 4}, "idx_off_start"), + ({"idx_on_start": 9, "idx_on_stop": 6}, "idx_on_start"), + # start == stop would be an empty window + ({"idx_off_start": 3, "idx_off_stop": 3}, "idx_off_start"), + ({"idx_on_start": 7, "idx_on_stop": 7}, "idx_on_start"), + ], +) +def test_timepix_scan_rejects_start_not_smaller_than_stop(scan_assembler, overrides, match): + with pytest.raises(ValueError, match=match): + _assemble_timepix_scan(scan_assembler, **overrides) + + +@pytest.mark.parametrize( + "overrides, name", + [ + ({"idx_off_stop": 11}, "idx_off_stop"), + ({"idx_on_stop": 11}, "idx_on_stop"), + ({"idx_on_start": 11, "idx_on_stop": 12}, "idx_on_start"), + ], +) +def test_timepix_scan_rejects_index_above_time_roi_n(scan_assembler, overrides, name): + with pytest.raises(ValueError, match=f"{name} with value"): + _assemble_timepix_scan(scan_assembler, **overrides) + + +def test_timepix_scan_accepts_stop_equal_to_time_roi_n(scan_assembler): + """stop is exclusive, so a window may end at time_roi_n.""" + scan = _assemble_timepix_scan(scan_assembler, idx_on_start=9, idx_on_stop=10) + + assert scan.scan_info.additional_scan_parameters["idx_on_stop"] == 10 + + +@pytest.mark.parametrize( + "overrides", [{"idx_off_start": -1}, {"time_roi_n": 0}, {"time_roi_step": 0}] +) +def test_timepix_scan_argument_bounds_are_validated(scan_assembler, overrides): + with pytest.raises(ScanInputValidationError): + _assemble_timepix_scan(scan_assembler, **overrides)