From 5284f55e9dbc637de182e7ac06f155d169ddc2ff Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 8 Oct 2026 22:48:42 +0200 Subject: [PATCH] feat(devices): keep the laser delay calibration in soft records on an IOC --- .../device_configs/x10da_laser_delay.yaml | 8 +- superxas_bec/devices/LASER_DELAY.md | 50 ++++++--- superxas_bec/devices/laser_delay.py | 100 ++++++++++++++++-- tests/tests_devices/test_laser_delay.py | 77 ++++++++++++++ 4 files changed, 210 insertions(+), 25 deletions(-) diff --git a/superxas_bec/device_configs/x10da_laser_delay.yaml b/superxas_bec/device_configs/x10da_laser_delay.yaml index 1c47889..5e349ab 100644 --- a/superxas_bec/device_configs/x10da_laser_delay.yaml +++ b/superxas_bec/device_configs/x10da_laser_delay.yaml @@ -10,9 +10,10 @@ # 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. +# or the weekly scope check. They are kept across BEC restarts either in soft records on an IOC +# (calibration_pv_prefix, preferred once controls has added them) or in a JSON file on the device +# server host (calibration_file); use one of the two. 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 @@ -32,6 +33,7 @@ laser_delay: phase_path: script # "shortest" is opt-in, see LASER_DELAY.md fine_rollover: true # FINE 200 is written as the next coarse unit and 0 (open question) calibration_file: ~/.superxas_bec/laser_delay_calibration.json + # calibration_pv_prefix: "X10DA-LAS-DELAY:" # once the soft records exist; remove calibration_file deviceTags: - laser - timing diff --git a/superxas_bec/devices/LASER_DELAY.md b/superxas_bec/devices/LASER_DELAY.md index 09d4f27..cbc5f93 100644 --- a/superxas_bec/devices/LASER_DELAY.md +++ b/superxas_bec/devices/LASER_DELAY.md @@ -49,10 +49,10 @@ from which scope value it was made, and whether it is verified: dev.laser_delay.get_calibration_info() # {'calibration': {...}, 'current_delay': 1.2, 'calibrated_at': '2026-10-07T19:09:28+00:00', # 'age_days': 0.1, 'scope_xray_minus_laser': -0.88, 'verified': True, 'unverified_reasons': [], -# 'storage': '/.superxas_bec/laser_delay_calibration.json'} +# 'storage': 'PVs X10DA-LAS-DELAY:*'} ``` -If it cannot be saved (file not writable), `calibrate` raises an error +If it cannot be saved (file not writable, calibration PVs not reachable), `calibrate` raises an error saying so. The calibration is active anyway, but would be lost at the next restart. ## 2. Move and scan @@ -177,12 +177,13 @@ phase shifter by hand. ## 4. After a BEC restart -The device keeps its calibration outside the device server, like the script's `Timing.offset`, in a -JSON file on the device server host (config key `calibration_file`). `~` is the home of the account -that runs the device server, so this only carries over while BEC always runs under the same account. -Keeping it in autosaved soft records on an IOC instead, independent of the account, is on the branch -`feat/laser-delay-calibration-pvs` (config key `calibration_pv_prefix`), once controls has added the -records. +The device keeps its calibration outside the device server, like the script's `Timing.offset`, in one +of two places (config keys, use one): + +- **`calibration_pv_prefix`** (preferred): soft records on an IOC, autosaved, independent of the + account and the host BEC runs on. The records are listed below; they have to be added by controls. +- **`calibration_file`**: a JSON file on the device server host. `~` is the home of the account that + runs the device server, so this only carries over while BEC always runs under the same account. It is written after every `calibrate` and `set_calibration`, and twice per move: before the first hardware write with a "move in progress" marker, and after the move without it. It holds the four @@ -203,7 +204,7 @@ When the device connects (device server start, config reload) it reads it back: - The current delay (the readback) comes back with a verified calibration. After a stopped move the store holds no delay, and the readback stays NaN until the next move. - A calibration older than 7 days is restored with a warning. -- Nothing stored (no file) leaves the device uncalibrated; an unreadable file too, with +- Nothing stored (no file, empty records) leaves the device uncalibrated; an unreadable store too, with an error in the log. The device still connects. A move is refused, before any hardware write, when the "move in progress" marker cannot be saved: @@ -211,6 +212,27 @@ without it a later restore could trust a stale phase. Fix the store (see the dev move again. If only the save after the ramp fails, the error is logged and the marker stays set, so the next restart refuses moves until `calibrate`. +### Calibration records (for controls) + +Prefix proposed for X10DA: `X10DA-LAS-DELAY:` (next to the old `X10DA-LAS-PHSH:`), on any IOC with +autosave, for example the timing IOC. All records soft (no hardware link), `PINI` YES, all fields in +autosave (`VAL`), no `DRVH`/`DRVL`: + +| record | type | unit | content | +|---|---|---|---| +| `X10DA-LAS-DELAY:LASER-PERIOD` | `ao`, `PREC` 4 | ns | laser period at calibration | +| `X10DA-LAS-DELAY:OFFSET-T` | `ao`, `PREC` 6 | ns | delay card offset | +| `X10DA-LAS-DELAY:OFFSET-PHASE-T` | `ao`, `PREC` 6 | ns | phase shifter offset | +| `X10DA-LAS-DELAY:PHASE-T` | `ao`, `PREC` 6 | ns | stored (unwrapped) phase, written twice per move | +| `X10DA-LAS-DELAY:DELAY` | `ao`, `PREC` 4 | ns | current delay, NaN when unknown | +| `X10DA-LAS-DELAY:CAL-SCOPE` | `ao`, `PREC` 4 | ns | scope reading of the last calibration, NaN after `set_calibration` | +| `X10DA-LAS-DELAY:CAL-TIME` | `stringout` | | time of the last calibration, ISO 8601 UTC | +| `X10DA-LAS-DELAY:MOVING` | `bo` (0/1) | | move in progress marker | + +The device treats `LASER-PERIOD` 0 or NaN as "no calibration stored" (fresh records). Once they exist, +set `calibration_pv_prefix: "X10DA-LAS-DELAY:"` and remove `calibration_file` in +`x10da_laser_delay.yaml`. The script could read the same four numbers instead of `Timing.offset`. + `set_calibration` restores values by hand (for example from another file): ```python @@ -253,7 +275,8 @@ changed in the YAML file and a config reload. | `step_sleep` | 0.1 | seconds between phase steps, at least 0.1 (the tested value) | | `phase_path` | `script` | `script`: ramp the phase change the script computes. `shortest`: fold it into ±half a cavity period (section 6) | | `fine_rollover` | `true` | write a fine value of 200 as the next coarse unit and FINE 0 (same delay). `false`: write 200 like the script (open question 5) | -| `calibration_file` | none (X10DA config for now: `~/.superxas_bec/laser_delay_calibration.json`) | JSON file that keeps the calibration across restarts (section 4); only changed by a config reload | +| `calibration_pv_prefix` | none (X10DA: `X10DA-LAS-DELAY:` once the records exist) | soft records that keep the calibration across restarts (section 4); only changed by a config reload | +| `calibration_file` | none (X10DA config for now: `~/.superxas_bec/laser_delay_calibration.json`) | JSON file that keeps the calibration instead (section 4); only changed by a config reload | | `delay_coarse_pv`, `delay_fine_pv`, `evr_period_pv`, `phase_v1_pv`, `phase_v2_pv` | see table above | PV names | | `limits` | none | optional `[low, high]` in ns, `low <= high` | @@ -298,7 +321,7 @@ phase shifter `cavity_wraps` cavity periods further than usual, once. - Optional `phase_path: shortest` (section 6); the default is the script's path. - A fine value of 200 is written as the next coarse unit and 0 (`fine_rollover`, section 5). - `vmax` above 5 V is refused, and no voltage above 5 V is ever written. -- The calibration is kept in a JSON file (section 4), restored automatically, and +- The calibration is kept in soft records or a JSON file (section 4), restored automatically, and checked against the hardware before it is used. - Moves are refused when the phase shifter was turned outside BEC, its amplitude is wrong, or a restored calibration is not verified (section 2a). The script would move to a wrong delay. @@ -319,7 +342,7 @@ Answered on 7 Oct 2026 (via Jan, after the kickoff meeting): | 0.1 s per phase step | an artificial value, tested and known to be good | `step_sleep: 0.1` stays the default | | Shorter phase path | tested once, could be faster, not a priority: an advanced parameter | `phase_path: shortest` stays opt-in | | Maximum phase shifter voltage | hard limit 5 V | `vmax` is capped at 5 V | -| Calibration after a BEC restart | redone about weekly, with the scope, after the laser is recalibrated | kept in a file and restored at startup, verified against the hardware (section 4) | +| Calibration after a BEC restart | redone about weekly, with the scope, after the laser is recalibrated | kept in soft records (or a file) and restored at startup, verified against the hardware (section 4) | | FINE = 200 | not answered | rolled over into the next coarse unit (`fine_rollover: true`), still to confirm | Still open: @@ -333,8 +356,7 @@ Still open: `phase_path: shortest` (section 6). 3. **Vmax** for phase shifter 2: is 1.0 V right? The script has a comment that it may differ. Anything up to the 5 V hard limit is accepted. -4. **Calibration records.** Controls to add the `X10DA-LAS-DELAY:` records; the device support for - them (`calibration_pv_prefix`) is on the branch `feat/laser-delay-calibration-pvs`. Until then the +4. **Calibration records.** Controls to add the `X10DA-LAS-DELAY:` records (section 4). Until then the calibration is kept in a file in the home of the account that runs the device server. ## 9. Simulation diff --git a/superxas_bec/devices/laser_delay.py b/superxas_bec/devices/laser_delay.py index aa25918..5af084b 100644 --- a/superxas_bec/devices/laser_delay.py +++ b/superxas_bec/devices/laser_delay.py @@ -35,8 +35,9 @@ Calibration (the script's ``CurrentSituation``, run after every laser restart) r 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``), see ``LASER_DELAY.md``. +``Timing.offset``; here they are config signals on the device, kept across BEC restarts in soft +records on an IOC (``calibration_pv_prefix``) or a JSON file (``calibration_file``), see +``LASER_DELAY.md``. 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 @@ -61,7 +62,7 @@ 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 Device, EpicsSignal, EpicsSignalRO from ophyd import FormattedComponent as FCpt from ophyd import Kind, Signal from ophyd.positioner import PositionerBase @@ -464,6 +465,23 @@ def _phase_path(value): # --------------------------------------------------------------------------------------------- # +class LaserDelayCalibrationPVs(Device): + """Soft records on an IOC that keep the laser delay calibration (autosaved). + + One record per value, under a common prefix (e.g. ``X10DA-LAS-DELAY:``). The record list and + types are in ``LASER_DELAY.md``. Used by ``LaserDelay`` when ``calibration_pv_prefix`` is set. + """ + + laser_period = Cpt(EpicsSignal, "LASER-PERIOD", kind=Kind.config) + offset_t = Cpt(EpicsSignal, "OFFSET-T", kind=Kind.config) + offset_phase_t = Cpt(EpicsSignal, "OFFSET-PHASE-T", kind=Kind.config) + phase_t = Cpt(EpicsSignal, "PHASE-T", kind=Kind.config) + current_delay = Cpt(EpicsSignal, "DELAY", kind=Kind.config) + scope = Cpt(EpicsSignal, "CAL-SCOPE", kind=Kind.config) + calibrated_at = Cpt(EpicsSignal, "CAL-TIME", string=True, kind=Kind.config) + moving = Cpt(EpicsSignal, "MOVING", kind=Kind.config) + + class _FileCalibrationStore: """Calibration kept in a JSON file on the device server host (like ``Timing.offset``).""" @@ -497,6 +515,60 @@ class _FileCalibrationStore: """Nothing to release.""" +class _PVCalibrationStore: + """Calibration kept in soft records on an IOC (``LaserDelayCalibrationPVs``).""" + + CONNECT_TIMEOUT = 5.0 + + def __init__(self, prefix: str, name: str): + self.prefix = prefix + self.pvs = LaserDelayCalibrationPVs(prefix, name=f"{name}_calibration_pvs") + + def __str__(self) -> str: + return f"PVs {self.prefix}*" + + def load(self) -> dict | None: + """Content of the records, or None if they hold no calibration (laser period 0 or NaN).""" + self.pvs.wait_for_connection(timeout=self.CONNECT_TIMEOUT) + laser_period = float(self.pvs.laser_period.get()) + if not math.isfinite(laser_period) or laser_period <= 0: + return None + delay = float(self.pvs.current_delay.get()) + scope = float(self.pvs.scope.get()) + return { + "calibration": { + "laser_period": laser_period, + "offset_t": float(self.pvs.offset_t.get()), + "offset_phase_t": float(self.pvs.offset_phase_t.get()), + "phase_t": float(self.pvs.phase_t.get()), + }, + "current_delay": delay if math.isfinite(delay) else None, + "scope_xray_minus_laser": scope if math.isfinite(scope) else None, + "calibrated_at": str(self.pvs.calibrated_at.get()) or None, + "move_in_progress": bool(int(self.pvs.moving.get())), + } + + def save(self, content: dict) -> None: + """Write the records. The move marker goes first when set and last when cleared.""" + moving = 1 if content.get("move_in_progress") else 0 + if moving: + self.pvs.moving.put(moving, wait=True) + calibration = content["calibration"] + for key in ("laser_period", "offset_t", "offset_phase_t", "phase_t"): + getattr(self.pvs, key).put(float(calibration[key]), wait=True) + delay = content.get("current_delay") + self.pvs.current_delay.put(math.nan if delay is None else float(delay), wait=True) + scope = content.get("scope_xray_minus_laser") + self.pvs.scope.put(math.nan if scope is None else float(scope), wait=True) + self.pvs.calibrated_at.put(content.get("calibrated_at") or "", wait=True) + if not moving: + self.pvs.moving.put(moving, wait=True) + + def destroy(self) -> None: + """Release the channel access connections.""" + self.pvs.destroy() + + # --------------------------------------------------------------------------------------------- # # Device # # --------------------------------------------------------------------------------------------- # @@ -539,7 +611,10 @@ class LaserDelay(PSIDeviceBase, PositionerBase): 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. + calibration across BEC restarts. + calibration_pv_prefix (str | None): prefix of the soft records that keep the calibration + (``LaserDelayCalibrationPVs``). Use this or ``calibration_file``, not both. None for + both: the calibration is lost at every restart. limits (list[float] | None): optional soft limits ``[low, high]`` in ns. """ @@ -658,6 +733,7 @@ class LaserDelay(PSIDeviceBase, PositionerBase): phase_v1_pv = _ConstructorOnly() phase_v2_pv = _ConstructorOnly() calibration_file = _ConstructorOnly() + calibration_pv_prefix = _ConstructorOnly() # pylint: disable=too-many-arguments,too-many-locals def __init__( @@ -678,17 +754,23 @@ class LaserDelay(PSIDeviceBase, PositionerBase): phase_path: str = "script", fine_rollover: bool = True, calibration_file: str | None = None, + calibration_pv_prefix: str | None = None, limits: list[float] | tuple[float, float] | None = None, scan_info: ScanInfo | None = None, device_manager: DeviceManagerBase | None = None, **kwargs, ): + if calibration_file and calibration_pv_prefix: + raise ValueError( + f"{name}: give either calibration_file or calibration_pv_prefix, not both" + ) 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._calibration_pv_prefix = calibration_pv_prefix 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 @@ -701,8 +783,10 @@ class LaserDelay(PSIDeviceBase, PositionerBase): ) self.readback.name = self.name self._position = math.nan - self._store: _FileCalibrationStore | None = None - if calibration_file: + self._store: _FileCalibrationStore | _PVCalibrationStore | None = None + if calibration_pv_prefix: + self._store = _PVCalibrationStore(calibration_pv_prefix, self.name) + elif 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)) @@ -819,7 +903,7 @@ class LaserDelay(PSIDeviceBase, PositionerBase): 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 + # Restore the calibration from the store (file or PVs), see _load_calibration. Never # raises: the device connects uncalibrated if the restore fails. try: self._load_calibration() @@ -995,7 +1079,7 @@ class LaserDelay(PSIDeviceBase, PositionerBase): (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). + file or PV prefix that keeps it across restarts, None if not kept). """ calibrated_at = self._calibration_meta.get("calibrated_at") age_days = None diff --git a/tests/tests_devices/test_laser_delay.py b/tests/tests_devices/test_laser_delay.py index 304866f..0fabe52 100644 --- a/tests/tests_devices/test_laser_delay.py +++ b/tests/tests_devices/test_laser_delay.py @@ -139,6 +139,7 @@ def test_default_pvs_constants_and_positioner_type(laser_delay): } assert config["laser_delay_fine_rollover"]["value"] is True assert laser_delay.calibration_file is None + assert laser_delay.calibration_pv_prefix is None assert isinstance(laser_delay, BECPositionerProtocol) assert isinstance(laser_delay, BECDeviceProtocol) @@ -194,6 +195,7 @@ def test_config_overrides_pvs_and_constants(): [ {"steps_per_rf_period": 35}, {"vmax": 0.0}, + {"calibration_file": "/tmp/x.json", "calibration_pv_prefix": "X10DA-LAS-DELAY:"}, {"phase_path": "fast"}, {"steps_per_rf_period": 0}, {"steps_per_rf_period": 2.5}, @@ -1485,3 +1487,78 @@ def test_clamped_phase_voltage_fails_the_move_and_refuses_the_next(calibrated): assert not calibrated.get_calibration_info()["verified"] with pytest.raises(LaserDelayNotCalibratedError): calibrated.set(2.0) + + +# --------------------------------------------------------------------------------------------- # +# Calibration kept in PVs # +# --------------------------------------------------------------------------------------------- # + +PV_PREFIX = "X10DA-LAS-DELAY:" + + +def store_values(device) -> dict: + pvs = device._store.pvs + return {sig: getattr(pvs, sig)._read_pv.mock_data for sig in pvs.component_names} + + +def test_calibration_pvs_are_written_and_restored(): + with patched_device( + LaserDelay, name="laser_delay", step_sleep=0.0, calibration_pv_prefix=PV_PREFIX + ) as device: + set_hardware(device) + device.calibrate(SCOPE) + device.set(1.5).wait(timeout=10) + before, hardware, saved = ( + device.get_calibration(), + hardware_of(device), + store_values(device), + ) + assert device._store.pvs.laser_period.pvname == f"{PV_PREFIX}LASER-PERIOD" + assert saved["phase_t"] == before["phase_t"] + assert saved["current_delay"] == 1.5 + assert saved["moving"] == 0 + assert device.get_calibration_info()["storage"] == f"PVs {PV_PREFIX}*" + with patched_device( + LaserDelay, name="laser_delay", step_sleep=0.0, calibration_pv_prefix=PV_PREFIX + ) as device: + set_hardware(device, **hardware) + for sig, value in saved.items(): + getattr(device._store.pvs, sig)._read_pv.mock_data = value + device.on_connected() + assert device.get_calibration() == before + assert device.read()["laser_delay"]["value"] == 1.5 + assert device.get_calibration_info()["verified"] + + +def test_empty_calibration_pvs_leave_the_device_uncalibrated(): + with patched_device( + LaserDelay, name="laser_delay", step_sleep=0.0, calibration_pv_prefix=PV_PREFIX + ) as device: + set_hardware(device) + device._store.pvs.laser_period._read_pv.mock_data = 0.0 # fresh records, no autosave + device.on_connected() + with pytest.raises(LaserDelayNotCalibratedError, match="not calibrated"): + device.set(1.0) + + +def test_calibration_pv_marker_is_set_first_and_cleared_last(): + with patched_device( + LaserDelay, name="laser_delay", step_sleep=0.0, calibration_pv_prefix=PV_PREFIX + ) as device: + set_hardware(device) + device.calibrate(SCOPE) + order = [] + for sig in device._store.pvs.component_names: + pv = getattr(device._store.pvs, sig)._write_pv + original = pv.put + + def recorded(value, *args, _sig=sig, _original=original, **kwargs): + order.append((_sig, value)) + return _original(value, *args, **kwargs) + + pv.put = recorded + device.set(1.0).wait(timeout=10) + names = [sig for sig, _ in order] + assert order[0] == ("moving", 1) + assert order[-1] == ("moving", 0) + assert names.count("phase_t") == 2