Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 21 additions & 48 deletions src/srx_caproto_iocs/zebra/caproto_ioc.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,59 +99,31 @@ class ZebraSaveIOC(CaprotoSaveIOC):
doc="Pick device type",
)

enc1 = pvproperty(
ch1 = pvproperty(
value=0,
dtype=ChannelType.DOUBLE,
doc="enc1 data",
doc="Generic channel 1 (zebra default: enc1, scaler default: i0)",
max_length=DEFAULT_MAX_LENGTH,
)

enc2 = pvproperty(
ch2 = pvproperty(
value=0,
dtype=ChannelType.DOUBLE,
doc="enc2 data",
doc="Generic channel 2 (zebra default: enc2, scaler default: im)",
max_length=DEFAULT_MAX_LENGTH,
)

enc3 = pvproperty(
ch3 = pvproperty(
value=0,
dtype=ChannelType.DOUBLE,
doc="enc3 data",
doc="Generic channel 3 (zebra default: enc3, scaler default: it)",
max_length=DEFAULT_MAX_LENGTH,
)

zebra_time = pvproperty(
ch4 = pvproperty(
value=0,
dtype=ChannelType.DOUBLE,
doc="zebra time",
max_length=DEFAULT_MAX_LENGTH,
)

i0 = pvproperty(
value=0,
dtype=ChannelType.DOUBLE,
doc="i0 data",
max_length=DEFAULT_MAX_LENGTH,
)

im = pvproperty(
value=0,
dtype=ChannelType.DOUBLE,
doc="im data",
max_length=DEFAULT_MAX_LENGTH,
)

it = pvproperty(
value=0,
dtype=ChannelType.DOUBLE,
doc="it data",
max_length=DEFAULT_MAX_LENGTH,
)

sis_time = pvproperty(
value=0,
dtype=ChannelType.DOUBLE,
doc="sis time",
doc="Generic channel 4 (zebra default: zebra_time, scaler default: sis_time)",
max_length=DEFAULT_MAX_LENGTH,
)

Expand All @@ -164,20 +136,20 @@ class ZebraSaveIOC(CaprotoSaveIOC):
# super().__init__(*args, **kwargs)
# self._external_pvs = external_pvs

#: Default dataset mappings keyed by dev_type. Keys are PV attribute names;
#: values are the corresponding HDF5 dataset names written to file.
#: Default dataset mappings keyed by dev_type. Keys are generic PV attribute
#: names (ch1–ch4); values are the HDF5 dataset names written to file.
_DEFAULT_DATASET_MAPS: dict[str, dict[str, str]] = {
DevTypes.ZEBRA.value: {
"enc1": "enc1",
"enc2": "enc2",
"enc3": "enc3",
"zebra_time": "zebra_time",
"ch1": "enc1",
"ch2": "enc2",
"ch3": "enc3",
"ch4": "zebra_time",
},
DevTypes.SCALER.value: {
"i0": "i0",
"im": "im",
"it": "it",
"sis_time": "sis_time",
"ch1": "i0",
"ch2": "im",
"ch3": "it",
"ch4": "sis_time",
},
}

Expand Down Expand Up @@ -249,8 +221,9 @@ def saver(request_queue, response_queue):
parser.add_argument(
"--dataset-map",
help=(
"JSON mapping of PV attribute names to HDF5 dataset names, "
'e.g. \'{"enc1": "x_pos", "enc2": "y_pos"}\'. '
"JSON mapping of generic channel names (ch1–ch4) to HDF5 dataset names. "
'Full example: \'{"ch1": "x_pos", "ch2": "y_pos", "ch3": "z_pos", "ch4": "t"}\'. '
'Partial example (FXI-style, 2 channels): \'{"ch1": "enc1_pi_r", "ch2": "zebra_time"}\'. '
"When omitted, the mapping is chosen from the built-in SRX defaults "
"based on the dev_type PV (zebra or scaler)."
),
Expand Down
16 changes: 5 additions & 11 deletions src/srx_caproto_iocs/zebra/ophyd.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,8 @@ class ZebraWithCaprotoIOC(OphydDeviceWithCaprotoIOC):
# Device-type selector (zebra / scaler)
dev_type = Cpt(EpicsSignal, "dev_type", string=True)

# Zebra position-capture channels
enc1 = Cpt(EpicsSignalRO, "enc1", auto_monitor=False)
enc2 = Cpt(EpicsSignalRO, "enc2", auto_monitor=False)
enc3 = Cpt(EpicsSignalRO, "enc3", auto_monitor=False)
zebra_time = Cpt(EpicsSignalRO, "zebra_time", auto_monitor=False)

# Scaler channels
i0 = Cpt(EpicsSignalRO, "i0", auto_monitor=False)
im = Cpt(EpicsSignalRO, "im", auto_monitor=False)
it = Cpt(EpicsSignalRO, "it", auto_monitor=False)
sis_time = Cpt(EpicsSignalRO, "sis_time", auto_monitor=False)
# Generic data channels (ch1–ch4)
ch1 = Cpt(EpicsSignalRO, "ch1", auto_monitor=False)
ch2 = Cpt(EpicsSignalRO, "ch2", auto_monitor=False)
ch3 = Cpt(EpicsSignalRO, "ch3", auto_monitor=False)
ch4 = Cpt(EpicsSignalRO, "ch4", auto_monitor=False)
97 changes: 96 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ def zebra_caproto_ioc(wait=5):
@pytest.fixture(scope="session")
def zebra_caproto_ioc_custom_map(wait=5):
"""ZebraSaveIOC with a custom --dataset-map overriding the SRX defaults."""
custom_map = {"enc1": "x_pos", "enc2": "y_pos", "enc3": "z_pos", "zebra_time": "t"}
custom_map = {"ch1": "x_pos", "ch2": "y_pos", "ch3": "z_pos", "ch4": "t"}
p = start_ioc_subprocess(
ioc_name="srx_caproto_iocs.zebra.caproto_ioc",
pv_prefix="ZEBRA_CUSTOM:{{Dev:Save1}}:",
Expand All @@ -167,6 +167,30 @@ def zebra_caproto_ioc_custom_map(wait=5):
print(f"STDERR:\n{sep}\n{std_err}")


@pytest.fixture(scope="session")
def zebra_caproto_ioc_fxi_map(wait=5):
"""ZebraSaveIOC with a partial 2-channel FXI-style --dataset-map."""
fxi_map = {"ch1": "enc1_pi_r", "ch2": "zebra_time"}
p = start_ioc_subprocess(
ioc_name="srx_caproto_iocs.zebra.caproto_ioc",
pv_prefix="ZEBRA_FXI:{{Dev:Save1}}:",
extra_args=(f"--dataset-map={json.dumps(fxi_map)}",),
)

print(f"Wait for {wait} seconds...")
ttime.sleep(wait)

yield p, fxi_map

p.terminate()

std_out, std_err = p.communicate()
std_out = std_out.decode()
sep = "=" * 80
print(f"STDOUT:\n{sep}\n{std_out}")
print(f"STDERR:\n{sep}\n{std_err}")


@pytest.fixture
def zebra_ophyd_device():
dev = ZebraWithCaprotoIOC(ZEBRA_OPHYD_PV_PREFIX, name="zebra_with_caproto_ioc")
Expand All @@ -182,3 +206,74 @@ def zebra_ophyd_device_custom_map():
dev.wait_for_connection(timeout=30)
yield dev
dev.ioc_stage.put("unstaged", timeout=10)


@pytest.fixture
def zebra_ophyd_device_fxi_map():
prefix = "ZEBRA_FXI:{Dev:Save1}:"
dev = ZebraWithCaprotoIOC(prefix, name="zebra_fxi_map")
dev.wait_for_connection(timeout=30)
yield dev
dev.ioc_stage.put("unstaged", timeout=10)


@pytest.fixture(scope="session")
def zebra_caproto_ioc_conc_srx_zebra(wait=5):
"""ZebraSaveIOC for 3-IOC concurrent test — SRX Zebra (default map)."""
p = start_ioc_subprocess(
ioc_name="srx_caproto_iocs.zebra.caproto_ioc",
pv_prefix="ZEBRA_CONC_SRXZ:{{Dev:Save1}}:",
)

print(f"Wait for {wait} seconds...")
ttime.sleep(wait)

yield p

p.terminate()

std_out, std_err = p.communicate()
std_out = std_out.decode()
sep = "=" * 80
print(f"STDOUT:\n{sep}\n{std_out}")
print(f"STDERR:\n{sep}\n{std_err}")


@pytest.fixture(scope="session")
def zebra_caproto_ioc_conc_srx_sis(wait=5):
"""ZebraSaveIOC for 3-IOC concurrent test — SRX SIS/scaler (explicit scaler map)."""
sis_map = {"ch1": "i0", "ch2": "im", "ch3": "it", "ch4": "sis_time"}
p = start_ioc_subprocess(
ioc_name="srx_caproto_iocs.zebra.caproto_ioc",
pv_prefix="ZEBRA_CONC_SRXS:{{Dev:Save1}}:",
extra_args=(f"--dataset-map={json.dumps(sis_map)}",),
)

print(f"Wait for {wait} seconds...")
ttime.sleep(wait)

yield p, sis_map

p.terminate()

std_out, std_err = p.communicate()
std_out = std_out.decode()
sep = "=" * 80
print(f"STDOUT:\n{sep}\n{std_out}")
print(f"STDERR:\n{sep}\n{std_err}")


@pytest.fixture
def zebra_ophyd_device_conc_srx_zebra():
dev = ZebraWithCaprotoIOC("ZEBRA_CONC_SRXZ:{Dev:Save1}:", name="zebra_conc_srx_zebra")
dev.wait_for_connection(timeout=30)
yield dev
dev.ioc_stage.put("unstaged", timeout=10)


@pytest.fixture
def zebra_ophyd_device_conc_srx_sis():
dev = ZebraWithCaprotoIOC("ZEBRA_CONC_SRXS:{Dev:Save1}:", name="zebra_conc_srx_sis")
dev.wait_for_connection(timeout=30)
yield dev
dev.ioc_stage.put("unstaged", timeout=10)
120 changes: 120 additions & 0 deletions tests/test_zebra_ioc.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,123 @@ def test_zebra_custom_dataset_map(
assert name not in f, (
f"PV attr name '{name}' should not appear as a dataset key"
)


@pytest.mark.cloud_friendly
def test_zebra_fxi_partial_dataset_map(
zebra_caproto_ioc_fxi_map, zebra_ophyd_device_fxi_map
):
"""FXI-style partial 2-channel map produces exactly 2 HDF5 datasets."""
_proc, fxi_map = zebra_caproto_ioc_fxi_map
expected_hdf5_keys = set(fxi_map.values()) # {"enc1_pi_r", "zebra_time"}

tmpdirname = f"/tmp/srx-caproto-iocs/{str(uuid.uuid4())[:8]}"
write_dir = Path(tmpdirname)
write_dir.mkdir(parents=True, exist_ok=True)

dev = zebra_ophyd_device_fxi_map
dev.write_dir.put(str(write_dir), timeout=10)
dev.file_name.put(f"test_{uuid.uuid4().hex[:8]}.h5", timeout=10)
dev.set("stage").wait(timeout=10)
dev.set("acquire").wait(timeout=10)
dev.set("unstage").wait(timeout=10)

full_file_path = dev.full_file_path.get(timeout=10)
assert Path(full_file_path).is_file(), f"HDF5 file not found: {full_file_path}"

with h5py.File(full_file_path, "r") as f:
actual_keys = set(f.keys())
assert actual_keys == expected_hdf5_keys, (
f"Expected exactly {expected_hdf5_keys}, got {actual_keys}"
)
assert len(actual_keys) == 2, (
f"Expected exactly 2 datasets, got {len(actual_keys)}: {actual_keys}"
)


@pytest.mark.cloud_friendly
def test_three_concurrent_iocs(
zebra_caproto_ioc_conc_srx_zebra,
zebra_caproto_ioc_conc_srx_sis,
zebra_caproto_ioc_fxi_map,
zebra_ophyd_device_conc_srx_zebra,
zebra_ophyd_device_conc_srx_sis,
zebra_ophyd_device_fxi_map,
):
"""Three IOCs save data simultaneously: SRX Zebra, SRX SIS, and FXI.

Verifies that each IOC independently produces an HDF5 file with exactly
the expected dataset keys while all three processes run concurrently.
"""
_proc_sis, sis_map = zebra_caproto_ioc_conc_srx_sis
_proc_fxi, fxi_map = zebra_caproto_ioc_fxi_map

srx_zebra_dir = Path(f"/tmp/srx-caproto-iocs/{str(uuid.uuid4())[:8]}")
srx_sis_dir = Path(f"/tmp/srx-caproto-iocs/{str(uuid.uuid4())[:8]}")
fxi_dir = Path(f"/tmp/srx-caproto-iocs/{str(uuid.uuid4())[:8]}")
for d in (srx_zebra_dir, srx_sis_dir, fxi_dir):
d.mkdir(parents=True, exist_ok=True)

dev_zebra = zebra_ophyd_device_conc_srx_zebra
dev_sis = zebra_ophyd_device_conc_srx_sis
dev_fxi = zebra_ophyd_device_fxi_map

# Configure all three devices
for dev, write_dir in [
(dev_zebra, srx_zebra_dir),
(dev_sis, srx_sis_dir),
(dev_fxi, fxi_dir),
]:
dev.write_dir.put(str(write_dir), timeout=10)
dev.file_name.put(f"test_{uuid.uuid4().hex[:8]}.h5", timeout=10)

# Stage all three concurrently (kick off, then wait)
st_zebra = dev_zebra.set("stage")
st_sis = dev_sis.set("stage")
st_fxi = dev_fxi.set("stage")
st_zebra.wait(timeout=10)
st_sis.wait(timeout=10)
st_fxi.wait(timeout=10)

# Acquire all three concurrently
st_zebra = dev_zebra.set("acquire")
st_sis = dev_sis.set("acquire")
st_fxi = dev_fxi.set("acquire")
st_zebra.wait(timeout=10)
st_sis.wait(timeout=10)
st_fxi.wait(timeout=10)

# Unstage all three
dev_zebra.set("unstage").wait(timeout=10)
dev_sis.set("unstage").wait(timeout=10)
dev_fxi.set("unstage").wait(timeout=10)

# --- Verify SRX Zebra: default map → exactly enc1, enc2, enc3, zebra_time ---
fp_zebra = dev_zebra.full_file_path.get(timeout=10)
assert fp_zebra, "SRX Zebra full_file_path PV is empty"
assert Path(fp_zebra).is_file(), f"SRX Zebra HDF5 file not found: {fp_zebra}"
with h5py.File(fp_zebra, "r") as f:
assert set(f.keys()) == {"enc1", "enc2", "enc3", "zebra_time"}, (
f"SRX Zebra: unexpected datasets {set(f.keys())}"
)

# --- Verify SRX SIS: explicit scaler map → exactly i0, im, it, sis_time ---
fp_sis = dev_sis.full_file_path.get(timeout=10)
assert fp_sis, "SRX SIS full_file_path PV is empty"
assert Path(fp_sis).is_file(), f"SRX SIS HDF5 file not found: {fp_sis}"
with h5py.File(fp_sis, "r") as f:
assert set(f.keys()) == set(sis_map.values()), (
f"SRX SIS: unexpected datasets {set(f.keys())}"
)

# --- Verify FXI: partial 2-channel map → exactly enc1_pi_r, zebra_time ---
fp_fxi = dev_fxi.full_file_path.get(timeout=10)
assert fp_fxi, "FXI full_file_path PV is empty"
assert Path(fp_fxi).is_file(), f"FXI HDF5 file not found: {fp_fxi}"
with h5py.File(fp_fxi, "r") as f:
assert set(f.keys()) == set(fxi_map.values()), (
f"FXI: unexpected datasets {set(f.keys())}"
)
assert len(f.keys()) == 2, (
f"FXI: expected exactly 2 datasets, got {len(f.keys())}: {set(f.keys())}"
)
Loading