88 * Lyuba Zehl, Michael Denker - fourth version
99 * Samuel Garcia, Julia Srenger - fifth version
1010 * Chadwick Boulay - FileSpec 3.0 and 3.0-PTP
11+ * Heberto Mayorquin - Time segmentation fixes, reporting and refactoring
1112
1213This IO supports reading only.
1314This IO is able to read:
@@ -101,6 +102,27 @@ class BlackrockRawIO(BaseRawIO):
101102 must be set at the init before parse_header().
102103 load_nev: bool, default: True
103104 Load (or not) events/spikes by ignoring or not the nev file.
105+ gap_tolerance_ms : float | None, default: None
106+ Maximum acceptable gap size in milliseconds for automatic segmentation.
107+
108+ **Default behavior (None)**: If timestamp gaps are detected, an error is raised
109+ with a detailed gap report. This ensures users are aware of data discontinuities.
110+
111+ **Opt-in segmentation**: Provide a value to automatically segment data at gaps
112+ larger than this threshold. Gaps smaller than the threshold are ignored (data
113+ treated as continuous).
114+
115+ Examples:
116+
117+ - None (default): Error on any detected gaps
118+ - 1.0: Tolerate gaps up to 1 ms, segment on larger gaps
119+ - 10.0: Tolerate gaps up to 10 ms (filters buffer artifacts)
120+ - 100.0: Tolerate gaps up to 100 ms (only major pauses create segments)
121+
122+ Applies to:
123+
124+ - PTP format (v3.0-ptp): Gaps in per-sample timestamps
125+ - Standard format (v2.2/2.3/3.0): Gaps between data blocks
104126
105127 Notes
106128 -----
@@ -135,13 +157,13 @@ class BlackrockRawIO(BaseRawIO):
135157 main_sampling_rate = 30000.0
136158
137159 def __init__ (
138- self , filename = None , nsx_override = None , nev_override = None , nsx_to_load = None , load_nev = True , verbose = False
160+ self , filename = None , nsx_override = None , nev_override = None , nsx_to_load = None , load_nev = True ,
161+ verbose = False , gap_tolerance_ms = None
139162 ):
140- """
141- Initialize the BlackrockIO class.
142- """
143163 BaseRawIO .__init__ (self )
144164
165+ self .gap_tolerance_ms = gap_tolerance_ms
166+
145167 self .filename = str (filename )
146168
147169 # remove extension from base _filenames
@@ -917,7 +939,7 @@ def _parse_nsx_data(self, spec, nsx_nb):
917939 │ - nb_data_points (e.g., 1000) │
918940 ├─────────────────────────────────────────────┤
919941 │ DATA BLOCK 1 DATA │ ← Actual samples
920- │ - 1000 samples × N channels │
942+ │ - 1000 samples x N channels │
921943 │ - int16 values │
922944 ├─────────────────────────────────────────────┤
923945 │ DATA BLOCK 2 HEADER │ ← Next block
@@ -926,15 +948,13 @@ def _parse_nsx_data(self, spec, nsx_nb):
926948 │ - nb_data_points (e.g., 1000) │
927949 ├─────────────────────────────────────────────┤
928950 │ DATA BLOCK 2 DATA │
929- │ - 1000 samples × N channels │
951+ │ - 1000 samples x N channels │
930952 └─────────────────────────────────────────────┘
931953
932954 Key characteristics:
933955 - Headers are EXPLICIT and SPARSE (only at block boundaries)
934956 - Each block has ONE scalar timestamp for ALL samples in that block
935957 - Reader LOOPS through file, finding headers
936- - Multiple blocks exist when recording was paused/resumed
937- - Timestamp indicates when each block started recording
938958
939959 PTP FORMAT (v3.0 with Precision Time Protocol)
940960 -----------------------------------------------
@@ -949,19 +969,19 @@ def _parse_nsx_data(self, spec, nsx_nb):
949969 │ - reserved (1 byte) │
950970 │ - timestamp (8 bytes, e.g., 1000) │
951971 │ - num_data_points (always 1) │
952- │ - samples (N channels × int16) │
972+ │ - samples (N channels x int16) │
953973 ├─────────────────────────────────────────────┤
954974 │ PACKET 1: │
955975 │ - reserved │
956976 │ - timestamp (e.g., 1033) │
957977 │ - num_data_points (1) │
958- │ - samples (N channels × int16) │
978+ │ - samples (N channels x int16) │
959979 ├─────────────────────────────────────────────┤
960980 │ PACKET 2: │
961981 │ - reserved │
962982 │ - timestamp (e.g., 1066) │
963983 │ - num_data_points (1) │
964- │ - samples (N channels × int16) │
984+ │ - samples (N channels x int16) │
965985 ├─────────────────────────────────────────────┤
966986 │ ...thousands more packets... │
967987 │ PACKET 500: │
@@ -973,8 +993,6 @@ def _parse_nsx_data(self, spec, nsx_nb):
973993 - NO separate headers and data - they're INTERLEAVED
974994 - EVERY sample has its own timestamp (per-sample nanosecond precision)
975995 - File is ONE CONTINUOUS ARRAY of uniform packets
976- - Segments must be INFERRED by detecting timestamp gaps
977- - Timestamp gap > 2× sampling period indicates segment boundary
978996
979997 V2.1 FORMAT
980998 -----------
@@ -1175,12 +1193,9 @@ def _parse_nsx_data_v30_ptp(self, nsx_nb):
11751193 }
11761194 }
11771195
1178- def _report_nsx_timestamp_gaps (self , gap_indices , timestamps_in_seconds , time_differences , threshold , nsx_nb ):
1196+ def _format_gap_report (self , gap_indices , timestamps_in_seconds , time_differences , nsx_nb ):
11791197 """
1180- Report detected timestamp gaps with detailed table.
1181-
1182- This function generates a warning message with a detailed table showing
1183- where gaps were detected in the timestamp sequence.
1198+ Format a detailed gap report showing where timestamp discontinuities occur.
11841199
11851200 Parameters
11861201 ----------
@@ -1190,16 +1205,14 @@ def _report_nsx_timestamp_gaps(self, gap_indices, timestamps_in_seconds, time_di
11901205 All timestamps converted to seconds
11911206 time_differences : np.ndarray
11921207 Time differences between consecutive timestamps
1193- threshold : float
1194- Gap threshold in seconds
11951208 nsx_nb : int
11961209 NSX file number for the report
1197- """
1198- import warnings
1199-
1200- threshold_ms = threshold * 1000
1201- num_segments = len (gap_indices ) + 1
12021210
1211+ Returns
1212+ -------
1213+ str
1214+ Formatted gap report with table
1215+ """
12031216 # Calculate gap details
12041217 gap_durations_seconds = time_differences [gap_indices ]
12051218 gap_durations_ms = gap_durations_seconds * 1000
@@ -1211,17 +1224,16 @@ def _report_nsx_timestamp_gaps(self, gap_indices, timestamps_in_seconds, time_di
12111224 for index , pos , dur in zip (gap_indices , gap_positions_seconds , gap_durations_ms )
12121225 ]
12131226
1214- segmentation_report_message = (
1215- f"\n Found { len ( gap_indices ) } gaps for nsx { nsx_nb } where samples are farther apart than { threshold_ms :.3f } ms. \n "
1216- f"Data will be segmented at these locations to create { num_segments } segments. \n \n "
1227+ return (
1228+ f"Gap Report for ns { nsx_nb } : \n "
1229+ f"Found { len ( gap_indices ) } timestamp gaps (detection threshold: 2 x sampling period) \n \n "
12171230 "Gap Details:\n "
12181231 "+-----------------+-----------------------+-----------------------+\n "
1219- "| Sample Index | Sample at (Seconds) | Gap Jump (ms) |\n "
1232+ "| Sample Index | Sample at (Seconds) | Gap Size (ms) |\n "
12201233 "+-----------------+-----------------------+-----------------------+\n "
12211234 + "" .join (gap_detail_lines )
12221235 + "+-----------------+-----------------------+-----------------------+\n "
12231236 )
1224- warnings .warn (segmentation_report_message )
12251237
12261238 def _segment_nsx_data (self , data_blocks_dict , nsx_nb ):
12271239 """
@@ -1279,21 +1291,39 @@ def _segment_nsx_data(self, data_blocks_dict, nsx_nb):
12791291 if isinstance (timestamps , np .ndarray ):
12801292 # Analyze timestamp gaps
12811293 sampling_rate = self ._nsx_sampling_frequency [nsx_nb ]
1282- segmentation_threshold = 2.0 / sampling_rate
1294+
1295+ # Detection threshold: use strict 2x sampling period to find ALL gaps
1296+ detection_threshold = 2.0 / sampling_rate
12831297
12841298 timestamps_sampling_rate = self ._nsx_basic_header [nsx_nb ]["timestamp_resolution" ]
12851299 timestamps_in_seconds = timestamps / timestamps_sampling_rate
12861300
12871301 time_differences = np .diff (timestamps_in_seconds )
1288- gap_indices = np .argwhere (time_differences > segmentation_threshold ).flatten ()
1302+ gap_indices = np .argwhere (time_differences > detection_threshold ).flatten ()
12891303
1290- # Report gaps if found
1304+ # If gaps found, check user's tolerance
12911305 if len (gap_indices ) > 0 :
1292- self ._report_nsx_timestamp_gaps (
1293- gap_indices , timestamps_in_seconds , time_differences ,
1294- segmentation_threshold , nsx_nb
1306+ gap_report = self ._format_gap_report (
1307+ gap_indices , timestamps_in_seconds , time_differences , nsx_nb
12951308 )
12961309
1310+ # Error by default - user must opt-in to segmentation
1311+ if self .gap_tolerance_ms is None :
1312+ raise ValueError (
1313+ f"Detected { len (gap_indices )} timestamp gaps in ns{ nsx_nb } file.\n "
1314+ f"{ gap_report } \n "
1315+ f"To load this data, provide gap_tolerance_ms parameter to automatically "
1316+ f"segment at gaps larger than the specified tolerance."
1317+ )
1318+
1319+ # User provided tolerance - filter gaps and segment
1320+ gap_tolerance_s = self .gap_tolerance_ms / 1000.0
1321+ significant_gap_mask = time_differences [gap_indices ] > gap_tolerance_s
1322+ significant_gap_indices = gap_indices [significant_gap_mask ]
1323+
1324+ # Use significant gaps for segmentation (no warning - user opted in)
1325+ gap_indices = significant_gap_indices
1326+
12971327 # Create segments based on gaps
12981328 segment_starts = np .hstack ((0 , gap_indices + 1 ))
12991329 segment_boundaries = list (segment_starts ) + [len (data )]
0 commit comments