Skip to content

Commit 58dac7d

Browse files
authored
Merge pull request #1786 from h-mayorquin/fix_neuralynx_streams
Separate signals on neuralynx and improve streams names
2 parents c7c90f6 + 15ce1f6 commit 58dac7d

File tree

3 files changed

+194
-19
lines changed

3 files changed

+194
-19
lines changed

neo/rawio/neuralynxrawio/neuralynxrawio.py

Lines changed: 85 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@
6363
from neo.rawio.neuralynxrawio.ncssections import NcsSection, NcsSectionsFactory
6464
from neo.rawio.neuralynxrawio.nlxheader import NlxHeader
6565

66+
# Named tuple for stream identification keys
67+
StreamKey = namedtuple('StreamKey', ['sampling_rate', 'input_range', 'filter_params'])
68+
6669

6770
class NeuralynxRawIO(BaseRawIO):
6871
"""
@@ -134,6 +137,20 @@ class NeuralynxRawIO(BaseRawIO):
134137
("samples", "int16", NcsSection._RECORD_SIZE),
135138
]
136139

140+
# Filter parameter keys used for stream differentiation
141+
_filter_keys = [
142+
"DSPLowCutFilterEnabled",
143+
"DspLowCutFrequency",
144+
"DspLowCutFilterType",
145+
"DspLowCutNumTaps",
146+
"DSPHighCutFilterEnabled",
147+
"DspHighCutFrequency",
148+
"DspHighCutFilterType",
149+
"DspHighCutNumTaps",
150+
"DspDelayCompensation",
151+
"DspFilterDelay_µs",
152+
]
153+
137154
def __init__(
138155
self,
139156
dirname="",
@@ -268,26 +285,49 @@ def _parse_header(self):
268285

269286
chan_uid = (chan_name, str(chan_id))
270287
if ext == "ncs":
271-
file_mmap = self._get_file_map(filename)
272-
n_packets = copy.copy(file_mmap.shape[0])
273-
if n_packets:
274-
t_start = copy.copy(file_mmap[0][0])
275-
else: # empty file
276-
t_start = 0
277-
stream_prop = (float(info["sampling_rate"]), int(n_packets), float(t_start))
278-
if stream_prop not in stream_props:
279-
stream_props[stream_prop] = {"stream_id": len(stream_props), "filenames": [filename]}
288+
# Calculate gain for this channel
289+
gain = info["bit_to_microVolt"][idx]
290+
if info.get("input_inverted", False):
291+
gain *= -1
292+
293+
# Build stream key from acquisition parameters
294+
sampling_rate = float(info["sampling_rate"])
295+
296+
# Get InputRange for this specific channel
297+
# Normalized by NlxHeader to always be a list
298+
input_range = info.get("InputRange")
299+
if isinstance(input_range, list):
300+
input_range = input_range[idx] if idx < len(input_range) else input_range[0]
301+
302+
# Build filter parameters tuple
303+
filter_params = []
304+
for key in self._filter_keys:
305+
if key in info:
306+
filter_params.append((key, info[key]))
307+
308+
# Create stream key (channels with same key go in same stream)
309+
stream_key = StreamKey(
310+
sampling_rate=sampling_rate,
311+
input_range=input_range,
312+
filter_params=tuple(sorted(filter_params)),
313+
)
314+
315+
if stream_key not in stream_props:
316+
stream_props[stream_key] = {
317+
"stream_id": len(stream_props),
318+
"filenames": [filename],
319+
"channels": set(),
320+
}
280321
else:
281-
stream_props[stream_prop]["filenames"].append(filename)
282-
stream_id = stream_props[stream_prop]["stream_id"]
322+
stream_props[stream_key]["filenames"].append(filename)
323+
324+
stream_id = stream_props[stream_key]["stream_id"]
325+
stream_props[stream_key]["channels"].add((chan_name, str(chan_id)))
283326
# @zach @ramon : we need to discuss this split by channel buffer
284327
buffer_id = ""
285328

286329
# a sampled signal channel
287330
units = "uV"
288-
gain = info["bit_to_microVolt"][idx]
289-
if info.get("input_inverted", False):
290-
gain *= -1
291331
offset = 0.0
292332
signal_channels.append(
293333
(
@@ -392,14 +432,40 @@ def _parse_header(self):
392432
event_channels = np.array(event_channels, dtype=_event_channel_dtype)
393433

394434
if signal_channels.size > 0:
395-
# ordering streams according from high to low sampling rates
396-
stream_props = {k: stream_props[k] for k in sorted(stream_props, reverse=True)}
397-
stream_names = [f"Stream (rate,#packet,t0): {sp}" for sp in stream_props]
398-
stream_ids = [stream_prop["stream_id"] for stream_prop in stream_props.values()]
399-
buffer_ids = ["" for sp in stream_props]
435+
# Build DSP filter configuration registry: filter_id -> filter parameters dict
436+
# Extract unique filter configurations from stream keys
437+
unique_filter_tuples = {stream_key.filter_params for stream_key in stream_props.keys()}
438+
_dsp_filter_configurations = {i: dict(filt) for i, filt in enumerate(sorted(unique_filter_tuples))}
439+
440+
# Create reverse mapping for looking up filter IDs during stream name construction
441+
seen_filters = {filt: i for i, filt in enumerate(sorted(unique_filter_tuples))}
442+
443+
# Store DSP filter configurations as private instance attribute
444+
# Keeping private for now - may expose via annotations or public API in future
445+
self._dsp_filter_configurations = _dsp_filter_configurations
446+
447+
# Order streams by sampling rate (high to low)
448+
# This is to keep some semblance of stability
449+
ordered_stream_keys = sorted(stream_props.keys(), reverse=True, key=lambda x: x.sampling_rate)
450+
451+
stream_names = []
452+
stream_ids = []
453+
buffer_ids = []
454+
455+
for stream_id, stream_key in enumerate(ordered_stream_keys):
456+
# Format stream name using namedtuple fields
457+
dsp_filter_id = seen_filters[stream_key.filter_params]
458+
voltage_mv = int(stream_key.input_range / 1000) if stream_key.input_range is not None else 0
459+
stream_name = f"stream{stream_id}_{int(stream_key.sampling_rate)}Hz_{voltage_mv}mVRange_DSPFilter{dsp_filter_id}"
460+
461+
stream_names.append(stream_name)
462+
stream_ids.append(str(stream_id))
463+
buffer_ids.append("")
464+
400465
signal_streams = list(zip(stream_names, stream_ids, buffer_ids))
401466
else:
402467
signal_streams = []
468+
self._filter_configurations = {}
403469
signal_buffers = np.array([], dtype=_signal_buffer_dtype)
404470
signal_streams = np.array(signal_streams, dtype=_signal_stream_dtype)
405471

neo/rawio/neuralynxrawio/nlxheader.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,9 @@ def __init__(self, filename, props_only=False):
199199
if not props_only:
200200
self._setTimeDate(txt_header)
201201

202+
# Normalize all types to proper Python types
203+
self._normalize_types()
204+
202205
@staticmethod
203206
def get_text_header(filename):
204207
"""
@@ -349,6 +352,70 @@ def _setTimeDate(self, txt_header):
349352
dt2 = sr.groupdict()
350353
self["recording_closed"] = dateutil.parser.parse(f"{dt2['date']} {dt2['time']}")
351354

