feat(aerotech): exposure centres, readout gap compensation, positions always captured
CI for csaxs_bec / test (push) Successful in 3m27s
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:
1 parent
3aca5030a2
commit
201e6406d2
9 files changed
+251
-70
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user