Skip to content

Commit b650886

Browse files
committed
Update to use session based output files and handle clock signals
1 parent 912157a commit b650886

11 files changed

+1139
-92
lines changed

QUICK_START.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,11 @@ This will:
127127

128128
**What happens:** The tool finds all your recording files and extracts the brain signal data from each one. For each NDF file (like M1555404530.ndf), it creates a matching subdirectory (M1555404530/) with separate channel files (E0.txt, E1.txt, E2.txt, etc.).
129129

130-
**Important note on channel zero:** The output channel 0 represents the clock messages channel and occur at 128 Hz.
130+
**Important note on channels:**
131+
- Channel 0: Clock signal (128 Hz) - used for timing synchronization
132+
- Channels 1-15: EEG data channels (512 Hz) - your brain activity recordings
133+
134+
The tool automatically detects and uses the correct rate for each channel type.
131135

132136
#### Performance Notes
133137

README.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ python bulk_converter.py input_folder
137137
python bulk_converter.py input_folder output_folder
138138

139139
# With custom settings
140-
python bulk_converter.py input_folder output_folder --sample-rate 512 --range 120
140+
python bulk_converter.py input_folder output_folder --range 120
141141
```
142142

143143
**Note:** The bulk converter now expects a directory structure with subdirectories containing E{channel}.txt files (e.g., E1.txt, E2.txt). Each subdirectory is converted into a single unified LabChart file with all channels as tab-separated columns.
@@ -148,7 +148,6 @@ python bulk_converter.py input_folder output_folder --sample-rate 512 --range 12
148148
python bulk_converter.py [input_dir] [output_dir] [options]
149149

150150
Options:
151-
--sample-rate, -sr Sample rate in Hz (default: 512)
152151
--range, -r Input dynamic range in mV (default: 120)
153152
--interval-length Length of each interval in seconds (default: 1.0)
154153
--commas Use European format (commas for decimals)
@@ -158,6 +157,8 @@ Options:
158157
--glitch-threshold Glitch filter threshold (default: 500, 0 to disable)
159158
--verbose, -v Verbose output
160159
--help, -h Show help message
160+
161+
Note: Sample rates are now auto-detected per channel (Ch0: 128Hz, others: 512Hz)
161162
```
162163

163164
#### Bulk Converter Examples
@@ -167,7 +168,7 @@ Options:
167168
python bulk_converter.py ndf_text_output output --range 120
168169

169170
# AC transmitter with different settings
170-
python bulk_converter.py ndf_text_output output --range 30 --sample-rate 1024
171+
python bulk_converter.py ndf_text_output output --range 30
171172

