MotionData Class Reference¶
The MotionData class is the core of motionbids, representing a single motion capture recording with BIDS-compliant metadata.
Class Overview¶
from motionbids import MotionData, Channel
import numpy as np
# Example: 10 markers, each with x, y, z components
n_markers = 10
n_channels = n_markers * 3
# Create channel metadata following BIDS schema
channels = [
Channel(
channel_name=f"marker{i}_{axis}",
channel_component=axis,
channel_type="POS",
channel_tracked_point=f"marker{i}",
channel_units="mm"
)
for i in range(n_markers)
for axis in ['x', 'y', 'z']
]
motion = MotionData(
# Required fields
subject="01",
task_name="walk",
tracksys="optical",
sampling_frequency=120.0,
tracked_points_count=10,
# Data and channels (REQUIRED)
data=np.random.randn(1200, n_channels),
channels=channels
)
Field Categories¶
Required Fields¶
Must be provided for all motion data:
| Field | Type | BIDS Name | Description |
|---|---|---|---|
subject |
str |
sub |
Subject identifier |
task_name |
str |
TaskName |
Task performed (e.g., "walk") |
tracksys |
str |
tracksys |
Tracking system (e.g., "optical") |
sampling_frequency |
float |
SamplingFrequency |
Sampling rate in Hz |
tracked_points_count |
int |
TrackedPointsCount |
Number of markers/points |
Recommended Fields¶
Should be provided when available:
| Field | Type | BIDS Name | Description |
|---|---|---|---|
manufacturer |
str |
Manufacturer |
System manufacturer |
manufacturers_model_name |
str |
ManufacturersModelName |
System model |
software_versions |
str |
SoftwareVersions |
Acquisition software |
motion_channel_count |
int |
MotionChannelCount |
Total channels |
recording_duration |
float |
RecordingDuration |
Duration in seconds |
recording_type |
str |
RecordingType |
Type (default: "continuous") |
Optional Entity Fields¶
For organizing multi-session/run studies:
| Field | Type | BIDS Name | Description |
|---|---|---|---|
session |
str |
ses |
Session identifier |
acquisition |
str |
acq |
Acquisition label |
run |
int |
run |
Run index (1-indexed) |
acq_time |
str |
- | ISO 8601 timestamp |
Data Fields¶
Time series data and channel configuration:
| Field | Type | Description |
|---|---|---|
data |
np.ndarray |
Motion data (rows=time, cols=channels) - REQUIRED |
channels |
List[Channel] |
Channel metadata objects - REQUIRED |
reference_frames |
List[ReferenceFrame] |
Descriptions of the coordinate systems used by the channels (optional) |
additional_metadata |
Dict |
Custom metadata fields |
Channel Objects Required
The channels field is required when providing data. It must be a list of Channel objects, and the length must match the number of columns in the data array. Each Channel object contains: name, component, type, tracked_point, units (all required), plus optional fields like placement, reference_frame, description, sampling_frequency, status, and status_description. Channel validation happens during Channel construction following BIDS schema.
Reference Frames Optional
The reference_frames field takes a list of ReferenceFrame objects describing what the labels in the reference_frame channel column mean. Frame names must be unique, and passing anything other than ReferenceFrame objects raises a TypeError. When set, export_bids_motion additionally writes the *_channels.json sidecar next to the *_channels.tsv it describes. See Reference Frame Description.
Relations to BIDS¶
Filename Mapping¶
Entity fields appear in the BIDS filename in this order:
motion = MotionData(
subject="01",
session="01",
task_name="walk",
tracksys="optical",
acquisition="indoor",
run=1
)
filename = motion.get_bids_filename()
# Result: sub-01_ses-01_task-walk_tracksys-optical_acq-indoor_run-01_motion.json
Entity order (required by BIDS):
1. sub (always first)
2. ses (if provided)
3. task (required)
4. tracksys (required for motion)
5. acq (if provided)
6. run (if provided)
JSON Metadata Mapping¶
Metadata fields are written to the *_motion.json file with BIDS-compliant names:
| Python Field | JSON Field |
|---|---|
task_name |
TaskName |
sampling_frequency |
SamplingFrequency |
tracked_points_count |
TrackedPointsCount |
manufacturer |
Manufacturer |
manufacturers_model_name |
ManufacturersModelName |
software_versions |
SoftwareVersions |
motion_channel_count |
MotionChannelCount |
recording_duration |
RecordingDuration |
recording_type |
RecordingType |
Example JSON output:
{
"TaskName": "walk",
"SamplingFrequency": 120.0,
"TrackedPointsCount": 10,
"Manufacturer": "Vicon",
"ManufacturersModelName": "Vantage V5",
"RecordingType": "continuous"
}
TSV Data Mapping¶
The data array is written to *_motion.tsv:
- Rows: timepoints (samples)
- Columns: channels
- Format: Tab-separated, no headers (per BIDS spec)
data = np.array([
[100.23, 150.45, 75.12], # timepoint 0
[100.25, 150.48, 75.15], # timepoint 1
[100.27, 150.51, 75.18] # timepoint 2
])
Channels File Mapping¶
The columns, units, and channel metadata lists define the *_channels.tsv structure:
columns = ["marker0_x", "marker0_y", "marker0_z"]
units = ["mm", "mm", "mm"]
channel_component = ["x", "y", "z"]
channel_type = ["POS", "POS", "POS"]
channel_tracked_point = ["marker0", "marker0", "marker0"]
Generates:
name component type tracked_point units
marker0_x x POS marker0 mm
marker0_y y POS marker0 mm
marker0_z z POS marker0 mm
Valid component values:
- x, y, z - Spatial axes
- quat_w, quat_x, quat_y, quat_z - Quaternion components
- n/a - Not applicable
Valid type values (uppercase required):
- POS - Position
- ORNT - Orientation
- VEL - Velocity
- ACCEL - Acceleration
- GYRO - Gyroscope
- ANGACCEL - Angular acceleration
- MAGN - Magnetometer
- JNTANG - Joint angle
- LATENCY - Latency
- MISC - Miscellaneous
Component-type compatibility is validated:
- Quaternion components (quat_*) can only be used with ORNT type
- Spatial components (x, y, z) can be used with POS, VEL, ACCEL, GYRO, MAGN, ANGACCEL types
Reference Frame Description¶
The reference_frame column of *_channels.tsv only holds a label per
channel (e.g. "global"). What that label means — where the axes point, how
rotations are applied — is described by ReferenceFrame objects and written to
the *_channels.json sidecar.
from motionbids import ReferenceFrame
global_frame = ReferenceFrame(
name="global", # the label used in channels.tsv
spatial_axes="PLS", # X posterior, Y left, Z superior
rotation_order="ZXY",
rotation_rule="left-hand",
description="Laboratory frame of the optical system."
)
motion = MotionData(..., channels=channels, reference_frames=[global_frame])
Fields:
| Field | Type | BIDS Key | Description |
|---|---|---|---|
name |
str |
(level key) | Label as used in the reference_frame column of *_channels.tsv - REQUIRED |
spatial_axes |
str (optional) |
SpatialAxes |
Direction the X, Y, Z axes point to, one character each |
rotation_order |
str (optional) |
RotationOrder |
Order of the extrinsic rotations |
rotation_rule |
str (optional) |
RotationRule |
Direction of rotation around each axis |
description |
str (optional) |
Description |
Free-form description of the frame |
spatial_axes characters — one per axis, in the order X, Y, Z, naming the
direction that axis points to:
| Character | Direction |
|---|---|
A |
anterior (forward) |
P |
posterior (backward) |
L |
left |
R |
right |
S |
superior (up) |
I |
inferior (down) |
_ |
axis not used (e.g. "P_S" for 2D data) |
An optical system whose X points to the back of the room, Y to the left and Z up
is therefore "PLS".
Anatomical vs. egocentric characters
The reference frame section of the motion specification uses the anatomical
characters A, P, L, R, S, I, _. The SpatialAxes object in the
BIDS schema documents the equivalent egocentric set F, B, L, R, U,
D, _ (forward, backward, left, right, up, down), so "PLS" and "BLU"
describe the same frame. Both are accepted; using F/B/U/D emits a
UserWarning suggesting the anatomical spelling.
Valid rotation_order values: XYZ, XZY, YXZ, YZX, ZXY, ZYX, n/a
Valid rotation_rule values: left-hand, right-hand, n/a
Validation rules (checked during construction):
namemust be a non-empty string without tabs or newlines (it ends up in a TSV cell).spatial_axestakes one character per axis from{A, P, L, R, S, I, F, B, U, D, _}, so at most 3 and at least one. Fewer than 3 is accepted with aUserWarningsuggesting the_-padded spelling ("AL"→"AL_").- At least one axis must point somewhere:
"___"is rejected. - No two axes may point along the same physical direction —
"APS"and"AFS"are both rejected. rotation_orderandrotation_rulemust be one of the allowed values above.- At least one of
spatial_axes,rotation_order,rotation_ruleordescriptionmust be given — a frame that describes nothing raises aValueError.
handedness¶
Property deriving the handedness of the axis triad from spatial_axes alone:
"right-hand" when X × Y = Z, "left-hand" when X × Y = -Z, and None when
spatial_axes is unset or does not describe all three axes.
ReferenceFrame(name="global", spatial_axes="ALS").handedness
# 'right-hand'
ReferenceFrame(name="global", spatial_axes="PLS").handedness
# 'left-hand' — back/left/up is a left-handed triad
This describes the axes, not the rotation_rule. The two normally agree, so a
mismatch is worth a second look at either the axis directions or the rotation
convention.
to_anatomical_axes()¶
Return spatial_axes written with the anatomical characters only
(F/B/U/D replaced by A/P/S/I), or None if unset.
to_level_dict()¶
Convert the frame to the JSON object describing one level, containing only the fields that were set, in specification order.
global_frame.to_level_dict()
# {
# "SpatialAxes": "PLS",
# "RotationOrder": "ZXY",
# "RotationRule": "left-hand",
# "Description": "Laboratory frame of the optical system."
# }
reference_frames_to_dict(reference_frames)¶
Build the complete *_channels.json content from an iterable of frames.
from motionbids import reference_frames_to_dict
reference_frames_to_dict([ReferenceFrame(name="global", spatial_axes="ALS")])
# {'reference_frame': {'Levels': {'global': {'SpatialAxes': 'ALS'}}}}
Raises: ValueError if no frames are given or two frames share a name;
TypeError if an element is not a ReferenceFrame.
export_channels_json(data, output_path)¶
Write the reference frame description of a MotionData instance to a
*_channels.json file.
from motionbids import export_channels_json
export_channels_json(motion, "sub-01_task-walk_tracksys-optical_channels.json")
Parameters:
| Parameter | Type | Description |
|---|---|---|
data |
MotionData |
Instance with reference_frames defined |
output_path |
str \| Path |
Path where the channels.json file will be written |
Returns: Path to the created file
Raises: ValueError if reference_frames is empty or two frames share a name
The sidecar uses the same entities as channels.tsv, with suffix channels and
extension json. It is written automatically by export_bids_motion whenever
reference_frames is set and a channels.tsv is written — it describes a
column of that file — and returned under the key 'channels_json':
files = export_bids_motion(motion, out_dir=motion_dir)
# {'json': ..., 'tsv': ..., 'channels': ..., 'channels_json': ...}
Creates: sub-01_task-walk_tracksys-optical_channels.json
{
"reference_frame": {
"Levels": {
"global": {
"SpatialAxes": "PLS",
"RotationOrder": "ZXY",
"RotationRule": "left-hand",
"Description": "Laboratory frame of the optical system."
}
}
}
}
check_reference_frames(data)¶
Check that the channel labels and the described frames agree. Called by
validate_motion_data, which turns each message into a ValidationWarning.
from motionbids import check_reference_frames
check_reference_frames(motion) # [] when the two sides agree
Returns: list of warning messages, one for each way the two can drift apart:
- channels use a label that no
ReferenceFramedescribes - a
ReferenceFrameis described that no channel uses - channels use labels but no
ReferenceFrameis given at all, so no*_channels.jsonwill be written
The n/a label — how channels.tsv spells "no reference frame" — is not
reported, since it is not a level channels.json is expected to describe.
Scans File Mapping¶
When acq_time is provided, a *_scans.tsv file is created:
motion = MotionData(
subject="01",
session="01",
task_name="walk",
acq_time="2025-11-05T14:30:00",
...
)
Creates: sub-01/sub-01_ses-01_scans.tsv
Participants File¶
Use export_participants_tsv to create or update a participants.tsv at the
dataset root. Existing entries for other participants are preserved.
from motionbids import export_participants_tsv
export_participants_tsv(
bids_root="my_study",
participant_id="01", # "sub-" prefix added automatically
age="25",
sex="F",
handedness="R",
group="control" # extra columns via **kwargs
)
Creates / updates: participants.tsv
Parameters:
| Parameter | Type | Description |
|---|---|---|
bids_root |
str \| Path |
Dataset root directory |
participant_id |
str |
Subject ID (auto-prefixed with sub-) |
age |
str (optional) |
Participant age |
sex |
str (optional) |
"M", "F", or "O" |
handedness |
str (optional) |
"L", "R", or "A" |
**kwargs |
Additional columns (e.g., group="control") |
Behaviour:
- If
participants.tsvdoes not exist → creates file with header + row. - If it exists and the participant is new → appends a row.
- If it exists and the participant already has a row → updates that row.
- A
UserWarningis emitted when the file already exists.
Class Methods¶
get_bids_filename(suffix, extension)¶
Generate BIDS-compliant filename.
motion.get_bids_filename()
# 'sub-01_task-walk_tracksys-optical_motion.json'
motion.get_bids_filename(suffix="channels", extension="tsv")
# 'sub-01_task-walk_tracksys-optical_channels.tsv'
Parameters:
- suffix (str): BIDS suffix (default: "motion")
- extension (str): File extension without dot (default: "json")
Returns: BIDS filename string
Raises: ValueError if tracksys is not provided
to_metadata_dict()¶
Convert to dictionary for JSON export.
metadata = motion.to_metadata_dict()
# {
# "TaskName": "walk",
# "SamplingFrequency": 120.0,
# "TrackedPointsCount": 10,
# ...
# }
Returns: Dictionary with BIDS-compliant field names (PascalCase)
Excludes:
- Entity fields (in filename)
- data, columns, units (in separate files)
Data Format Requirements¶
Array Shape¶
# ✓ Correct: rows=time, columns=channels
data = np.random.randn(1000, 30) # 1000 timepoints, 30 channels
# ✗ Wrong: transposed
data = np.random.randn(30, 1000) # Would interpret as 30 timepoints!
Dimension Matching¶
# All must match:
data.shape[1] == len(columns) == len(units)
# Example:
data = np.random.randn(1200, 30) # 30 channels
columns = [f"ch{i}" for i in range(30)] # 30 names
units = ["mm"] * 30 # 30 units
Validation Rules¶
Required Field Validation¶
# ✓ Valid
motion = MotionData(
subject="01",
task_name="walk",
tracksys="optical",
sampling_frequency=120.0,
tracked_points_count=10
)
# ✗ Missing tracksys → ValueError
motion = MotionData(
subject="01",
task_name="walk",
sampling_frequency=120.0,
tracked_points_count=10
)
Numeric Validation¶
# ✓ Valid
sampling_frequency = 120.0 # Positive
# ✗ Invalid → ValueError
sampling_frequency = 0.0 # Zero
sampling_frequency = -120.0 # Negative
Run Validation¶
Reference Frame Validation¶
# ✓ Valid
ReferenceFrame(name="global", spatial_axes="PLS")
# ✗ Invalid → ValueError (a 4th character has no axis to map onto)
ReferenceFrame(name="global", spatial_axes="PLSI")
# ✗ Invalid → ValueError (no axis points anywhere)
ReferenceFrame(name="global", spatial_axes="___")
# ⚠️ UserWarning: only 2 of 3 axes described, "PL_" suggested instead
ReferenceFrame(name="planar", spatial_axes="PL")
# ✗ Invalid → ValueError (X and Y on the same direction)
ReferenceFrame(name="global", spatial_axes="APS")
# ✗ Invalid → ValueError (describes nothing)
ReferenceFrame(name="global")
# ⚠️ UserWarning: egocentric characters, "PLS" suggested instead
ReferenceFrame(name="global", spatial_axes="BLU")
Complete Example¶
import numpy as np
from motionbids import MotionData
# Generate sample data
n_timepoints = 1200
n_markers = 10
data = np.random.randn(n_timepoints, n_markers * 3)
# Create motion object with all field types
motion = MotionData(
# Required
subject="01",
task_name="walk",
tracksys="optical",
sampling_frequency=120.0,
tracked_points_count=10,
# Recommended
manufacturer="Vicon",
manufacturers_model_name="Vantage V5",
software_versions="Nexus 2.12",
motion_channel_count=30,
recording_duration=10.0,
recording_type="continuous",
# Optional entities
session="01",
acquisition="indoor",
run=1,
acq_time="2025-11-05T14:30:00",
# Data and channels
data=data,
channels=channels,
# Custom
additional_metadata={
"CaptureVolume": "8m x 6m x 3m",
"CalibrationDate": "2025-11-01"
}
)
# Access fields
print(motion.subject) # "01"
print(motion.task_name) # "walk"
print(motion.data.shape) # (1200, 30)
print(len(motion.channels)) # 30
# Generate filename
filename = motion.get_bids_filename()
# "sub-01_ses-01_task-walk_tracksys-optical_acq-indoor_run-01_motion.json"
# Export metadata
metadata = motion.to_metadata_dict()
# {"TaskName": "walk", "SamplingFrequency": 120.0, ...}
BIDS Compliance Summary¶
| Python Component | BIDS File/Field | Purpose |
|---|---|---|
| Entity fields | Filename entities | Organize dataset hierarchy |
| Metadata fields | *_motion.json |
Acquisition parameters |
data array |
*_motion.tsv |
Time series data |
columns + units |
*_channels.tsv |
Channel descriptions |
reference_frames |
*_channels.json |
Reference frame descriptions |
acq_time |
*_scans.tsv |
Acquisition timestamps |
Key principle: Python uses snake_case, BIDS uses PascalCase. The package handles all conversions automatically.