diff --git a/src/srx_caproto_iocs/zebra/caproto_ioc.py b/src/srx_caproto_iocs/zebra/caproto_ioc.py index e875dfe..ec23481 100644 --- a/src/srx_caproto_iocs/zebra/caproto_ioc.py +++ b/src/srx_caproto_iocs/zebra/caproto_ioc.py @@ -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, ) @@ -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", }, } @@ -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)." ), diff --git a/src/srx_caproto_iocs/zebra/ophyd.py b/src/srx_caproto_iocs/zebra/ophyd.py index c1367ee..c2b3c97 100644 --- a/src/srx_caproto_iocs/zebra/ophyd.py +++ b/src/srx_caproto_iocs/zebra/ophyd.py @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py index c3a0024..1f6155d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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}}:", @@ -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") @@ -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) diff --git a/tests/test_zebra_ioc.py b/tests/test_zebra_ioc.py index bcdac8d..953e1ae 100644 --- a/tests/test_zebra_ioc.py +++ b/tests/test_zebra_ioc.py @@ -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())}" + )