172173
# European format with microvolts
173174
python bulk_converter.py ndf_text_output output --commas --microvolts
@@ -206,10 +207,11 @@ Options:
206207
--output, -o Output directory (default: input_path + '_text')
207208
--channels, -c Specific channels to extract (default: all channels)
208209
--format, -f Output format: simple, detailed, csv (default: simple)
209-
--sample-rate, -sr Sample rate in Hz (default: 512)
210210
--timestamps Include timestamp information
211211
--no-metadata Exclude metadata headers
212212
--verbose, -v Verbose output
213+
214+
Note: Sample rates are auto-detected per channel (Ch0: 128Hz, others: 512Hz)
213215
```
214216

215217
#### Output Formats
@@ -238,6 +240,9 @@ The NDF reader automatically handles:
238240
- **Message Parsing**: Decodes 8-byte telemetry messages
239241
- **Timing Reconstruction**: Converts timestamps to relative timing
240242
- **Data Validation**: Ensures 16-bit signal compatibility
243+
- **Per-Channel Sample Rates**: Auto-detects channel-specific rates
244+
- Channel 0: 128 Hz (clock signal channel)
245+
- Channels 1-15: 512 Hz (data channels)
241246

242247
### Manual Conversion (API Usage)
243248

@@ -314,8 +319,8 @@ reader = NDFReader('data.ndf')
314319
print(f"Created: {reader.get_creation_date()}")
315320
print(f"Channels: {reader.get_available_channels()}")
316321

317-
# Extract specific channel
318-
intervals = reader.read_channel_data(channel_num=1, sample_rate=512.0)
322+
# Extract specific channel (sample rate auto-detected: Ch0=128Hz, others=512Hz)
323+
intervals = reader.read_channel_data(channel_num=1)
319324

320325
# Export to LabChart
321326
exporter = LabChartExporter(sample_rate=512.0, range_mV=120.0)
@@ -333,6 +338,7 @@ output_file = exporter.export_channel(
333338
from ndf_reader import SimpleBinarySignalReader
334339

335340
# Read 16-bit binary data
341+
# Note: Binary files require explicit sample_rate since auto-detection only works for NDF files
336342
intervals = SimpleBinarySignalReader.read_signal(
337343
filepath="signal_data.bin",
338344
sample_rate=512.0,
@@ -353,6 +359,7 @@ exporter.export_channel(
353359
from ndf_reader import TextSignalReader
354360

355361
# Read text file (one value per line)
362+
# Note: Text files require explicit sample_rate since auto-detection only works for NDF files
356363
intervals = TextSignalReader.read_signal(
357364
filepath="signal_data.txt",
358365
sample_rate=512.0,

bulk_converter.py

Lines changed: 68 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,16 @@
2626

2727
def find_channel_directories(input_dir: str) -> List[str]:
2828
"""
29-
Find all subdirectories that contain E{channel}.txt files.
29+
Find all session subdirectories that contain E{channel}.txt files.
30+
31+
Looks for directories named session_* or session_{timestamp} containing
32+
channel data files from continuous recording sessions.
3033
3134
Args:
3235
input_dir: Parent directory to search
3336
3437
Returns:
35-
List of subdirectory paths containing channel files
38+
List of session directory paths containing channel files, sorted chronologically
3639
"""
3740
subdirs = []
3841

@@ -45,6 +48,7 @@ def find_channel_directories(input_dir: str) -> List[str]:
4548
if channel_files:
4649
subdirs.append(full_path)
4750

51+
# Sort by directory name (which includes timestamp for session directories)
4852
return sorted(subdirs)
4953

5054

@@ -74,15 +78,15 @@ def find_channel_files(directory: str) -> Dict[int, str]:
7478

7579
def load_channel_data(
7680
channel_files: Dict[int, str],
77-
sample_rate: float,
7881
interval_length: float = 1.0,
7982
) -> Dict[int, List[Tuple[float, List[int]]]]:
8083
"""
81-
Load data from all channel files.
84+
Load data from all channel files with per-channel sample rates.
85+
86+
Channel 0 uses 128 Hz (clock signal), all other channels use 512 Hz.
8287
8388
Args:
8489
channel_files: Dictionary mapping channel numbers to file paths
85-
sample_rate: Sample rate in Hz
8690
interval_length: Length of each interval in seconds
8791
8892
Returns:
@@ -92,14 +96,21 @@ def load_channel_data(
9296

9397
for channel_num, file_path in channel_files.items():
9498
try:
99+
# Auto-detect sample rate based on channel number
100+
# Channel 0: 128 Hz (clock signal)
101+
# Other channels: 512 Hz (default)
102+
sample_rate = 128.0 if channel_num == 0 else 512.0
103+
95104
intervals = TextSignalReader.read_signal(
96105
filepath=file_path,
97106
sample_rate=sample_rate,
98107
interval_length=interval_length,
99108
)
100109
if intervals:
101110
channel_data[channel_num] = intervals
102-
print(f" Channel {channel_num}: {len(intervals)} intervals loaded")
111+
print(
112+
f" Channel {channel_num}: {len(intervals)} intervals loaded ({sample_rate} Hz)"
113+
)
103114
else:
104115
print(f" Channel {channel_num}: Warning - no data found")
105116
except Exception as e:
@@ -115,10 +126,13 @@ def convert_directory(
115126
interval_length: float = 1.0,
116127
) -> Optional[str]:
117128
"""
118-
Convert all channel files in a directory to a single unified LabChart file.
129+
Convert all channel files in a session directory to a single unified LabChart file.
130+
131+
Extracts session timestamp from directory name (session_{timestamp}) for both
132+
creation date and output filename.
119133
120134
Args:
121-
input_dir: Directory containing E{channel}.txt files
135+
input_dir: Session directory containing E{channel}.txt files
122136
output_dir: Output directory for LabChart file
123137
exporter: LabChartExporter instance
124138
interval_length: Length of each interval in seconds
@@ -138,21 +152,29 @@ def convert_directory(
138152

139153
print(f" Found {len(channel_files)} channel files: {sorted(channel_files.keys())}")
140154

141-
# Load all channel data
155+
# Load all channel data with per-channel sample rates
142156
channel_data = load_channel_data(
143157
channel_files=channel_files,
144-
sample_rate=exporter.sample_rate,
145158
interval_length=interval_length,
146159
)
147160

148161
if not channel_data:
149162
print(f" Warning: No valid data loaded from any channels")
150163
return None
151164

152-
# Get creation date from directory metadata or first file
153-
first_file = list(channel_files.values())[0]
154-
file_mtime = os.path.getmtime(first_file)
155-
creation_date = datetime.fromtimestamp(file_mtime).strftime("%Y-%m-%d %H:%M:%S")
165+
# Extract session timestamp from directory name (session_{timestamp})
166+
# Fallback to file modification time if extraction fails
167+
session_pattern = re.compile(r"session_(\d{10})")
168+
match = session_pattern.match(dir_name)
169+
170+
if match:
171+
timestamp = int(match.group(1))
172+
creation_date = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
173+
else:
174+
# Fallback to first file modification time
175+
first_file = list(channel_files.values())[0]
176+
file_mtime = os.path.getmtime(first_file)
177+
creation_date = datetime.fromtimestamp(file_mtime).strftime("%Y-%m-%d %H:%M:%S")
156178

157179
# Create output filename based on directory name
158180
output_file = os.path.join(output_dir, f"{dir_name}.txt")
@@ -184,12 +206,16 @@ def bulk_convert(
184206
glitch_threshold: int = 500,
185207
) -> List[str]:
186208
"""
187-
Bulk convert all channel directories to unified LabChart format.
209+
Bulk convert all session directories to unified LabChart format.
210+
211+
Sample rates are auto-detected per channel:
212+
- Channel 0: 128 Hz (clock signal)
213+
- Other channels: 512 Hz
188214
189215
Args:
190-
input_dir: Input directory containing subdirectories with E{channel}.txt files
216+
input_dir: Input directory containing session subdirectories with E{channel}.txt files
191217
output_dir: Output directory (default: input_dir + '_labchart')
192-
sample_rate: Sample rate in Hz
218+
sample_rate: DEPRECATED - Sample rates are now auto-detected per channel
193219
range_mV: Input dynamic range in millivolts
194220
interval_length: Length of each interval in seconds
195221
use_commas: Use European format (commas for decimals)
@@ -250,7 +276,9 @@ def bulk_convert(
250276
created_files.append(output_file)
251277

252278
print(f"\nConversion complete!")
253-
print(f"Successfully converted {len(created_files)} out of {len(channel_dirs)} directories")
279+
print(
280+
f"Successfully converted {len(created_files)} out of {len(channel_dirs)} directories"
281+
)
254282
print(f"Output files: {output_dir}")
255283

256284
return created_files
@@ -259,38 +287,44 @@ def bulk_convert(
259287
def main():
260288
"""Main entry point for command line usage"""
261289
parser = argparse.ArgumentParser(
262-
description="Bulk convert EEG channel directories to unified LabChart format",
290+
description="Bulk convert EEG session directories to unified LabChart format",
263291
formatter_class=argparse.RawDescriptionHelpFormatter,
264292
epilog="""
265293
Examples:
266294
# Basic conversion with default settings
267-
python bulk_converter.py ndf_files_text
295+
python bulk_converter.py test_sessions
268296
269-
# Custom output directory and sample rate
270-
python bulk_converter.py ndf_files_text output --sample-rate 1024 --range 30
297+
# Custom output directory with options
298+
python bulk_converter.py test_sessions output --range 120 --commas
271299
272300
# European format with microvolts
273-
python bulk_converter.py data output --commas --microvolts
301+
python bulk_converter.py test_sessions output --commas --microvolts
274302
275-
# High precision timing in milliseconds
276-
python bulk_converter.py data output --milliseconds --absolute-time
303+
# High precision timing with absolute UNIX timestamps
304+
python bulk_converter.py test_sessions output --milliseconds --absolute-time
277305
278306
Expected Input Structure:
279307
input_dir/
280-
├── M1555404530/
281-
│ ├── E0.txt
282-
│ ├── E1.txt
283-
│ ├── E2.txt
284-
│ └── E15.txt
285-
└── M1555404531/
308+
├── session_1555404530/ (continuous recording session)
309+
│ ├── E0.txt (Channel 0: 128 Hz clock signal)
310+
│ ├── E1.txt (Channel 1: 512 Hz)
311+
│ ├── E2.txt (Channel 2: 512 Hz)
312+
│ └── E15.txt (Channel 15: 512 Hz)
313+
└── session_1558948567/ (next session after gap)
286314
├── E0.txt
287315
├── E1.txt
288316
└── E2.txt
289317
290318
Output:
291319
output_dir/
292-
├── M1555404530.txt (contains all channels in unified format)
293-
└── M1555404531.txt (contains all channels in unified format)
320+
├── session_1555404530.txt (all channels merged, tab-separated)
321+
└── session_1558948567.txt (all channels merged, tab-separated)
322+
323+
Notes:
324+
- Channel 0 is automatically detected as 128 Hz (clock signal)
325+
- All other channels are automatically detected as 512 Hz
326+
- The --sample-rate option is deprecated but retained for compatibility
327+
- Session directories are typically created by ndf_to_text_converter.py
294328
""",
295329
)
296330

@@ -310,7 +344,7 @@ def main():
310344
"-sr",
311345
type=float,
312346
default=512.0,
313-
help="Sample rate in Hz (default: 512)",
347+
help="DEPRECATED - Sample rates are auto-detected per channel (Ch0: 128Hz, others: 512Hz)",
314348
)
315349

316350
parser.add_argument(

labchart_exporter.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import os
1010
import struct
1111
from datetime import datetime
12-
from typing import List, Optional, Tuple
12+
from typing import Dict, List, Optional, Tuple
1313

1414

1515
class LabChartExporter:
@@ -230,6 +230,10 @@ def export_multi_channel(
230230
"""
231231
Export multiple channels to a single unified LabChart file.
232232
233+
Per-channel sample rates are auto-detected:
234+
- Channel 0: 128 Hz (clock signal)
235+
- Other channels: 512 Hz
236+
233237
Args:
234238
output_file: Path to output file
235239
channel_data: Dictionary mapping channel numbers to their intervals
@@ -258,13 +262,17 @@ def export_multi_channel(
258262
all_samples = []
259263

260264
for channel in channels:
265+
# Determine sample rate for this channel
266+
# Channel 0: 128 Hz (clock signal), others: 512 Hz
267+
channel_sample_rate = 128.0 if channel == 0 else 512.0
268+
261269
intervals = channel_data[channel]
262270
for start_time, signal_values in intervals:
263271
# Apply glitch filter if enabled
264272
filtered_values = self._apply_glitch_filter(signal_values)
265273

266-
# Calculate time for each sample
267-
sample_period = 1.0 / self.sample_rate
274+
# Calculate time for each sample using per-channel sample rate
275+
sample_period = 1.0 / channel_sample_rate
268276
for i, value in enumerate(filtered_values):
269277
sample_time = start_time + (i * sample_period)
270278
all_samples.append((sample_time, channel, value))
@@ -274,7 +282,7 @@ def export_multi_channel(
274282

275283
# Group samples by timestamp using rounding for efficiency
276284
# Round to nearest nanosecond to handle floating point precision
277-
timestamp_groups = {}
285+
timestamp_groups: Dict[float, Dict[int, int]] = {}
278286

279287
for sample_time, channel, value in all_samples:
280288
# Round timestamp to avoid floating point comparison issues
@@ -293,6 +301,10 @@ def export_multi_channel(
293301
if not self.absolute_time:
294302
if self.start_time is None:
295303
self.start_time = sorted_times[0] if sorted_times else 0.0
304+
# Ensure start_time is set for type checking
305+
start_time_value = self.start_time
306+
else:
307+
start_time_value = 0.0 # Not used in absolute mode
296308

297309
with open(output_file, "a") as f:
298310
for sample_time in sorted_times:
@@ -302,7 +314,7 @@ def export_multi_channel(
302314
if self.absolute_time:
303315
display_time = sample_time
304316
else:
305-
display_time = sample_time - self.start_time
317+
display_time = sample_time - start_time_value
306318

307319
# Format time
308320
time_str = self._format_value(display_time, is_time=True)

0 commit comments

Comments
 (0)