Skip to content

Commit a7b819f

Browse files
authored
Merge branch 'master' into fix-bad-brainvision-fileref
2 parents e9dff0c + ab3d0f5 commit a7b819f

File tree

4 files changed

+101
-39
lines changed

4 files changed

+101
-39
lines changed

neo/rawio/blackrockrawio.py

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -331,17 +331,16 @@ def _parse_header(self):
331331
# read nsx headers
332332
nsx_header_reader = self._nsx_header_reader[spec_version]
333333
self._nsx_basic_header[nsx_nb], self._nsx_ext_header[nsx_nb] = nsx_header_reader(nsx_nb)
334-
334+
335335
# The Blackrock defines period as the number of 1/30_000 seconds between data points
336336
# E.g. it is 1 for 30_000, 3 for 10_000, etc
337337
nsx_period = self._nsx_basic_header[nsx_nb]["period"]
338338
sampling_rate = 30_000.0 / nsx_period
339339
self._nsx_sampling_frequency[nsx_nb] = float(sampling_rate)
340340

341-
342341
# Parase data packages
343342
for nsx_nb in self._avail_nsx:
344-
343+
345344
# The only way to know if it is the Precision Time Protocol of file spec 3.0
346345
# is to check for nanosecond timestamp resolution.
347346
is_ptp_variant = (
@@ -399,7 +398,9 @@ def _parse_header(self):
399398

400399
self.nsx_datas = {}
401400
# Keep public attribute for backward compatibility but let's use the private one and maybe deprecate this at some point
402-
self.sig_sampling_rates = {nsx_number: self._nsx_sampling_frequency[nsx_number] for nsx_number in self.nsx_to_load}
401+
self.sig_sampling_rates = {
402+
nsx_number: self._nsx_sampling_frequency[nsx_number] for nsx_number in self.nsx_to_load
403+
}
403404
if len(self.nsx_to_load) > 0:
404405
for nsx_nb in self.nsx_to_load:
405406
basic_header = self._nsx_basic_header[nsx_nb]
@@ -1072,7 +1073,6 @@ def _read_nsx_dataheader_spec_v30_ptp(
10721073
# some packets have more than 1 sample. Not actually ptp. Revert to non-ptp variant.
10731074
return self._read_nsx_dataheader_spec_v22_30(nsx_nb, filesize=filesize, offset=header_size)
10741075

1075-
10761076
# Segment data, at the moment, we segment, where the data has gaps that are longer
10771077
# than twice the sampling period.
10781078
sampling_rate = self._nsx_sampling_frequency[nsx_nb]
@@ -1081,29 +1081,32 @@ def _read_nsx_dataheader_spec_v30_ptp(
10811081
# The raw timestamps are the indices of an ideal clock that ticks at `timestamp_resolution` times per second.
10821082
# We convert this indices to actual timestamps in seconds
10831083
raw_timestamps = struct_arr["timestamps"]
1084-
timestamps_sampling_rate = self._nsx_basic_header[nsx_nb]["timestamp_resolution"] # clocks per sec uint64 or uint32
1084+
timestamps_sampling_rate = self._nsx_basic_header[nsx_nb][
1085+
"timestamp_resolution"
1086+
] # clocks per sec uint64 or uint32
10851087
timestamps_in_seconds = raw_timestamps / timestamps_sampling_rate
10861088

10871089
time_differences = np.diff(timestamps_in_seconds)
10881090
gap_indices = np.argwhere(time_differences > segmentation_threshold).flatten()
10891091
segment_starts = np.hstack((0, 1 + gap_indices))
1090-
1092+
10911093
# Report gaps if any are found
10921094
if len(gap_indices) > 0:
10931095
import warnings
1096+
10941097
threshold_ms = segmentation_threshold * 1000
1095-
1098+
10961099
# Calculate all gap details in vectorized operations
10971100
gap_durations_seconds = time_differences[gap_indices]
10981101
gap_durations_ms = gap_durations_seconds * 1000
10991102
gap_positions_seconds = timestamps_in_seconds[gap_indices] - timestamps_in_seconds[0]
1100-
1103+
11011104
# Build gap detail lines all at once
11021105
gap_detail_lines = [
11031106
f"| {index:>15,} | {pos:>21.6f} | {dur:>21.3f} |\n"
11041107
for index, pos, dur in zip(gap_indices, gap_positions_seconds, gap_durations_ms)
11051108
]
1106-
1109+
11071110
segmentation_report_message = (
11081111
f"\nFound {len(gap_indices)} gaps for nsx {nsx_nb} where samples are farther apart than {threshold_ms:.3f} ms.\n"
11091112
f"Data will be segmented at these locations to create {len(segment_starts)} segments.\n\n"
@@ -1112,15 +1115,17 @@ def _read_nsx_dataheader_spec_v30_ptp(
11121115
"| Sample Index | Sample at | Gap Jump |\n"
11131116
"| | (Seconds) | (Milliseconds) |\n"
11141117
"+-----------------+-----------------------+-----------------------+\n"
1115-
+ ''.join(gap_detail_lines) +
1116-
"+-----------------+-----------------------+-----------------------+\n"
1118+
+ "".join(gap_detail_lines)
1119+
+ "+-----------------+-----------------------+-----------------------+\n"
11171120
)
11181121
warnings.warn(segmentation_report_message)
1119-
1122+
11201123
# Calculate all segment boundaries and derived values in one operation
11211124
segment_boundaries = list(segment_starts) + [len(struct_arr) - 1]
1122-
segment_num_data_points = [segment_boundaries[i+1] - segment_boundaries[i] for i in range(len(segment_starts))]
1123-
1125+
segment_num_data_points = [
1126+
segment_boundaries[i + 1] - segment_boundaries[i] for i in range(len(segment_starts))
1127+
]
1128+
11241129
size_of_data_block = struct_arr.dtype.itemsize
11251130
segment_offsets = [header_size + pos * size_of_data_block for pos in segment_starts]
11261131

neo/rawio/neuralynxrawio/neuralynxrawio.py

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
)
5656
import numpy as np
5757
import os
58-
import pathlib
58+
from pathlib import Path
5959
import copy
6060
import warnings
6161
from collections import namedtuple, OrderedDict
@@ -151,15 +151,15 @@ def __init__(
151151

152152
if filename is not None:
153153
include_filenames = [filename]
154-
warnings.warn("`filename` is deprecated and will be removed. Please use `include_filenames` instead")
154+
warnings.warn("`filename` is deprecated and will be removed in version 1.0. Please use `include_filenames` instead")
155155

156156
if exclude_filename is not None:
157157
if isinstance(exclude_filename, str):
158158
exclude_filenames = [exclude_filename]
159159
else:
160160
exclude_filenames = exclude_filename
161161
warnings.warn(
162-
"`exclude_filename` is deprecated and will be removed. Please use `exclude_filenames` instead"
162+
"`exclude_filename` is deprecated and will be removed in version 1.0. Please use `exclude_filenames` instead"
163163
)
164164

165165
if include_filenames is None:
@@ -214,30 +214,43 @@ def _parse_header(self):
214214
unit_annotations = []
215215
event_annotations = []
216216

217-
if self.rawmode == "one-dir":
218-
filenames = sorted(os.listdir(self.dirname))
219-
else:
220-
filenames = self.include_filenames
221-
222-
filenames = [f for f in filenames if f not in self.exclude_filenames]
223-
full_filenames = [os.path.join(self.dirname, f) for f in filenames]
224-
225-
for filename in full_filenames:
226-
if not os.path.isfile(filename):
227-
raise ValueError(
228-
f"Provided Filename is not a file: "
229-
f"{filename}. If you want to provide a "
230-
f"directory use the `dirname` keyword"
231-
)
217+
# 1) Get file paths based on mode and validate existence for multiple-files mode
218+
if self.rawmode == "multiple-files":
219+
# For multiple-files mode, validate that all explicitly provided files exist
220+
file_paths = []
221+
for filename in self.include_filenames:
222+
full_path = Path(self.dirname) / filename
223+
if not full_path.is_file():
224+
raise ValueError(
225+
f"Provided Filename is not a file: "
226+
f"{full_path}. If you want to provide a "
227+
f"directory use the `dirname` keyword"
228+
)
229+
file_paths.append(full_path)
230+
else: # one-dir mode
231+
# For one-dir mode, get all files from directory
232+
dir_path = Path(self.dirname)
233+
file_paths = [p for p in dir_path.iterdir() if p.is_file()]
234+
file_paths = sorted(file_paths, key=lambda p: p.name)
235+
236+
# 2) Filter by exclude filenames
237+
file_paths = [fp for fp in file_paths if fp.name not in self.exclude_filenames]
238+
239+
# 3) Filter to keep only files with correct extensions
240+
# Note: suffix[1:] removes the leading dot from file extension (e.g., ".ncs" -> "ncs")
241+
valid_file_paths = [
242+
fp for fp in file_paths
243+
if fp.suffix[1:].lower() in self.extensions
244+
]
245+
246+
# Convert back to strings for backwards compatibility with existing code
247+
full_filenames = [str(fp) for fp in valid_file_paths]
232248

233249
stream_props = {} # {(sampling_rate, n_samples, t_start): {stream_id: [filenames]}
234250

235251
for filename in full_filenames:
236252
_, ext = os.path.splitext(filename)
237-
ext = ext[1:] # remove dot
238-
ext = ext.lower() # make lower case for comparisons
239-
if ext not in self.extensions:
240-
continue
253+
ext = ext[1:].lower() # remove dot and make lower case
241254

242255
# Skip Ncs files with only header. Other empty file types
243256
# will have an empty dataset constructed later.
@@ -574,7 +587,7 @@ def _get_file_map(filename):
574587
Create memory maps when needed
575588
see also https://github.com/numpy/numpy/issues/19340
576589
"""
577-
filename = pathlib.Path(filename)
590+
filename = Path(filename)
578591
suffix = filename.suffix.lower()[1:]
579592

580593
if suffix == "ncs":

neo/rawio/neuralynxrawio/nlxheader.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,13 @@ def _to_bool(txt):
5555
("DspHighCutNumTaps", "", None),
5656
("DspHighCutFilterType", "", None),
5757
("DspDelayCompensation", "", None),
58-
("DspFilterDelay_µs", "", None),
58+
# DspFilterDelay key with flexible µ symbol matching
59+
# Different Neuralynx versions encode the µ (micro) symbol differently:
60+
# - Some files use single-byte encoding (latin-1): DspFilterDelay_µs (raw bytes: \xb5)
61+
# - Other files use UTF-8 encoding: DspFilterDelay_µs (raw bytes: \xc2\xb5)
62+
# When UTF-8 encoded µ (\xc2\xb5) is decoded with latin-1, it becomes "µ"
63+
# This regex matches both variants: "µs" and "µs" but normalizes to "DspFilterDelay_µs"
64+
(r"DspFilterDelay_[Â]?µs", "DspFilterDelay_µs", None),
5965
("DisabledSubChannels", "", None),
6066
("WaveformLength", "", int),
6167
("AlignmentPt", "", None),

neo/test/rawiotest/test_neuralynxrawio.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class TestNeuralynxRawIO(
3131
"neuralynx/Cheetah_v5.6.3/original_data",
3232
"neuralynx/Cheetah_v5.7.4/original_data",
3333
"neuralynx/Cheetah_v6.3.2/incomplete_blocks",
34+
"neuralynx/two_streams_different_header_encoding",
3435
]
3536

3637
def test_scan_ncs_files(self):
@@ -175,6 +176,43 @@ def test_exclude_filenames(self):
175176
self.assertEqual(len(rawio.header["spike_channels"]), 8)
176177
self.assertEqual(len(rawio.header["event_channels"]), 0)
177178

179+
def test_directory_in_data_folder(self):
180+
"""
181+
Test that directories inside the data folder are properly ignored
182+
and don't cause errors during parsing.
183+
"""
184+
import tempfile
185+
import shutil
186+
187+
# Use existing test data directory
188+
dname = self.get_local_path("neuralynx/Cheetah_v5.6.3/original_data/")
189+
190+
# Create a temporary copy to avoid modifying test data
191+
with tempfile.TemporaryDirectory() as temp_dir:
192+
temp_data_dir = os.path.join(temp_dir, "test_data")
193+
shutil.copytree(dname, temp_data_dir)
194+
195+
# Create a subdirectory inside the test data
196+
test_subdir = os.path.join(temp_data_dir, "raw fscv data with all recorded ch")
197+
os.makedirs(test_subdir, exist_ok=True)
198+
199+
# Create some files in the subdirectory to make it more realistic
200+
with open(os.path.join(test_subdir, "some_file.txt"), "w") as f:
201+
f.write("test file content")
202+
203+
# This should not raise an error despite the directory presence
204+
rawio = NeuralynxRawIO(dirname=temp_data_dir)
205+
rawio.parse_header()
206+
207+
# Verify that the reader still works correctly
208+
self.assertEqual(rawio._nb_segment, 2)
209+
self.assertEqual(len(rawio.ncs_filenames), 2)
210+
self.assertEqual(len(rawio.nev_filenames), 1)
211+
sigHdrs = rawio.header["signal_channels"]
212+
self.assertEqual(sigHdrs.size, 2)
213+
self.assertEqual(len(rawio.header["spike_channels"]), 8)
214+
self.assertEqual(len(rawio.header["event_channels"]), 2)
215+
178216

179217
class TestNcsRecordingType(BaseTestRawIO, unittest.TestCase):
180218
"""

0 commit comments

Comments
 (0)