motionbids 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- motionbids/__init__.py +92 -0
- motionbids/bids_constants.py +46 -0
- motionbids/channel.py +135 -0
- motionbids/datamodel.py +230 -0
- motionbids/datamodel_dynamic.py +355 -0
- motionbids/datautils.py +24 -0
- motionbids/exporter.py +518 -0
- motionbids/schema_utils.py +147 -0
- motionbids/validator.py +241 -0
- motionbids-0.1.0.dist-info/METADATA +226 -0
- motionbids-0.1.0.dist-info/RECORD +13 -0
- motionbids-0.1.0.dist-info/WHEEL +4 -0
- motionbids-0.1.0.dist-info/licenses/LICENSE +21 -0
motionbids/__init__.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
motionbids: Lightweight converter for motion data to BIDS format.
|
|
3
|
+
|
|
4
|
+
This package provides tools to define, validate, and export BIDS-compliant
|
|
5
|
+
motion capture data.
|
|
6
|
+
|
|
7
|
+
Example usage:
|
|
8
|
+
>>> from motionbids import MotionData, validate_motion_data, export_bids_motion
|
|
9
|
+
>>> import numpy as np
|
|
10
|
+
>>>
|
|
11
|
+
>>> # Create motion data object
|
|
12
|
+
>>> motion = MotionData(
|
|
13
|
+
... subject_id="01",
|
|
14
|
+
... task_name="rest",
|
|
15
|
+
... sampling_frequency=100,
|
|
16
|
+
... tracked_points_count=10,
|
|
17
|
+
... manufacturer="Vicon",
|
|
18
|
+
... data=np.random.randn(1000, 10),
|
|
19
|
+
... columns=[f"marker{i}" for i in range(10)],
|
|
20
|
+
... units=["mm"] * 10
|
|
21
|
+
... )
|
|
22
|
+
>>>
|
|
23
|
+
>>> # Validate
|
|
24
|
+
>>> validate_motion_data(motion)
|
|
25
|
+
True
|
|
26
|
+
>>>
|
|
27
|
+
>>> # Export to BIDS format
|
|
28
|
+
>>> files = export_bids_motion(motion, out_dir="bids_out/")
|
|
29
|
+
>>> print(files)
|
|
30
|
+
{'json': ..., 'tsv': ..., 'channels': ...}
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
34
|
+
__author__ = "Julius Welzel"
|
|
35
|
+
__email__ = "julius.welzel@gmail.com"
|
|
36
|
+
|
|
37
|
+
# Import main classes and functions
|
|
38
|
+
# Using dynamic schema-based MotionData class
|
|
39
|
+
from .datamodel_dynamic import MotionData
|
|
40
|
+
from .channel import Channel
|
|
41
|
+
from .validator import (
|
|
42
|
+
validate_motion_data,
|
|
43
|
+
validate_bids_compliance,
|
|
44
|
+
ValidationError,
|
|
45
|
+
ValidationWarning,
|
|
46
|
+
check_required_fields,
|
|
47
|
+
check_recommended_fields,
|
|
48
|
+
)
|
|
49
|
+
from .exporter import (
|
|
50
|
+
export_bids_motion,
|
|
51
|
+
export_json_metadata,
|
|
52
|
+
export_tsv_data,
|
|
53
|
+
export_channels_tsv,
|
|
54
|
+
export_scans_tsv,
|
|
55
|
+
create_bids_directory_structure,
|
|
56
|
+
export_dataset_description,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Optional: Schema utilities (users typically won't need these directly)
|
|
60
|
+
from . import schema_utils
|
|
61
|
+
|
|
62
|
+
# Define public API
|
|
63
|
+
__all__ = [
|
|
64
|
+
# Version info
|
|
65
|
+
"__version__",
|
|
66
|
+
"__author__",
|
|
67
|
+
"__email__",
|
|
68
|
+
|
|
69
|
+
# Core data model
|
|
70
|
+
"MotionData",
|
|
71
|
+
"Channel",
|
|
72
|
+
|
|
73
|
+
# Validation
|
|
74
|
+
"validate_motion_data",
|
|
75
|
+
"validate_bids_compliance",
|
|
76
|
+
"ValidationError",
|
|
77
|
+
"ValidationWarning",
|
|
78
|
+
"check_required_fields",
|
|
79
|
+
"check_recommended_fields",
|
|
80
|
+
|
|
81
|
+
# Export functions
|
|
82
|
+
"export_bids_motion",
|
|
83
|
+
"export_json_metadata",
|
|
84
|
+
"export_tsv_data",
|
|
85
|
+
"export_channels_tsv",
|
|
86
|
+
"export_scans_tsv",
|
|
87
|
+
"create_bids_directory_structure",
|
|
88
|
+
"export_dataset_description",
|
|
89
|
+
|
|
90
|
+
# Schema utilities module (advanced usage)
|
|
91
|
+
"schema_utils",
|
|
92
|
+
]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BIDS schema constants for motion data validation.
|
|
3
|
+
|
|
4
|
+
This module defines the allowed values for channel components and types
|
|
5
|
+
according to the BIDS specification.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# Allowed channel components (from BIDS schema)
|
|
9
|
+
ALLOWED_CHANNEL_COMPONENTS = {
|
|
10
|
+
'x', # Position along X-axis, rotation about X-axis, or magnetic field strength along X-axis
|
|
11
|
+
'y', # Position along Y-axis, rotation about Y-axis, or magnetic field strength along Y-axis
|
|
12
|
+
'z', # Position along Z-axis, rotation about Z-axis, or magnetic field strength along Z-axis
|
|
13
|
+
'quat_x', # Quaternion component associated with X-axis
|
|
14
|
+
'quat_y', # Quaternion component associated with Y-axis
|
|
15
|
+
'quat_z', # Quaternion component associated with Z-axis
|
|
16
|
+
'quat_w', # Non-axial quaternion component
|
|
17
|
+
'n/a', # Channels with no corresponding spatial axis
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
# Allowed channel types (from BIDS schema - uppercase required)
|
|
21
|
+
ALLOWED_CHANNEL_TYPES = {
|
|
22
|
+
'POS', # Position in space
|
|
23
|
+
'ORNT', # Orientation
|
|
24
|
+
'VEL', # Velocity
|
|
25
|
+
'ACCEL', # Accelerometer
|
|
26
|
+
'GYRO', # Gyrometer
|
|
27
|
+
'ANGACCEL', # Angular acceleration
|
|
28
|
+
'MAGN', # Magnetic field strength
|
|
29
|
+
'JNTANG', # Joint angle between bodyparts
|
|
30
|
+
'LATENCY', # Sample latency from recording onset
|
|
31
|
+
'MISC', # Miscellaneous channels
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Component requirements for each channel type
|
|
35
|
+
CHANNEL_TYPE_COMPONENT_REQUIREMENTS = {
|
|
36
|
+
'POS': {'x', 'y', 'z'}, # Requires spatial axes
|
|
37
|
+
'ORNT': {'x', 'y', 'z', 'quat_x', 'quat_y', 'quat_z', 'quat_w'}, # Requires axes or quaternions
|
|
38
|
+
'VEL': {'x', 'y', 'z'}, # Requires spatial axes
|
|
39
|
+
'ACCEL': {'x', 'y', 'z'}, # Requires spatial axes
|
|
40
|
+
'GYRO': {'x', 'y', 'z'}, # Requires spatial axes
|
|
41
|
+
'ANGACCEL': {'x', 'y', 'z'}, # Requires spatial axes
|
|
42
|
+
'MAGN': {'x', 'y', 'z'}, # Requires spatial axes
|
|
43
|
+
'JNTANG': ALLOWED_CHANNEL_COMPONENTS, # Can use any component
|
|
44
|
+
'LATENCY': ALLOWED_CHANNEL_COMPONENTS, # Can use any component
|
|
45
|
+
'MISC': ALLOWED_CHANNEL_COMPONENTS, # Can use any component
|
|
46
|
+
}
|
motionbids/channel.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Channel model for BIDS-compliant motion data.
|
|
3
|
+
|
|
4
|
+
This module defines the Channel dataclass that represents a single channel
|
|
5
|
+
in motion capture data, following the BIDS specification.
|
|
6
|
+
"""
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from .bids_constants import (
|
|
10
|
+
ALLOWED_CHANNEL_COMPONENTS,
|
|
11
|
+
ALLOWED_CHANNEL_TYPES,
|
|
12
|
+
CHANNEL_TYPE_COMPONENT_REQUIREMENTS
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Channel:
|
|
18
|
+
"""
|
|
19
|
+
Dataclass representing a single channel in BIDS motion data.
|
|
20
|
+
|
|
21
|
+
This follows the BIDS specification for channels.tsv files:
|
|
22
|
+
https://bids-specification.readthedocs.io/en/stable/modality-specific-files/motion.html#channels-description-_channelstsv
|
|
23
|
+
|
|
24
|
+
Required Fields (MUST appear in this order in channels.tsv):
|
|
25
|
+
name: Label of the channel (e.g., "marker0_x", "imu1_acc_x")
|
|
26
|
+
component: Spatial axis or quaternion component
|
|
27
|
+
Must be one of: x, y, z, quat_x, quat_y, quat_z, quat_w, n/a
|
|
28
|
+
type: Channel type (MUST be uppercase)
|
|
29
|
+
Must be one of: POS, ORNT, VEL, ACCEL, GYRO, ANGACCEL, MAGN, JNTANG, LATENCY, MISC
|
|
30
|
+
tracked_point: Label of the tracked point (e.g., "LeftFoot", "RightWrist")
|
|
31
|
+
units: Physical or virtual unit (e.g., "m", "rad", "m/s^2", "deg")
|
|
32
|
+
|
|
33
|
+
Recommended Fields:
|
|
34
|
+
placement: Placement on body (e.g., "torso", "left arm")
|
|
35
|
+
reference_frame: Reference frame specification (links to channels.json)
|
|
36
|
+
description: Brief description or other info
|
|
37
|
+
sampling_frequency: Sampling rate in Hz (if different from main rate)
|
|
38
|
+
status: Data quality - "good", "bad", or "n/a"
|
|
39
|
+
status_description: Explanation if status is "bad"
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
>>> channel = Channel(
|
|
43
|
+
... channel_name="marker0_x",
|
|
44
|
+
... channel_component="x",
|
|
45
|
+
... channel_type="POS",
|
|
46
|
+
... channel_tracked_point="marker0",
|
|
47
|
+
... channel_units="mm"
|
|
48
|
+
... )
|
|
49
|
+
|
|
50
|
+
Component-Type Compatibility:
|
|
51
|
+
- Quaternion components (quat_*) can only be used with ORNT type
|
|
52
|
+
- Spatial components (x, y, z) can be used with POS, VEL, ACCEL, GYRO, MAGN, ANGACCEL
|
|
53
|
+
- n/a can be used with JNTANG, LATENCY, MISC
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
# Required fields (MUST be in this order)
|
|
57
|
+
channel_name: str
|
|
58
|
+
channel_component: str
|
|
59
|
+
channel_type: str
|
|
60
|
+
channel_tracked_point: str
|
|
61
|
+
channel_units: str
|
|
62
|
+
|
|
63
|
+
# Recommended fields
|
|
64
|
+
channel_placement: Optional[str] = None
|
|
65
|
+
channel_reference_frame: Optional[str] = None
|
|
66
|
+
channel_description: Optional[str] = None
|
|
67
|
+
channel_sampling_frequency: Optional[float] = None
|
|
68
|
+
channel_status: Optional[str] = None
|
|
69
|
+
channel_status_description: Optional[str] = None
|
|
70
|
+
|
|
71
|
+
def __post_init__(self):
|
|
72
|
+
"""Validate channel fields against BIDS schema."""
|
|
73
|
+
# Validate component
|
|
74
|
+
if self.channel_component not in ALLOWED_CHANNEL_COMPONENTS:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"Invalid component '{self.channel_component}' for channel '{self.channel_name}'. "
|
|
77
|
+
f"Must be one of: {sorted(ALLOWED_CHANNEL_COMPONENTS)}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Validate type (must be uppercase)
|
|
81
|
+
if self.channel_type not in ALLOWED_CHANNEL_TYPES:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"Invalid type '{self.channel_type}' for channel '{self.channel_name}'. "
|
|
84
|
+
f"Must be one of (uppercase required): {sorted(ALLOWED_CHANNEL_TYPES)}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Validate component-type compatibility
|
|
88
|
+
if self.channel_type in CHANNEL_TYPE_COMPONENT_REQUIREMENTS:
|
|
89
|
+
allowed_components = CHANNEL_TYPE_COMPONENT_REQUIREMENTS[self.channel_type]
|
|
90
|
+
if self.channel_component not in allowed_components:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"Component '{self.channel_component}' is not allowed for type '{self.channel_type}' "
|
|
93
|
+
f"in channel '{self.channel_name}'. "
|
|
94
|
+
f"Allowed components for {self.channel_type}: {sorted(allowed_components)}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Validate status if provided
|
|
98
|
+
if self.channel_status is not None and self.channel_status not in ["good", "bad", "n/a"]:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"Invalid status '{self.channel_status}' for channel '{self.channel_name}'. "
|
|
101
|
+
f"Must be one of: 'good', 'bad', 'n/a'"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def to_tsv_row(self) -> dict:
|
|
105
|
+
"""
|
|
106
|
+
Convert channel to dictionary for TSV row export.
|
|
107
|
+
|
|
108
|
+
Returns only non-None fields in the correct BIDS order.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Dictionary with channel data for TSV export
|
|
112
|
+
"""
|
|
113
|
+
row = {
|
|
114
|
+
'name': self.channel_name,
|
|
115
|
+
'component': self.channel_component,
|
|
116
|
+
'type': self.channel_type,
|
|
117
|
+
'tracked_point': self.channel_tracked_point,
|
|
118
|
+
'units': self.channel_units,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# Add optional fields if present
|
|
122
|
+
if self.channel_placement is not None:
|
|
123
|
+
row['placement'] = self.channel_placement
|
|
124
|
+
if self.channel_reference_frame is not None:
|
|
125
|
+
row['reference_frame'] = self.channel_reference_frame
|
|
126
|
+
if self.channel_description is not None:
|
|
127
|
+
row['description'] = self.channel_description
|
|
128
|
+
if self.channel_sampling_frequency is not None:
|
|
129
|
+
row['sampling_frequency'] = self.channel_sampling_frequency
|
|
130
|
+
if self.channel_status is not None:
|
|
131
|
+
row['status'] = self.channel_status
|
|
132
|
+
if self.channel_status_description is not None:
|
|
133
|
+
row['status_description'] = self.channel_status_description
|
|
134
|
+
|
|
135
|
+
return row
|
motionbids/datamodel.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data model for BIDS-compliant motion data.
|
|
3
|
+
|
|
4
|
+
This module defines the MotionData dataclass that represents motion capture
|
|
5
|
+
data with BIDS-compliant metadata fields.
|
|
6
|
+
"""
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Optional, List, Dict, Any
|
|
9
|
+
import numpy as np
|
|
10
|
+
from dataclasses_json import dataclass_json
|
|
11
|
+
from .channel import Channel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass_json
|
|
15
|
+
@dataclass
|
|
16
|
+
class MotionData:
|
|
17
|
+
"""
|
|
18
|
+
Dataclass representing BIDS-compliant motion capture data.
|
|
19
|
+
|
|
20
|
+
This class includes both required and optional metadata fields for motion data,
|
|
21
|
+
along with the actual motion time series data.
|
|
22
|
+
|
|
23
|
+
**Data Format:**
|
|
24
|
+
- `data`: NumPy array where **rows = timepoints** and **columns = channels** (REQUIRED)
|
|
25
|
+
- `channels`: List of Channel objects defining channel metadata (REQUIRED)
|
|
26
|
+
|
|
27
|
+
The `channels` list follows the BIDS specification for channels.tsv files.
|
|
28
|
+
Each Channel object contains: name, component, type, tracked_point, units (all required),
|
|
29
|
+
plus optional fields like placement, reference_frame, description, sampling_frequency, status.
|
|
30
|
+
|
|
31
|
+
The number of Channel objects MUST match the number of columns in the data array.
|
|
32
|
+
Channel metadata is validated against BIDS schema during Channel construction.
|
|
33
|
+
|
|
34
|
+
Required Fields (must be provided):
|
|
35
|
+
subject_id: Subject identifier (BIDS entity 'sub')
|
|
36
|
+
task_name: Name of the task (BIDS metadata 'TaskName')
|
|
37
|
+
sampling_frequency: Sampling frequency in Hz (BIDS metadata 'SamplingFrequency')
|
|
38
|
+
tracked_points_count: Number of tracked points (BIDS metadata 'TrackedPointsCount')
|
|
39
|
+
tracksys: Tracking system label (BIDS entity 'tracksys') - REQUIRED for filenames
|
|
40
|
+
|
|
41
|
+
Recommended Fields (should be provided when available):
|
|
42
|
+
manufacturer: Manufacturer of the motion capture system
|
|
43
|
+
manufacturers_model_name: Model name of the motion capture system
|
|
44
|
+
software_versions: Software version used for acquisition
|
|
45
|
+
motion_channel_count: Total number of motion channels
|
|
46
|
+
recording_duration: Duration of the recording in seconds
|
|
47
|
+
recording_type: Type of recording (e.g., 'continuous')
|
|
48
|
+
|
|
49
|
+
Optional Fields:
|
|
50
|
+
session_id: Session identifier (BIDS entity 'ses')
|
|
51
|
+
acquisition: Acquisition label (BIDS entity 'acq')
|
|
52
|
+
run: Run index (BIDS entity 'run')
|
|
53
|
+
acq_time: Acquisition time in ISO 8601 format with optional fractional seconds
|
|
54
|
+
(e.g., '2023-06-15T14:30:00' or '2023-06-15T14:30:00.123456')
|
|
55
|
+
Supports sub-millisecond precision. If provided, a scans.tsv file will be generated
|
|
56
|
+
additional_metadata: Dictionary for any additional custom metadata
|
|
57
|
+
|
|
58
|
+
Example:
|
|
59
|
+
>>> import numpy as np
|
|
60
|
+
>>> from motionbids.channel import Channel
|
|
61
|
+
>>> data = np.random.randn(1000, 30) # 1000 timepoints, 30 channels
|
|
62
|
+
>>> channels = [
|
|
63
|
+
... Channel(channel_name=f"marker{i//3}_{ax}", channel_component=ax, channel_type="POS",
|
|
64
|
+
... tracked_point=f"marker{i//3}", channel_units="mm")
|
|
65
|
+
... for i in range(30) for ax in ['x', 'y', 'z'] if i % 3 == ['x', 'y', 'z'].index(ax)
|
|
66
|
+
... ]
|
|
67
|
+
>>> motion = MotionData(
|
|
68
|
+
... subject_id="01",
|
|
69
|
+
... task_name="walk",
|
|
70
|
+
... sampling_frequency=120.0,
|
|
71
|
+
... tracked_points_count=10,
|
|
72
|
+
... tracksys="optical",
|
|
73
|
+
... data=data,
|
|
74
|
+
... channels=channels
|
|
75
|
+
... )
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
# Required fields
|
|
79
|
+
subject_id: str
|
|
80
|
+
task_name: str
|
|
81
|
+
sampling_frequency: float
|
|
82
|
+
tracked_points_count: int
|
|
83
|
+
tracksys: str # REQUIRED for BIDS motion data filenames
|
|
84
|
+
|
|
85
|
+
# Recommended fields
|
|
86
|
+
manufacturer: Optional[str] = None
|
|
87
|
+
manufacturers_model_name: Optional[str] = None
|
|
88
|
+
software_versions: Optional[str] = None
|
|
89
|
+
motion_channel_count: Optional[int] = None
|
|
90
|
+
recording_duration: Optional[float] = None
|
|
91
|
+
recording_type: Optional[str] = "continuous"
|
|
92
|
+
|
|
93
|
+
# Optional BIDS entities
|
|
94
|
+
session_id: Optional[str] = None
|
|
95
|
+
acquisition: Optional[str] = None
|
|
96
|
+
run: Optional[int] = None
|
|
97
|
+
acq_time: Optional[str] = None # ISO 8601 with optional fractional seconds (e.g., '2023-06-15T14:30:00.123456')
|
|
98
|
+
|
|
99
|
+
# Motion data (REQUIRED - have defaults to avoid field ordering issues, validated in __post_init__)
|
|
100
|
+
data: Optional[np.ndarray] = field(default=None, repr=False)
|
|
101
|
+
channels: Optional[List[Channel]] = None
|
|
102
|
+
|
|
103
|
+
# Additional metadata
|
|
104
|
+
additional_metadata: Optional[Dict[str, Any]] = field(default_factory=dict)
|
|
105
|
+
|
|
106
|
+
def __post_init__(self):
|
|
107
|
+
"""Validate basic field types and constraints."""
|
|
108
|
+
if self.sampling_frequency <= 0:
|
|
109
|
+
raise ValueError("sampling_frequency must be positive")
|
|
110
|
+
|
|
111
|
+
if self.tracked_points_count <= 0:
|
|
112
|
+
raise ValueError("tracked_points_count must be positive")
|
|
113
|
+
|
|
114
|
+
if self.run is not None and self.run < 1:
|
|
115
|
+
raise ValueError("run must be >= 1")
|
|
116
|
+
|
|
117
|
+
# Validate data and channels together - both required if either is provided
|
|
118
|
+
if self.data is not None or self.channels is not None:
|
|
119
|
+
if self.data is None:
|
|
120
|
+
raise ValueError("data is required when channels are provided")
|
|
121
|
+
|
|
122
|
+
if self.channels is None:
|
|
123
|
+
raise ValueError("channels is required when data is provided")
|
|
124
|
+
|
|
125
|
+
if not isinstance(self.data, np.ndarray):
|
|
126
|
+
raise TypeError("data must be a numpy array")
|
|
127
|
+
|
|
128
|
+
if self.data.ndim not in [1, 2]:
|
|
129
|
+
raise ValueError("data must be 1D or 2D array")
|
|
130
|
+
|
|
131
|
+
# Determine expected number of channels
|
|
132
|
+
if self.data.ndim == 1:
|
|
133
|
+
expected_channels = 1
|
|
134
|
+
else:
|
|
135
|
+
expected_channels = self.data.shape[1]
|
|
136
|
+
|
|
137
|
+
# Validate channels list length matches data dimensions
|
|
138
|
+
if len(self.channels) != expected_channels:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"Number of channels ({len(self.channels)}) must match "
|
|
141
|
+
f"data columns ({expected_channels})"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Channel validation happens in Channel.__post_init__
|
|
145
|
+
|
|
146
|
+
def get_bids_filename(self, suffix: str = "motion", extension: str = "json") -> str:
|
|
147
|
+
"""
|
|
148
|
+
Generate BIDS-compliant filename for this motion data.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
suffix: BIDS suffix (default: 'motion')
|
|
152
|
+
extension: File extension without dot (default: 'json')
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
BIDS-compliant filename string
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
ValueError: If tracksys is not provided (required for motion data)
|
|
159
|
+
|
|
160
|
+
Example:
|
|
161
|
+
>>> motion = MotionData(subject_id="01", task_name="rest", tracksys="optical", ...)
|
|
162
|
+
>>> motion.get_bids_filename()
|
|
163
|
+
'sub-01_task-rest_tracksys-optical_motion.json'
|
|
164
|
+
|
|
165
|
+
Note:
|
|
166
|
+
Entity order follows BIDS specification:
|
|
167
|
+
sub-<label>[_ses-<label>]_task-<label>_tracksys-<label>[_acq-<label>][_run-<index>]
|
|
168
|
+
The tracksys entity is REQUIRED for motion data.
|
|
169
|
+
"""
|
|
170
|
+
if not self.tracksys:
|
|
171
|
+
raise ValueError(
|
|
172
|
+
"tracksys entity is required for BIDS motion data filenames. "
|
|
173
|
+
"Please provide a tracking system label (e.g., 'optical', 'imu', 'video')."
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
parts = [f"sub-{self.subject_id}"]
|
|
177
|
+
|
|
178
|
+
if self.session_id:
|
|
179
|
+
parts.append(f"ses-{self.session_id}")
|
|
180
|
+
|
|
181
|
+
parts.append(f"task-{self.task_name}")
|
|
182
|
+
|
|
183
|
+
# tracksys is REQUIRED and comes before acq
|
|
184
|
+
parts.append(f"tracksys-{self.tracksys}")
|
|
185
|
+
|
|
186
|
+
if self.acquisition:
|
|
187
|
+
parts.append(f"acq-{self.acquisition}")
|
|
188
|
+
|
|
189
|
+
if self.run:
|
|
190
|
+
parts.append(f"run-{self.run:02d}")
|
|
191
|
+
|
|
192
|
+
parts.append(suffix)
|
|
193
|
+
|
|
194
|
+
filename = "_".join(parts) + f".{extension}"
|
|
195
|
+
return filename
|
|
196
|
+
|
|
197
|
+
def to_metadata_dict(self) -> Dict[str, Any]:
|
|
198
|
+
"""
|
|
199
|
+
Convert MotionData to a dictionary suitable for JSON export.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Dictionary containing all metadata fields (excluding data array, columns, and units)
|
|
203
|
+
"""
|
|
204
|
+
metadata = {
|
|
205
|
+
"TaskName": self.task_name,
|
|
206
|
+
"SamplingFrequency": self.sampling_frequency,
|
|
207
|
+
"TrackedPointsCount": self.tracked_points_count,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
# Add recommended fields if present
|
|
211
|
+
if self.manufacturer:
|
|
212
|
+
metadata["Manufacturer"] = self.manufacturer
|
|
213
|
+
if self.manufacturers_model_name:
|
|
214
|
+
metadata["ManufacturersModelName"] = self.manufacturers_model_name
|
|
215
|
+
if self.software_versions:
|
|
216
|
+
metadata["SoftwareVersions"] = self.software_versions
|
|
217
|
+
if self.motion_channel_count:
|
|
218
|
+
metadata["MotionChannelCount"] = self.motion_channel_count
|
|
219
|
+
if self.recording_duration:
|
|
220
|
+
metadata["RecordingDuration"] = self.recording_duration
|
|
221
|
+
if self.recording_type:
|
|
222
|
+
metadata["RecordingType"] = self.recording_type
|
|
223
|
+
|
|
224
|
+
# Note: columns and units are NOT exported to JSON - they go in channels.tsv
|
|
225
|
+
|
|
226
|
+
# Add any additional metadata
|
|
227
|
+
if self.additional_metadata:
|
|
228
|
+
metadata.update(self.additional_metadata)
|
|
229
|
+
|
|
230
|
+
return metadata
|