feat(aerotech): exposure centres, readout gap compensation, positions always captured
CI for csaxs_bec / test (push) Successful in 3m27s

- readout time from the trigger hardware (DDG2 effective_readout_times()["ab"], PandA
  device config readout_time, default 0.2 ms), no scan argument; the trigger positions
  move inward by v * readout / 2 so the gates are centred in their cells and forward /
  backward lines have the same exposure centres
- aerotech_cont_grid always captures the positions (capture_positions removed):
  aerocapture publishes the captured gate starts (enc<ch>) and the exposure centres
  (enc<ch>_centre, + v * gate_width / 2 in the direction of motion)
- statistics at the end of the scan (sent to the client): centre - nominal cell centre
  per direction, backward - forward with slope and peak-peak, scan and line times
- aerox acceleration 500 mm/s^2 (1000 left a speed drift in the lines at 20 mm/s);
  aerocapture enabled, channels X and Y only

Hardware 2026-10-29 (PandA, 0.2 ms readout, 6 snake lines each), backward - forward:
2 mm/s +2.3 / +3.2 um, 0.5 mm/s -0.1 / -0.75 um, no slope; 20 mm/s -1.2 um,
slope -1.2 um/mm (was +24 um, 5 um/mm with 1000 mm/s^2 and no compensation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8zoDyJxbBLmjZCEPUo5FM
This commit is contained in:
Mirko HollerandClaude Opus 5.5 committed 2026-10-29 12:27:26 +01:00
1 parent 3aca5030a2
commit 201e6406d2
9 files changed
+251 -70

No files matched your search

+5 -7
View File
@@ -12,8 +12,8 @@
# controller soft limits: X -50..150, Z -100..100, Y -10..10 mm (active after home)
# tolerance: X 1 um, Z/Y 5 um (InPositionDistance X 10 nm, Z/Y 5 um)
# velocity / acceleration below = DefaultSpeed / DefaultRampRate (MaxSpeedClamp:
# X 200, Z 10, Y 10 mm/s, U 720 deg/s), except X: acceleration 1000 mm/s^2 (short
# run-up in aerotech_cont_grid), scans up to max_scan_velocity 50 mm/s
# X 200, Z 10, Y 10 mm/s, U 720 deg/s), except X: acceleration 500 mm/s^2 (1000 left a
# speed drift in the lines at 20 mm/s, 2026-10-29), scans up to max_scan_velocity 50 mm/s
# Wiring for aerotech_cont_grid: PSO output (J7 "PSO INTERFACE") -> DDG1 EXT,
# detector trigger -> UINT1 (J12 "MISC I/O").
# UU (ADRS-150 slipring follower) follows U as gantry slave and is not a BEC device.
@@ -27,7 +27,7 @@ aerox:
axis_nr: 0
pso_input: 6
pso_counts_divider: 1
acceleration: 1000.0
acceleration: 500.0
host: pc14643.psi.ch
port: 8000
velocity: 5.0
@@ -114,7 +114,7 @@ aerorot:
deviceTags:
- sastt_aerotech
aerocapture:
# disabled until the position sampling stage (moves -> continuous scans -> trigger -> capture)
# always used by aerotech_cont_grid (positions at the start and centre of each exposure)
description: Aerotech position capture (Npaq DATAACQ, latched on the detector trigger at UINT1)
deviceClass: csaxs_bec.devices.aerotech.a3200.A3200PositionCapture
deviceConfig:
@@ -125,10 +125,8 @@ aerocapture:
# keys are quoted: integer map keys break the config message (msgpack strict_map_key)
channels:
"6": X
"11": Z
"2": Y
"9": U
enabled: false
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: async
@@ -16,7 +16,7 @@ aerox:
axis_nr: 0
pso_input: 6
pso_counts_divider: 1
acceleration: 1000.0
acceleration: 500.0
host: pc14643.psi.ch
port: 8000
velocity: 5.0
@@ -103,7 +103,7 @@ aerorot:
deviceTags:
- sastt_aerotech
aerocapture:
# disabled until the position sampling stage (moves -> continuous scans -> trigger -> capture)
# always used by aerotech_cont_grid (positions at the start and centre of each exposure)
description: Aerotech position capture (Npaq DATAACQ, latched on the detector trigger at UINT1)
deviceClass: csaxs_bec.devices.aerotech.a3200.A3200PositionCapture
deviceConfig:
@@ -114,10 +114,8 @@ aerocapture:
# keys are quoted: integer map keys break the config message (msgpack strict_map_key)
channels:
"6": X
"11": Z
"2": Y
"9": U
enabled: false
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: async
@@ -131,6 +129,8 @@ pandatrig:
port: 8888
input_termination: 50-Ohm
trigger_edge: rising
# gap between the detector gates, as the DDG2 default at the beamline
readout_time: 0.0002
enabled: true
onFailure: buffer
readOnly: false
@@ -12,7 +12,7 @@ aerox:
axis_nr: 0
pso_input: 6
pso_counts_divider: 1
acceleration: 1000.0
acceleration: 500.0
host: sim-a3200-saxstt
port: 8000
velocity: 5.0
@@ -113,9 +113,7 @@ aerocapture:
# keys are quoted: integer map keys break the config message (msgpack strict_map_key)
channels:
"6": X
"11": Z
"2": Y
"9": U
enabled: true
onFailure: buffer
readOnly: false
+21 -6
View File
@@ -81,8 +81,14 @@ scans.aerotech_cont_grid(dev.aerox, 0, 1, 0.01, dev.aeroy, 0, 1, 0.1, exp_time=0
gates per trigger; DDG1/DDG2 take exp_time and frames_per_trigger from the scan info at
stage; after each line the scan checks that the MCS reached the line's last channel),
`"panda"` (test setup: PandA configured and armed once, every line checked with
`wait_lines_done`; `readout_time`, `shutter_delay` arguments) or `None` (no trigger
hardware, motion tests)
`wait_lines_done`; `shutter_delay` argument) or `None` (no trigger hardware, motion
tests)
- readout time (gap between the detector gates) from the trigger hardware, no scan
argument: DDG2 `effective_readout_times()["ab"]` (default 0.2 ms) or the PandA device
config `readout_time` (default 0.2 ms). The trigger positions move inward by
`v * readout_time / 2`, so every gate is centred in its cell of `fast_step_size` and
forward and backward lines have the same exposure centres (without it they differ by
`fast_step_size * readout_time / exp_time`, measured 2026-10-29)
- `trigger_edge` (DDG1): `"rising"` (default) for the opto output (J7-6/7, the tested
wiring), `"falling"` for the active low open collector (J7-8) or differential (J7-1/2)
output (not tested). The PandA has the same choice as device config `trigger_edge`
@@ -92,13 +98,22 @@ scans.aerotech_cont_grid(dev.aerox, 0, 1, 0.01, dev.aeroy, 0, 1, 0.1, exp_time=0
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.
- 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
- `capture_positions=True` (default False until commissioned): device `aerocapture` latches
the encoder counters on each detector trigger (DATAACQ on UINT1, up to 16 kHz, 4 kHz with
- positions are always captured: device `aerocapture` (must be enabled) latches the
encoder counters on each detector trigger (DATAACQ on UINT1, up to 16 kHz, 4 kHz with
MXR sources). The Npaq input is inverted compared with the A3200 help (measured
2026-10-29): DATAACQ trigger 3 latches at the rising TTL edge at J12 (gate start, the
default), trigger 0 at the falling one (gate end); after each line they are read back (~2600 values/s over the ASCII interface)
and published as async data `aerocapture_positions_enc<channel>`. The counter offset is
calibrated in pre_scan with one software PSO fire, before any trigger receiver is armed.
and published as async data, both as captured (`aerocapture_positions_enc<channel>`, start
of each exposure) and as exposure centres (`aerocapture_positions_enc<channel>_centre`,
+ `v * gate_width / 2` in the direction of motion for the moving fast axis). The counter
offset is calibrated in pre_scan with one software PSO fire, before any trigger receiver
is armed. At the end the scan sends statistics to the client: centre - nominal cell
centre per direction, backward - forward offset with slope and peak-peak along the line,
scan and line times.
- snake symmetry measured 2026-10-29 (PandA, 0.2 ms readout, before the readout
compensation): at 0.5 and 2 mm/s no trend along the line, backward - forward constant
within 3 um; at 20 mm/s with 1000 mm/s^2 +24 um with a trend of 5 um/mm, with 500 mm/s^2
+4.7 um (+4 um of it the readout gap) -> aerox acceleration 500 mm/s^2
Soft test 2026-10-28 (scan sequence on the hardware, trigger_source=None, 1x1 mm, X 1 mm/s,
11 lines): 15.3 s, line 1.11 s, Y step 0.28 s; PSO "inside window" switched on at X = 0.25
+18 -5
View File
@@ -809,7 +809,8 @@ class A3200PositionCapture(PSIDeviceBase):
positions = Cpt(
AsyncMultiSignal,
name="positions",
signals=[f"enc{i}" for i in range(DATAACQ_POSITION_SOURCES)],
signals=[f"enc{i}" for i in range(DATAACQ_POSITION_SOURCES)]
+ [f"enc{i}_centre" for i in range(DATAACQ_POSITION_SOURCES)],
ndim=1,
async_update={"type": "add", "max_shape": [None]},
max_size=1000,
@@ -853,7 +854,7 @@ class A3200PositionCapture(PSIDeviceBase):
raise ValueError("Configure at least two encoder channels for position capture.")
# only the configured channels are published (strict signal validation)
self.positions.signal_info.signals = self.positions._unify_signals(
[f"enc{ch}" for ch in sorted(self.channels)]
[f"enc{ch}{suffix}" for ch in sorted(self.channels) for suffix in ("", "_centre")]
)
def wait_for_connection(self, all_signals=False, timeout: float = 30.0) -> None:
@@ -900,8 +901,16 @@ class A3200PositionCapture(PSIDeviceBase):
def disarm(self) -> None:
self.controller.dataacq_off(self.drive_axis)
def read_line(self) -> dict[str, list[float]]:
"""Read the positions of the armed triggers and publish them. Returns them too."""
def read_line(self, centre_shift: float = 0.0) -> dict[str, list[float]]:
"""
Read the positions of the armed triggers and publish them, both as captured
(enc<channel>: position at the trigger edge, i.e. the start of the exposure) and
as exposure centres (enc<channel>_centre). centre_shift is the distance from the
start to the centre of an exposure (the scan passes v * gate_width / 2); it is
applied in the direction of motion to the channels that move during the line
(span of the line larger than 2 * centre_shift), the others are unchanged.
Returns the positions too.
"""
channels = sorted(self.channels)
raw = self.controller.read_drive_array(self.drive_axis, 0, self._n_triggers * len(channels))
# Npaq interleaves the inputs per trigger, in ascending channel order
@@ -910,7 +919,11 @@ class A3200PositionCapture(PSIDeviceBase):
result = {}
for i, ch in enumerate(channels):
cpu = self.controller.counts_per_unit(self.channels[ch])
result[f"enc{ch}"] = data[:, i] / cpu + self._offsets[ch]
captured = data[:, i] / cpu + self._offsets[ch]
span = captured[-1] - captured[0] if len(captured) > 1 else 0.0
shift = np.sign(span) * centre_shift if abs(span) > 2 * abs(centre_shift) else 0.0
result[f"enc{ch}"] = captured
result[f"enc{ch}_centre"] = captured + shift
self.positions.put(
{
f"{self.positions.name}_{key}": {"value": value, "timestamp": timestamp}
@@ -111,6 +111,8 @@ class PandaLineTrigger(Device):
output) or "High-Z".
trigger_edge: edge of TTLIN3 that starts a line, "rising" (Npaq opto output) or
"falling" (active low open collector / differential output).
readout_time: detector readout time in s, gap between the gates (gate width =
exp_time - readout_time). Default 0.2 ms, as the DDG2 default at the beamline.
"""
USER_ACCESS = [
@@ -121,6 +123,7 @@ class PandaLineTrigger(Device):
"software_trigger",
"line_status",
"wait_lines_done",
"get_readout_time",
"panda",
]
@@ -148,10 +151,12 @@ class PandaLineTrigger(Device):
port: int = 8888,
input_termination: str = "50-Ohm",
trigger_edge: str = "rising",
readout_time: float = 2e-4,
device_manager=None,
**kwargs,
):
self.panda = PandaControl(host, port)
self.readout_time = float(readout_time)
self.input_termination = input_termination
if trigger_edge not in ("rising", "falling"):
raise ValueError(f"trigger_edge must be 'rising' or 'falling', got {trigger_edge!r}.")
@@ -232,6 +237,10 @@ class PandaLineTrigger(Device):
]
return fields
def get_readout_time(self) -> float:
"""Readout time (gap between the detector gates) used by the scans, in s."""
return self.readout_time
def setup_layout(self) -> None:
"""Write the wiring of the line trigger blocks (other blocks are not touched)."""
written = [field for field, value in self._layout() if self._set_if_changed(field, value)]
+116 -32
View File
@@ -6,9 +6,17 @@ as a snake (alternating direction). The start of each line is triggered in hardw
the A3200 PSO output of the fast axis, which goes high when the axis enters the PSO window.
The trigger positions are
trigger_low = fast_start - v * shutter_delay, trigger_high = exposure_end + v * shutter_delay
trigger_low = fast_start - v * shutter_delay + v * readout_time / 2
trigger_high = exposure_end + v * shutter_delay - v * readout_time / 2
and the window is open on the far side of the line: [trigger_low, beyond line_end] on
The readout term centres each gate (width exp_time - readout_time) in its cell of
fast_step_size: without it the forward gates start at the low edge of their cells and the
backward gates end at the high edge, a shift of fast_step_size * readout_time / exp_time
between the directions (measured 2026-10-29). The readout time is taken from the trigger
hardware: DDG2 effective_readout_times()["ab"] (beamline, default 0.2 ms) or
get_readout_time() of the PandA device (default 0.2 ms); 0 without trigger hardware.
The window is open on the far side of the line: [trigger_low, beyond line_end] on
forward lines, [beyond line_start, trigger_high] on backward lines, so in both directions
the first exposure starts one shutter delay after the trigger at the first point of the
line. The axis never leaves the window during a line: with a closed window the exit
@@ -37,8 +45,13 @@ Trigger sources (trigger_source):
sessions without hardware triggered detectors / MCS, they would wait for
frames that never come.
With a position capture device (capture_positions=True), the encoder positions are latched
at each detector trigger (Npaq UINT1) and published per line.
The positions are always captured (device aerocapture, Npaq DATAACQ on UINT1 = detector
gates): per line the captured positions at the start of each exposure
(aerocapture_positions_enc<channel>) and the exposure centres
(aerocapture_positions_enc<channel>_centre = captured + v * gate_width / 2 in the direction
of motion for the moving fast axis). At the end the scan reports statistics of the
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.
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
@@ -98,6 +111,7 @@ class AerotechLineParameter(TypedDict):
target_velocity: float | None
acceleration: float | None
shutter_open_delay: float | None
readout_time: float | None
trigger_low: float | None
trigger_high: float | None
exposure_end: float | None
@@ -127,7 +141,6 @@ class AerotechContGrid(ScanBase):
"exp_time",
"relative",
"trigger_source",
"capture_positions",
],
}
@@ -147,18 +160,15 @@ class AerotechContGrid(ScanBase):
trigger_source: str | None = "ddg",
trigger_edge: str = "rising",
trigger_device: str = "pandatrig",
readout_time: float = 0.0,
shutter_delay: float | None = None,
settle_time: float = 0.15,
capture_positions: bool = False,
capture_device: str = "aerocapture",
**kwargs,
# fmt: on
):
"""
Continuous grid scan for A3200 stages: the PSO output of the fast axis triggers the
start of each line, optionally the axis positions are latched at each detector
trigger.
start of each line, the axis positions are latched at each detector trigger.
Args:
fast_axis (DeviceBase): A3200 axis with continuous motion (needs pso_input and acceleration configured).
@@ -174,11 +184,9 @@ class AerotechContGrid(ScanBase):
trigger_source (str | None): "ddg" (beamline, DDG1/MCS), "panda" (PandABox line trigger) or None (no trigger hardware). Default is "ddg".
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): Name of the PandaLineTrigger device for trigger_source="panda". Default is "pandatrig".
readout_time (float): Detector readout time, gate width = exp_time - readout_time (trigger_source="panda"). Default is 0.
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.
capture_positions (bool): Latch the axis positions at each detector trigger. Default is False.
capture_device (str): Name of the A3200PositionCapture device. Default is "aerocapture".
capture_device (str): Name of the A3200PositionCapture device (always used). Default is "aerocapture".
Returns:
ScanReport
@@ -231,28 +239,28 @@ class AerotechContGrid(ScanBase):
self.relative = relative
self.exp_time = exp_time
self.trigger_source = trigger_source
self.readout_time = readout_time
self.shutter_delay = shutter_delay
self.settle_time = settle_time
self.capture_positions = capture_positions
devices = self.device_manager.devices
self.ddg1 = self.mcs = self.panda = None
self.ddg1 = self.ddg2 = self.mcs = self.panda = None
if trigger_source == "ddg":
self.ddg1 = devices["ddg1"]
self.ddg2 = devices["ddg2"]
self.mcs = devices["mcs"]
elif trigger_source == "panda":
if trigger_device not in devices:
raise ScanAbortion(f"PandA line trigger device '{trigger_device}' not found.")
self.panda = devices[trigger_device]
self.capture = None
if capture_positions:
if capture_device not in devices:
raise ScanAbortion(
f"Position capture device '{capture_device}' not found."
" Use capture_positions=False to scan without it."
)
self.capture = devices[capture_device]
if capture_device not in devices:
raise ScanAbortion(
f"Position capture device '{capture_device}' not found; aerotech_cont_grid"
" always captures the positions (enable it in the device config)."
)
self.capture = devices[capture_device]
self._captured_lines: list[dict] = []
self._line_times: list[float] = []
self._scan_start_time = None
self.motors = [fast_axis, stepper_axis]
self.frames_per_line = 0
@@ -330,8 +338,7 @@ class AerotechContGrid(ScanBase):
scan velocity and arm the trigger receiver.
"""
self._premove_motor_status.wait()
if self.capture is not None:
self.capture.calibrate()
self.capture.calibrate()
if "target_velocity" not in self._line: # pre_scan without prepare_scan
self._compute_line_geometry()
line = self._line
@@ -343,10 +350,11 @@ class AerotechContGrid(ScanBase):
self.ddg1.set_trigger(DDG_TRIGGER_EDGES[self.trigger_edge])
elif self.trigger_source == "panda":
self.panda.configure_line(
self.frames_per_line, self.exp_time, self.readout_time, line["shutter_open_delay"]
self.frames_per_line, self.exp_time, line["readout_time"], line["shutter_open_delay"]
)
self.panda.arm()
self.actions.pre_scan_all_devices()
self._scan_start_time = time.time()
@scan_hook
def scan_core(self):
@@ -375,8 +383,8 @@ class AerotechContGrid(ScanBase):
Run one line: arm the position capture, move the fast axis through the PSO window,
then check that the line was triggered and read the captured positions.
"""
if self.capture is not None:
self.capture.arm(self.frames_per_line)
line_start_time = time.time()
self.capture.arm(self.frames_per_line)
move_status = self.actions.set(motors, positions, wait=False)
while not move_status.done:
self.actions.read_monitored_devices(wait=True)
@@ -390,13 +398,14 @@ class AerotechContGrid(ScanBase):
elif self.trigger_source == "panda":
self.panda.wait_lines_done(line_index + 1, timeout=self._line_duration() + 2)
if self.capture is not None:
self.capture.read_line()
self._line_times.append(time.time() - line_start_time)
self._captured_lines.append(self.capture.read_line(centre_shift=self._centre_shift()))
@scan_hook
def post_scan(self):
"""Restore trigger receiver, PSO and fast axis settings, complete devices."""
self._restore()
self._report_statistics()
status = self.actions.complete_all_devices(wait=False)
if self.relative:
self.components.move_and_wait(self.motors, self.start_positions)
@@ -429,6 +438,73 @@ class AerotechContGrid(ScanBase):
def _is_forward(self, line_index: int) -> bool:
return line_index % 2 == 0
def _readout_time(self) -> float:
"""Readout time (gap between the detector gates) of the trigger hardware, in s."""
if self.trigger_source == "ddg":
return float(self.ddg2.effective_readout_times()["ab"])
if self.trigger_source == "panda":
return float(self.panda.get_readout_time())
return 0.0
def _centre_shift(self) -> float:
"""Distance from the start to the centre of an exposure along the fast axis."""
line = self._line
return line["target_velocity"] * (self.exp_time - line["readout_time"]) / 2
def _report_statistics(self):
"""Log and send to the client: centre deviations of the fast axis and line times."""
try:
summary = self._statistics()
except Exception as exc: # pylint: disable=broad-except
logger.warning(f"aerotech_cont_grid: no statistics: {exc}")
return
if summary:
logger.info(summary)
self.actions.send_client_info(summary)
def _statistics(self) -> str:
lines = [d for d in self._captured_lines if d]
if not lines:
return ""
# fast axis = the centre channel with the largest span over a line
keys = [k for k in lines[0] if k.endswith("_centre")]
fast_key = max(keys, key=lambda k: abs(lines[0][k][-1] - lines[0][k][0]))
n, step = self.frames_per_line, self.fast_step_size
cells = self.fast_start + step * (np.arange(n) + 0.5)
centres = []
for line_index, data in enumerate(lines):
c = np.asarray(data[fast_key], dtype=float)
if len(c) != n:
return f"aerotech_cont_grid: line {line_index}: {len(c)} positions, expected {n}."
centres.append(c if self._is_forward(line_index) else c[::-1])
centres = np.array(centres)
dev = (centres - cells) * 1e3 # um, by cell; fast axis in controller units = mm
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" centre - nominal cell centre [um]: forward mean {fwd.mean():+.2f} rms"
f" {fwd.std():.2f} max {np.abs(fwd).max():.2f}",
]
if len(bwd):
pairs = min(len(fwd), len(bwd))
diff = (bwd[:pairs] - fwd[:pairs]).mean(axis=0)
slope = np.polyfit(cells - cells.mean(), diff, 1)[0] if n > 1 else 0.0
text += [
f" centre - nominal cell centre [um]: backward mean {bwd.mean():+.2f} rms"
f" {bwd.std():.2f} max {np.abs(bwd).max():.2f}",
f" backward - forward [um]: mean {diff.mean():+.2f}, slope {slope:+.2f} / mm,"
f" peak-peak {np.ptp(diff):.2f}",
]
if self._line_times:
total = time.time() - self._scan_start_time if self._scan_start_time else 0.0
text.append(
f" times: scan {total:.1f} s, line mean {np.mean(self._line_times):.2f} s"
f" (min {np.min(self._line_times):.2f}, max {np.max(self._line_times):.2f}),"
f" exposure per line {n * self.exp_time:.2f} s"
)
return "\n".join(text)
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
@@ -479,6 +555,12 @@ class AerotechContGrid(ScanBase):
line["shutter_open_delay"] = self.ddg1.get_shutter_to_open_delay()
else:
line["shutter_open_delay"] = 0.0
line["readout_time"] = self._readout_time()
if not 0 <= line["readout_time"] < self.exp_time:
raise ScanAbortion(
f"Readout time {line['readout_time']} s of the trigger hardware must be shorter"
f" than exp_time {self.exp_time} s."
)
line["original_velocity"] = self.fast_axis.motor_velocity.get()
line["acceleration"] = self.fast_axis.motor_acceleration.get()
@@ -497,11 +579,13 @@ class AerotechContGrid(ScanBase):
ramp_distance = velocity**2 / (2 * line["acceleration"])
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
line["target_velocity"] = velocity
line["exposure_end"] = self.fast_start + self.frames_per_line * self.fast_step_size
line["trigger_low"] = self.fast_start - lead
line["trigger_high"] = line["exposure_end"] + lead
line["trigger_low"] = self.fast_start - lead + gap
line["trigger_high"] = line["exposure_end"] + lead - gap
line["line_start"] = line["trigger_low"] - ramp_distance - margin
line["line_end"] = line["trigger_high"] + ramp_distance + margin
+32 -1
View File
@@ -322,7 +322,12 @@ def test_capture_line_publishes_positions():
assert result["enc6"] == pytest.approx([0.0, 0.2, 0.4, 0.6, 0.8])
assert result["enc2"] == pytest.approx([0.3] * 5)
msg = capture.positions.get()
assert set(msg.signals) == {"aerocapture_positions_enc2", "aerocapture_positions_enc6"}
assert set(msg.signals) == {
"aerocapture_positions_enc2",
"aerocapture_positions_enc6",
"aerocapture_positions_enc2_centre",
"aerocapture_positions_enc6_centre",
}
assert msg.metadata["async_update"] == {"type": "add", "max_shape": [None]}
@@ -409,3 +414,29 @@ def test_move_without_in_position_wait():
def test_max_scan_velocity_defaults_to_velocity():
assert _sim_motor(velocity=5.0).max_scan_velocity.get() == 5.0
assert _sim_motor(velocity=5.0, max_scan_velocity=50.0).max_scan_velocity.get() == 50.0
def test_capture_line_centre_shift():
x, _, capture = _capture_setup()
capture.calibrate()
x.move(-1.0, wait=True)
capture.arm(5)
x.arm_line_trigger(0.0, 1.0)
x.move(2.0, wait=True)
result = capture.read_line(centre_shift=0.1)
# X moves forward: shifted forward; Y does not move: unchanged
assert result["enc6_centre"] == pytest.approx([0.1, 0.3, 0.5, 0.7, 0.9])
assert result["enc2_centre"] == pytest.approx(result["enc2"])
def test_capture_line_centre_shift_backward():
_, _, capture = _capture_setup()
capture.arm(3)
# backward line: X (channel 6) counts down, Y (channel 2) constant; interleaved 2, 6
capture.controller.read_drive_array = lambda axis, start, count: [
3000, 60000, 3000, 40000, 3000, 20000
]
result = capture.read_line(centre_shift=0.1)
assert result["enc6_centre"] == pytest.approx([x - 0.1 for x in result["enc6"]])
assert result["enc2_centre"] == pytest.approx(result["enc2"])
@@ -59,12 +59,18 @@ def _add_devices(
)
ddg1.get_shutter_to_open_delay = mock.MagicMock(return_value=2e-3)
ddg1.set_trigger = mock.MagicMock()
ddg2 = MockCustomDevice(
"ddg2",
device_info={"signals": {"readback": _signal("ddg2", "hinted")}},
signal_read_values={"ddg2": 0.0},
)
ddg2.effective_readout_times = mock.MagicMock(return_value={"ab": 2e-4, "cd": 2e-4})
mcs = MockCustomDevice(
"mcs",
device_info={"signals": {"readback": _signal("mcs", "hinted")}},
signal_read_values={"mcs": 0.0},
)
devices = [aerox, aeroy, ddg1, mcs]
devices = [aerox, aeroy, ddg1, ddg2, mcs]
if panda:
pandatrig = MockCustomDevice(
"pandatrig",
@@ -73,6 +79,7 @@ def _add_devices(
)
for method in ("configure_line", "arm", "disarm", "wait_lines_done"):
setattr(pandatrig, method, mock.MagicMock())
pandatrig.get_readout_time = mock.MagicMock(return_value=0.02)
devices.append(pandatrig)
if capture:
aerocapture = MockCustomDevice(
@@ -129,13 +136,15 @@ def test_line_geometry(v4_scan_assembler, device_manager):
ramp = velocity**2 / (2 * 100.0)
margin = 1.0 * ramp + velocity * 0.1
lead = velocity * 2e-3 # DDG1 shutter delay
gap = velocity * 2e-4 / 2 # half the DDG2 readout time: gates centred in their cells
assert line["readout_time"] == pytest.approx(2e-4)
assert scan.frames_per_line == 5
assert line["num_lines"] == 3
assert line["target_velocity"] == pytest.approx(velocity)
assert line["exposure_end"] == pytest.approx(-1.0 + 5 * 0.5)
# PSO window: entered at the low edge going forward, at the high edge going backward
assert line["trigger_low"] == pytest.approx(-1.0 - lead)
assert line["trigger_high"] == pytest.approx(line["exposure_end"] + lead)
assert line["trigger_low"] == pytest.approx(-1.0 - lead + gap)
assert line["trigger_high"] == pytest.approx(line["exposure_end"] + lead - gap)
assert line["line_start"] == pytest.approx(line["trigger_low"] - ramp - margin)
assert line["line_end"] == pytest.approx(line["trigger_high"] + ramp + margin)
assert scan.scan_info.frames_per_trigger == 5
@@ -155,6 +164,7 @@ def test_shutter_delay_without_ddg(v4_scan_assembler, device_manager):
scan = _assemble(v4_scan_assembler, device_manager, trigger_source=None)
scan.prepare_scan()
assert scan._line["shutter_open_delay"] == 0.0
assert scan._line["readout_time"] == 0.0
assert scan._line["trigger_low"] == pytest.approx(-1.0)
assert scan.ddg1 is None and scan.mcs is None
@@ -162,7 +172,8 @@ def test_shutter_delay_without_ddg(v4_scan_assembler, device_manager):
def test_shutter_delay_argument(v4_scan_assembler, device_manager):
scan = _assemble(v4_scan_assembler, device_manager, trigger_source="panda", shutter_delay=4e-3)
scan.prepare_scan()
assert scan._line["trigger_low"] == pytest.approx(-1.0 - 5.0 * 4e-3)
# PandA readout time 20 ms (device setting): gap 5 mm/s * 10 ms
assert scan._line["trigger_low"] == pytest.approx(-1.0 - 5.0 * 4e-3 + 5.0 * 0.01)
def test_invalid_trigger_source_aborts(v4_scan_assembler, device_manager):
@@ -238,18 +249,25 @@ def test_missing_acceleration_aborts(v4_scan_assembler, device_manager):
def test_missing_capture_device_aborts(v4_scan_assembler, device_manager):
device_manager.devices.pop("aerocapture", None)
with pytest.raises(ScanAbortion, match="aerocapture"):
_assemble(v4_scan_assembler, device_manager, capture=False, capture_positions=True)
_assemble(v4_scan_assembler, device_manager, capture=False)
def test_capture_is_off_by_default(v4_scan_assembler, device_manager):
def test_capture_is_always_used(v4_scan_assembler, device_manager):
scan = _assemble(v4_scan_assembler, device_manager)
assert scan.capture is None
assert scan.capture is device_manager.devices["aerocapture"]
def test_readout_time_longer_than_exposure_aborts(v4_scan_assembler, device_manager):
scan = _assemble(v4_scan_assembler, device_manager)
device_manager.devices["ddg2"].effective_readout_times.return_value = {"ab": 0.2}
with pytest.raises(ScanAbortion, match="Readout time"):
scan.prepare_scan()
def test_pre_scan_arms_once_after_calibration(
v4_scan_assembler, device_manager, nth_done_status_mock
):
scan = _assemble(v4_scan_assembler, device_manager, capture_positions=True)
scan = _assemble(v4_scan_assembler, device_manager)
scan.prepare_scan()
order = mock.MagicMock()
scan.capture = order.capture
@@ -298,7 +316,6 @@ def test_pre_scan_configures_and_arms_panda(
v4_scan_assembler,
device_manager,
trigger_source="panda",
readout_time=0.02,
shutter_delay=0.003,
)
scan.prepare_scan()
@@ -381,7 +398,7 @@ def test_ddg_missing_line_trigger_aborts(v4_scan_assembler, device_manager, nth_
def test_at_each_point_reads_capture(v4_scan_assembler, device_manager, nth_done_status_mock):
scan = _ready_for_core(
_assemble(v4_scan_assembler, device_manager, trigger_source=None, capture_positions=True),
_assemble(v4_scan_assembler, device_manager, trigger_source=None),
nth_done_status_mock,
)
scan.capture = mock.MagicMock()
@@ -389,7 +406,23 @@ def test_at_each_point_reads_capture(v4_scan_assembler, device_manager, nth_done
scan.at_each_point([scan.fast_axis], np.array([scan._line["line_end"]]), line_index=0)
scan.capture.arm.assert_called_once_with(5)
scan.capture.read_line.assert_called_once_with()
# no readout time without trigger hardware: half of 0.5 mm per exposure
scan.capture.read_line.assert_called_once_with(centre_shift=pytest.approx(0.25))
def test_statistics_of_a_symmetric_snake(v4_scan_assembler, device_manager):
scan = _assemble(v4_scan_assembler, device_manager, trigger_source=None)
scan.prepare_scan()
cells = -1.0 + 0.5 * (np.arange(5) + 0.5)
fwd = {"enc6_centre": list(cells + 1e-3), "enc2_centre": [0.0] * 5}
bwd = {"enc6_centre": list((cells + 3e-3)[::-1]), "enc2_centre": [0.5] * 5}
scan._captured_lines = [fwd, bwd, fwd]
scan._line_times = [1.0, 1.2, 1.1]
text = scan._statistics()
assert "forward mean +1.00" in text
assert "backward mean +3.00" in text
assert "backward - forward [um]: mean +2.00" in text and "peak-peak 0.00" in text
assert "line mean 1.10 s" in text
def test_on_exception_restores_ddg_pso_and_axis(v4_scan_assembler, device_manager):