diff --git a/csaxs_bec/device_configs/sastt_aerotech.yaml b/csaxs_bec/device_configs/sastt_aerotech.yaml index c8d444cb..9bf4f75e 100644 --- a/csaxs_bec/device_configs/sastt_aerotech.yaml +++ b/csaxs_bec/device_configs/sastt_aerotech.yaml @@ -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 diff --git a/csaxs_bec/device_configs/sastt_aerotech_panda.yaml b/csaxs_bec/device_configs/sastt_aerotech_panda.yaml index 7fd01781..c87a3910 100644 --- a/csaxs_bec/device_configs/sastt_aerotech_panda.yaml +++ b/csaxs_bec/device_configs/sastt_aerotech_panda.yaml @@ -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 diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_sastt_aerotech.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_sastt_aerotech.yaml index 03b62f73..d81a613d 100644 --- a/csaxs_bec/device_configs/simulated_omny/simulated_sastt_aerotech.yaml +++ b/csaxs_bec/device_configs/simulated_omny/simulated_sastt_aerotech.yaml @@ -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 diff --git a/csaxs_bec/devices/aerotech/README.md b/csaxs_bec/devices/aerotech/README.md index 9d8282c2..b64cceb1 100644 --- a/csaxs_bec/devices/aerotech/README.md +++ b/csaxs_bec/devices/aerotech/README.md @@ -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`. 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`, start + of each exposure) and as exposure centres (`aerocapture_positions_enc_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 diff --git a/csaxs_bec/devices/aerotech/a3200.py b/csaxs_bec/devices/aerotech/a3200.py index b4838345..a7b1e7ba 100644 --- a/csaxs_bec/devices/aerotech/a3200.py +++ b/csaxs_bec/devices/aerotech/a3200.py @@ -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: position at the trigger edge, i.e. the start of the exposure) and + as exposure centres (enc_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} diff --git a/csaxs_bec/devices/panda_box/panda_line_trigger.py b/csaxs_bec/devices/panda_box/panda_line_trigger.py index 4924730f..6d9660a0 100644 --- a/csaxs_bec/devices/panda_box/panda_line_trigger.py +++ b/csaxs_bec/devices/panda_box/panda_line_trigger.py @@ -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)] diff --git a/csaxs_bec/scans/scans_v4/aerotech_cont_grid.py b/csaxs_bec/scans/scans_v4/aerotech_cont_grid.py index 5d3fb258..e6e1ea74 100644 --- a/csaxs_bec/scans/scans_v4/aerotech_cont_grid.py +++ b/csaxs_bec/scans/scans_v4/aerotech_cont_grid.py @@ -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) and the exposure centres +(aerocapture_positions_enc_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 diff --git a/tests/tests_devices/test_a3200.py b/tests/tests_devices/test_a3200.py index cba35c3a..a5f8c28a 100644 --- a/tests/tests_devices/test_a3200.py +++ b/tests/tests_devices/test_a3200.py @@ -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"]) diff --git a/tests/tests_scans/test_aerotech_cont_grid_scan.py b/tests/tests_scans/test_aerotech_cont_grid_scan.py index de65cc24..6231dda2 100644 --- a/tests/tests_scans/test_aerotech_cont_grid_scan.py +++ b/tests/tests_scans/test_aerotech_cont_grid_scan.py @@ -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):