Feat/aerotech calibration #352
No files matched your search
@@ -53,8 +53,9 @@ class cSAXSAerotech:
|
||||
waveform, curves = self._setup_plot()
|
||||
if scan_number is not None:
|
||||
_draw(curves, self._history_lines(scan_number))
|
||||
waveform.title = _title(scan_number)
|
||||
return waveform
|
||||
self._live = _LivePositions(self, curves)
|
||||
self._live = _LivePositions(self, waveform, curves)
|
||||
self._live.start()
|
||||
return waveform
|
||||
|
||||
@@ -74,7 +75,7 @@ class cSAXSAerotech:
|
||||
if waveform is None:
|
||||
waveform = window.new("Waveform", object_name=self.PLOT, timeout=self.GUI_RPC_TIMEOUT)
|
||||
waveform.clear_all()
|
||||
waveform.title = "aerotech_cont_grid: exposure centres"
|
||||
waveform.title = _title(None)
|
||||
waveform.x_label = "X centre (aerox)"
|
||||
waveform.y_label = "Y centre (aeroy)"
|
||||
waveform.lock_aspect_ratio = True
|
||||
@@ -96,6 +97,13 @@ class cSAXSAerotech:
|
||||
return _lines_from_file(scan._msg.file_path)
|
||||
|
||||
|
||||
def _title(scan_number: int | None) -> str:
|
||||
"""Plot title with the number of the plotted scan."""
|
||||
if scan_number is None:
|
||||
return "aerotech_cont_grid: exposure centres"
|
||||
return f"aerotech_cont_grid scan {scan_number}: exposure centres"
|
||||
|
||||
|
||||
def _lines_from_file(file_path: str) -> list[tuple[np.ndarray, np.ndarray]]:
|
||||
"""Exposure centres per line from an aerotech_cont_grid data file."""
|
||||
with h5py.File(file_path, "r") as f:
|
||||
@@ -150,12 +158,15 @@ def _markers_only(x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray]
|
||||
class _LivePositions:
|
||||
"""Follows aerotech_cont_grid scans and redraws the exposure centres per line."""
|
||||
|
||||
def __init__(self, parent: cSAXSAerotech, curves: dict):
|
||||
def __init__(self, parent: cSAXSAerotech, waveform, curves: dict):
|
||||
self.parent = parent
|
||||
self.connector = parent.client.connector
|
||||
self.waveform = waveform
|
||||
self.curves = curves
|
||||
self._lock = threading.Lock()
|
||||
self._lines: list[tuple[np.ndarray, np.ndarray]] = []
|
||||
self._scan_number: int | None = None
|
||||
self._shown_scan_number: int | None = None
|
||||
self._scan_id = None
|
||||
self._stream_topic = None
|
||||
self._dirty = threading.Event()
|
||||
@@ -177,7 +188,8 @@ class _LivePositions:
|
||||
|
||||
def _show_last_scan(self) -> None:
|
||||
try:
|
||||
self._set_lines(_lines_from_file(self.parent.client.history[-1]._msg.file_path))
|
||||
msg = self.parent.client.history[-1]._msg
|
||||
self._set_lines(_lines_from_file(msg.file_path), scan_number=msg.scan_number)
|
||||
except Exception: # no scan yet, or the last one is not an aerotech_cont_grid
|
||||
pass
|
||||
|
||||
@@ -191,7 +203,7 @@ class _LivePositions:
|
||||
return
|
||||
self._unfollow()
|
||||
self._scan_id = status.scan_id
|
||||
self._set_lines([])
|
||||
self._set_lines([], scan_number=status.scan_number)
|
||||
self._stream_topic = MessageEndpoints.device_async_signal(
|
||||
scan_id=status.scan_id,
|
||||
device=self.parent.CAPTURE_DEVICE,
|
||||
@@ -216,9 +228,10 @@ class _LivePositions:
|
||||
self._lines.append((x, y))
|
||||
self._dirty.set()
|
||||
|
||||
def _set_lines(self, lines) -> None:
|
||||
def _set_lines(self, lines, scan_number: int | None = None) -> None:
|
||||
with self._lock:
|
||||
self._lines = list(lines)
|
||||
self._scan_number = scan_number
|
||||
self._dirty.set()
|
||||
|
||||
def _redraw_loop(self) -> None:
|
||||
@@ -229,8 +242,12 @@ class _LivePositions:
|
||||
self._dirty.clear()
|
||||
with self._lock:
|
||||
lines = list(self._lines)
|
||||
scan_number = self._scan_number
|
||||
try:
|
||||
_draw(self.curves, lines)
|
||||
if scan_number != self._shown_scan_number:
|
||||
self.waveform.title = _title(scan_number)
|
||||
self._shown_scan_number = scan_number
|
||||
except Exception as exc: # window or plot closed, GUI restarted
|
||||
# stop following instead of failing on every line of every scan
|
||||
logger.warning(
|
||||
|
||||
@@ -38,6 +38,34 @@ aerox:
|
||||
- -50
|
||||
- 150
|
||||
sign: 1
|
||||
userParameter:
|
||||
# aerotech_cont_grid trigger calibration: offset [um] = half of the backward - forward
|
||||
# exposure centre offset, per scan velocity [mm/s] and settle_time [s] (DDG1 trigger,
|
||||
# 500 mm/s^2; exp_time 5 / 20 ms gave the same within the scatter, 2026-11-03). A scan uses
|
||||
# the entries of its settle_time (and minimal_run_up: true entries only with
|
||||
# minimal_run_up): linear in between, proportional below the lowest velocity, none above
|
||||
# the highest. The statistics of each scan print the entry it measured. The trigger
|
||||
# shift moves backward - forward by ~1.15 x 2 * offset: take entries from scans with the
|
||||
# calibration applied, or the value where two such scans cross zero.
|
||||
trigger_calibration:
|
||||
# settle_time 0.3 s (default): from uncorrected scans, to be refined with calibrated ones
|
||||
- {velocity: 2.0, settle_time: 0.3, offset: -1.61}
|
||||
- {velocity: 4.0, settle_time: 0.3, offset: -2.89}
|
||||
- {velocity: 10.0, settle_time: 0.3, offset: -4.09}
|
||||
- {velocity: 20.0, settle_time: 0.3, offset: -1.58}
|
||||
- {velocity: 30.0, settle_time: 0.3, offset: -0.67}
|
||||
- {velocity: 40.0, settle_time: 0.3, offset: -0.36}
|
||||
# settle_time 0.15 s: 8 / 10 mm/s zero crossing of two calibrated scans each; 20 and
|
||||
# 30 mm/s good to about +-5 um only (uncorrected backward - forward at 20 mm/s varied
|
||||
# from +18 to +28 um between repeats)
|
||||
- {velocity: 1.0, settle_time: 0.15, offset: 0.43}
|
||||
- {velocity: 2.0, settle_time: 0.15, offset: 1.03}
|
||||
- {velocity: 4.0, settle_time: 0.15, offset: 4.64}
|
||||
- {velocity: 8.0, settle_time: 0.15, offset: 7.02}
|
||||
- {velocity: 10.0, settle_time: 0.15, offset: 8.45}
|
||||
- {velocity: 20.0, settle_time: 0.15, offset: 9.06}
|
||||
- {velocity: 30.0, settle_time: 0.15, offset: -2.91}
|
||||
- {velocity: 40.0, settle_time: 0.15, offset: -4.39}
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
|
||||
@@ -59,7 +59,7 @@ Documentation: A3200 6.04.004 help (`A3200.chm`, topic "ASCII Command Interface"
|
||||
|
||||
```python
|
||||
scans.aerotech_cont_grid(dev.aerox, 0, 1, 0.01, dev.aeroy, 0, 1, 0.1, exp_time=0.01,
|
||||
trigger_source="ddg", settle_time=0.15)
|
||||
trigger_source="ddg", settle_time=0.3)
|
||||
```
|
||||
|
||||
- fast axis X moves continuously at `fast_step_size / exp_time`, the step axis Y steps between
|
||||
@@ -95,8 +95,10 @@ scans.aerotech_cont_grid(dev.aerox, 0, 1, 0.01, dev.aeroy, 0, 1, 0.1, exp_time=0
|
||||
output (not tested). The PandA has the same choice as device config `trigger_edge`
|
||||
(default `"rising"`, opto wiring).
|
||||
- run-up / run-out = twice the ramp distance `v^2 / (2 * acceleration)` + `v * settle_time`
|
||||
(default 0.15 s, measured 2026-10-28: the following error needs ~100-200 ms after the
|
||||
ramp at 1 mm/s / 10 mm/s^2). In a snake the run-out of a line is the run-up of the next.
|
||||
(default 0.3 s since 2026-11-03, was 0.15 s; measured 2026-10-28: the following error
|
||||
needs ~100-200 ms after the ramp at 1 mm/s / 10 mm/s^2; at 20 mm/s 0.15 s left a
|
||||
forward / backward offset that changed between repeats, see the trigger calibration in
|
||||
`docs/developer/aerotech_stages.md`). In a snake the run-out of a line is the run-up of the next.
|
||||
- at the turnarounds X does not wait for In Position (`wait_in_position` signal off during
|
||||
the scan); X reaches 1 um within ~60 ms but 10 nm only after 0.5-2 s
|
||||
- positions are always captured: device `aerocapture` (must be enabled) latches the
|
||||
|
||||
@@ -59,6 +59,24 @@ 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 and the settling (DDG1 at the
|
||||
beamline, 2026-11-03, backward - forward, independent of exp_time within the scatter):
|
||||
with settle_time 0.15 s +2 / +19 / +26 / -9 um at 2 / 10 / 20 / 40 mm/s, at 20 mm/s
|
||||
varying by 10 um between repeats (the oscillation after the ramp has not decayed); with
|
||||
0.3 s -3 / -8 / -3 / -1 um, at 20 mm/s within 3 um. 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, settle_time, offset [um]}, optionally
|
||||
minimal_run_up: true): only entries of the scan's settle_time and run-up mode, linear
|
||||
between their velocities, proportional to the velocity below the lowest one, no
|
||||
correction above the highest one or without entries. The statistics at the end show the
|
||||
applied offset and the table entry this scan measured (applied + half of the remaining
|
||||
backward - forward); the trigger shift moves backward - forward by about 1.15 x 2h, so
|
||||
take new entries from scans with the calibration applied. 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
|
||||
@@ -72,9 +90,11 @@ and the step axis starts; the next line starts when both have arrived.
|
||||
minimal_run_up=True reduces the run-up / run-out to the ramp distance alone (no margin, no
|
||||
settle_time; at least MIN_RUN_UP = 0.01 so the axis never turns at a PSO window edge), for data analysis that works with unevenly spaced (captured) positions.
|
||||
|
||||
settle_time stays 0.15 s also at high speed: at 20 mm/s / 500 mm/s^2 shorter values
|
||||
(0.08 / 0.04 / 0.02 / 0 s) gave backward - forward offsets of -24 / +10 / +4.5 / -21 um,
|
||||
a ~10 Hz oscillation after the ramp that decays within ~0.15 s (measured 2026-10-29).
|
||||
settle_time is 0.3 s by default: at 20 mm/s / 500 mm/s^2 shorter values (0.08 / 0.04 /
|
||||
0.02 / 0 s) gave backward - forward offsets of -24 / +10 / +4.5 / -21 um, a ~10 Hz
|
||||
oscillation after the ramp (measured 2026-10-29); at the beamline (2026-11-03) 0.15 s
|
||||
still left +18 .. +28 um at 20 mm/s, different in every repeat, 0.3 s -2 .. -5 um. 0.3 s
|
||||
costs ~0.22-0.28 s per line compared with 0.15 s.
|
||||
|
||||
Scan procedure:
|
||||
- prepare_scan
|
||||
@@ -122,6 +142,33 @@ 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, settle_time: float, minimal_run_up: bool = False
|
||||
) -> float | None:
|
||||
"""
|
||||
Trigger offset h [um] for a scan velocity from a calibration table of
|
||||
{"velocity": ..., "settle_time": ..., "offset": ...} entries (optionally
|
||||
"minimal_run_up": True). Only the entries of the given settle_time and run-up mode are
|
||||
used: linear between their velocities, proportional to the velocity below the lowest
|
||||
one (no offset at rest), None above the highest one or without entries.
|
||||
"""
|
||||
points = sorted(
|
||||
(float(e["velocity"]), float(e["offset"]))
|
||||
for e in table
|
||||
if bool(e.get("minimal_run_up", False)) == bool(minimal_run_up)
|
||||
and e.get("settle_time") is not None
|
||||
and np.isclose(float(e["settle_time"]), settle_time)
|
||||
)
|
||||
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 +183,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 +228,7 @@ class AerotechContGrid(ScanBase):
|
||||
"relative",
|
||||
"trigger_source",
|
||||
"minimal_run_up",
|
||||
"trigger_calibration",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -198,9 +249,10 @@ class AerotechContGrid(ScanBase):
|
||||
trigger_edge: str = "rising",
|
||||
trigger_device: str | None = None,
|
||||
shutter_delay: float | None = None,
|
||||
settle_time: float = 0.15,
|
||||
settle_time: float = 0.3,
|
||||
minimal_run_up: bool = False,
|
||||
capture_device: str = "aerocapture",
|
||||
trigger_calibration: bool = True,
|
||||
**kwargs,
|
||||
# fmt: on
|
||||
):
|
||||
@@ -223,9 +275,10 @@ class AerotechContGrid(ScanBase):
|
||||
trigger_edge (str): DDG1 external trigger edge, "rising" (opto output, J7-6/7) or "falling" (open collector / differential output). Default is "rising".
|
||||
trigger_device (str | None): Name of the PandaLineTrigger device for trigger_source="panda"; ignored otherwise. Default is None ("pandatrig").
|
||||
shutter_delay (float | None): Delay from the line trigger to the first exposure (time for the shutter to open) in s. Default: the value of the trigger hardware (DDG1 / PandA shutter-to-open delay, 8 ms), 0 without trigger hardware. With "ddg" a given value is set on DDG1 for this scan and restored afterwards.
|
||||
settle_time (float): Run-up at constant velocity before the trigger position, in s. Default is 0.15.
|
||||
settle_time (float): Run-up at constant velocity before the trigger position, in s; the trigger calibration is per settle_time. Default is 0.3.
|
||||
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, settle_time and run-up mode, 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 +335,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 +412,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 +654,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 +671,46 @@ 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, self.settle_time, self.minimal_run_up
|
||||
)
|
||||
if offset is None:
|
||||
logger.warning(
|
||||
f"aerotech_cont_grid: no trigger calibration of {self.fast_axis.name} for"
|
||||
f" {velocity:g} / s, settle_time {self.settle_time:g} s"
|
||||
f"{', minimal run-up' if self.minimal_run_up else ''}: 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 / settle_time"
|
||||
else:
|
||||
state = "off"
|
||||
mode = ", minimal_run_up: true" if self.minimal_run_up else ""
|
||||
return (
|
||||
f" trigger calibration [um]: {state}; this scan measured"
|
||||
f" {{velocity: {line['target_velocity']:g}, settle_time: {self.settle_time:g},"
|
||||
f" offset: {measured:.2f}{mode}}}"
|
||||
)
|
||||
|
||||
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 +808,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
|
||||
|
||||
|
||||
@@ -99,15 +99,52 @@ trigger_high = exposure_end + v * shutter_delay - v * readout_time / 2 (backwa
|
||||
### Run-up, settling and limits
|
||||
|
||||
Run-up / run-out per side: `2 * v^2 / (2 * acceleration) + v * settle_time`. In a
|
||||
snake the run-out of a line is the run-up of the next. `settle_time` stays 0.15 s: at
|
||||
20 mm/s and 500 mm/s^2 there is a ~10 Hz oscillation after the ramp that decays within
|
||||
~0.15 s; shorter values (0.08 / 0.04 / 0.02 / 0 s) gave backward minus forward offsets
|
||||
of -24 / +10 / +4.5 / -21 um (2026-10-29).
|
||||
snake the run-out of a line is the run-up of the next. `settle_time` is 0.3 s by
|
||||
default (was 0.15 s until 2026-11-03): at 20 mm/s and 500 mm/s^2 there is a ~10 Hz
|
||||
oscillation after the ramp; shorter values (0.08 / 0.04 / 0.02 / 0 s) gave backward
|
||||
minus forward offsets of -24 / +10 / +4.5 / -21 um (2026-10-29), and at the beamline
|
||||
0.15 s left +18 .. +28 um at 20 mm/s, different in every repeat, 0.3 s -2 .. -5 um
|
||||
(2026-11-03). 0.3 s costs ~0.22-0.28 s per line.
|
||||
|
||||
`prepare_scan` checks the whole travel range including run-up, and the Y range,
|
||||
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 an amount that
|
||||
depends on the velocity and the settling (DDG1 at the beamline, 2026-11-03, backward
|
||||
minus forward; 5 vs 20 ms exposure time the same within the scatter):
|
||||
|
||||
| v [mm/s] | 1 | 2 | 4 | 10 | 20 | 30 | 40 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| settle 0.15 s | +0.9 | +2.1 | +9.3 | +18.6 | +18 .. +28 | +3.0 | -8.8 |
|
||||
| settle 0.3 s | | -3.2 | -5.8 | -8.2 | -4.6 / -1.7 | -1.1 / -1.6 | -0.7 |
|
||||
|
||||
With 0.15 s it repeats within 0.5 um up to 10 mm/s and at 40 mm/s, but not at 20 mm/s:
|
||||
the oscillation after the ramp has not decayed. It is not a constant delay (about
|
||||
0.5-1 ms up to 20 mm/s at 0.15 s, then it 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`): only the entries of the scan's `settle_time` (and
|
||||
`minimal_run_up: true` entries only with `minimal_run_up`), linear between their
|
||||
velocities, proportional to the velocity below the lowest one, `None` above the
|
||||
highest one or without entries. 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`.
|
||||
|
||||
The shift acts slightly more than 1:1: backward minus forward changed by about
|
||||
1.15 x 2h at 8 and 10 mm/s. New entries are therefore taken from scans with the
|
||||
calibration applied, or as the zero crossing of two calibrated scans. Only the mean
|
||||
offset per direction is corrected; the variation along the line (rms 3-6 um per
|
||||
direction from 10 mm/s on, peak-to-peak 13-16 um) does not change with the settle time
|
||||
or the trigger shift. The calibration is for the configured acceleration (500 mm/s^2).
|
||||
|
||||
### Step during the run-out
|
||||
|
||||
As soon as X has left the exposure range of a line, the PSO window of the next line
|
||||
|
||||
@@ -71,9 +71,10 @@ scans.aerotech_cont_grid(
|
||||
| `exp_time` | exposure time per cell in s; the X velocity is `fast_step_size / exp_time` |
|
||||
| `relative` | `True`: positions are offsets from the current X / Y position, and both axes return there after the scan |
|
||||
| `trigger_source` | `"ddg"` (default, beamline timing) |
|
||||
| `settle_time` | constant velocity time before the first exposure of a line, default 0.15 s (leave it) |
|
||||
| `settle_time` | constant velocity time before the first exposure of a line, default 0.3 s; 0.15 s is faster but less precise at high velocity (see below) |
|
||||
| `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,58 @@ 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 and on the settle time (measured
|
||||
2026-11-03 at the beamline, backward minus forward in um):
|
||||
|
||||
| Velocity [mm/s] | 2 | 4 | 10 | 20 | 30 | 40 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `settle_time=0.3` (default) | -3.2 | -5.8 | -8.2 | -4.6 / -1.7 | -1.1 / -1.6 | -0.7 |
|
||||
| `settle_time=0.15` | +2.1 | +9.3 | +18.6 | +18 .. +28 | +3.0 | -8.8 |
|
||||
|
||||
It does not depend on the exposure time. With 0.15 s the offset is larger and, from
|
||||
20 mm/s on, different in every repeat: X still oscillates after the acceleration when
|
||||
the exposures start. With 0.3 s it is small and reproducible; this costs about 0.22 s
|
||||
more per line (about 27 % on fast scans, negligible on slow high-resolution scans).
|
||||
|
||||
The scan moves the trigger positions of both directions by half of the offset, 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, settle
|
||||
time, offset), with entries for 0.3 s and 0.15 s. A scan uses the entries of its own
|
||||
`settle_time`, interpolated between the calibrated velocities. Without entries for its
|
||||
settle time, with `minimal_run_up=True`, or above 40 mm/s 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 -4.09 um applied; this scan measured {velocity: 10, settle_time: 0.3, offset: -3.80}
|
||||
```
|
||||
|
||||
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 3-6 um from 10 mm/s on) is
|
||||
the motion of X and cannot be corrected by the trigger or the settle time.
|
||||
|
||||
**Other settings:** `settle_time` and `minimal_run_up` stay available as options. For a
|
||||
faster scan use `settle_time=0.15` (calibrated, but at 20-30 mm/s only to about
|
||||
+-5 um). For any other settle time or `minimal_run_up=True` there is no calibration:
|
||||
the lines are offset as in the table above.
|
||||
|
||||
**New calibration point** (other settle time, acceleration, or velocity): run a short
|
||||
scan at that velocity and settle time, 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: ..., settle_time: ..., offset: ...}` to
|
||||
`trigger_calibration` of `aerox` in `sastt_aerotech.yaml` (ask the beamline staff to
|
||||
commit it). Then repeat the scan with the calibration on and take the entry it prints:
|
||||
the trigger shift acts slightly more than 1:1, so the second value is the better one.
|
||||
|
||||
### Examples
|
||||
|
||||
Fields of view centred on the current position (`relative=True`), X fast, Y step:
|
||||
@@ -123,12 +176,12 @@ scans.aerotech_cont_grid(dev.aerox, -1.75, 1.75, 0.01, dev.aeroy, -1.5, 1.5, 0.0
|
||||
### Run-up and soft limits
|
||||
|
||||
To pass through the field of view at constant velocity, X travels beyond it on both
|
||||
sides: twice the acceleration distance `v^2 / (2 * 500 mm/s^2)` plus `v * 0.15 s` to
|
||||
settle. The run-up per side is
|
||||
sides: twice the acceleration distance `v^2 / (2 * 500 mm/s^2)` plus `v * settle_time`
|
||||
(0.3 s) to settle. The run-up per side is
|
||||
|
||||
| Scan velocity | 0.5 mm/s | 2 mm/s | 20 mm/s |
|
||||
|---------------|----------|--------|---------|
|
||||
| Run-up per side | 0.08 mm | 0.31 mm | 3.8 mm |
|
||||
| Scan velocity | 0.5 mm/s | 2 mm/s | 20 mm/s | 40 mm/s |
|
||||
|---------------|----------|--------|---------|---------|
|
||||
| Run-up per side | 0.15 mm | 0.61 mm | 6.8 mm | 15.2 mm |
|
||||
|
||||
Before anything moves, the scan checks the whole range including the run-up against
|
||||
the soft limits. If it does not fit, the scan stops with a message that says by how
|
||||
@@ -136,7 +189,7 @@ much and on which side, for example
|
||||
|
||||
```text
|
||||
The scan range of aerox 146.5000 .. 150.0000 is within the limits, but aerox needs
|
||||
3.8400 extra travel on each side to accelerate to the scan velocity 20 / s and settle
|
||||
6.9580 extra travel on each side to accelerate to the scan velocity 20 / s and settle
|
||||
...
|
||||
```
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ def test_plot_history_draws_forward_and_backward_curves():
|
||||
"Waveform", object_name="aerotech_positions", timeout=20
|
||||
)
|
||||
assert waveform.lock_aspect_ratio is True
|
||||
assert waveform.title == "aerotech_cont_grid scan 8494: exposure centres"
|
||||
for curve in (forward, backward):
|
||||
x, y = curve.set_data.call_args.args
|
||||
# markers only: a NaN after every point, X finite and increasing
|
||||
@@ -132,9 +133,31 @@ def test_live_follows_an_aerotech_scan_line_by_line():
|
||||
|
||||
x, _ = backward.set_data.call_args.args
|
||||
assert len(x) == 2 * 5
|
||||
assert waveform.title == "aerotech_cont_grid scan 1: exposure centres"
|
||||
client.connector.unregister.assert_called()
|
||||
|
||||
|
||||
def test_live_title_shows_the_last_scan():
|
||||
client = _client()
|
||||
waveform = _waveform(client)
|
||||
forward, backward = mock.MagicMock(), mock.MagicMock()
|
||||
waveform.plot.side_effect = [forward, backward]
|
||||
client.history.__getitem__.return_value._msg = SimpleNamespace(
|
||||
file_path="scan.h5", scan_number=4897
|
||||
)
|
||||
tool = cSAXSAerotech(client)
|
||||
with mock.patch(
|
||||
"csaxs_bec.bec_ipython_client.plugins.cSAXS.aerotech._lines_from_file",
|
||||
return_value=_snake(),
|
||||
):
|
||||
tool.plot()
|
||||
deadline = time.time() + 3
|
||||
while time.time() < deadline and "4897" not in str(waveform.title):
|
||||
time.sleep(0.05)
|
||||
tool.stop_plot()
|
||||
assert waveform.title == "aerotech_cont_grid scan 4897: exposure centres"
|
||||
|
||||
|
||||
def test_live_ignores_other_scans():
|
||||
client = _client()
|
||||
client.history.__getitem__.side_effect = IndexError
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
@@ -199,10 +206,21 @@ def test_velocity_above_configured_velocity_aborts(v4_scan_assembler, device_man
|
||||
|
||||
|
||||
def test_scan_faster_than_move_velocity_restores_move_velocity(v4_scan_assembler, device_manager):
|
||||
# 20 mm/s scan, move velocity 10 mm/s, max_scan_velocity 50 mm/s
|
||||
# 20 mm/s scan, move velocity 10 mm/s, max_scan_velocity 50 mm/s; settle_time 0.15 s
|
||||
# so that the run-up fits the +-10 limits at 100 mm/s^2
|
||||
_add_devices(device_manager, max_scan_velocity=50.0)
|
||||
scan = v4_scan_assembler(
|
||||
"aerotech_cont_grid", "aerox", -1.0, 1.0, 0.1, "aeroy", -2.0, 2.0, 2, exp_time=0.005
|
||||
"aerotech_cont_grid",
|
||||
"aerox",
|
||||
-1.0,
|
||||
1.0,
|
||||
0.1,
|
||||
"aeroy",
|
||||
-2.0,
|
||||
2.0,
|
||||
2,
|
||||
exp_time=0.005,
|
||||
settle_time=0.15,
|
||||
)
|
||||
scan.prepare_scan()
|
||||
assert scan._line["target_velocity"] == pytest.approx(20.0)
|
||||
@@ -215,7 +233,7 @@ def test_scan_faster_than_move_velocity_restores_move_velocity(v4_scan_assembler
|
||||
|
||||
|
||||
def test_run_up_outside_limits_aborts(v4_scan_assembler, device_manager):
|
||||
# 20 mm/s at 100 mm/s^2: 2 * 2 mm ramp + 3 mm settle run-up, beyond the +-10 limits
|
||||
# 20 mm/s at 100 mm/s^2: 2 * 2 mm ramp + 6 mm settle run-up, beyond the +-10 limits
|
||||
_add_devices(device_manager, max_scan_velocity=50.0)
|
||||
scan = v4_scan_assembler(
|
||||
"aerotech_cont_grid", "aerox", -4.0, 4.0, 0.1, "aeroy", -2.0, 2.0, 2, exp_time=0.005
|
||||
@@ -681,3 +699,108 @@ 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, "settle_time": 0.3, "offset": 4.0},
|
||||
{"velocity": 2.0, "settle_time": 0.3, "offset": 1.0},
|
||||
{"velocity": 20.0, "settle_time": 0.3, "offset": -2.0},
|
||||
# other settling and run-up mode: never mixed into the 0.3 s entries
|
||||
{"velocity": 5.0, "settle_time": 0.15, "offset": 50.0},
|
||||
{"velocity": 5.0, "settle_time": 0.3, "minimal_run_up": True, "offset": 60.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, 0.3)
|
||||
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, 0.3) is None
|
||||
|
||||
|
||||
def test_interpolate_trigger_offset_per_settle_time_and_run_up_mode():
|
||||
assert interpolate_trigger_offset(CALIBRATION, 5.0, 0.15) == pytest.approx(50.0)
|
||||
assert interpolate_trigger_offset(CALIBRATION, 5.0, 0.3, minimal_run_up=True) == 60.0
|
||||
assert interpolate_trigger_offset(CALIBRATION, 5.0, 0.2) is None
|
||||
# entries without settle_time are not used
|
||||
assert interpolate_trigger_offset([{"velocity": 5.0, "offset": 1.0}], 5.0, 0.3) is None
|
||||
|
||||
|
||||
def test_default_settle_time_is_0p3(v4_scan_assembler, device_manager):
|
||||
scan = _assemble(v4_scan_assembler, device_manager)
|
||||
assert scan.settle_time == 0.3
|
||||
|
||||
|
||||
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, settle_time: 0.3, offset: 2.62}" in summary
|
||||
scan.trigger_calibration = False
|
||||
scan._line["trigger_offset"] = None
|
||||
assert "off; this scan measured {velocity: 5, settle_time: 0.3, offset: 0.50}" in (
|
||||
scan._calibration_summary(1.0)
|
||||
)
|
||||
Reference in new issue
Block a user