diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py index ad05299d..b1140bca 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -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( diff --git a/tests/tests_devices/test_delay_generator_csaxs.py b/tests/tests_devices/test_delay_generator_csaxs.py index 84f0061d..2d5d1f72 100644 --- a/tests/tests_devices/test_delay_generator_csaxs.py +++ b/tests/tests_devices/test_delay_generator_csaxs.py @@ -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)