feat(aerotech): minimal_run_up option, run-up only the ramp distance
CI for csaxs_bec / test (push) Successful in 3m39s

aerotech_cont_grid(..., minimal_run_up=True): run-up / run-out = v^2 / (2a),
without the 2x margin and settle_time, for analysis with unevenly spaced
(captured) positions; at least 0.01 so the fast axis never stops and reverses
at the edge of the next line's PSO window (0.25 um at 0.5 mm/s otherwise).
Shown in the statistics header and the limit message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8zoDzDfsCTY2o5bT3J7Wv
This commit is contained in:
x01dcandClaude Opus 5.5 committed 2026-10-29 13:58:31 +01:00
1 parent c86e825e61
commit 3c551ba38e
2 files changed
+62 -5

No files matched your search

+29 -5
View File
@@ -63,6 +63,9 @@ In Position. The step axis moves during the run-out and turnaround of the fast a
soon as the fast axis has left the exposure range, the PSO window of the next line is set
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).
@@ -110,6 +113,12 @@ DDG_TRIGGER_EDGES = {"rising": DDG_EXT_RISING_EDGE, "falling": DDG_EXT_FALLING_E
# servo following error at the end of the ramp)
RAMP_MARGIN_FRACTION = 1.0
# Smallest run-up / run-out with minimal_run_up, in units of the fast axis: at low speed the
# ramp distance is sub-um (0.5 mm/s, 500 mm/s^2: 0.25 um), and the fast axis would stop and
# reverse right at the edge of the next line's PSO window, where ringing or overshoot could
# start the line before the axis moves.
MIN_RUN_UP = 0.01
TRIGGER_SOURCES = ("ddg", "panda", None)
# Poll interval of the fast axis position while waiting for the end of the exposures, s
@@ -153,6 +162,7 @@ class AerotechContGrid(ScanBase):
"exp_time",
"relative",
"trigger_source",
"minimal_run_up",
],
}
@@ -174,6 +184,7 @@ class AerotechContGrid(ScanBase):
trigger_device: str = "pandatrig",
shutter_delay: float | None = None,
settle_time: float = 0.15,
minimal_run_up: bool = False,
capture_device: str = "aerocapture",
**kwargs,
# fmt: on
@@ -198,6 +209,7 @@ class AerotechContGrid(ScanBase):
trigger_device (str): Name of the PandaLineTrigger device for trigger_source="panda". Default is "pandatrig".
shutter_delay (float | None): Delay from the line trigger to the first exposure in s. Default: DDG1 shutter delay for "ddg", 0 otherwise.
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".
Returns:
@@ -253,6 +265,7 @@ class AerotechContGrid(ScanBase):
self.trigger_source = trigger_source
self.shutter_delay = shutter_delay
self.settle_time = settle_time
self.minimal_run_up = minimal_run_up
devices = self.device_manager.devices
self.ddg1 = self.ddg2 = self.mcs = self.panda = None
@@ -530,7 +543,8 @@ class AerotechContGrid(ScanBase):
fwd, bwd = dev[0::2], dev[1::2]
text = [
f"aerotech_cont_grid statistics ({len(lines)} lines x {n} points, v"
f" {self._line['target_velocity']:g} / s, readout {self._line['readout_time'] * 1e3:g} ms):",
f" {self._line['target_velocity']:g} / s, readout {self._line['readout_time'] * 1e3:g} ms"
f"{', minimal run-up' if self.minimal_run_up else ''}):",
f" centre - nominal cell centre [um]: forward mean {fwd.mean():+.2f} rms"
f" {fwd.std():.2f} max {np.abs(fwd).max():.2f}",
]
@@ -632,7 +646,11 @@ class AerotechContGrid(ScanBase):
" run-up distance of the line."
)
ramp_distance = velocity**2 / (2 * line["acceleration"])
margin = RAMP_MARGIN_FRACTION * ramp_distance + velocity * self.settle_time
if self.minimal_run_up:
# exposures start right after the ramp; the captured positions show the result
margin = max(MIN_RUN_UP - ramp_distance, 0.0)
else:
margin = RAMP_MARGIN_FRACTION * ramp_distance + velocity * self.settle_time
lead = velocity * line["shutter_open_delay"]
# centre each gate (exp_time - readout_time) in its cell: same centres in both directions
gap = velocity * line["readout_time"] / 2
@@ -660,12 +678,18 @@ class AerotechContGrid(ScanBase):
beyond = f"beyond the high limit {high} by {line['line_end'] - high:.4f}"
else:
beyond = f"beyond the low limit {low} by {low - line['line_start']:.4f}"
if self.minimal_run_up:
why = f"(acceleration {line['acceleration']:g} / s^2, minimal run-up)"
else:
why = (
f"and settle (acceleration {line['acceleration']:g} / s^2, settle_time"
f" {self.settle_time:g} s)"
)
raise LimitError(
f"The scan range of {name} {self.fast_start:.4f} .. {self.fast_end:.4f} is"
f" within the limits, but {name} needs {extra:.4f} extra travel on each side"
f" to accelerate to the scan velocity {line['target_velocity']:g} / s and"
f" settle (acceleration {line['acceleration']:g} / s^2, settle_time"
f" {self.settle_time:g} s). It would travel {line['line_start']:.4f} .."
f" to accelerate to the scan velocity {line['target_velocity']:g} / s"
f" {why}. It would travel {line['line_start']:.4f} .."
f" {line['line_end']:.4f}, {beyond}. Move the scan away from the limit or"
" scan slower (larger exp_time or smaller fast_step_size)."
)
@@ -530,3 +530,36 @@ def test_scan_calls_only_user_access_methods(attribute, device_cls):
signals = {"motor_velocity", "wait_in_position", "max_scan_velocity", "motor_acceleration"}
assert called, f"no calls on self.{attribute} found"
assert not (called - signals) - set(cls.USER_ACCESS)
def test_minimal_run_up_is_the_ramp_distance_only(v4_scan_assembler, device_manager):
scan = _assemble(v4_scan_assembler, device_manager, settle_time=0.1, minimal_run_up=True)
scan.prepare_scan()
line = scan._line
ramp = (0.5 / 0.1) ** 2 / (2 * 100.0)
assert ramp > 0.01
assert line["line_start"] == pytest.approx(line["trigger_low"] - ramp)
assert line["line_end"] == pytest.approx(line["trigger_high"] + ramp)
def test_minimal_run_up_has_a_floor_at_low_speed(v4_scan_assembler, device_manager):
# 0.5 mm/s at 100 mm/s^2: ramp 1.25 um, run-up raised to 10 um
_add_devices(device_manager)
scan = v4_scan_assembler(
"aerotech_cont_grid", "aerox", -1.0, 1.0, 0.01, "aeroy", -2.0, 2.0, 2,
exp_time=0.02, minimal_run_up=True,
)
scan.prepare_scan()
line = scan._line
assert line["trigger_low"] - line["line_start"] == pytest.approx(0.01)
assert line["line_end"] - line["trigger_high"] == pytest.approx(0.01)
def test_minimal_run_up_limit_message(v4_scan_assembler, device_manager):
_add_devices(device_manager, max_scan_velocity=50.0)
scan = v4_scan_assembler(
"aerotech_cont_grid", "aerox", -9.5, 9.5, 0.1, "aeroy", -2.0, 2.0, 2,
exp_time=0.005, minimal_run_up=True,
)
with pytest.raises(LimitError, match="minimal run-up"):
scan.prepare_scan()