feat(aerotech): trigger calibration aligns forward and backward lines

The backward exposure centres are offset from the forward ones by a velocity
dependent amount (DDG1: +2 um at 2 mm/s, +19 um at 10 mm/s, +26 um at 20 mm/s,
-9 um at 40 mm/s backward - forward, independent of exp_time). With
trigger_calibration=True (default) the scan shifts trigger_low by +h and
trigger_high by -h, h = half of backward - forward from the table
userParameter "trigger_calibration" of the fast axis: linear between the
calibrated velocities, proportional below the lowest, none above the highest.
The applied offset is stored in the scan info (trigger_offset, scan_velocity);
the statistics print the table entry the scan measured, for training scans
with trigger_calibration=False.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017NrH3poJsYBCTVq2Y9fuHZ
This commit is contained in:
x12saandClaude Opus 5.5 committed 2026-11-03 12:34:01 +01:00
1 parent 663a6cf518
commit a33d089d3d
4 files changed
+240 -2

No files matched your search

@@ -59,6 +59,19 @@ of motion for the moving fast axis). At the end the scan reports statistics of t
centres of the fast axis (deviation from the nominal cell centres per direction,
backward - forward offset and its slope along the line) and of the line times.
Trigger calibration: in a snake the backward exposure centres are offset from the forward
ones, by an amount that depends on the scan velocity (DDG1 at the beamline, 2026-11-03:
backward - forward +2 um at 2 mm/s, +19 um at 10 mm/s, +26 um at 20 mm/s, -9 um at
40 mm/s; independent of exp_time within the scatter). With trigger_calibration=True the
scan shifts the trigger positions by the calibrated offset h (half of backward - forward):
trigger_low by +h, trigger_high by -h, so that the centres of both directions are on the
same cells. h comes from the table userParameter "trigger_calibration" of the fast axis
(device config, a list of {velocity, offset [um]}): linear between the calibrated
velocities, proportional to the velocity below the lowest one, no correction above the
highest one. The statistics at the end show the applied offset and the table entry this
scan measured (applied + half of the remaining backward - forward), for new or better
calibration points; the applied offset is stored in the scan info (trigger_offset).
Run-up / run-out of each line: twice the ramp distance v^2 / (2 * acceleration) + v *
settle_time beyond the trigger positions. The whole range including run-up / run-out is
checked against the soft limits before the scan starts. The scan velocity may be higher
@@ -122,6 +135,24 @@ class _DeviceExample(str):
DDG_EXT_RISING_EDGE = 1
DDG_EXT_FALLING_EDGE = 2
DDG_SINGLE_SHOT = 5
def interpolate_trigger_offset(table: list[dict], velocity: float) -> float | None:
"""
Trigger offset h [um] for a scan velocity from a calibration table of
{"velocity": ..., "offset": ...} entries: linear between the calibrated velocities,
proportional to the velocity below the lowest one (no offset at rest), None above the
highest one or without a table.
"""
points = sorted((float(e["velocity"]), float(e["offset"])) for e in table)
if not points:
return None
velocities, offsets = zip(*points)
if velocity > velocities[-1] * (1 + 1e-9):
return None
if velocity <= velocities[0]:
return offsets[0] * velocity / velocities[0]
return float(np.interp(velocity, velocities, offsets))
DDG_TRIGGER_EDGES = {"rising": DDG_EXT_RISING_EDGE, "falling": DDG_EXT_FALLING_EDGE}
# Extra run-up / run-out beyond the ramp distance, as a fraction of it (headroom for the
@@ -136,6 +167,9 @@ MIN_RUN_UP = 0.01
TRIGGER_SOURCES = ("ddg", "panda", None)
# userParameter of the fast axis with the trigger calibration table, see the module docstring
TRIGGER_CALIBRATION_PARAMETER = "trigger_calibration"
# Poll interval of the fast axis position while waiting for the end of the exposures, s
STEP_POLL_INTERVAL = 0.01
@@ -178,6 +212,7 @@ class AerotechContGrid(ScanBase):
"relative",
"trigger_source",
"minimal_run_up",
"trigger_calibration",
],
}
@@ -201,6 +236,7 @@ class AerotechContGrid(ScanBase):
settle_time: float = 0.15,
minimal_run_up: bool = False,
capture_device: str = "aerocapture",
trigger_calibration: bool = True,
**kwargs,
# fmt: on
):
@@ -226,6 +262,7 @@ class AerotechContGrid(ScanBase):
settle_time (float): Run-up at constant velocity before the trigger position, in s. Default is 0.15.
minimal_run_up (bool): Run-up / run-out only the ramp distance v^2 / (2 * acceleration), at least 0.01, without the 2x margin and settle_time: faster lines, but the exposures start right after the ramp while the fast axis may still oscillate, so the captured positions are less evenly spaced. Default is False.
capture_device (str): Name of the A3200PositionCapture device (always used). Default is "aerocapture".
trigger_calibration (bool): Shift the trigger positions by the calibrated offset of the fast axis for the scan velocity, so that forward and backward exposure centres are on the same cells (userParameter "trigger_calibration"). False for training scans of new calibration points. Default is True.
Returns:
ScanReport
@@ -282,6 +319,7 @@ class AerotechContGrid(ScanBase):
self._ddg1_shutter_delay = None # DDG1 value to restore after the scan
self.settle_time = settle_time
self.minimal_run_up = minimal_run_up
self.trigger_calibration = trigger_calibration
devices = self.device_manager.devices
self.ddg1 = self.ddg2 = self.mcs = self.panda = None
@@ -358,6 +396,8 @@ class AerotechContGrid(ScanBase):
computed_positions=positions,
num_lines=num_lines,
readout_time=self._line["readout_time"],
scan_velocity=self._line["target_velocity"],
trigger_offset=self._line["trigger_offset"],
)
# progress bar in the client, which also prints the statistics at the end: the
@@ -598,6 +638,7 @@ class AerotechContGrid(ScanBase):
f" backward - forward [um]: mean {diff.mean():+.2f}, slope {slope:+.2f} / mm,"
f" peak-peak {np.ptp(diff):.2f}",
]
text.append(self._calibration_summary(diff.mean()))
if self._line_starts and self._last_line_end is not None:
# line period: start to start of the next line (motion, trigger check, capture
# readout, wait for the step); the last line ends with its capture readout
@@ -614,6 +655,40 @@ class AerotechContGrid(ScanBase):
)
return "\n".join(text)
def _trigger_offset(self, velocity: float) -> float | None:
"""Calibrated trigger offset [um] for the velocity, None if not applied."""
if not self.trigger_calibration:
return None
# the device config, as user_parameter of DeviceBaseWithConfig (not on every device class)
config = getattr(self.fast_axis, "_config", None) # pylint: disable=protected-access
parameters = config.get("userParameter") if isinstance(config, dict) else None
table = (
parameters.get(TRIGGER_CALIBRATION_PARAMETER) if isinstance(parameters, dict) else None
)
offset = interpolate_trigger_offset(table or [], velocity)
if offset is None:
logger.warning(
f"aerotech_cont_grid: no trigger calibration of {self.fast_axis.name} for"
f" {velocity:g} / s, forward and backward exposures are not aligned."
)
return offset
def _calibration_summary(self, backward_minus_forward: float) -> str:
"""Applied offset and the calibration entry measured by this scan."""
line = self._line
applied = line.get("trigger_offset")
measured = (applied or 0.0) + backward_minus_forward / 2
if applied is not None:
state = f"offset {applied:+.2f} um applied"
elif self.trigger_calibration:
state = "no calibration for this velocity"
else:
state = "off"
return (
f" trigger calibration [um]: {state}; this scan measured"
f" {{velocity: {line['target_velocity']:g}, offset: {measured:.2f}}}"
)
def _trigger_window(self, line_index: int) -> tuple[float, float]:
"""PSO window of a line: entered at the trigger position, open beyond the line end."""
line = self._line
@@ -711,6 +786,11 @@ class AerotechContGrid(ScanBase):
line["exposure_end"] = self.fast_start + self.frames_per_line * self.fast_step_size
line["trigger_low"] = self.fast_start - lead + gap
line["trigger_high"] = line["exposure_end"] + lead - gap
# calibrated offset [um]: later trigger in the direction of motion, both directions
line["trigger_offset"] = self._trigger_offset(velocity)
shift = (line["trigger_offset"] or 0.0) * 1e-3
line["trigger_low"] += shift
line["trigger_high"] -= shift
line["line_start"] = line["trigger_low"] - ramp_distance - margin
line["line_end"] = line["trigger_high"] + ramp_distance + margin
+23
View File
@@ -108,6 +108,29 @@ of -24 / +10 / +4.5 / -21 um (2026-10-29).
against the soft limits before anything moves. The message separates the scan range
from the run-up so a user can see why a scan inside the limits does not fit.
### Trigger calibration
The backward exposure centres are offset from the forward ones by a velocity
dependent amount (DDG1 at the beamline, 2026-11-03, scans 4897-4917: backward minus
forward +0.9 / +2.1 / +9.3 / +18.6 / +25.7 / +3.0 / -8.8 um at 1 / 2 / 4 / 10 / 20 / 30 /
40 mm/s; 5 vs 20 ms exposure time the same within the scatter; repeats within 0.1 um
at 1 mm/s and 0.2 um at 40 mm/s). It is not a constant delay: about 0.5-1 ms up to
20 mm/s, then it falls and changes sign. With the PandA (2026-10-29) it was below 1 um at
20 mm/s.
`trigger_calibration=True` (default) shifts `trigger_low` by `+h` and `trigger_high`
by `-h` (later in the direction of motion), with `h` = half of backward minus forward
from the table `userParameter.trigger_calibration` of the fast axis
(`interpolate_trigger_offset`: linear between the points, proportional to the velocity
below the lowest one, `None` above the highest one). The device config is read from
`_config` of the fast axis (the scan server's devices are `DeviceBaseWithConfig`,
whose `user_parameter` reads the same). The applied `h` is in the scan info
(`trigger_offset`, with `scan_velocity`); the statistics print the entry the scan
measured, `h_applied + (backward - forward) / 2`. Only the mean offset per direction is
corrected; the variation along the line at high velocity (oscillation after the ramp)
remains. The calibration is for the configured acceleration (500 mm/s^2) and
`settle_time` 0.15 s.
### Step during the run-out
As soon as X has left the exposure range of a line, the PSO window of the next line
@@ -74,6 +74,7 @@ scans.aerotech_cont_grid(
| `settle_time` | constant velocity time before the first exposure of a line, default 0.15 s (leave it) |
| `shutter_delay` | time from the line trigger to the first exposure, for the shutter to open. Default: the value of DDG1 (8 ms); a given value is set on DDG1 for this scan only |
| `minimal_run_up` | `True`: run-up only the acceleration distance (at least 10 um), no settling. Faster lines, but the exposures are less evenly spaced; use the captured positions. Default `False` |
| `trigger_calibration` | `True` (default): forward and backward lines are aligned with the calibration of `aerox` (see below). `False` only for training scans of new calibration points |
The number of points per line is `ceil(|fast_end - fast_start| / fast_step_size) + 1`,
the number of lines `ceil(|stepper_stop - stepper_start| / stepper_step_size) + 1`.
@@ -97,6 +98,46 @@ csaxs.aerotech.stop_plot() # stop following (the plot stays)
Every exposure is shown as a marker. Forward and backward lines alternate, so the two
colours alternate in Y by one line spacing.
### Forward and backward alignment (trigger calibration)
Without correction the backward lines are offset from the forward lines along X, by
an amount that depends on the scan velocity (measured 2026-11-03 at the beamline,
backward minus forward):
| Velocity [mm/s] | 0.5 | 1 | 2 | 4 | 8 | 10 | 20 | 30 | 40 |
|---|---|---|---|---|---|---|---|---|---|
| Offset [um] | -0.6 | +0.9 | +2.1 | +9.3 | +15.8 | +18.6 | +25.7 | +3.0 | -8.8 |
It does not depend on the exposure time and is reproducible. The scan therefore moves
the trigger positions of both directions by half of it, so that forward and backward
exposures land on the same cells. The calibration is a table in the device config of
`aerox` (`userParameter` `trigger_calibration`, velocity and offset), interpolated
between the calibrated velocities. Above 40 mm/s there is no calibration: the scan
runs uncorrected and says so in its summary.
The summary of each scan shows the applied offset and the table entry the scan
measured, for example:
```text
trigger calibration [um]: offset +9.29 um applied; this scan measured {velocity: 10, offset: 9.41}
```
A calibrated scan should show a backward - forward mean of a few um at most. What
remains along a line at high velocity (rms per direction 4-7 um from 10 mm/s on) is
the motion of X and cannot be corrected by the trigger.
**New calibration point** (other acceleration, settle time, or a velocity outside the
table): run a short scan with `trigger_calibration=False` at that velocity, for example
X +-1 mm, 6 lines:
```python
scans.aerotech_cont_grid(dev.aerox, -1.0, 1.0, 0.1, dev.aeroy, -0.1, 0.1, 0.04, exp_time=0.005, relative=True, trigger_calibration=False) # 20 mm/s
```
and add the printed `{velocity: ..., offset: ...}` to `trigger_calibration` of `aerox`
in `sastt_aerotech.yaml` (ask the beamline staff to commit it). For a quick test without
a config change: `dev.aerox.update_user_parameter({...})`.
### Examples
Fields of view centred on the current position (`relative=True`), X fast, Y step:
@@ -4,6 +4,7 @@ import numpy as np
import pytest
from bec_server.scan_server.errors import LimitError
from bec_server.scan_server.tests.scan_fixtures import MockCustomDevice
from csaxs_bec.scans.scans_v4.aerotech_cont_grid import interpolate_trigger_offset
from bec_server.scan_server.tests.scan_hook_tests import (
DEFAULT_HOOK_TESTS,
PREMOVE_HOOK_TESTS,
@@ -18,7 +19,12 @@ def _signal(obj_name, kind="config"):
def _add_devices(
device_manager, capture=True, acceleration=100.0, panda=True, max_scan_velocity=10.0
device_manager,
capture=True,
acceleration=100.0,
panda=True,
max_scan_velocity=10.0,
user_parameter=None,
):
aerox = MockCustomDevice(
"aerox",
@@ -37,6 +43,7 @@ def _add_devices(
"aerox_wait_in_position": True,
},
)
aerox._config = {"userParameter": user_parameter or {}}
aerox.arm_line_trigger = mock.MagicMock()
aerox.disarm_line_trigger = mock.MagicMock()
aerox.set_line_trigger_window = mock.MagicMock()
@@ -101,7 +108,7 @@ def _assemble(v4_scan_assembler, device_manager, **kwargs):
device_manager,
**{
k: kwargs.pop(k)
for k in ("capture", "acceleration", "panda", "max_scan_velocity")
for k in ("capture", "acceleration", "panda", "max_scan_velocity", "user_parameter")
if k in kwargs
},
)
@@ -681,3 +688,90 @@ def test_progress_bar_from_the_capture_without_mcs(v4_scan_assembler, device_man
scan = _assemble(v4_scan_assembler, device_manager, trigger_source="panda")
scan.prepare_scan()
assert _progress_devices(scan) == ["aerocapture"]
CALIBRATION = [
{"velocity": 10.0, "offset": 4.0},
{"velocity": 2.0, "offset": 1.0},
{"velocity": 20.0, "offset": -2.0},
]
@pytest.mark.parametrize(
("velocity", "expected"),
[
(0.0, 0.0),
(1.0, 0.5), # below the lowest point: proportional to the velocity
(2.0, 1.0),
(6.0, 2.5), # linear between the points (table unsorted)
(15.0, 1.0),
(20.0, -2.0),
(20.0 * (1 + 1e-12), -2.0), # rounding of fast_step_size / exp_time
(25.0, None), # above the highest point: not calibrated
],
)
def test_interpolate_trigger_offset(velocity, expected):
offset = interpolate_trigger_offset(CALIBRATION, velocity)
if expected is None:
assert offset is None
else:
assert offset == pytest.approx(expected)
def test_interpolate_trigger_offset_without_table():
assert interpolate_trigger_offset([], 5.0) is None
def _trigger_positions(v4_scan_assembler, device_manager, **kwargs):
scan = _assemble(v4_scan_assembler, device_manager, **kwargs)
scan.prepare_scan()
return scan, scan._line["trigger_low"], scan._line["trigger_high"]
def test_trigger_calibration_shifts_both_directions(v4_scan_assembler, device_manager):
# 5 / s: offset 2.125 um between the points at 2 and 10 / s
_, low, high = _trigger_positions(v4_scan_assembler, device_manager, trigger_calibration=False)
scan, low_cal, high_cal = _trigger_positions(
v4_scan_assembler,
device_manager,
user_parameter={"trigger_calibration": CALIBRATION},
)
# later in the direction of motion: forward triggers at a higher, backward at a lower position
assert low_cal - low == pytest.approx(2.125e-3)
assert high - high_cal == pytest.approx(2.125e-3)
info = scan.scan_info.additional_scan_parameters
assert info["trigger_offset"] == pytest.approx(2.125)
assert info["scan_velocity"] == pytest.approx(5.0)
def test_trigger_calibration_off_or_not_calibrated(v4_scan_assembler, device_manager):
_, low, high = _trigger_positions(v4_scan_assembler, device_manager) # no table
scan, low_off, high_off = _trigger_positions(
v4_scan_assembler,
device_manager,
user_parameter={"trigger_calibration": CALIBRATION},
trigger_calibration=False,
)
assert (low_off, high_off) == pytest.approx((low, high))
assert scan._line["trigger_offset"] is None
# 5 / s is above a table that ends at 2 / s
scan, low_high, _ = _trigger_positions(
v4_scan_assembler,
device_manager,
user_parameter={"trigger_calibration": CALIBRATION[1:2]},
)
assert low_high == pytest.approx(low)
assert scan._line["trigger_offset"] is None
def test_calibration_summary_reports_the_measured_entry(v4_scan_assembler, device_manager):
scan, _, _ = _trigger_positions(
v4_scan_assembler, device_manager, user_parameter={"trigger_calibration": CALIBRATION}
)
# applied 2.125 um, still +1 um backward - forward: the entry is 2.625 um
summary = scan._calibration_summary(1.0)
assert "offset +2.12 um applied" in summary
assert "{velocity: 5, offset: 2.62}" in summary
scan.trigger_calibration = False
scan._line["trigger_offset"] = None
assert "off; this scan measured {velocity: 5, offset: 0.50}" in scan._calibration_summary(1.0)