fix(nidaq): forward all EPICS channels plus energy and encoder in software mode

This commit is contained in:
2026-08-06 13:20:19 +02:00
parent 95918c8ec6
commit 736306cc84
2 changed files with 40 additions and 52 deletions
+18 -26
View File
@@ -48,9 +48,15 @@ NIDAQ_STREAM_CHANNELS = [
"xrd_ai0_std_dev",
]
#: Groups fed from the EPICS scalars during software-triggered scans. The
#: std_dev and stream groups have no synchronous source and stay hardware-only.
NIDAQ_SW_FORWARD_GROUPS = ("mean", "max")
#: Group channel -> device attribute of the live EPICS scalar backing it in
#: software-triggered scans. The backend's standby acquisition feeds ALL
#: channels (plus energy and encoder), so everything with an EPICS counterpart
#: is forwarded; std_dev and the derived stream channels are hardware-only.
NIDAQ_SW_FORWARD_SOURCES: dict[str, dict[str, str]] = {
"mean": {channel: channel for channel in NIDAQ_MEAN_CHANNELS},
"max": {channel: channel for channel in NIDAQ_MAX_CHANNELS},
"stream": {"energy": "energy_epics", "enc": "enc_epics"},
}
class NidaqError(Exception):
@@ -215,31 +221,17 @@ class Nidaq(PSIDeviceBase, NidaqControl):
self._forward_epics_readings()
return data
def _enabled_sw_channels(self) -> dict[str, list[str]]:
"""Channels currently enabled via the bitmasks, per software-forwarded group.
Used as a data-quality filter for the software-mode forwarding only (do
not forward junk values of disabled inputs); it is NOT a validation
contract for the hardware stream, whose active channel set cannot be
predicted upfront.
"""
ai_mask = int(self.ai_chans.get() or 0)
ci_mask = int(self.ci_chans.get() or 0)
di_mask = int(self.di_chans.get() or 0)
return {
"mean": [f"ai{i}" for i in range(8) if ai_mask >> i & 1]
+ [f"ci{i}" for i in range(18) if ci_mask >> i & 1],
"max": [f"di{i}" for i in range(5) if di_mask >> i & 1],
}
def _forward_epics_readings(self) -> None:
"""Forward the current EPICS scalar readings to the statistic groups.
Software-triggered scans only: during the backend-driven scans
(valid_scan_names) the per-oscillation statistics arrive through the
hardware path, which owns the groups. Only the groups whose members
have a synchronous EPICS counterpart are fed (NIDAQ_SW_FORWARD_GROUPS),
restricted to the bitmask-enabled channels.
hardware path, which owns the groups. ALL channels are forwarded: the
backend's standby acquisition runs every input regardless of the
measurement bitmasks (AIChans/CIChans/DIChans describe the last
hardware task, not what is live), so every EPICS scalar carries a
valid reading. std_dev and the derived stream channels have no EPICS
counterpart and stay hardware-only (NIDAQ_SW_FORWARD_SOURCES).
"""
if self._staged != Staged.yes:
return
@@ -250,10 +242,10 @@ class Nidaq(PSIDeviceBase, NidaqControl):
):
return
now = time.time()
for group, channels in self._enabled_sw_channels().items():
for group, sources in NIDAQ_SW_FORWARD_SOURCES.items():
batch = {
channel: {"value": getattr(self, channel).get(), "timestamp": now}
for channel in channels
channel: {"value": getattr(self, attr).get(), "timestamp": now}
for channel, attr in sources.items()
}
if batch:
# Annotate the alignment: these rows are in sync with the
+22 -26
View File
@@ -217,31 +217,20 @@ from types import SimpleNamespace
from ophyd import Staged
def _enable_channels(dev, ai_mask=0b1, ci_mask=0b1000, di_mask=0b110):
"""Enable ai0, ci3 and di1/di2 via the channel bitmasks."""
dev.ai_chans._read_pv.mock_data = ai_mask
dev.ci_chans._read_pv.mock_data = ci_mask
dev.di_chans._read_pv.mock_data = di_mask
def test_enabled_sw_channels_follow_bitmasks(mock_nidaq):
def test_read_forwards_all_epics_scalars_when_gates_open(mock_nidaq, scan_info_mock):
"""Software-triggered scans: the monitored readout forwards ALL EPICS
values into the SAME groups the hardware-triggered stream feeds. The
measurement bitmasks are irrelevant: the backend's standby acquisition
runs every channel."""
dev = mock_nidaq
_enable_channels(dev, ai_mask=0b101, ci_mask=0b10, di_mask=0b110)
enabled = dev._enabled_sw_channels()
assert enabled["mean"] == ["ai0", "ai2", "ci1"]
assert enabled["max"] == ["di1", "di2"]
def test_read_forwards_enabled_epics_scalars_when_gates_open(mock_nidaq, scan_info_mock):
"""Software-triggered scans: the monitored readout forwards the ENABLED
EPICS values into the SAME groups the hardware-triggered stream feeds."""
dev = mock_nidaq
_enable_channels(dev) # ai0, ci3, di1, di2
dev.ai_chans._read_pv.mock_data = 0b1 # stale hardware-scan bitmask, must be ignored
dev.ai0._read_pv.mock_data = 5.0
dev.ai1._read_pv.mock_data = 99.0 # not in the bitmask, forwarded anyway
dev.ci3._read_pv.mock_data = 42
dev.di1._read_pv.mock_data = 0
dev.di2._read_pv.mock_data = 1
dev.ai1._read_pv.mock_data = 99.0 # disabled channel, must not be forwarded
dev.energy_epics._read_pv.mock_data = 8000.0
dev.enc_epics._read_pv.mock_data = 12.5
scan_info_mock.scan_name = "xas_simple_scan"
dev.scan_parameters = scan_info_mock
@@ -255,19 +244,26 @@ def test_read_forwards_enabled_epics_scalars_when_gates_open(mock_nidaq, scan_in
dev.read()
assert dev.mean.get() is None
# software-triggered scan -> enabled channels forwarded under the unified names
# software-triggered scan -> ALL channels forwarded under the unified names
scan_info_mock.scan_name = "line_scan"
dev.read()
out = dev.mean.get()
assert len(out.signals) == 26 # all 8 ai + 18 ci, bitmask ignored
assert out.signals["nidaq_mean_ai0"]["value"] == 5.0
assert out.signals["nidaq_mean_ai1"]["value"] == 99.0
assert out.signals["nidaq_mean_ci3"]["value"] == 42
assert "nidaq_mean_ai1" not in out.signals # disabled -> filtered
assert out.metadata["acquisition_group"] == "monitored"
assert dev.max.get().signals["nidaq_max_di1"]["value"] == 0
assert dev.max.get().signals["nidaq_max_di2"]["value"] == 1
# hardware-only groups untouched in software mode
out_max = dev.max.get()
assert len(out_max.signals) == 5
assert out_max.signals["nidaq_max_di1"]["value"] == 0
assert out_max.signals["nidaq_max_di2"]["value"] == 1
# energy and encoder are live standby readings -> stream group
out_stream = dev.stream.get()
assert set(out_stream.signals) == {"nidaq_stream_energy", "nidaq_stream_enc"}
assert out_stream.signals["nidaq_stream_energy"]["value"] == 8000.0
assert out_stream.signals["nidaq_stream_enc"]["value"] == 12.5
# no software source for standard deviations
assert dev.std_dev.get() is None
assert dev.stream.get() is None
# scan not open -> no forwarding
dev.ai0._read_pv.mock_data = 7.0