fix(aerotech): PandA mimics the DDG1 shutter delay, shutter_delay reaches DDG1
CI for csaxs_bec / test (push) Successful in 3m47s

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8zoDzDfsCTY2o5bT3J7Wv
This commit is contained in:
x01dcandClaude Opus 5.5 committed 2026-10-30 06:44:25 +01:00
1 parent 171fc17cd2
commit ffba9394eb
8 files changed
+112 -3

No files matched your search

@@ -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
+2 -1
View File
@@ -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
@@ -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)]
+25 -2
View File
@@ -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":
+10
View File
@@ -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
@@ -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.
@@ -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
@@ -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()