Skip to content
Merged
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
75 changes: 51 additions & 24 deletions src/ndi/fun/epoch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 23 additions & 20 deletions src/ndi/fun/ndi_matlab_python_bridge.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
68 changes: 55 additions & 13 deletions tests/test_fun.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": []}


# ===========================================================================
Expand Down
Loading