From ffba9394ebdb93cde6828edd69693e45bbed0ffe Mon Sep 17 00:00:00 2001 From: x01dc Date: Fri, 30 Oct 2026 06:44:25 +0100 Subject: [PATCH] fix(aerotech): PandA mimics the DDG1 shutter delay, shutter_delay reaches DDG1 - PandA line trigger: shutter_delay (default 8 ms, as DDG1) between the line trigger and the first gate, get/set_shutter_to_open_delay like DDG1; the scan now reads it for trigger_source="panda" (was 0, so the test series ran with the first gate at the trigger, unlike the beamline) - a shutter_delay scan argument with "ddg" only moved the trigger positions, DDG1 kept its own delay; it is now set on DDG1 before staging and restored in post_scan / on_exception - docs: DDG1/DDG2 timing chain (exp_time = gate period, readout subtracted), note that the 2026-10-29 series ran without shutter delay Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01M8zoDzDfsCTY2o5bT3J7Wv --- .../device_configs/sastt_aerotech_panda.yaml | 2 ++ csaxs_bec/devices/aerotech/README.md | 3 +- .../devices/panda_box/panda_line_trigger.py | 25 ++++++++++++++++ .../scans/scans_v4/aerotech_cont_grid.py | 27 +++++++++++++++-- docs/developer/aerotech_stages.md | 10 +++++++ .../saxs/aerotech_stages/aerotech_stages.md | 8 +++++ .../tests_devices/test_panda_line_trigger.py | 10 +++++++ .../test_aerotech_cont_grid_scan.py | 30 +++++++++++++++++++ 8 files changed, 112 insertions(+), 3 deletions(-) diff --git a/csaxs_bec/device_configs/sastt_aerotech_panda.yaml b/csaxs_bec/device_configs/sastt_aerotech_panda.yaml index c87a3910..af25697c 100644 --- a/csaxs_bec/device_configs/sastt_aerotech_panda.yaml +++ b/csaxs_bec/device_configs/sastt_aerotech_panda.yaml @@ -131,6 +131,8 @@ pandatrig: trigger_edge: rising # gap between the detector gates, as the DDG2 default at the beamline readout_time: 0.0002 + # line trigger -> first gate, as the DDG1 shutter-to-open delay at the beamline + shutter_delay: 0.008 enabled: true onFailure: buffer readOnly: false diff --git a/csaxs_bec/devices/aerotech/README.md b/csaxs_bec/devices/aerotech/README.md index f53944e6..ac20b489 100644 --- a/csaxs_bec/devices/aerotech/README.md +++ b/csaxs_bec/devices/aerotech/README.md @@ -81,7 +81,8 @@ 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`; `shutter_delay` argument) or `None` (no trigger hardware, motion + `wait_lines_done`; first gate one shutter-to-open delay after the edge, device config + `shutter_delay`, default 8 ms as DDG1) 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 diff --git a/csaxs_bec/devices/panda_box/panda_line_trigger.py b/csaxs_bec/devices/panda_box/panda_line_trigger.py index 6bd4a9e4..49b50fc8 100644 --- a/csaxs_bec/devices/panda_box/panda_line_trigger.py +++ b/csaxs_bec/devices/panda_box/panda_line_trigger.py @@ -113,6 +113,9 @@ class PandaLineTrigger(Device): "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. + shutter_delay: time from the line trigger edge to the first gate, in s, for the + shutter to open. Default 8 ms, as DDG1 at the beamline (it opens the shutter at + the trigger and starts DDG2 this much later). """ USER_ACCESS = [ @@ -124,9 +127,14 @@ class PandaLineTrigger(Device): "line_status", "wait_lines_done", "get_readout_time", + "get_shutter_to_open_delay", + "set_shutter_to_open_delay", "panda", ] + # same guard rail as DDG1 + MAX_SHUTTER_TO_OPEN_DELAY = 50e-3 + TRIGGER_INPUT = "TTLIN3" SOFT_TRIGGER_BIT = "BITS.A" RAW_TRIGGER_LUT = "LUT8" @@ -152,11 +160,14 @@ class PandaLineTrigger(Device): input_termination: str = "50-Ohm", trigger_edge: str = "rising", readout_time: float = 2e-4, + shutter_delay: float = 8e-3, device_manager=None, **kwargs, ): self.panda = PandaControl(host, port) self.readout_time = float(readout_time) + self._shutter_to_open_delay = 0.0 + self.set_shutter_to_open_delay(shutter_delay) 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}.") @@ -242,6 +253,20 @@ class PandaLineTrigger(Device): """Readout time (gap between the detector gates) used by the scans, in s.""" return self.readout_time + def get_shutter_to_open_delay(self) -> float: + """Time from the line trigger to the first gate used by the scans, in s (as DDG1).""" + return self._shutter_to_open_delay + + def set_shutter_to_open_delay(self, delay: float) -> None: + """Set the time from the line trigger to the first gate, in s (as DDG1).""" + delay = float(delay) + if not 0 <= delay <= self.MAX_SHUTTER_TO_OPEN_DELAY: + raise ValueError( + f"Shutter-to-open delay must be between 0 and {self.MAX_SHUTTER_TO_OPEN_DELAY}" + f" s, got {delay}." + ) + self._shutter_to_open_delay = delay + 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 71626551..fda9378f 100644 --- a/csaxs_bec/scans/scans_v4/aerotech_cont_grid.py +++ b/csaxs_bec/scans/scans_v4/aerotech_cont_grid.py @@ -40,7 +40,8 @@ Trigger sources (trigger_source): outputs are active low according to the manual: "falling". "panda": PSO output -> PandABox TTLIN3 (device pandatrig, test setup). The PandA is configured and armed once and sends the shutter pulse and the detector gates - of each line. + of each line, like DDG1 / DDG2: shutter at the trigger, the first gate one + shutter-to-open delay later (8 ms, device config shutter_delay). None: no trigger hardware (motion tests); the PSO window is still armed. Only for sessions without hardware triggered detectors / MCS, they would wait for frames that never come. @@ -207,7 +208,7 @@ 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". - shutter_delay (float | None): Delay from the line trigger to the first exposure in s. Default: DDG1 shutter delay for "ddg", 0 otherwise. + shutter_delay (float | None): Delay from the line trigger to the first exposure (time for the shutter to open) in s. Default: the value of the trigger hardware (DDG1 / PandA shutter-to-open delay, 8 ms), 0 without trigger hardware. With "ddg" a given value is set on DDG1 for this scan and restored afterwards. settle_time (float): Run-up at constant velocity before the trigger position, in s. Default is 0.15. minimal_run_up (bool): Run-up / run-out only the ramp distance v^2 / (2 * acceleration), at least 0.01, without the 2x margin and settle_time: faster lines, but the exposures start right after the ramp while the fast axis may still oscillate, so the captured positions are less evenly spaced. Default is False. capture_device (str): Name of the A3200PositionCapture device (always used). Default is "aerocapture". @@ -264,6 +265,7 @@ class AerotechContGrid(ScanBase): self.exp_time = exp_time self.trigger_source = trigger_source self.shutter_delay = shutter_delay + self._ddg1_shutter_delay = None # DDG1 value to restore after the scan self.settle_time = settle_time self.minimal_run_up = minimal_run_up @@ -328,6 +330,7 @@ class AerotechContGrid(ScanBase): self._compute_line_geometry() self._check_line_limits() + self._apply_shutter_delay() # One readout per line: the last point of each line, as in cont_grid self.positions = positions[(self.frames_per_line - 1) :: self.frames_per_line, :].copy() @@ -618,10 +621,14 @@ class AerotechContGrid(ScanBase): def _compute_line_geometry(self): """Velocity, PSO window and run-up / run-out of the fast axis.""" line = self._line + # the trigger hardware opens the shutter at the trigger and starts the gates this + # much later (DDG1 -> DDG2 at the beamline, the PandA in the test setup) if self.shutter_delay is not None: line["shutter_open_delay"] = self.shutter_delay elif self.trigger_source == "ddg": line["shutter_open_delay"] = self.ddg1.get_shutter_to_open_delay() + elif self.trigger_source == "panda": + line["shutter_open_delay"] = self.panda.get_shutter_to_open_delay() else: line["shutter_open_delay"] = 0.0 line["readout_time"] = self._readout_time() @@ -701,9 +708,25 @@ class AerotechContGrid(ScanBase): f" is outside the limits ({low}, {high})." ) + def _apply_shutter_delay(self): + """ + A shutter_delay argument must also reach DDG1, which times the gates itself (the + trigger positions alone would not match it). Set before DDG1 stages, restored in + _restore. The PandA gets the delay with configure_line. + """ + if self.trigger_source != "ddg" or self.shutter_delay is None: + return + current = self.ddg1.get_shutter_to_open_delay() + if current != self.shutter_delay: + self._ddg1_shutter_delay = current + self.ddg1.set_shutter_to_open_delay(self.shutter_delay) + def _restore(self): """Undo the scan settings; every step is tried even if an earlier one fails.""" steps = [] + if self._ddg1_shutter_delay is not None: + delay = self._ddg1_shutter_delay + steps.append(lambda: self.ddg1.set_shutter_to_open_delay(delay)) if self.trigger_source == "ddg": steps.append(lambda: self.ddg1.set_trigger(DDG_SINGLE_SHOT)) elif self.trigger_source == "panda": diff --git a/docs/developer/aerotech_stages.md b/docs/developer/aerotech_stages.md index 9d7799d7..a9bd2951 100644 --- a/docs/developer/aerotech_stages.md +++ b/docs/developer/aerotech_stages.md @@ -80,6 +80,16 @@ trigger_high = exposure_end + v * shutter_delay - v * readout_time / 2 (backwa (typically one extra edge per line, logged at info level). - **Armed once** in `pre_scan` (one position reference); before each line only the range is switched (`PSOWINDOW RANGE`), while the axis is outside both windows. +- **Shutter delay:** at the beamline DDG1 opens the shutter at the line trigger and + triggers DDG2 `shutter_to_open_delay` later (8 ms, measured from the first-point + deficit; `ddg1.set_shutter_to_open_delay()`). DDG2 then bursts `frames_per_line` + gates with period `exp_time` and width `exp_time - readout_time`, so `exp_time` is the + cell period and the readout is subtracted from the exposure. The trigger positions + lead the first cell by `v * shutter_delay`, so the first gate starts at the first cell + in both directions for any delay (no need for multiples of `exp_time`). A + `shutter_delay` scan argument is set on DDG1 for the scan and restored afterwards. + The PandA mimics this chain: shutter output at the edge, first gate + `shutter_delay` later (device config, default 8 ms), same gate period and width. - **Readout gap centred:** each gate (`exp_time - readout_time`) is centred in its cell, so forward and backward lines expose the same intervals. Without it the directions differ by `fast_step_size * readout_time / exp_time`. The readout time diff --git a/docs/user/saxs/aerotech_stages/aerotech_stages.md b/docs/user/saxs/aerotech_stages/aerotech_stages.md index 4b664527..a1fd9446 100644 --- a/docs/user/saxs/aerotech_stages/aerotech_stages.md +++ b/docs/user/saxs/aerotech_stages/aerotech_stages.md @@ -72,6 +72,7 @@ scans.aerotech_cont_grid( | `relative` | `True`: positions are offsets from the current X / Y position, and both axes return there after the scan | | `trigger_source` | `"ddg"` (default, beamline timing) | | `settle_time` | constant velocity time before the first exposure of a line, default 0.15 s (leave it) | +| `shutter_delay` | time from the line trigger to the first exposure, for the shutter to open. Default: the value of DDG1 (8 ms); a given value is set on DDG1 for this scan only | | `minimal_run_up` | `True`: run-up only the acceleration distance (at least 10 um), no settling. Faster lines, but the exposures are less evenly spaced; use the captured positions. Default `False` | The number of points per line is `ceil(|fast_end - fast_start| / fast_step_size) + 1`, @@ -161,6 +162,13 @@ centres from the nominal cells, backward minus forward offset, and the line time Measured 2026-10-29 with the PandABox as trigger (readout 0.2 ms), X acceleration 500 mm/s^2, X centred at 123.25, and the same nine scans in both run-up modes: scans 8486-8494 with the default run-up, 8501-8509 with `minimal_run_up=True`. + +!!! note "Without shutter delay" + In these scans the first exposure of a line started at the line trigger. At the + beamline DDG1 opens the shutter at the trigger and starts the exposures 8 ms later, + and the trigger comes correspondingly earlier on the line. These 8 ms give X more + time to settle after the ramp, so the minimal run-up results at the beamline should + be better than shown here. The target resolution is about 20 um; the positioning accuracy should be well below that. diff --git a/tests/tests_devices/test_panda_line_trigger.py b/tests/tests_devices/test_panda_line_trigger.py index 95feadef..6125a386 100644 --- a/tests/tests_devices/test_panda_line_trigger.py +++ b/tests/tests_devices/test_panda_line_trigger.py @@ -297,3 +297,13 @@ def test_ignored_edges_logged_once_per_line_as_info(trigger): assert len(messages) == 2 assert "1 trigger edge(s) ignored in line 1" in messages[0] assert "1 trigger edge(s) ignored in line 2" in messages[1] and "2 since arm" in messages[1] + + +def test_shutter_to_open_delay_as_ddg1(trigger): + assert trigger.get_shutter_to_open_delay() == pytest.approx(8e-3) # DDG1 default + trigger.set_shutter_to_open_delay(0.012) + assert trigger.get_shutter_to_open_delay() == pytest.approx(0.012) + with pytest.raises(ValueError, match="Shutter-to-open delay"): + trigger.set_shutter_to_open_delay(0.2) + configured = PandaLineTrigger(name="p2", host="fake-panda", shutter_delay=0.0) + assert configured.get_shutter_to_open_delay() == 0.0 diff --git a/tests/tests_scans/test_aerotech_cont_grid_scan.py b/tests/tests_scans/test_aerotech_cont_grid_scan.py index 7eec820f..a36ddc78 100644 --- a/tests/tests_scans/test_aerotech_cont_grid_scan.py +++ b/tests/tests_scans/test_aerotech_cont_grid_scan.py @@ -58,6 +58,7 @@ def _add_devices( signal_read_values={"ddg1": 0.0}, ) ddg1.get_shutter_to_open_delay = mock.MagicMock(return_value=2e-3) + ddg1.set_shutter_to_open_delay = mock.MagicMock() ddg1.set_trigger = mock.MagicMock() ddg2 = MockCustomDevice( "ddg2", @@ -80,6 +81,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) + pandatrig.get_shutter_to_open_delay = mock.MagicMock(return_value=8e-3) devices.append(pandatrig) if capture: aerocapture = MockCustomDevice( @@ -563,3 +565,31 @@ def test_minimal_run_up_limit_message(v4_scan_assembler, device_manager): ) with pytest.raises(LimitError, match="minimal run-up"): scan.prepare_scan() + + +def test_panda_uses_its_shutter_to_open_delay(v4_scan_assembler, device_manager): + scan = _assemble(v4_scan_assembler, device_manager, trigger_source="panda") + scan.prepare_scan() + line = scan._line + velocity = 0.5 / 0.1 + assert line["shutter_open_delay"] == pytest.approx(8e-3) + # trigger one shutter delay of travel before the first cell (gap: readout 0.02 s) + assert line["trigger_low"] == pytest.approx(-1.0 - velocity * 8e-3 + velocity * 0.02 / 2) + + +def test_ddg_shutter_delay_argument_is_set_on_ddg1_and_restored(v4_scan_assembler, device_manager): + scan = _assemble(v4_scan_assembler, device_manager, shutter_delay=0.012) + scan.prepare_scan() + assert scan._line["shutter_open_delay"] == pytest.approx(0.012) + scan.ddg1.set_shutter_to_open_delay.assert_called_once_with(0.012) + scan.fast_axis = mock.MagicMock() + scan.on_exception(RuntimeError("aborted")) + assert scan.ddg1.set_shutter_to_open_delay.call_args_list[-1] == mock.call(2e-3) + + +def test_ddg_without_shutter_delay_argument_leaves_ddg1_alone(v4_scan_assembler, device_manager): + scan = _assemble(v4_scan_assembler, device_manager) + scan.prepare_scan() + scan.fast_axis = mock.MagicMock() + scan.on_exception(RuntimeError("aborted")) + scan.ddg1.set_shutter_to_open_delay.assert_not_called()