diff --git a/src/ndi/fun/epoch.py b/src/ndi/fun/epoch.py index ee6b70a7..2ec14505 100644 --- a/src/ndi/fun/epoch.py +++ b/src/ndi/fun/epoch.py @@ -84,48 +84,75 @@ def epochid2element( def filename2epochid( session: Any, - filenames: list[str], + filenames: list[str] | str, ) -> dict[str, list[str]]: - """Map filenames to their epoch IDs by searching DAQ epoch tables. + """Map filenames to the epoch IDs of the DAQ epochs whose files they name. MATLAB equivalent: ndi.fun.epoch.filename2epochid + Loads every DAQ system of the session (``session.daqsystem_load()``) and + asks each for its LIVE ``epochtable()``, as MATLAB does. A filename + matches an epoch when it is a substring of any of the epoch's underlying + files (``underlying_epochs['underlying']``; MATLAB's ``contains``). + Several filenames may name the same epoch (NDI-matlab 774a01f71). + + This used to read ``daqsystem.epoch_table`` out of the stored daq_system + documents. No document schema has that field and nothing writes one, so + the lookup came back empty for every filename ever passed in. + Args: session: An NDI session instance. - filenames: List of filename strings. + filenames: A filename or list of filename strings. Returns: - Dict mapping each filename to a list of matching epoch IDs. + Dict mapping each filename to the list of matching epoch IDs. MATLAB + returns a cell array the same size as the input holding one epoch id + per filename; the dict keyed by filename is the same information in + the shape a Python caller indexes, and keeps every match rather than + only the last. + + Warns: + UserWarning: naming any filename no epoch claims, as MATLAB does. """ - from ndi.query import ndi_query + if isinstance(filenames, str): + filenames = [filenames] - # Search for DAQ system documents - docs = session.database_search(ndi_query("").isa("daq_system")) + devs = session.daqsystem_load() + if devs is None: + devs = [] + elif not isinstance(devs, (list, tuple)): + devs = [devs] result: dict[str, list[str]] = {fn: [] for fn in filenames} - for doc in docs: - props = doc.document_properties if hasattr(doc, "document_properties") else doc - if not isinstance(props, dict): - continue - - et = props.get("daqsystem", {}).get("epoch_table", []) - if not isinstance(et, list): - et = [] - - for entry in et: + for dev in devs: + et = dev.epochtable() + if isinstance(et, tuple): + # Some epochtable() implementations return (table, hash). + et = et[0] + for entry in et or []: if not isinstance(entry, dict): continue epoch_id = entry.get("epoch_id", "") - underlying = entry.get("underlying_files", []) + underlying_epochs = entry.get("underlying_epochs") or {} + underlying = ( + underlying_epochs.get("underlying", []) + if isinstance(underlying_epochs, dict) + else [] + ) if isinstance(underlying, str): underlying = [underlying] - for uf in underlying: - if not isinstance(uf, str): - continue - for fn in filenames: - if fn in uf: - result[fn].append(epoch_id) + underlying = [str(uf) for uf in underlying or []] + for fn in filenames: + if any(fn in uf for uf in underlying) and epoch_id not in result[fn]: + result[fn].append(epoch_id) + + missing = [fn for fn in filenames if not result[fn]] + if missing: + warnings.warn( + "No file was found matching the filename(s): \n " + "\n".join(missing), + stacklevel=2, + ) return result diff --git a/src/ndi/fun/ndi_matlab_python_bridge.yaml b/src/ndi/fun/ndi_matlab_python_bridge.yaml index 903d3e39..a8ffa701 100644 --- a/src/ndi/fun/ndi_matlab_python_bridge.yaml +++ b/src/ndi/fun/ndi_matlab_python_bridge.yaml @@ -1200,11 +1200,9 @@ functions: whenever a type was given. MATLAB filters on element.type, and the filters now go to getelements as element.name and element.type. And MATLAB WARNS naming every epoch id no element claims; the port returned - empty lists in silence. THE SIBLING ENTRY filename2epochid HAS THE SAME - DEFECT and its record understates it: it reads daqsystem.epoch_table, - which is equally absent from every schema, so it too returns nothing for - every input. Left for its own change rather than edited from here, since - that entry's record is being written on another branch. Two structural + empty lists in silence. THE SIBLING ENTRY filename2epochid HAD THE SAME + DEFECT (it read daqsystem.epoch_table, equally absent from every schema) + and has since been fixed the same way; see its entry. Two structural differences that stand: MATLAB returns a cell array the same size as the input where this returns a dict keyed by epoch id, and MATLAB drives a ProgressBarWindow that has no counterpart in a headless port. @@ -1225,27 +1223,32 @@ functions: type_python: "Any" - name: filenames type_matlab: "char | cellstr (filename — can be single)" - type_python: "list[str]" + type_python: "list[str] | str" output_arguments: - name: result type_python: "dict[str, list[str]]" decision_log: > - Exact match. Maps filenames to their epoch IDs by searching - DAQ epoch tables. - - Examined against NDI-matlab fe64a9f53, the file's last-touching commit. - Two divergences, both structural rather than wrong. MATLAB returns a - cell array the same size as the filename input, one entry per filename; - the port returns a dict keyed by filename, which is the same information - in the shape a Python caller indexes. And MATLAB reads the LIVE - epochtable of each loaded daqsystem (session.daqsystem_load, then - dev{d}.epochtable) where the port reads daqsystem.epoch_table out of the - stored daq_system documents -- the same table when the documents are - current, and stale when they are not. + Maps filenames to their epoch IDs by searching the LIVE epoch tables of + the session's DAQ systems. + + Examined against NDI-matlab fe64a9f53 and 774a01f71, AND UNTIL NOW IT + COULD NOT FIND ANYTHING. It read daqsystem.epoch_table out of the stored + daq_system documents: that field appears in no document schema in + ndi_common and nothing writes one, so the lookup came back empty for + every filename ever passed in -- the same defect epochid2element had. + Now mirrors MATLAB: session.daqsystem_load(), then each system's + epochtable(), matching a filename when it is a substring of any of + underlying_epochs['underlying'] (MATLAB's contains). A single system + (not a list) and a single filename string are both accepted, as MATLAB + accepts both. MATLAB WARNS naming every filename no epoch claims, and so + does this now. One structural difference stands: MATLAB returns a cell + array the same size as the filename input holding one epoch id per + filename (the last match wins); the port returns a dict keyed by + filename whose value lists every matching epoch id (normally one). NDI-matlab 774a01f71 assigns the epoch id to every filename that names the epoch (epochid(fileInd) = {...}); it used to fail when several - filenames matched one epoch. No-op here: each filename already gets - its own list of matching epoch ids. + filenames matched one epoch. Mirrored: each filename gets its own list, + tested in tests/test_fun.py. - name: t0_t1cell2array type: function diff --git a/tests/test_fun.py b/tests/test_fun.py index 8aef58d1..3c4a10fc 100644 --- a/tests/test_fun.py +++ b/tests/test_fun.py @@ -453,25 +453,67 @@ def test_the_type_filter_reaches_getelements(self): class TestFilename2EpochId: """Tests for epoch.filename2epochid.""" + # This used to build daq_system DOCUMENTS carrying a + # `daqsystem.epoch_table` field. No schema has that field and nothing + # writes it, so the function returned nothing for real data. It now asks + # each loaded DAQ system for its live epochtable(), as MATLAB does. + + @staticmethod + def _dev(table): + dev = MagicMock() + dev.epochtable.return_value = [ + {"epoch_id": eid, "underlying_epochs": {"underlying": files}} for eid, files in table + ] + return dev + def test_basic(self): from ndi.fun.epoch import filename2epochid - doc = MagicMock() - doc.document_properties = { - "daqsystem": { - "epoch_table": [ - { - "epoch_id": "ep1", - "underlying_files": ["/data/recording_001.dat"], - } - ], - }, - } + dev = self._dev([("ep1", ["/data/recording_001.dat", "/data/recording_001.epochprobemap"])]) session = MagicMock() - session.database_search.return_value = [doc] + session.daqsystem_load.return_value = dev # a single system, not a list result = filename2epochid(session, ["recording_001.dat"]) - assert "ep1" in result["recording_001.dat"] + assert result == {"recording_001.dat": ["ep1"]} + session.daqsystem_load.assert_called_once_with() + + def test_single_string_and_several_systems(self): + from ndi.fun.epoch import filename2epochid + + session = MagicMock() + session.daqsystem_load.return_value = [ + self._dev([("ep1", ["/d/t00001/a.smr"])]), + self._dev([("ep2", ["/d/t00002/b.rhd"])]), + ] + assert filename2epochid(session, "b.rhd") == {"b.rhd": ["ep2"]} + + def test_several_filenames_name_one_epoch(self): + # NDI-matlab 774a01f71: every filename naming the epoch gets its id. + from ndi.fun.epoch import filename2epochid + + session = MagicMock() + session.daqsystem_load.return_value = [ + self._dev([("ep1", ["/d/t00001/stimtimes.txt", "/d/t00001/spike2data.smr"])]) + ] + result = filename2epochid(session, ["stimtimes.txt", "spike2data.smr"]) + assert result == {"stimtimes.txt": ["ep1"], "spike2data.smr": ["ep1"]} + + def test_not_found_warns(self): + from ndi.fun.epoch import filename2epochid + + session = MagicMock() + session.daqsystem_load.return_value = [self._dev([("ep1", ["/d/a.dat"])])] + with pytest.warns(UserWarning, match="nonexistent.dat"): + result = filename2epochid(session, ["a.dat", "nonexistent.dat"]) + assert result == {"a.dat": ["ep1"], "nonexistent.dat": []} + + def test_no_daq_systems(self): + from ndi.fun.epoch import filename2epochid + + session = MagicMock() + session.daqsystem_load.return_value = None + with pytest.warns(UserWarning): + assert filename2epochid(session, ["x"]) == {"x": []} # ===========================================================================