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:
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user