Skip to content

Reading files

rec = mm.read(path)          # dispatches on content
mm.sniff(path)               # -> the layout name, without reading the data

read inspects the file's first lines rather than its extension. In the corpus this was built for, the extension lies: the balance-board dumps are named .tsv and are space-delimited and headerless.

What you get back

Every reader returns a MotionRecord with the same fields whatever the source:

Field Meaning
data (n_samples, n_channels) float, gaps as NaN
fs sampling rate, measured where the file has a timebase
channels column names
kind "position" or "acceleration" — decides differentiate or integrate
unit "mm", "m", "g", "m/s^2", "counts"
vertical which axis is up; not always Z
t, t0 timestamps and absolute start, where they exist
meta whatever else the header carried

Supported layouts

Reader Format
read_qualisys Qualisys and Qualisys-style TSV, all three header shapes
read_sverm plain-header optical export, Time plus per-subject columns
read_ax3 Axivity AX3, ts,x,y,z
read_phone Physics Toolbox phone log, raw app export or cleaned TSV
read_equivital Equivital physiology CSV
read_balance_board Wii balance board, headerless and irregular
read_fnirs Artinis Brite wide export, IMU channels only

The Qualisys reader detects its own variant by trying to parse the eleventh line as numbers, so exports differing in whether they carry a column-name row, or Frame/Time columns, all read correctly.

Coordinate frames: which axis is up, and how to rotate

MotionRecord.vertical exists because the vertical axis is not always Z. Getting this wrong does not raise anything. It silently swaps a vertical measurement for a horizontal one, and every magnitude still looks plausible.

What the systems do

System Default frame Units in export
Qualisys (QTM) Z-up, right-handed millimetres
OptiTrack (Motive) Y-up, right-handed — but configurable, and configured differently between sessions metres
C3D generally whatever the source wrote; read POINT:UNITS declared in the file

Do not infer the frame from the system. The same OptiTrack rig, configured differently at two sessions, will give you Y-up files from one and Z-up from the other. The only reliable checks are the file's own metadata (POINT:UNITS for C3D) and the data itself, since a standing head marker sits at roughly 1.5–1.9 in whatever unit is in force, on whichever axis is vertical.

import numpy as np
med = [np.nanmedian(a[:, k::3]) for k in range(3)]   # a is (frames, 3*markers)
vertical = int(np.argmax(med))                        # the axis holding head height

Rotating Y-up to Z-up

X_zup = X_yup
Y_zup = -Z_yup
Z_zup =  Y_yup

Use this rather than swapping Y and Z. A swap is a reflection: it mirrors the horizontal plane, so every sway direction reverses sign and every rotational statistic inverts, while all magnitudes, speed and range and quantity of motion, stay exactly as they were and give no hint that anything moved. The mapping above is a rotation, which preserves handedness: X × (−Z) = −(X × Z) = Y, the new Z.

Gaps survive it. Missing samples are the zero triplet and negating zero leaves zero.

The check that a rotation was correct

Quantity of motion is the magnitude of a 3-D speed, so it is invariant under rotation and must come out unchanged to floating-point precision. If it moves at all, the transform is wrong. The vertical median should land on the new axis at the value it had on the old one.

That test is cheap and worth running on any converted corpus: it distinguishes a rotation from a reflection, from an axis swap, and from a unit error, all of which otherwise produce data that looks entirely reasonable.

Fix the data, then hunt for the compensations

If you rotate a dataset at source, every script that special-cased its old frame becomes wrong. A known wrinkle tends to be worked around in many places, and those workarounds are invisible until the wrinkle is gone, and at that point each one silently inverts the correction it was making. Grep for the compensation before changing the data, and empty it in the same commit.

Physics Toolbox Sensor Suite

A widely used phone sensor-logging app. read_phone accepts both the app's own export and the cleaned tab-separated form, detecting which from the first two lines, so nothing needs converting by hand.

What the app's CSV actually is

Not a plain CSV. Each of these has cost time at least once:

Quirk What goes wrong without it
Blank first line; header on line 2 header read as data
Semicolon delimiter one string column per row
Decimal comma (0,0523) every value parses as text
Unicode minus U+2212 () negatives become NaN — a tilt one way reads as missing, the other way reads fine
in the Gain column column stays text and poisons arithmetic
Trailing ; on each row a phantom Unnamed column
Latitude, Longitude, Speed present identifying location data ships with the motion

The Unicode minus is the dangerous one, because it fails asymmetrically: the sign of the value decides whether it survives, so a file can look nine-tenths intact and be systematically biased.

