Skip to content

Commit 44cd884

Browse files
Merge pull request #43 from Waltham-Data-Science/claude/add-symmetry-tests-7IWoH
Add symmetry tests for downloaded ingested datasets
2 parents 0e4cc6e + 376a304 commit 44cd884

41 files changed

Lines changed: 98079 additions & 170 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test-symmetry.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ jobs:
6060
python -m pip install --upgrade pip
6161
python ndi_install.py --dev --no-validate --verbose
6262
63+
# ── Download test datasets ────────────────────────────────────────
64+
- name: Download test datasets
65+
run: |
66+
curl -L -o /tmp/69a8705aa9ab25373cdc6563.tgz \
67+
https://github.com/Waltham-Data-Science/file-passing/raw/refs/heads/main/69a8705aa9ab25373cdc6563.tgz
68+
6369
# ── Stage 1: MATLAB makeArtifacts ──────────────────────────────────
6470
- name: "Stage 1: MATLAB makeArtifacts"
6571
uses: matlab-actions/run-command@v2

examples/integration_demo.py

Lines changed: 34 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
# Cofounder's compression library
2424
try:
2525
import ndicompress
26+
2627
HAS_COMPRESS = True
2728
except ImportError:
2829
HAS_COMPRESS = False
@@ -60,7 +61,7 @@ def demo_full_workflow():
6061
spike_times = np.random.choice(num_samples, size=20, replace=False)
6162
for st in spike_times:
6263
if st + 30 < num_samples:
63-
data[st:st+30, ch] += np.sin(np.linspace(0, np.pi, 30)) * 500
64+
data[st : st + 30, ch] += np.sin(np.linspace(0, np.pi, 30)) * 500
6465

