fix(aerotech): PandA mimics the DDG1 shutter delay, shutter_delay reaches DDG1
CI for csaxs_bec / test (push) Successful in 3m47s
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:
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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user