fix(ddg1): give the shutter the time it actually needs to open

The first point of every line is under-exposed. Four commissioning scans
put a number on it, against the 2e-3 head start that was in place:

    scan   exp_time   first/near   lost
     324     15 ms       0.655     5.18 ms
     254     20 ms       0.732     5.36 ms
     411     50 ms       0.881     5.93 ms
     450     50 ms       0.879     6.04 ms

A fixed time, not a fixed fraction: it varies by 17% across a 3.3x range
of exposure while the fraction varies by 2.9x. The same deficit appears
on the integrated scattering, a different detector behind a different
gate, so the cause is upstream of both readout chains rather than in
either of them. 2e-3 allowed and ~5.6e-3 still lost means the shutter
needs about 7.6 ms, rounded up to 8 ms: the spread across the four scans
is 0.9 ms, so the third digit is not meaningful, and overshooting costs
only the difference in dead time at the start of each line.

The trigger scheme was already right -- the shutter fires on cd at t0
and the acquisition on ab is held back by _shutter_to_open_delay, with
the widths, burst_period and cont_grid's acc_time and premove all
derived from it. Only the value was wrong, and it was a literal in two
places, so setting one and not the other would have been silently undone
by keep_shutter_open_during_scan.

Lifts it to DEFAULT_SHUTTER_TO_OPEN_DELAY next to the other defaults,
with the measurement recorded, and adds set_shutter_to_open_delay to
USER_ACCESS so the value can be converged from the client instead of by
redeploying the device server. It is bounded, because a delay is paid on
every line and a fat-fingered value would stretch the scan rather than
fail.

Cost at the new value is 6 ms per line -- 0.26 s over scan 450 -- and
0.8 um of extra premove.

