Skip to content

Commit b2e6f6b

Browse files
committed
Add ndi.fun.probe package with export_binary, export_all_binary, and location
Port three functions from NDI-matlab's +ndi/+fun/+probe/ package: - export_binary: exports a single probe's timeseries data to an int16 binary file - export_all_binary: batch exports all n-trode probes to kilosort-compatible binaries - location: finds probe_location documents by traversing the underlying_element tree The ndi.fun.probe subpackage is registered in ndi.fun.__init__ so that ndi.fun.probe.* is importable. https://claude.ai/code/session_01U7Zn3csCPw6VJyMNfF59dz
1 parent 6186957 commit b2e6f6b

4 files changed

Lines changed: 270 additions & 1 deletion

File tree

src/ndi/fun/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
44
MATLAB equivalent: +ndi/+fun/
55
6-
Provides document, epoch, file, data, stimulus, session, and dataset utilities.
6+
Provides document, epoch, file, data, stimulus, session, dataset,
7+
and probe utilities.
78
"""
89

910
from __future__ import annotations
1011

12+
from . import probe # noqa: F401 — make ndi.fun.probe accessible
1113
from .utils import (
1214
channelname2prefixnumber,
1315
name2variable_name,
@@ -18,6 +20,7 @@
1820
__all__ = [
1921
"channelname2prefixnumber",
2022
"name2variable_name",
23+
"probe",
2124
"pseudorandomint",
2225
"timestamp",
2326
]

src/ndi/fun/probe/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""
2+
ndi.fun.probe - Probe utility functions.
3+
4+
MATLAB equivalent: +ndi/+fun/+probe/
5+
6+
Provides utility functions for exporting probe data and finding
7+
probe location documents.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from .export_binary import export_all_binary, export_binary
13+
from .location import location
14+
15+
__all__ = [
16+
"export_all_binary",
17+
"export_binary",
18+
"location",
19+
]

src/ndi/fun/probe/export_binary.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""
2+
ndi.fun.probe.export_binary - Export probe data to binary files.
3+
4+
MATLAB equivalents:
5+
+ndi/+fun/+probe/export_binary.m
6+
+ndi/+fun/+probe/export_all_binary.m
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import struct
12+
from pathlib import Path
13+
from typing import Any
14+
15+
import numpy as np
16+
17+
18+
def export_binary(
19+
probe: Any,
20+
outputfile: str | Path,
21+
*,
22+
multiplier: float = 1.0,
23+
verbose: bool = True,
24+
precision: str = "int16",
25+
) -> None:
26+
"""Export data from a probe to a binary file.
27+
28+
MATLAB equivalent: ndi.fun.probe.export_binary
29+
30+
Exports data from *probe* (an :class:`ndi.element.Element` or
31+
:class:`ndi.probe.Probe` of type ``n-trode``) to a binary file.
32+
Before converting to the output precision the data are scaled by
33+
*multiplier*. A text metadata file is created alongside *outputfile*
34+
with the extension ``.metadata``.
35+
36+
Args:
37+
probe: An NDI probe/element object with ``epochtable``,
38+
``times2samples``, ``readtimeseries``, ``samplerate``, and
39+
``elementstring`` methods.
40+
outputfile: Path for the output binary file.
41+
multiplier: Scaling factor applied to data before conversion.
42+
verbose: If ``True``, print progress messages.
43+
precision: NumPy-compatible dtype string for the output
44+
(default ``'int16'``).
45+
"""
46+
outputfile = Path(outputfile)
47+
metafile = outputfile.with_suffix(outputfile.suffix + ".metadata")
48+
49+
et = probe.epochtable()
50+
if isinstance(et, tuple):
51+
et = et[0]
52+
53+
dtype = np.dtype(precision)
54+
chunk_duration = 100 # seconds
55+
56+
epoch_sample_counts: list[int] = []
57+
epoch_sample_rates: list[float] = []
58+
num_channels = 0
59+
60+
with open(outputfile, "wb") as fid:
61+
for e_idx, entry in enumerate(et):
62+
epoch_id = entry.get("epoch_id", e_idx + 1)
63+
if verbose:
64+
print(
65+
f"Processing epoch {e_idx + 1} of {len(et)}."
66+
)
67+
68+
t0_t1 = entry.get("t0_t1", [])
69+
if isinstance(t0_t1, list) and len(t0_t1) > 0:
70+
t0_t1_pair = t0_t1[0]
71+
else:
72+
t0_t1_pair = t0_t1
73+
74+
if isinstance(t0_t1_pair, (list, tuple, np.ndarray)) and len(t0_t1_pair) >= 2:
75+
t_start = float(t0_t1_pair[0])
76+
t_end = float(t0_t1_pair[1])
77+
else:
78+
continue
79+
80+
samples = probe.times2samples(epoch_id, np.array([t_start, t_end]))
81+
sample_count = int(samples[1] - samples[0] + 1)
82+
epoch_sample_counts.append(sample_count)
83+
84+
sr = probe.samplerate(epoch_id)
85+
epoch_sample_rates.append(float(sr))
86+
single_sample_time = 1.0 / sr if sr > 0 else 0.0
87+
88+
chunk_starts = np.arange(t_start, t_end, chunk_duration)
89+
for c_idx, cs in enumerate(chunk_starts):
90+
if verbose:
91+
print(
92+
f" Processing epoch {e_idx + 1}, "
93+
f"chunk {c_idx + 1} of {len(chunk_starts)}."
94+
)
95+
start_time = float(cs)
96+
end_time = min(cs + chunk_duration - single_sample_time, t_end)
97+
98+
data, _t, _tr = probe.readtimeseries(
99+
epoch=epoch_id, t0=start_time, t1=end_time
100+
)
101+
if data is None or len(data) == 0:
102+
continue
103+
104+
num_channels = data.shape[1] if data.ndim == 2 else 1
105+
106+
# Scale and convert — write channel-interleaved (transposed)
107+
scaled = (multiplier * data).T
108+
out = scaled.astype(dtype)
109+
fid.write(out.tobytes())
110+
111+
# Write metadata file
112+
probe_name = probe.elementstring()
113+
with open(metafile, "w") as mf:
114+
mf.write(f"epoch_sample_counts: {epoch_sample_counts}\n")
115+
mf.write(f"epoch_sample_rates: {epoch_sample_rates}\n")
116+
mf.write(f"multiplier: {multiplier}\n")
117+
mf.write(f"num_channels: {num_channels}\n")
118+
mf.write(f"probe_name: {probe_name}\n")
119+
120+
121+
def export_all_binary(
122+
session: Any,
123+
*,
124+
kilosort_dir: str = "kilosort",
125+
verbose: bool = True,
126+
multiplier: float = 1 / 0.195,
127+
) -> None:
128+
"""Export all n-trode probes in a session to binary files.
129+
130+
MATLAB equivalent: ndi.fun.probe.export_all_binary
131+
132+
Creates a *kilosort_dir* directory inside the session path. For each
133+
probe of type ``n-trode``, a subdirectory named after the probe's
134+
element string is created and a ``kilosort.bin`` file is written using
135+
:func:`export_binary`.
136+
137+
Args:
138+
session: An NDI session object (must have ``path`` and
139+
``getprobes`` attributes).
140+
kilosort_dir: Name of the output subdirectory (default
141+
``'kilosort'``).
142+
verbose: If ``True``, print progress messages.
143+
multiplier: Scaling factor (default ``1/0.195``, assumes Intan
144+
data).
145+
"""
146+
if verbose:
147+
print(f"About to look for probes in {session.reference}")
148+
149+
probe_list = session.getprobes(type="n-trode")
150+
151+
if verbose:
152+
print(f"Found {len(probe_list)} probe(s) of type 'n-trode'.")
153+
154+
kilosort_path = Path(session.path) / kilosort_dir
155+
kilosort_path.mkdir(parents=True, exist_ok=True)
156+
157+
for probe in probe_list:
158+
elestr = probe.elementstring()
159+
if verbose:
160+
print(f"Now working on probe {elestr}.")
161+
162+
# Replace spaces with underscores for directory name
163+
safe_name = elestr.replace(" ", "_")
164+
this_path = kilosort_path / safe_name
165+
this_path.mkdir(parents=True, exist_ok=True)
166+
167+
outfile = this_path / "kilosort.bin"
168+
export_binary(
169+
probe,
170+
outfile,
171+
multiplier=multiplier,
172+
verbose=verbose,
173+
)
174+
175+
if verbose:
176+
print(f"Done processing {session.reference}")

src/ndi/fun/probe/location.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""
2+
ndi.fun.probe.location - Find probe location documents for an element.
3+
4+
MATLAB equivalent: +ndi/+fun/+probe/location.m
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from typing import Any
10+
11+
12+
def location(
13+
session: Any,
14+
element: Any | str,
15+
) -> tuple[list[Any], Any | None]:
16+
"""Find probe location documents and probe object for an NDI element.
17+
18+
MATLAB equivalent: ndi.fun.probe.location
19+
20+
Given an NDI element *element*, traverse down the ``underlying_element``
21+
dependency tree until an :class:`ndi.probe.Probe` object is found, then
22+
return all ``probe_location`` documents associated with that probe.
23+
24+
Args:
25+
session: An NDI session or dataset object.
26+
element: An :class:`ndi.element.Element` object **or** the string
27+
identifier of an element.
28+
29+
Returns:
30+
Tuple of ``(probe_locations, probe_obj)`` where *probe_locations*
31+
is a list of probe-location documents and *probe_obj* is the
32+
:class:`ndi.probe.Probe` found (or ``None`` if none was found).
33+
"""
34+
from ndi.database_fun import ndi_document2ndi_object
35+
from ndi.probe import Probe
36+
from ndi.query import Query
37+
38+
# Step 1: resolve string identifier to an element object
39+
if isinstance(element, str):
40+
docs = session.database_search(
41+
Query("base.id", "exact_string", element, "")
42+
)
43+
if not docs:
44+
raise ValueError(f"Could not find an element with id '{element}'.")
45+
element = ndi_document2ndi_object(docs[0], session)
46+
47+
# Step 2: traverse down to the probe
48+
current = element
49+
while not isinstance(current, Probe):
50+
underlying = getattr(current, "underlying_element", None)
51+
if underlying is None:
52+
break
53+
if callable(underlying) and not isinstance(underlying, property):
54+
underlying = underlying()
55+
current = underlying
56+
57+
probe_obj: Any | None = current if isinstance(current, Probe) else None
58+
59+
if probe_obj is None:
60+
return [], None
61+
62+
# Step 3: get probe identifier
63+
probe_id = probe_obj.id
64+
if callable(probe_id):
65+
probe_id = probe_id()
66+
67+
# Step 4: query for probe_location documents
68+
q = Query("", "depends_on", "probe_id", probe_id) & Query("", "isa", "probe_location")
69+
probe_locations = session.database_search(q)
70+
71+
return probe_locations, probe_obj

0 commit comments

Comments
 (0)