feat(devices): keep the laser delay calibration in soft records on an IOC
CI for superxas_bec / test (push) Successful in 2m1s
CI for superxas_bec / test (pull_request) Successful in 1m58s

This commit is contained in:
wyzula_j committed 2026-10-08 22:48:42 +02:00
1 parent f42c6baca6
commit 5284f55e9d
4 files changed
+210 -25

No files matched your search

@@ -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
+36 -14
View File
@@ -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': '<home>/.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
+92 -8
View File
@@ -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
+77
View File
@@ -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