fix(ddg1): give the shutter the time it actually needs to open
CI for csaxs_bec / test (push) Successful in 1m53s
CI for csaxs_bec / test (push) Successful in 1m53s
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.
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 5.6 ms per line -- 0.25 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:
@@ -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,39 @@ 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.
|
||||
# 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 = 7.6e-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 +136,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 +173,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 +207,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 +278,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 +320,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 +337,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 +398,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 +408,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)
|
||||
|
||||
Reference in New Issue
Block a user