Feat/aerotech calibration #352

Merged
holler merged 6 commits from feat/aerotech-calibration into main 2026-11-03 13:06:32 +01:00
8 changed files with 415 additions and 30 deletions

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
+5 -3
View File
@@ -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
+107 -5
View File
@@ -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
+41 -4
View File
@@ -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)
)