Columns, and which acceleration to use

gFx/gFy/gFz are the accelerometer: total specific force in g, gravity included, magnitude 1.0 on a phone at rest. ax/ay/az are linear acceleration in m/s², gravity removed by fusing the accelerometer with the gyroscope and magnetometer.

Use gF*, converted to m/s², which is what read_phone returns by default; pass channel="fused" for the other. A fusion cannot output faster than its slowest input, so a* advances at the gyroscope's rate, about 15 Hz, and in a 0.2–5 Hz band most of what it carries is its own noise floor rather than the body. At standstill amplitudes the body is below that floor, and the floor differs between handsets—so two phones recording the same stillness disagree by a factor that looks like a device calibration difference and is not. Measured: an S21 and an S23 differed by 2.29× on the fused channel and by 0.855× on the accelerometer, pointing the other way.

Two traps remain either side of that choice. Reading a* as if it were g inflates every quantity of motion by 9.80665, a clean constant, so rankings and correlations survive it and nothing looks wrong. Reading gF* without removing gravity inflates band-limited motion roughly 4000-fold, because a slowly rotating gravity vector has ample 0.2–5 Hz content that no band-pass removes; high-pass or detrend each axis first, which qom and accel_to_speed do.

The fusion's tilt correction is the one real argument for a*, and it is worth measuring rather than assuming. Reconstructed from a gyroscope on one chest recording, tilt accounted for 6 % of the accelerometer's band-limited content, and removing it did not move that channel toward the fused one.

Column availability varies by handset: the Galaxy A52s writes no p (pressure) column, so joining logs by column position rather than by name misaligns every channel after wz.

The sampling rate is not what you asked for

Physics Toolbox passes on whatever the Android sensor stack delivers. A log requested at 100 Hz arrives somewhere between about 100 and 170 Hz, with millisecond-scale jitter, and two handsets recording the same event will differ. In one three-phone session the measured rates were 105, 123 and 169 Hz. Always resample onto a uniform grid before filtering.

Dropouts are silent, and they are the real hazard

Logging stops when the app is backgrounded or the screen sleeps, then resumes without any marker. A file that looks like one continuous recording can contain a hole of tens of seconds. In the same three-phone session every one of the six files had at least one gap, the worst being 108 s inside a 111 s file, which left 2.6 s of actual data.

The timestamps are the app's own awake-time, not wall-clock. When Android suspends the app the clock stops with it. A ten-minute recording whose screen slept produced a file spanning 160 s. The missing 440 s is not present as a gap, it is absent from the timeline. Two phones dozed at different moments therefore have time bases that drift apart non-linearly, so no cross-correlation can recover a fixed offset between two such files, since there is no fixed offset to find. Align multi-device recordings on a physical event that appears in the signal itself, such as a sharp tap on a rigid stack holding all the phones, rather than on their clocks, and not on an acoustic clap, which leaves no accelerometer transient at all.

read_phone therefore reports:

meta key Meaning
gaps (start, end) in seconds for every break over 1 s
segments index ranges of the continuous runs
longest_continuous_s the longest unbroken stretch — usually the only analysable part
fs_span_average rate over the whole span, for reference

fs is measured over the longest continuous run, not the whole span. Averaging across a hole gives a number that is not a sampling rate at all: the 111 s file above yields 3.8 Hz by span average against a true 120 Hz, and handing that to a 0.2–5 Hz filter produces confident nonsense rather than an error.

Check longest_continuous_s before trusting a file's duration. A recording is only as long as its longest segment.

Sentinels become NaN

Each of these is a plausible-looking value that is not a measurement, and each has cost an analysis at least once:

Source Sentinel
optical zero coordinate triplet — a "marker at the origin"
phone exact-zero rows at the start of a recording
respiration belt samples pinned at 0 or 1023, the converter's rails
balance board centre of pressure at exactly (0.5, 0.5) under no load

Reading markers

rec.markers                  # names
rec.marker("HF")             # (n_samples, 3) for one marker

Always by name. Six files in one collection break the documented 22-marker order, and a positional read mis-assigns every marker in all of them.

Gaps

mm.gap_report(rec.data, rec.fs)
clean = mm.interpolate_gaps(rec.data, max_gap=200)

interpolate_gaps bridges short runs and leaves long ones as NaN. The line is drawn there because bridging a dropped frame is reconstruction, while bridging a 469-second hole is invention. A single missing fraction hides the distinction that matters. One per cent scattered evenly is a usable recording, and one per cent in a single block is two recordings.