The existing stage test asserted the 2e-3 literal and now asserts the
constant. New tests cover the default, the bound, the USER_ACCESS entry,
that a set value actually reaches the ab channel while cd still fires at
t0, and that keep_shutter_open_during_scan discards a tuned value, which
is a sharp edge worth pinning rather than leaving to be rediscovered.
This commit is contained in:
2026-09-09 12:30:16 +02:00
parent 3bf85ab36a
commit 3ddd0d7957
2 changed files with 131 additions and 11 deletions
@@ -21,7 +21,7 @@ DELAY PAIRS:
PULSE train for the MCS card. The MCS card needs one extra pulse to forward points.
DELAY CHANNELS:
- a = t0 + 2ms (2ms delay to allow the shutter to open)
- a = t0 + DEFAULT_SHUTTER_TO_OPEN_DELAY (to allow the shutter to open)
- b = a + 1us (short pulse)
- c = t0
- d = a + exp_time * burst_count
@@ -92,6 +92,42 @@ DEFAULT_IO_CONFIG: dict[AllChannelNames, ChannelConfig] = {
DEFAULT_TRIGGER_SOURCE: TRIGGERSOURCE = TRIGGERSOURCE.SINGLE_SHOT
# NOTE How long the shutter is given to open before the first gate of a burst.
# The shutter fires on `cd` at t0 and the acquisition on `ab` is held back by this
# much, so whatever the shutter still owes at that point is lost from the FIRST
# point of every line and from nothing else.
#
# Measured 2026-08-25 from the first-point deficit in four commissioning scans,
# against the 2e-3 that was in place when they were taken:
#
# scan exp_time first/near lost
# 324 15 ms 0.655 5.18 ms
# 254 20 ms 0.732 5.36 ms
# 411 50 ms 0.881 5.93 ms
# 450 50 ms 0.879 6.04 ms
#
# The deficit is a fixed time, not a fixed fraction: it varies by 17% across a
# 3.3x range of exposure while the fraction varies by 2.9x. That is what makes it
# a shutter opening late rather than anything dose-dependent. The same deficit
# appears on the integrated scattering -- a different detector behind a different
# gate -- so the cause sits upstream of both readout chains.
#
# 2e-3 already allowed, ~5.6e-3 still lost => the shutter needs about 7.6 ms,
# rounded up to 8 ms. The spread across the four scans is 0.9 ms, so the third
# digit is not meaningful, and overshooting costs only the difference in dead
# time at the start of each line.
# To re-measure after changing this, take the first point of each line over the
# mean of its next few neighbours, averaged over all lines, and multiply the
# shortfall by exp_time. That is the exposure still being lost, in seconds.
# Compare against its own neighbours rather than the whole-line mean: a scan
# whose intensity drifts along the line otherwise reports a deficit that is not
# there.
DEFAULT_SHUTTER_TO_OPEN_DELAY = 8e-3
# Guard rail for set_shutter_to_open_delay. The delay is paid once per line, so a
# fat-fingered value would quietly stretch every line rather than fail.
MAX_SHUTTER_TO_OPEN_DELAY = 50e-3
# NOTE Default readout times for each channel, can be adapted as needed.
# These values are relevant to calculate proper widths of the timing signals.
# They also define a minimum exposure time that can be used as they are subtracted
@@ -103,7 +139,7 @@ DEFAULT_READOUT_TIMES = {"ab": 2e-4, "cd": 2e-4, "ef": 2e-4, "gh": 2e-4} # 0.2
# If the trigger scheme changes, adapt the values here together with the README and
# PDF `trigger_scheme_ddg1_ddg2.pdf`.
DEFAULT_REFERENCES: list[tuple[LiteralChannels, CHANNELREFERENCE]] = [
("A", CHANNELREFERENCE.T0), # T0 + 2ms delay
("A", CHANNELREFERENCE.T0), # T0 + shutter-to-open delay
("B", CHANNELREFERENCE.A),
("C", CHANNELREFERENCE.T0), # T0
("D", CHANNELREFERENCE.C),
@@ -140,6 +176,7 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS):
"keep_shutter_open_during_scan",
"set_trigger",
"get_shutter_to_open_delay",
"set_shutter_to_open_delay",
"prepare_mcs_on_trigger",
]
@@ -173,7 +210,7 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS):
super().__init__(
name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs
)
self._shutter_to_open_delay = 2e-3
self._shutter_to_open_delay = DEFAULT_SHUTTER_TO_OPEN_DELAY
self.device_manager = device_manager
self._poll_thread = threading.Thread(target=self._poll_event_status, daemon=True)
self._poll_thread_run_event = threading.Event()
@@ -244,11 +281,40 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS):
"""Get the current delay that is set to open the shutter before the exposure time."""
return self._shutter_to_open_delay
def set_shutter_to_open_delay(self, delay: float) -> None:
"""Set how long the shutter is given to open before the first gate fires.
Exists so the value can be converged without redeploying the device server.
Too small and the first point of every line is under-exposed; too large and
every line is stretched by the difference for nothing. To measure the
result, see the note at DEFAULT_SHUTTER_TO_OPEN_DELAY.
Args:
delay (float): Delay in seconds. 0 starts the acquisition together with
the shutter trigger.
Raises:
ValueError: If the delay is negative or above MAX_SHUTTER_TO_OPEN_DELAY.
"""
delay = float(delay)
if not 0 <= delay <= MAX_SHUTTER_TO_OPEN_DELAY:
raise ValueError(
f"Shutter-to-open delay must be between 0 and"
f" {MAX_SHUTTER_TO_OPEN_DELAY} s, got {delay}."
)
self._shutter_to_open_delay = delay
def keep_shutter_open_during_scan(self, open: True) -> None:
"""
Method to configure the delay generator for keeping the shutter open during a scans.
This means that the additional delay to open the shutter needs to be removed (2e-3)
from the timing of the signals.
This means that the additional delay to open the shutter needs to be removed
from the timing of the signals: a shutter that is already open owes nothing
to the first gate.
NOTE Switching this off restores DEFAULT_SHUTTER_TO_OPEN_DELAY, and so
discards any value set with set_shutter_to_open_delay. Tune with the setter
and leave this alone, or change the default if the tuned value is the one
the beamline should keep.
Args:
open (bool): If True, the shutter will be kept open during the scan.
@@ -257,7 +323,7 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS):
if open is True:
self._shutter_to_open_delay = 0
else:
self._shutter_to_open_delay = 2e-3
self._shutter_to_open_delay = DEFAULT_SHUTTER_TO_OPEN_DELAY
def on_stage(self) -> None:
"""
@@ -274,8 +340,10 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS):
- We check if any default burst parameters need to be set, and set them if needed.
- We calculate the burst pulse width based on the exposure time and frames_per_trigger.
- We set the burst_period and the shutter signal (delay pairs cd) to be
exposure_time * frames_per_trigger + 3ms (2ms for shutter to open, 1ms to close).
- We set the delay pairs ab to be 2ms delayed (to allow the shutter to open) with a width of 1us to trigger DDG2.
exposure_time * frames_per_trigger + twice the shutter-to-open delay
(once to open, once to close).
- We set the delay pairs ab to be delayed by the shutter-to-open delay (to
allow the shutter to open) with a width of 1us to trigger DDG2.
- We set the delay pairs ef to be triggered after the shutter closes with a width of 1us to trigger the MCS card.
- Finally, we add a short sleep to ensure that the IOC and DDG HW process the values properly.
"""
@@ -333,7 +401,7 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS):
self.burst_period.put(total_exposure_time)
# Trigger DDG2
# a = t0 + 2ms, b = a + 1us
# a = t0 + shutter-to-open delay, b = a + 1us
# a has reference to t0, b has reference to a
# AB is delayed by the shutter opening time, and the falling edge indicates the shutter has
# fully closed, it has to be considered as the blocking signal for the next acquisition to start.
@@ -343,7 +411,8 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS):
# Trigger shutter
# d = c/t0 + self._shutter_to_open_delay + exp_time * burst_count + 1ms
# c has reference to t0, d has reference to c
# Shutter opens without delay at t0, closes after exp_time * burst_count + 2ms (self._shutter_to_open_delay)
# Shutter opens without delay at t0, closes after
# exp_time * burst_count + self._shutter_to_open_delay
self.set_delay_pairs(channel="cd", delay=0, width=shutter_width)
self.set_delay_pairs(
@@ -22,6 +22,10 @@ from csaxs_bec.devices.epics.delay_generator_csaxs.ddg_1 import (
from csaxs_bec.devices.epics.delay_generator_csaxs.ddg_1 import (
DEFAULT_TRIGGER_SOURCE as DDG1_DEFAULT_TRIGGER_SOURCE,
)
from csaxs_bec.devices.epics.delay_generator_csaxs.ddg_1 import (
DEFAULT_SHUTTER_TO_OPEN_DELAY,
MAX_SHUTTER_TO_OPEN_DELAY,
)
from csaxs_bec.devices.epics.delay_generator_csaxs.ddg_1 import PROC_EVENT_MODE
from csaxs_bec.devices.epics.delay_generator_csaxs.ddg_2 import (
DEFAULT_IO_CONFIG as DDG2_DEFAULT_IO_CONFIG,
@@ -272,6 +276,53 @@ def test_ddg1_prepare_mcs(mock_ddg1: DDG1, mock_mcs_csaxs: MCSCardCSAXS):
assert st.success is True
def test_ddg1_shutter_delay_default(mock_ddg1: DDG1):
assert mock_ddg1.get_shutter_to_open_delay() == DEFAULT_SHUTTER_TO_OPEN_DELAY
def test_ddg1_shutter_delay_reaches_the_trigger_channel(mock_ddg1: DDG1):
"""The point of the setter: the value must land on `ab`, which gates the
acquisition, while the shutter on `cd` keeps firing at t0."""
exp_time, frames = 0.1, 10
mock_ddg1.scan_info.msg.info["exp_time"] = exp_time
mock_ddg1.scan_info.msg.info["frames_per_trigger"] = frames
mock_ddg1.fast_shutter_control._read_pv.mock_data = 0
mock_ddg1.set_shutter_to_open_delay(9e-3)
mock_ddg1.stage()
assert np.isclose(mock_ddg1.ab.delay.get(), 9e-3)
assert np.isclose(mock_ddg1.cd.delay.get(), 0)
assert np.isclose(mock_ddg1.cd.width.get(), 9e-3 + exp_time * frames)
assert np.isclose(mock_ddg1.burst_period.get(), 2 * 9e-3 + exp_time * frames + 3e-6)
mock_ddg1.unstage()
@pytest.mark.parametrize("bad", [-1e-3, MAX_SHUTTER_TO_OPEN_DELAY + 1e-3])
def test_ddg1_shutter_delay_is_bounded(mock_ddg1: DDG1, bad: float):
"""A fat-fingered value would stretch every line rather than fail, so it is
refused instead of applied."""
with pytest.raises(ValueError):
mock_ddg1.set_shutter_to_open_delay(bad)
assert mock_ddg1.get_shutter_to_open_delay() == DEFAULT_SHUTTER_TO_OPEN_DELAY
def test_ddg1_shutter_delay_is_reachable_from_the_client(mock_ddg1: DDG1):
"""Without this the setter cannot be called over RPC, which is the whole
reason it exists -- the value needs tuning without a redeploy."""
assert "set_shutter_to_open_delay" in DDG1.USER_ACCESS
def test_ddg1_keeping_the_shutter_open_discards_a_tuned_delay(mock_ddg1: DDG1):
"""Documents a sharp edge: the two methods write the same attribute, so
toggling this reverts a tuned value to the default rather than restoring it."""
mock_ddg1.set_shutter_to_open_delay(9e-3)
mock_ddg1.keep_shutter_open_during_scan(True)
assert mock_ddg1.get_shutter_to_open_delay() == 0
mock_ddg1.keep_shutter_open_during_scan(False)
assert mock_ddg1.get_shutter_to_open_delay() == DEFAULT_SHUTTER_TO_OPEN_DELAY
def test_ddg1_stage(mock_ddg1: DDG1):
"""Test the on_stage method of DDG1."""
exp_time = 0.1
@@ -295,7 +346,7 @@ def test_ddg1_stage(mock_ddg1: DDG1):
assert np.isclose(mock_ddg1.burst_period.get(), total_exposure)
# Trigger DDG2 through EXT/EN
assert np.isclose(mock_ddg1.ab.delay.get(), 2e-3)
assert np.isclose(mock_ddg1.ab.delay.get(), DEFAULT_SHUTTER_TO_OPEN_DELAY)
assert np.isclose(mock_ddg1.ab.width.get(), shutter_width)
# Shutter channel cd
assert np.isclose(mock_ddg1.cd.delay.get(), 0)