355+
def _normalize_types(self):
356+
"""
357+
Convert all header values to proper Python types.
358+
359+
This ensures that:
360+
- Boolean strings ('True', 'False', 'Enabled', 'Disabled') become Python bools
361+
- Numeric strings ('0.1', '8000') become Python floats/ints
362+
- Single-element lists are extracted to scalars (for single-channel files)
363+
364+
This normalization makes the header values directly usable for
365+
stream identification without additional conversion in NeuralynxRawIO.
366+
"""
367+
368+
# Convert boolean strings to actual booleans
369+
bool_keys = [
370+
'DSPLowCutFilterEnabled',
371+
'DSPHighCutFilterEnabled',
372+
'DspDelayCompensation',
373+
]
374+
375+
for key in bool_keys:
376+
if key in self and isinstance(self[key], str):
377+
if self[key] in ('True', 'Enabled'):
378+
self[key] = True
379+
elif self[key] in ('False', 'Disabled'):
380+
self[key] = False
381+
382+
# Convert numeric strings to numbers
383+
numeric_keys = [
384+
'DspLowCutFrequency',
385+
'DspHighCutFrequency',
386+
'DspLowCutNumTaps',
387+
'DspHighCutNumTaps',
388+
]
389+
390+
for key in numeric_keys:
391+
if key in self and isinstance(self[key], str):
392+
try:
393+
# Try int first
394+
if '.' not in self[key]:
395+
self[key] = int(self[key])
396+
else:
397+
self[key] = float(self[key])
398+
except ValueError:
399+
# Keep as string if conversion fails
400+
pass
401+
402+
# Handle DspFilterDelay_µs (could be string or already converted)
403+
delay_key = 'DspFilterDelay_µs'
404+
if delay_key in self and isinstance(self[delay_key], str):
405+
try:
406+
self[delay_key] = int(self[delay_key])
407+
except ValueError:
408+
pass
409+
410+
# Extract single-channel InputRange from list
411+
# For multi-channel files, keep as list
412+
# For single-channel files, extract the single value
413+
if 'InputRange' in self and isinstance(self['InputRange'], list):
414+
if len(self['InputRange']) == 1:
415+
# Single channel file: extract the value
416+
self['InputRange'] = self['InputRange'][0]
417+
# else: multi-channel, keep as list
418+
352419
def type_of_recording(self):
353420
"""
354421
Determines type of recording in Ncs file with this header.

neo/test/rawiotest/test_neuralynxrawio.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,48 @@ def test_directory_in_data_folder(self):
213213
self.assertEqual(len(rawio.header["spike_channels"]), 8)
214214
self.assertEqual(len(rawio.header["event_channels"]), 2)
215215

216+
def test_two_streams_different_header_encoding(self):
217+
"""
218+
Test that streams are correctly differentiated based on filter parameters.
219+
This dataset contains eye-tracking and ephys channels with different filter settings.
220+
"""
221+
from pathlib import Path
222+
223+
# Get the path using the same machinery as other tests
224+
dname = self.get_local_path("neuralynx/two_streams_different_header_encoding")
225+
226+
# Test with Path object (as shown in user's notebook)
227+
rawio = NeuralynxRawIO(dirname=Path(dname))
228+
rawio.parse_header()
229+
230+
# Should have 2 streams due to different filter configurations
231+
self.assertEqual(rawio.signal_streams_count(), 2)
232+
233+
# Check stream names follow the new naming convention
234+
stream_names = [rawio.header["signal_streams"][i][0] for i in range(rawio.signal_streams_count())]
235+
236+
# Stream names should include sampling rate (Hz), voltage range (mV), and DSP filter ID
237+
for stream_name in stream_names:
238+
self.assertRegex(stream_name, r"stream\d+_\d+Hz_\d+mVRange_DSPFilter\d+")
239+
240+
# Verify we have the expected streams:
241+
# - Eye-tracking channels (CSC145, CSC146): 32000Hz, 100mV range, low-cut disabled
242+
# - Ephys channel (CSC76): 32000Hz, 1mV range, low-cut enabled
243+
expected_names = {"stream0_32000Hz_100mVRange_DSPFilter0", "stream1_32000Hz_1mVRange_DSPFilter1"}
244+
self.assertEqual(set(stream_names), expected_names)
245+
246+
# Verify DSP filter configurations are stored (private for now)
247+
self.assertTrue(hasattr(rawio, "_dsp_filter_configurations"))
248+
self.assertEqual(len(rawio._dsp_filter_configurations), 2)
249+
250+
# Verify filter 0 (eye-tracking): low-cut disabled
251+
filter_0 = rawio._dsp_filter_configurations[0]
252+
self.assertFalse(filter_0.get("DSPLowCutFilterEnabled", True))
253+
254+
# Verify filter 1 (ephys): low-cut enabled
255+
filter_1 = rawio._dsp_filter_configurations[1]
256+
self.assertTrue(filter_1.get("DSPLowCutFilterEnabled", False))
257+
216258

217259
class TestNcsRecordingType(BaseTestRawIO, unittest.TestCase):
218260
"""

0 commit comments

Comments
 (0)