-
Notifications
You must be signed in to change notification settings - Fork 2
Add XDF importer for multi-stream Lab Streaming Layer files #36
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 11 commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
b89ab10
Add XDF importer for multi-stream Lab Streaming Layer files
neuromechanist 71d20af
Add multi-stream XDF test file and tests
neuromechanist a39cf68
Add XDF writer utility scripts
neuromechanist ef460cb
Add XDF documentation
neuromechanist 78c3755
Bump version to 0.2.1
neuromechanist 2f66787
Add include_timestamps option to XDF importer
neuromechanist fac5a4e
Document include_timestamps option for XDF
neuromechanist 67b49b2
Address PR review: fix dead code, division by zero, hardcoded paths
neuromechanist 1c6fc8c
Add error handling to create_multistream_test_xdf.py
neuromechanist 7eca53d
Guard against IndexError when timestamps array is empty
neuromechanist 7d1f134
Add site/ to gitignore for MkDocs build output
neuromechanist 347a26e
Use highest sampling rate as reference for multi-stream XDF loading
neuromechanist 4bbf334
Address PR review follow-up items
neuromechanist beeb528
Address second round of Copilot review comments
neuromechanist fcf07b2
Add error handling for length mismatch without timestamps
neuromechanist 4b0ad07
Address fourth round of review comments
neuromechanist File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -49,6 +49,9 @@ env/ | |
| # Ruff | ||
| .ruff_cache/ | ||
|
|
||
| # MkDocs | ||
| site/ | ||
|
|
||
| # Context and planning files | ||
| .context/ | ||
| CLAUDE.md | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| # XDF Importer | ||
|
|
||
| The `XDFImporter` class handles importing data from XDF (Extensible Data Format) files, the native format for Lab Streaming Layer (LSL) recordings. It supports multi-stream files with different sampling rates and data types. | ||
|
|
||
| ## Class Documentation | ||
|
|
||
| ::: emgio.importers.xdf | ||
| options: | ||
| show_root_heading: true | ||
| show_source: true | ||
| members: true | ||
|
|
||
| ## Usage Examples | ||
|
|
||
| ### Basic Loading | ||
|
|
||
| ```python | ||
| from emgio import EMG | ||
| from emgio.importers.xdf import XDFImporter | ||
|
|
||
| # Method 1: Using EMG.from_file (recommended) | ||
| emg = EMG.from_file('recording.xdf') | ||
|
|
||
| # Method 2: Using the importer directly | ||
| importer = XDFImporter() | ||
| emg = importer.load('recording.xdf') | ||
| ``` | ||
|
|
||
| ### Exploring File Contents | ||
|
|
||
| Before loading, explore what streams are available: | ||
|
|
||
| ```python | ||
| from emgio.importers.xdf import summarize_xdf | ||
|
|
||
| summary = summarize_xdf('recording.xdf') | ||
| print(summary) | ||
|
|
||
| # Output example: | ||
| # XDF File: recording.xdf | ||
| # ---------------------------------------- | ||
| # Stream 1: MyEEG (EEG) | ||
| # Channels: 8, Rate: 256.0 Hz | ||
| # Samples: 15360, Duration: 60.0s | ||
| # Stream 2: MyEMG (EMG) | ||
| # Channels: 2, Rate: 2048.0 Hz | ||
| # Samples: 122880, Duration: 60.0s | ||
| # Stream 3: Markers (Markers) | ||
| # Channels: 1, Rate: 0.0 Hz (irregular) | ||
| # Samples: 10 | ||
| ``` | ||
|
|
||
| ### Selective Stream Loading | ||
|
|
||
| ```python | ||
| # Load only specific stream types | ||
| emg = EMG.from_file('recording.xdf', stream_types=['EMG']) | ||
|
|
||
| # Load multiple types | ||
| emg = EMG.from_file('recording.xdf', stream_types=['EMG', 'EEG']) | ||
|
|
||
| # Load by stream name | ||
| emg = EMG.from_file('recording.xdf', stream_names=['MyEMGDevice']) | ||
|
|
||
| # Load by stream ID | ||
| emg = EMG.from_file('recording.xdf', stream_ids=[2]) | ||
| ``` | ||
|
|
||
| ### Setting Default Channel Type | ||
|
|
||
| ```python | ||
| # For streams without explicit channel type metadata | ||
| emg = EMG.from_file('recording.xdf', default_channel_type='EMG') | ||
| ``` | ||
|
|
||
| ### Preserving LSL Timestamps | ||
|
|
||
| ```python | ||
| # Include original LSL timestamps as additional channels | ||
| emg = EMG.from_file('recording.xdf', include_timestamps=True) | ||
|
|
||
| # Each stream gets a "{stream_name}_LSL_timestamps" channel | ||
| # Useful for synchronization with other LSL-recorded data | ||
| ``` | ||
|
|
||
| ## File Format Support | ||
|
|
||
| The XDF importer supports: | ||
|
|
||
| 1. Single-stream and multi-stream XDF files | ||
| 2. Compressed XDF files (.xdfz) | ||
| 3. Numeric data types: float32, float64, int8, int16, int32, int64 | ||
| 4. Different sampling rates across streams (with resampling) | ||
| 5. Channel labels from stream descriptors | ||
|
|
||
| ## Stream Selection Parameters | ||
|
|
||
| | Parameter | Type | Description | | ||
| |-----------|------|-------------| | ||
| | `stream_names` | `list[str]` | Filter by stream names (case-insensitive) | | ||
| | `stream_types` | `list[str]` | Filter by stream types (e.g., "EMG", "EEG") | | ||
| | `stream_ids` | `list[int]` | Filter by stream IDs | | ||
| | `default_channel_type` | `str` | Default type for channels without explicit type | | ||
| | `include_timestamps` | `bool` | If True, add LSL timestamp channels for each stream | | ||
|
|
||
| ## Return Values | ||
|
|
||
| The `load()` method returns an `EMG` object with: | ||
|
|
||
| ### Signals (pandas.DataFrame) | ||
| - Time-indexed signal data | ||
| - Channels as columns | ||
| - Resampled to common time base if multiple streams | ||
|
|
||
| ### Channels (dict) | ||
| For each channel: | ||
| - `channel_type`: Inferred or default type | ||
| - `physical_dimension`: Unit (default "a.u.") | ||
| - `sample_frequency`: Effective sampling rate | ||
| - `stream_name`: Original stream name | ||
| - `stream_id`: Original stream ID | ||
|
|
||
| ### Metadata (dict) | ||
| - `device`: "XDF" | ||
| - `source_file`: Path to the XDF file | ||
| - `stream_count`: Number of streams in file | ||
| - `stream_names`: List of all stream names | ||
| - `stream_types`: List of all stream types | ||
|
|
||
| ## Helper Classes | ||
|
|
||
| ### XDFSummary | ||
|
|
||
| Provides an overview of the XDF file: | ||
|
|
||
| ```python | ||
| summary = summarize_xdf('recording.xdf') | ||
|
|
||
| # Access all streams | ||
| for stream in summary.streams: | ||
| print(f"{stream.name}: {stream.channel_count} channels") | ||
|
|
||
| # Find streams by type | ||
| emg_streams = summary.get_streams_by_type('EMG') | ||
|
|
||
| # Find stream by name | ||
| stream = summary.get_stream_by_name('MyDevice') | ||
| ``` | ||
|
|
||
| ### XDFStreamInfo | ||
|
|
||
| Contains metadata for a single stream: | ||
|
|
||
| - `stream_id`: Unique stream identifier | ||
| - `name`: Stream name | ||
| - `stream_type`: Stream type (EEG, EMG, etc.) | ||
| - `channel_count`: Number of channels | ||
| - `nominal_srate`: Declared sampling rate | ||
| - `effective_srate`: Actual measured sampling rate | ||
| - `channel_format`: Data format (float32, string, etc.) | ||
| - `source_id`: Source identifier | ||
| - `hostname`: Recording machine hostname | ||
| - `sample_count`: Number of samples | ||
| - `duration_seconds`: Recording duration | ||
| - `channel_labels`: List of channel names | ||
|
|
||
| ## Implementation Notes | ||
|
|
||
| 1. **String/Marker Streams:** Streams with `channel_format='string'` are excluded from signal loading but appear in summaries. | ||
|
|
||
| 2. **Time Alignment:** When loading multiple streams, timestamps are aligned to start at 0. | ||
|
|
||
| 3. **Resampling:** Multiple streams with different rates are resampled using linear interpolation to the highest rate. | ||
|
neuromechanist marked this conversation as resolved.
|
||
|
|
||
| 4. **Channel Naming:** Channels are prefixed with stream name to avoid conflicts (e.g., "StreamName_ChannelLabel"). | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| # XDF Example | ||
|
|
||
| This example demonstrates how to work with XDF files from Lab Streaming Layer (LSL) recordings, including exploring multi-stream files and selective loading. | ||
|
|
||
| ## Exploring XDF Contents | ||
|
|
||
| XDF files often contain multiple streams. Before loading, explore what's available: | ||
|
|
||
| ```python | ||
| from emgio.importers.xdf import summarize_xdf | ||
|
|
||
| # Summarize all streams in the file | ||
| summary = summarize_xdf('examples/multi_stream_test.xdf') | ||
| print(summary) | ||
| ``` | ||
|
|
||
| Output: | ||
| ``` | ||
| XDF File: examples/multi_stream_test.xdf | ||
| ---------------------------------------- | ||
| Stream 1: TestEEG (EEG) | ||
| Channels: 8, Rate: 256.0 Hz | ||
| Samples: 1280, Duration: 5.0s | ||
| Labels: EEG1, EEG2, EEG3, EEG4, EEG5, EEG6, EEG7, EEG8 | ||
| Stream 2: TestEMG (EMG) | ||
| Channels: 2, Rate: 2048.0 Hz | ||
| Samples: 10240, Duration: 5.0s | ||
| Labels: EMG_L, EMG_R | ||
| Stream 3: TestMocap (Mocap) | ||
| Channels: 6, Rate: 120.0 Hz | ||
| Samples: 600, Duration: 5.0s | ||
| Labels: Marker1_X, Marker1_Y, Marker1_Z, Marker2_X, Marker2_Y, Marker2_Z | ||
| Stream 4: TestMarkers (Markers) | ||
| Channels: 1, Rate: 0.0 Hz (irregular) | ||
| Samples: 5 | ||
| ``` | ||
|
|
||
| ## Finding Specific Streams | ||
|
|
||
| ```python | ||
| # Find all EMG streams | ||
| emg_streams = summary.get_streams_by_type('EMG') | ||
| for stream in emg_streams: | ||
| print(f"Found EMG stream: {stream.name} with {stream.channel_count} channels") | ||
|
|
||
| # Find a specific stream by name | ||
| mocap = summary.get_stream_by_name('TestMocap') | ||
| if mocap: | ||
| print(f"Mocap rate: {mocap.nominal_srate} Hz") | ||
| print(f"Mocap channels: {mocap.channel_labels}") | ||
| ``` | ||
|
|
||
| ## Loading All Numeric Data | ||
|
|
||
| ```python | ||
| from emgio import EMG | ||
|
|
||
| # Load all numeric streams (EEG, EMG, Mocap - excludes Markers) | ||
| emg = EMG.from_file('examples/multi_stream_test.xdf') | ||
|
|
||
| print(f"Total channels: {len(emg.channels)}") | ||
| print(f"Channel names: {list(emg.channels.keys())}") | ||
| ``` | ||
|
|
||
| ## Selective Stream Loading | ||
|
|
||
| ### Load by Stream Type | ||
|
|
||
| ```python | ||
| # Load only EMG data | ||
| emg_data = EMG.from_file('examples/multi_stream_test.xdf', stream_types=['EMG']) | ||
| print(f"EMG channels: {list(emg_data.channels.keys())}") | ||
| # Output: ['EMG_L', 'EMG_R'] | ||
|
|
||
| # Load EEG and EMG together | ||
| combined = EMG.from_file('examples/multi_stream_test.xdf', stream_types=['EEG', 'EMG']) | ||
| print(f"Combined channels: {len(combined.channels)}") | ||
| # Output: 10 (8 EEG + 2 EMG) | ||
| ``` | ||
|
|
||
| ### Load by Stream Name | ||
|
|
||
| ```python | ||
| # Load specific streams by name | ||
| emg_data = EMG.from_file('examples/multi_stream_test.xdf', stream_names=['TestEMG']) | ||
| ``` | ||
|
|
||
| ## Working with Multi-Rate Data | ||
|
|
||
| When loading streams with different sampling rates, they're resampled to a common time base: | ||
|
|
||
| ```python | ||
| # Load EEG (256 Hz) and EMG (2048 Hz) | ||
| combined = EMG.from_file('examples/multi_stream_test.xdf', stream_types=['EEG', 'EMG']) | ||
|
|
||
| # Check the resulting sample rate (will be the highest: 2048 Hz) | ||
|
neuromechanist marked this conversation as resolved.
|
||
| first_channel = list(combined.channels.keys())[0] | ||
| print(f"Sample rate: {combined.channels[first_channel]['sample_frequency']} Hz") | ||
| ``` | ||
|
|
||
| ## Preserving LSL Timestamps | ||
|
|
||
| XDF files contain per-sample LSL timestamps. To preserve these for synchronization: | ||
|
|
||
| ```python | ||
| # Load with timestamp channels | ||
| emg = EMG.from_file('examples/multi_stream_test.xdf', | ||
| stream_types=['EMG'], | ||
| include_timestamps=True) | ||
|
|
||
| # Each stream gets a timestamp channel | ||
| print(list(emg.channels.keys())) | ||
| # ['EMG_L', 'EMG_R', 'TestEMG_LSL_timestamps'] | ||
|
|
||
| # Access the original LSL timestamps | ||
| ts = emg.signals['TestEMG_LSL_timestamps'] | ||
| print(f"First timestamp: {ts.iloc[0]:.6f}s") | ||
| print(f"Last timestamp: {ts.iloc[-1]:.6f}s") | ||
| ``` | ||
|
|
||
| ## Exporting to EDF | ||
|
|
||
| After loading, export to EDF/BDF format: | ||
|
|
||
| ```python | ||
| # Load EMG streams with timestamps for synchronization | ||
| emg = EMG.from_file('examples/multi_stream_test.xdf', | ||
| stream_types=['EMG'], | ||
| include_timestamps=True) | ||
|
|
||
| # Export to EDF (timestamps are preserved as a channel) | ||
| emg.to_edf('output_emg.edf') | ||
|
|
||
| # Verify the export | ||
| emg_reloaded = EMG.from_file('output_emg.edf') | ||
| print(f"Exported channels: {list(emg_reloaded.channels.keys())}") | ||
| ``` | ||
|
|
||
| ## Complete Workflow Example | ||
|
|
||
| ```python | ||
| from emgio import EMG | ||
| from emgio.importers.xdf import summarize_xdf | ||
|
|
||
| # 1. Explore the file | ||
| summary = summarize_xdf('recording.xdf') | ||
| print(summary) | ||
|
|
||
| # 2. Identify streams of interest | ||
| emg_streams = summary.get_streams_by_type('EMG') | ||
| print(f"Found {len(emg_streams)} EMG streams") | ||
|
|
||
| # 3. Load selected data | ||
| emg = EMG.from_file('recording.xdf', stream_types=['EMG']) | ||
|
|
||
| # 4. Check loaded data | ||
| print(f"Channels: {list(emg.channels.keys())}") | ||
| print(f"Duration: {emg.signals.index[-1]:.1f}s") | ||
| print(f"Sample rate: {emg.channels[list(emg.channels.keys())[0]]['sample_frequency']} Hz") | ||
|
|
||
| # 5. Plot signals | ||
| emg.plot_signals(time_range=(0, 5)) | ||
|
|
||
| # 6. Export | ||
| emg.to_edf('emg_export.edf', verify=True) | ||
| ``` | ||
|
|
||
| ## Notes | ||
|
|
||
| - Marker streams (string data) are not loaded as signal channels | ||
| - When multiple streams are loaded, channels are prefixed with stream names | ||
| - Time indices are normalized to start at 0 | ||
| - The `pyxdf` package is used internally for reading XDF files | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.