6566
data = data.astype(np.int16) # Convert to int16 for ephys
6667
print(f" Data shape: {data.shape}")
@@ -90,40 +91,30 @@ def demo_full_workflow():
9091
# In real usage, we'd load from JSON schema
9192
# For demo, we create a simple document structure
9293
doc_props = {
93-
'base': {
94-
'id': Ido().id,
95-
'datestamp': timestamp(),
96-
'name': 'ephys_recording_001',
97-
'session_id': ''
98-
},
99-
'document_class': {
100-
'class_name': 'ndi_document_ephys',
101-
'superclasses': []
94+
"base": {
95+
"id": Ido().id,
96+
"datestamp": timestamp(),
97+
"name": "ephys_recording_001",
98+
"session_id": "",
10299
},
103-
'ephys': {
104-
'num_channels': num_channels,
105-
'sample_rate': sample_rate,
106-
'num_samples': num_samples,
107-
'duration_seconds': num_samples / sample_rate,
108-
'data_type': 'int16',
109-
'compression': 'ndi_compress_ephys'
100+
"document_class": {"class_name": "ndi_document_ephys", "superclasses": []},
101+
"ephys": {
102+
"num_channels": num_channels,
103+
"sample_rate": sample_rate,
104+
"num_samples": num_samples,
105+
"duration_seconds": num_samples / sample_rate,
106+
"data_type": "int16",
107+
"compression": "ndi_compress_ephys",
110108
},
111-
'files': {
112-
'file_list': ['ephys_data.nbf_#'],
113-
'file_info': []
114-
}
109+
"files": {"file_list": ["ephys_data.nbf_#"], "file_info": []},
115110
}
116111

117112
doc = Document(doc_props)
118113

119114
# === Step 4: Link compressed file to document ===
120115
print("\n[4] Linking compressed file to document...")
121116

122-
doc = doc.add_file(
123-
name='ephys_data.nbf_1',
124-
location=compressed_file,
125-
ingest=True
126-
)
117+
doc = doc.add_file(name="ephys_data.nbf_1", location=compressed_file, ingest=True)
127118

128119
print(f" Document ID: {doc.id}")
129120
print(f" Document class: {doc.doc_class()}")
@@ -133,9 +124,9 @@ def demo_full_workflow():
133124
print("\n[5] Query demonstration...")
134125

135126
# These queries would work with ndi.database
136-
q1 = Query('ephys.num_channels') == 4
137-
q2 = Query('ephys.sample_rate') > 20000
138-
q3 = Query('base.name').contains('ephys')
127+
q1 = Query("ephys.num_channels") == 4
128+
q2 = Query("ephys.sample_rate") > 20000
129+
q3 = Query("base.name").contains("ephys")
139130

140131
# Combined query
141132
_q_combined = q1 & q2 & q3
@@ -166,9 +157,9 @@ def demo_full_workflow():
166157
# Still show document creation without compression
167158
print("\n[3] Creating ndi.Document (without compression)...")
168159

169-
doc = Document('base')
170-
doc = doc.set_session_id('demo_session')
171-
doc = doc.setproperties(**{'base.name': 'ephys_demo'})
160+
doc = Document("base")
161+
doc = doc.set_session_id("demo_session")
162+
doc = doc.setproperties(**{"base.name": "ephys_demo"})
172163

173164
print(f" Document ID: {doc.id}")
174165
print(f" Session ID: {doc.session_id}")
@@ -214,17 +205,19 @@ def demo_document_features():
214205
print("=" * 60)
215206

216207
# Create a document
217-
doc = Document('base')
208+
doc = Document("base")
218209
print(f"\n1. Created document with ID: {doc.id}")
219210

220211
# Set session
221-
doc = doc.set_session_id('session_abc123')
212+
doc = doc.set_session_id("session_abc123")
222213
print(f"2. Set session ID: {doc.session_id}")
223214

224215
# Bulk property setting (useful for ephys metadata)
225-
doc = doc.setproperties(**{
226-
'base.name': 'neural_recording',
227-
})
216+
doc = doc.setproperties(
217+
**{
218+
"base.name": "neural_recording",
219+
}
220+
)
228221
print("3. Set name via setproperties")
229222

230223
# Document equality (by ID)
@@ -233,9 +226,9 @@ def demo_document_features():
233226

234227
# Static methods for working with document arrays
235228
docs = [
236-
Document('base'),
237-
Document('base'),
238-
Document('base'),
229+
Document("base"),
230+
Document("base"),
231+
Document("base"),
239232
]
240233

241234
newest, idx, ts = Document.find_newest(docs)
@@ -251,6 +244,6 @@ def demo_document_features():
251244
print("\nAll features work and are ready to integrate with ndicompress!")
252245

253246

254-
if __name__ == '__main__':
247+
if __name__ == "__main__":
255248
demo_full_workflow()
256249
demo_document_features()

pythonArtifacts.tar.gz

6.97 MB
Binary file not shown.

src/ndi/daq/metadatareader/__init__.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,13 @@ def __init__(
6060

6161
# Load from document if provided
6262
if document is not None:
63-
doc_props = getattr(document, "document_properties", document)
64-
if hasattr(doc_props, "base") and hasattr(doc_props.base, "id"):
65-
self._id = doc_props.base.id
66-
if hasattr(doc_props, "daqmetadatareader"):
67-
self._tab_separated_file_parameter = getattr(
68-
doc_props.daqmetadatareader, "tab_separated_file_parameter", ""
69-
)
63+
doc_props = document.document_properties
64+
base_id = doc_props.get("base", {}).get("id")
65+
if base_id:
66+
self._id = base_id
67+
dmr = doc_props.get("daqmetadatareader", {})
68+
if dmr:
69+
self._tab_separated_file_parameter = dmr.get("tab_separated_file_parameter", "")
7070

7171
@property
7272
def tab_separated_file_parameter(self) -> str:

src/ndi/daq/mfdaq.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -568,7 +568,7 @@ def getchannelsepoch_ingested(
568568
See also: getchannelsepoch
569569
"""
570570
doc = self.getingesteddocument(epochfiles, session)
571-
et = doc.document_properties.daqreader_epochdata_ingested.epochtable
571+
et = doc.document_properties["daqreader_epochdata_ingested"]["epochtable"]
572572

573573
channels_raw = et.get("channels", [])
574574
channels = []
@@ -618,7 +618,7 @@ def readchannels_epochsamples_ingested(
618618
See also: readchannels_epochsamples
619619
"""
620620
doc = self.getingesteddocument(epochfiles, session)
621-
et = doc.document_properties.daqreader_epochdata_ingested.epochtable
621+
et = doc.document_properties["daqreader_epochdata_ingested"]["epochtable"]
622622

623623
# Normalize inputs
624624
if isinstance(channel, int):

src/ndi/daq/reader_base.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,10 @@ def __init__(
5656

5757
# Load from document if provided
5858
if document is not None:
59-
doc_props = getattr(document, "document_properties", document)
60-
if hasattr(doc_props, "base") and hasattr(doc_props.base, "id"):
61-
self._id = doc_props.base.id
59+
doc_props = document.document_properties
60+
base_id = doc_props.get("base", {}).get("id")
61+
if base_id:
62+
self._id = base_id
6263

6364
def epochclock(
6465
self,
@@ -160,8 +161,8 @@ def ingested2epochs_t0t1_epochclock(
160161

161162
for doc in d_ingested:
162163
props = doc.document_properties
163-
epochid = props.epochid.epochid
164-
et = props.daqreader_epochdata_ingested.epochtable
164+
epochid = props["epochid"]["epochid"]
165+
et = props["daqreader_epochdata_ingested"]["epochtable"]
165166

166167
# Extract epoch clock
167168
ec_list = []
@@ -201,7 +202,8 @@ def epochclock_ingested(
201202
See also: epochclock, ndi_time_clocktype
202203
"""
203204
doc = self.getingesteddocument(epochfiles, session)
204-
et = doc.document_properties.daqreader_epochdata_ingested.epochtable
205+
props = doc.document_properties
206+
et = props["daqreader_epochdata_ingested"]["epochtable"]
205207

206208
ec_list = []
207209
for ec_str in et.get("epochclock", []):
@@ -230,7 +232,8 @@ def t0_t1_ingested(
230232
See also: t0_t1, epochclock_ingested
231233
"""
232234
doc = self.getingesteddocument(epochfiles, session)
233-
et = doc.document_properties.daqreader_epochdata_ingested.epochtable
235+
props = doc.document_properties
236+
et = props["daqreader_epochdata_ingested"]["epochtable"]
234237

235238
t0t1_raw = et.get("t0_t1", [])
236239
if not isinstance(t0t1_raw, list):
@@ -288,9 +291,24 @@ def ingest_epochfiles(
288291
ec_strings = [c.value if isinstance(c, ndi_time_clocktype) else str(c) for c in ec]
289292
t0t1 = self.t0_t1(epochfiles)
290293

294+
# Convert NaN values to None so json.dumps produces null instead of
295+
# non-standard NaN that MATLAB's jsondecode cannot parse.
296+
import math
297+
298+
sanitized_t0t1 = []
299+
for pair in t0t1:
300+
if isinstance(pair, (list, tuple)):
301+
sanitized_t0t1.append(
302+
[None if (isinstance(v, float) and math.isnan(v)) else v for v in pair]
303+
)
304+
else:
305+
sanitized_t0t1.append(
306+
None if (isinstance(pair, float) and math.isnan(pair)) else pair
307+
)
308+
291309
epochtable = {
292310
"epochclock": ec_strings,
293-
"t0_t1": t0t1,
311+
"t0_t1": sanitized_t0t1,
294312
}
295313

296314
doc = ndi_document(

src/ndi/daq/system.py

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,17 @@ def _serialize_t0_t1(t0_t1: Any) -> list:
4242
return t0_t1
4343

4444

45+
def _serialize_single_epochprobemap(epm: Any) -> dict[str, Any]:
46+
"""Convert a single epochprobemap object to a JSON-compatible dict."""
47+
if isinstance(epm, dict):
48+
return epm
49+
if hasattr(epm, "to_dict"):
50+
return epm.to_dict()
51+
if hasattr(epm, "__dict__"):
52+
return {k: v for k, v in epm.__dict__.items() if not k.startswith("_")}
53+
return epm
54+
55+
4556
def _serialize_epochnode(node: dict[str, Any]) -> None:
4657
"""Normalize epoch node dict in-place to MATLAB-compatible JSON format."""
4758
# epoch_clock: list of ClockType -> single dict (MATLAB unwraps single)
@@ -68,12 +79,12 @@ def _serialize_epochnode(node: dict[str, Any]) -> None:
6879
if isinstance(ue_t, list):
6980
ue["t0_t1"] = [_serialize_t0_t1(t) for t in ue_t]
7081

71-
# epochprobemap: if it's a list, handle; if it has a to_dict method, use it
82+
# epochprobemap: serialize to JSON-compatible format
7283
epm = node.get("epochprobemap")
73-
if epm is not None and hasattr(epm, "to_dict"):
74-
node["epochprobemap"] = epm.to_dict()
75-
elif epm is not None and hasattr(epm, "__dict__") and not isinstance(epm, dict):
76-
node["epochprobemap"] = {k: v for k, v in epm.__dict__.items() if not k.startswith("_")}
84+
if isinstance(epm, list):
85+
node["epochprobemap"] = [_serialize_single_epochprobemap(item) for item in epm]
86+
elif epm is not None and not isinstance(epm, (dict, str)):
87+
node["epochprobemap"] = _serialize_single_epochprobemap(epm)
7788

7889

7990
class ndi_daq_system(ndi_ido):
@@ -460,8 +471,10 @@ def getprobes(self) -> list[dict[str, Any]]:
460471
- name: ndi_probe name
461472
- reference: ndi_probe reference
462473
- type: ndi_probe type
463-
- subject_id: ndi_subject identifier
474+
- subject_id: ndi_subject document ID
464475
"""
476+
from ..subject import ndi_subject
477+
465478
et = self.epochtable()
466479
probes = []
467480
seen = set()
@@ -481,12 +494,24 @@ def getprobes(self) -> list[dict[str, Any]]:
481494
key = (item.name, item.reference, item.type)
482495
if key not in seen:
483496
seen.add(key)
497+
# Look up the subject document ID from the
498+
# subjectstring (e.g. "anteater27@nosuchlab.org").
499+
# MATLAB does the same lookup and stores the
500+
# document ID, not the local_identifier string.
501+
subjectstring = getattr(item, "subjectstring", "")
502+
sid = ""
503+
if subjectstring and self.session is not None:
504+
_, doc_id = ndi_subject.does_subjectstring_match_session_document(
505+
self.session, subjectstring
506+
)
507+
if doc_id is not None:
508+
sid = doc_id
484509
probes.append(
485510
{
486511
"name": item.name,
487512
"reference": item.reference,
488513
"type": item.type,
489-
"subject_id": getattr(item, "subjectstring", ""),
514+
"subject_id": sid,
490515
}
491516
)
492517

@@ -600,7 +625,7 @@ def ingest(self) -> tuple[bool, list[Any]]:
600625

601626
# Set session IDs and add to database
602627
if self.session is not None:
603-
session_id = self.session.id
628+
session_id = self.session.id()
604629
for doc in docs:
605630
doc.set_session_id(session_id)
606631
self.session.database_add(docs)
@@ -746,7 +771,7 @@ def newdocument(self) -> list[Any]:
746771
)
747772

748773
if self.session is not None:
749-
sys_doc.set_session_id(self.session.id)
774+
sys_doc.set_session_id(self.session.id())
750775

751776
if self._filenavigator is not None:
752777
sys_doc.set_dependency_value("filenavigator_id", self._filenavigator.id)
@@ -775,7 +800,7 @@ def searchquery(self) -> Any:
775800
if self._name:
776801
q = q & (ndi_query("base.name") == self._name)
777802
if self.session is not None:
778-
q = q & (ndi_query("base.session_id") == self.session.id)
803+
q = q & (ndi_query("base.session_id") == self.session.id())
779804

780805
return q
781806

0 commit comments

Comments
 (0)