API reference¶
Quantity of motion¶
micromotion.qom ¶
Quantity of motion.
The signature measure of this research programme: the average speed of a body part, in millimetres per second, restricted to the micromotion band.
There is one definition and three named variants. The point of naming them is that they answer different questions and previously differed only by an undocumented line of filter code. Any figure or paper should be able to say which variant produced its numbers.
Definition
Band-limit each axis to 0.3-10 Hz, bring it to velocity, band-limit again, take the Euclidean norm across axes, and report the mean in mm/s.
Acceleration is brought to velocity by integration, position by differentiation. The second band-limiting is not cosmetic. Integrating a signal with any residual offset produces a ramp that dominates the result, and differentiating amplifies the high-frequency noise that the first pass was meant to exclude.
Variants
raw
The band as defined. Contains respiration, the ballistocardiac impulse and postural
sway together. This is what the deposited files report unless they say otherwise.
compensated
Respiration and cardiac activity removed: the lower edge is raised to 0.5 Hz and the
per-recording cardiac peak is notched out. What is left is postural micromotion.
tilt_corrected
For a single accelerometer only. A body-worn accelerometer cannot distinguish leaning
from translating: tilting into gravity produces an acceleration with no displacement.
Where a gyroscope is available the tilt component is estimated and removed. Measured
directly on the fNIRS session, tilt inflates raw QoM by 1.56x.
G
module-attribute
¶
G = 9.80665
Standard gravity, m/s^2.
Present because accelerometers export in g and the band-limited result must be in SI before integration. Getting this wrong is not hypothetical: every phone quantity of motion in this project was 9.80665x too large until 2026-07-28.
BANDS
module-attribute
¶
BANDS = {'micromotion': filters.BAND, 'optical_legacy': filters.OPTICAL_LEGACY_BAND}
The two conventions in use, by name.
micromotion is 0.3-10 Hz and is the only one that can be applied to every sensor, so it
is the one a cross-collection comparison must use. optical_legacy is the 10 Hz low-pass
behind the published championship figures; it retains sub-0.3 Hz postural drift and reads
about 15 per cent higher.
QomResult
dataclass
¶
Quantity of motion, with the series it was reduced from.
Source code in src/micromotion/qom.py
56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 | |
binned ¶
binned(bin_s: float = 5.0)
Average speed in fixed-width bins, as a DataFrame.
The final bin is usually partial and is flagged rather than dropped, because a short bin is not comparable with a full one and silently including it inflated the deposited five-second series three- to fourteenfold.
Source code in src/micromotion/qom.py
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 | |
speed_from_acceleration ¶
speed_from_acceleration(acc, fs: float, unit: str = 'm/s^2', lo: float = filters.BAND[0], hi: float = filters.BAND[1], notch_hz: float | None = None, integrate: str = 'rectangle') -> np.ndarray
Band-limited speed, in mm/s, from acceleration.
acc is (n_samples, n_axes). unit is "m/s^2" or "g".
integrate selects the quadrature rule, and the choice is not cosmetic. Both are in
use in this corpus: the StillStanding365 and fNIRS pipelines integrate with the trapezoid
rule, the Stillness2025, Taqasim and 2024 championship pipelines with a rectangle sum.
They differ by about 0.26 per cent on real phone data, which is small but is a systematic
bias rather than noise -- the rectangle rule lags the signal by half a sample.
Neither rule is universally right here, so the default is the one that keeps this
package's own published numbers self-consistent: "rectangle" is what the harmonised
cross-collection table and every figure derived from it were computed with, and it
reproduces the deposited Taqasim value (93.140 against 93.091 mm/s) where the trapezoid
rule gives 93.405. Pass integrate="trapezoid" to reproduce StillStanding365 and
fNIRS, whose deposited pipelines use it.
Which rule the project should standardise on is an open question, deliberately not settled by this default.
Source code in src/micromotion/qom.py
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | |
derivative ¶
derivative(x, fs: float) -> np.ndarray
Fourth-order central difference along the first axis.
The two-point central difference that numpy.gradient computes has the frequency
response sin(w*dt)/dt rather than w, so it increasingly under-reads towards Nyquist.
That does not matter at 200 Hz, where the top of the micromotion band is a twentieth of
Nyquist, but it matters at the 20 Hz common rate where the band edge is Nyquist itself:
on real optical data the two-point rule loses 4.9 per cent of the quantity of motion
across that resampling, and this rule loses 2.0 per cent.
A spectral derivative would be exact for a truly band-limited signal and is not used here, because these recordings do not begin and end at the same value and the implied wraparound step adds broadband energy that differentiation then amplifies. Measured on the same file, it inflated the result by 24 per cent.
Source code in src/micromotion/qom.py
149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 | |
speed_from_position ¶
speed_from_position(pos, fs: float, unit: str = 'mm', lo: float = filters.BAND[0], hi: float = filters.BAND[1]) -> np.ndarray
Band-limited speed, in mm/s, from position.
pos is (n_samples, n_axes), normally the three coordinates of one optical marker.
unit is "mm" or "m".
Source code in src/micromotion/qom.py
173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 | |
qom ¶
qom(data, fs: float, kind: str = 'acceleration', unit: str | None = None, variant: str = 'raw', band: str = 'micromotion', gyro=None, integrate: str = 'rectangle') -> QomResult
Quantity of motion for one recording.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
(n_samples, n_axes) acceleration or position. |
required | |
fs
|
float
|
Measured sampling rate. Use the rate measured from the timestamps, not the nominal
one; see :func: |
required |
kind
|
str
|
|
'acceleration'
|
unit
|
str | None
|
Defaults to |
None
|
variant
|
str
|
|
'raw'
|
band
|
str
|
|
'micromotion'
|
gyro
|
(n_samples, 3) angular velocity in rad/s. Required by |
None
|
Source code in src/micromotion/qom.py
209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 | |
remove_tilt ¶
remove_tilt(acc, gyro, fs: float, unit: str = 'm/s^2') -> np.ndarray
Subtract the gravity component that rotation moves between axes.
The sensor's orientation is tracked by integrating the gyroscope, the gravity vector is rotated into the sensor frame at each sample, and what remains is translation. The integration drifts, so the estimated gravity direction is high-passed back towards the measured one; this is a complementary filter, not an attitude estimator, and it is adequate only because the body barely moves.
Source code in src/micromotion/qom.py
292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 | |
tilt_fraction ¶
tilt_fraction(acc, gyro, fs: float, unit: str = 'm/s^2') -> dict
How much of a body-worn accelerometer's quantity of motion is tilt rather than travel.
An accelerometer cannot tell leaning from moving: rotating in the gravity field produces an acceleration with no displacement. Where a gyroscope is present the rotation is known, so the gravity component it accounts for can be removed and the two compared.
Measured on the fNIRS session this returns 1.56, meaning the raw figure is a little over half again the translational one. Do not read a single session as a population value; the point is that the inflation is measurable rather than assumed, and the assumption in circulation was 1.3 to 1.5.
Source code in src/micromotion/qom.py
322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 | |
envelope ¶
envelope(x, fs, smooth=1.0, normalize=True)
Smooth, optionally z-scored envelope of a signal: Savitzky-Golay
smoothing (order 2, window smooth seconds) followed by
standardisation. Used to compare motion/audio envelopes across sources
on a common, amplitude-free scale.
Source: Westney-comparisons study (Jensenius).
Args: x (np.ndarray): Input 1-D signal. fs (float): Sampling rate of the signal (Hz). smooth (float, optional): Smoothing window in seconds. None or 0 disables smoothing. Defaults to 1.0. normalize (bool, optional): If True, z-score the result. Defaults to True.
Returns: np.ndarray: The smoothed (and optionally z-scored) envelope, same length as the input.
Source code in src/micromotion/qom.py
362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 | |
bin_series ¶
bin_series(x, fs, bin_s=1.0)
Mean of consecutive, non-overlapping bins of a signal (e.g. a per-second quantity-of-motion envelope from a per-frame speed series). Trailing samples that do not fill a whole bin are dropped.
Source: stillstanding study (Jensenius); also used in the Westney-comparisons study as a per-second envelope.
Args: x (np.ndarray): Input 1-D signal. fs (float): Sampling rate of the signal (Hz). bin_s (float, optional): Bin length in seconds. Defaults to 1.0.
Returns: np.ndarray: One mean value per bin (empty if the signal is shorter than two bins).
Source code in src/micromotion/qom.py
393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 | |
band_limited_qom ¶
band_limited_qom(pos, fs, lo=0.3, hi=15.0, order=4, auto_decimate=True)
Band-limited quantity of motion from a position trajectory: the position
is band-pass filtered (zero phase) to [lo, hi] Hz and the QoM is the
per-frame speed, i.e. the Euclidean norm of the first difference times
the sampling rate (units of the input per second).
For very low bands relative to the sampling rate (band edge below about
fs/40), a direct high-order band-pass is numerically fragile; in that
regime the trajectory is first decimated (zero phase) so the band sits
comfortably in the new Nyquist range, then filtered with a second-order
section (SOS) band-pass. This is the "slow sway" regime (e.g. 0.1-0.5 Hz
postural sway from 100 Hz mocap). Set auto_decimate=False to force the
direct filter.
Source: stillstanding study and Westney-comparisons study (Jensenius) -- this unifies the band-limited QoM cores used on mocap markers (mm), MediaPipe landmarks (px) and slow postural sway across both studies.
Args: pos (np.ndarray): Position trajectory of shape (N,) or (N, D) (e.g. D=2 image coordinates or D=3 mocap coordinates). Non-finite samples are linearly interpolated per dimension. fs (float): Sampling rate of the trajectory (Hz). lo (float, optional): Lower band edge (Hz). Defaults to 0.3. hi (float, optional): Upper band edge (Hz), clipped to 0.9 x Nyquist. Defaults to 15.0. order (int, optional): Butterworth order of the direct band-pass. Defaults to 4. auto_decimate (bool, optional): Enable the decimate+SOS low-band regime. Defaults to True.
Returns:
tuple: (speed, fs_out) where speed is the per-frame speed series
(length N-1, or shorter when decimated) and fs_out is its
sampling rate (equal to fs unless decimated). speed is empty
(and fs_out equals the input fs) when the input has fewer
than int(fs) + 5 samples, or when it still contains non-finite
samples after per-dimension interpolation (i.e. a dimension had
fewer than 3 finite samples to interpolate from). In the
auto-decimate regime, speed is also empty (with fs_out the
decimated rate) when decimation leaves fewer than ~30 samples --
too few for a stable SOS band-pass.
Raises:
ValueError: If the band is invalid, i.e. does not satisfy
0 < lo < hi <= 0.45*fs (after hi is clipped to 0.9 x Nyquist).
Source code in src/micromotion/qom.py
419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 | |
accel_to_speed ¶
accel_to_speed(acc, fs, highpass=0.3, order=2, normalize_gravity=False)
Integrated speed from a 3-axis accelerometer: each axis is high-pass filtered (removing gravity and DC), integrated to velocity, high-pass filtered again (killing integration drift), and the speed is the Euclidean norm of the velocity (m/s for input in m/s^2).
Source: stillstanding study (Jensenius) -- the "corpus method" for integrated quantity of motion from chest-worn accelerometers.
Args:
acc (np.ndarray): Acceleration of shape (N, 3) in m/s^2 (or raw counts
with normalize_gravity=True).
fs (float): Sampling rate (Hz).
highpass (float, optional): High-pass cutoff (Hz) used both before and
after integration. Defaults to 0.3.
order (int, optional): Butterworth order of the high-pass filters.
Defaults to 2.
normalize_gravity (bool, optional): If True, rescale the raw input so
that the median vector magnitude equals 1 g (9.80665 m/s^2) before
filtering -- useful for uncalibrated sensors whose resting output
should be gravity. Defaults to False.
Returns: np.ndarray: Speed series of length N (m/s).
Source code in src/micromotion/qom.py
497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 | |
group_qom ¶
group_qom(points, fs, lo=0.3, hi=15.0, **kwargs)
Mean band-limited quantity of motion over a group of markers/landmarks,
plus the group's mean speed envelope: each trajectory is passed through
band_limited_qom and the per-trajectory speeds are averaged.
Source: stillstanding study and Westney-comparisons study (Jensenius) -- per-body-part QoM (head, shoulders, arms, wrists) from mocap markers and pose landmarks.
Args:
points (np.ndarray): Trajectories of shape (N, M, D): N frames, M
markers/landmarks, D spatial dimensions.
fs (float): Sampling rate (Hz).
lo (float, optional): Lower band edge (Hz). Defaults to 0.3.
hi (float, optional): Upper band edge (Hz). Defaults to 15.0.
**kwargs: Passed on to band_limited_qom.
Returns:
tuple: (qom, speed, fs_out) where qom is the mean speed across
markers and time (NaN if no marker yields a valid series), speed
is the group's mean per-frame speed series, and fs_out its
sampling rate.
Source code in src/micromotion/qom.py
536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 | |
pose_qom ¶
pose_qom(landmarks, fs, lo=0.3, hi=5.0, **kwargs)
Band-limited quantity of motion of 2-D pose landmarks (px/s): a thin
wrapper around group_qom with the band used for image-space pose
trajectories (0.3-5 Hz), where higher bands are dominated by landmark
jitter rather than motion.
Source: Westney-comparisons study (Jensenius).
Args:
landmarks (np.ndarray): Landmark trajectories of shape (N, L, 2) in
pixels (a single landmark of shape (N, 2) is also accepted).
fs (float): Sampling rate (Hz, e.g. video frame rate).
lo (float, optional): Lower band edge (Hz). Defaults to 0.3.
hi (float, optional): Upper band edge (Hz). Defaults to 5.0.
**kwargs: Passed on to band_limited_qom.
Returns:
tuple: (qom, speed, fs_out) as in group_qom.
Source code in src/micromotion/qom.py
573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 | |
body_scale ¶
body_scale(landmarks, upper=(11, 12), lower=(23, 24))
Body-size scale (in the landmarks' own units, e.g. pixels) as the median
torso length: the distance from the midpoint of the upper landmarks
(shoulders) to the midpoint of the lower landmarks (hips). The torso
length is preferred over shoulder width because it stays robust in a
profile view, where the shoulder width collapses.
The default indices are MediaPipe Pose landmarks (11/12 shoulders, 23/24 hips).
Source: Westney-comparisons study (Jensenius).
Args: landmarks (np.ndarray): Landmark trajectories of shape (N, L, C) with C >= 2; only the first two coordinates are used. upper (tuple, optional): Indices of the two shoulder landmarks. Defaults to (11, 12). lower (tuple, optional): Indices of the two hip landmarks. Defaults to (23, 24).
Returns: float: Median torso length (NaN if no finite frames).
Source code in src/micromotion/qom.py
599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 | |
normalized_qom ¶
normalized_qom(landmarks, fs, scale=None, lo=0.3, hi=5.0, upper=(11, 12), lower=(23, 24), **kwargs)
Body-scale-normalised quantity of motion (body-lengths per second):
the pose QoM divided by the performer's own body scale (median torso
length, see body_scale). Being dimensionless, this is invariant to
camera framing/zoom and comparable across recordings.
Source: Westney-comparisons study (Jensenius) -- framing-invariant with/without-audience comparison of a pianist's motion.
Args:
landmarks (np.ndarray): Landmark trajectories of shape (N, L, 2).
fs (float): Sampling rate (Hz).
scale (float, optional): Precomputed body scale. Defaults to None (which
computes body_scale(landmarks, upper, lower)).
lo (float, optional): Lower band edge (Hz). Defaults to 0.3.
hi (float, optional): Upper band edge (Hz). Defaults to 5.0.
upper (tuple, optional): Shoulder landmark indices for body_scale. Defaults to (11, 12).
lower (tuple, optional): Hip landmark indices for body_scale. Defaults to (23, 24).
**kwargs: Passed on to band_limited_qom.
Returns:
tuple: (qom, speed, fs_out) as in group_qom, with both qom and
speed divided by the body scale. When scale is non-finite
(e.g. body_scale found no finite torso-length sample) or not
strictly positive (degenerate, coincident upper/lower landmarks),
division would otherwise silently propagate NaN/inf through
qom and speed; instead both are explicitly returned as NaN
(qom as a NaN scalar, speed as an all-NaN array of the same
shape) so the invalid-scale case is unambiguous rather than
merely inferred from the arithmetic.
Source code in src/micromotion/qom.py
631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 | |
grid_qom ¶
grid_qom(frames, grid=(6, 4), region=(0.0, 1.0, 0.0, 1.0), threshold=8.0)
Spatial grid quantity of motion from a stack of grayscale frames: the
absolute inter-frame difference is thresholded (small differences set to
zero to suppress sensor noise) and averaged within each cell of a
grid[0] x grid[1] grid laid over region, yielding one motion time
series per cell plus a per-cell mean-motion heatmap.
Source: Westney-comparisons study (Jensenius) -- audience-region motion mapping in a concert hall.
Args: frames (np.ndarray): Grayscale frames of shape (T, H, W). grid (tuple, optional): Grid size (columns, rows). Defaults to (6, 4). region (tuple, optional): Region of interest as fractions (x0, x1, y0, y1) of the frame. Defaults to the full frame. threshold (float, optional): Absolute-difference threshold below which pixel changes are zeroed (0-255 scale). Defaults to 8.0.
Returns:
tuple: (series, heat) where series has shape (T-1, rows*cols)
(cells in row-major order) and heat has shape (rows, cols) with
each cell's time-mean motion.
Raises:
ValueError: If frames is not 3-D (T, H, W), as in
_motionanalysis.motiongram_data.
Source code in src/micromotion/qom.py
673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 | |
Filters and bands¶
micromotion.filters ¶
Band-limiting for the micromotion band.
One definition, used everywhere. Across the Still Standing repository 46 scripts defined their own filter and they did not all agree; the differences were invisible in the output and moved quantity of motion by up to 10 per cent.
The canonical band is 0.3-10 Hz, a zero-phase Butterworth of order 4 applied as second-order sections. The lower edge sits below the respiratory rate and above the postural drift that integration turns into a ramp; the upper edge is where accelerometer noise begins to dominate the signal of a person standing still.
BAND
module-attribute
¶
BAND = (0.2, 10.0)
The micromotion band, in Hz.
The lower edge was 0.3 Hz until 2026-07-29, inherited rather than chosen. Swept across seven optical datasets, 665 recordings, the between-dataset spread is 3.2 per cent at 0.15 Hz, 2.1 at 0.20, 2.7 at 0.25, 6.2 at 0.30 and 10.1 at 0.40. 0.2 Hz is a clear optimum, and it is also where the 20 Hz origin dataset stops being an outlier in either direction.
A lower edge this close to DC has to be checked against integration drift, since that is what the edge is there to control. It survives: on accelerometer data the ratio of mean to median speed, which rises if drift is leaking in, is 2.07 at 0.2 Hz against 2.00 at 0.3 -- flat. What does change is the absolute value, by about 73 per cent, because more of the low-frequency content is retained.
See deposit/_analysis/osdb_qom/REPORT.md.
OPTICAL_LEGACY_BAND
module-attribute
¶
OPTICAL_LEGACY_BAND = (None, 10.0)
A 10 Hz low-pass with no lower edge: the convention behind the published championship quantity of motion.
It is kept because those numbers are in print, not because it is interchangeable with
:data:BAND. Optical position is an absolute measurement, so sub-0.3 Hz postural drift in
it is real movement and there is no reason to discard it. A body-worn accelerometer cannot
offer the same choice: gravity is a DC term, and integrating any residual offset produces a
ramp that swamps the result. So the lower edge is optional for position and mandatory for
acceleration.
The two are not the same measure. On the 2015 championship the band-pass reads 15.5 per
cent below the low-pass. Any table that puts optical and accelerometer collections side by
side must therefore use :data:BAND throughout, and say so.
NARROW_BAND_RATIO
module-attribute
¶
NARROW_BAND_RATIO = 40.0
Warn when the sampling rate exceeds the upper band edge by more than this.
A band-pass whose edges sit very close to zero in normalised frequency is numerically fragile, and how fragile depends on how it is realised. Second-order sections, which this module uses, stay accurate far longer than the transfer-function form -- but not forever, and a caller designing their own filter should be warned before they are bitten.
The failure is silent and it is not hypothetical. A 0.1-0.5 Hz third-order band-pass at
250 Hz, written in the usual butter(3, [lo/ny, hi/ny]) transfer-function form, has a
largest pole radius of 0.9979 and a measured passband gain of 0.84 at 0.15 Hz where it should
be 0.99. Nothing raises, nothing looks wrong, and every amplitude downstream is 16 per cent
low. The same design as second-order sections gives 0.9875.
The fix when this warns is to decimate first, so the band sits comfortably inside the new Nyquist range, then filter.
NYQUIST_MARGIN
module-attribute
¶
NYQUIST_MARGIN = 0.99
How close to Nyquist an upper band edge may sit, as a fraction.
A filter designed right at Nyquist has no transition band left, so the edge is pulled in.
The default is conservative. The Still Standing corpus uses 0.999 throughout, which matters
whenever the band edge is already near Nyquist -- a 10 Hz low-pass on 20 Hz data becomes
9.9 Hz here and 9.99 Hz there, and on real optical data that moved quantity of motion by
7e-5. Pass margin to match whatever convention the surrounding analysis uses.
bandpass ¶
bandpass(x, fs: float, lo: float | None = BAND[0], hi: float = BAND[1], order: int = ORDER, margin: float = NYQUIST_MARGIN)
Zero-phase band-limiting along the first axis.
lo=None gives a pure low-pass, which is the :data:OPTICAL_LEGACY_BAND convention.
Source code in src/micromotion/filters.py
107 108 109 110 111 112 113 114 115 116 117 | |
lowpass ¶
lowpass(x, fs: float, fc: float = BAND[1], order: int = ORDER, margin: float = NYQUIST_MARGIN)
Zero-phase low-pass along the first axis.
margin is how close to Nyquist fc may sit; see :data:NYQUIST_MARGIN.
Source code in src/micromotion/filters.py
120 121 122 123 124 125 126 127 128 129 | |
highpass ¶
highpass(x, fs: float, fc: float = BAND[0], order: int = ORDER)
Zero-phase high-pass along the first axis.
Used where the upper edge is meaningless because the rate is already near the band limit, and for gravity removal when no low-pass is wanted.
Source code in src/micromotion/filters.py
132 133 134 135 136 137 138 139 140 141 142 | |
notch ¶
notch(x, fs: float, f0: float, q: float = 6.0)
Zero-phase notch at f0 Hz.
Used to remove the cardiac peak when isolating postural micromotion. f0 is
normally found with :func:micromotion.spectral.cardiac_peak.
Source code in src/micromotion/filters.py
145 146 147 148 149 150 151 152 153 154 | |
edge_transient_samples ¶
edge_transient_samples(fs: float, lo: float | None = BAND[0], order: int = ORDER) -> int
Samples at each end that filtfilt contaminates.
A conservative estimate: the impulse response of the low edge, doubled for the
forward-backward pass. Callers should either trim this or flag it, as the deposited
five-second binning does with its edge column.
Source code in src/micromotion/filters.py
157 158 159 160 161 162 163 164 | |
Sampling rates and resampling¶
micromotion.resample ¶
Rate measurement and resampling.
Two rules, both learned the hard way.
Measure the rate, do not read it. Nominal rates in this corpus are wrong by up to 4.4 per cent, and one record's documented rate was out by a factor of 37.
Downsample, never upsample. Upsampling invents structure between samples, and every method that reads across scales treats the invention as real. On 2026-07-28 an analysis that upsampled 20 Hz data to 25 Hz produced multifractal widths up to 6.6 where the plausible range is around 1. Nothing failed; the numbers were simply wrong.
COMMON_RATE
module-attribute
¶
COMMON_RATE = 20.0
Hz. The cross-collection analysis rate.
Not a compromise: the micromotion band stops at 10 Hz, so 20 Hz is its Nyquist rate and
everything above it is discarded by the band-pass anyway. It is also the native rate of the
slowest collection, so reaching it never requires upsampling. See
deposit/SAMPLING_RATES.md.
measured_rate ¶
measured_rate(t) -> float
Sampling rate in Hz, from a timestamp vector.
Sample count over elapsed span, deliberately not the reciprocal of the median interval. Where timestamps are rounded to whole milliseconds the intervals become a mixture of adjacent integers and their median is a quantisation artefact: on Stillness2025 that route returns exactly 250 Hz for a recording that runs at 256, and on the pre-study phones it returned 636 Hz for a stream arriving at 106.
Source code in src/micromotion/resample.py
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | |
rate_quality ¶
rate_quality(t) -> dict
How regular a timestamp vector actually is.
Returns the measured rate, the jitter, the largest gap, and counts of duplicated and backward timestamps. The balance-board files carry 4.7 per cent duplicates and 83 backward steps, and one pre-study file is 19 per cent covered because of a single 132-second gap; neither is visible from the rate alone.
Source code in src/micromotion/resample.py
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 | |
to_rate ¶
to_rate(x, fs_in: float, fs_out: float = COMMON_RATE)
Anti-alias resample to fs_out, refusing to upsample.
Raises rather than upsampling. If a series genuinely cannot reach the target it does not belong in a comparison built at that rate, and interpolating it in would corrupt the comparison silently.
Source code in src/micromotion/resample.py
74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 | |
regularize ¶
regularize(t, x, fs_out: float | None = None, max_gap_s: float | None = None)
Put an irregularly sampled signal onto a uniform grid.
Sorts, drops duplicate and backward timestamps, then interpolates linearly. Samples
that fall inside a gap longer than max_gap_s are returned as NaN rather than
bridged, so that a 132-second hole cannot be mistaken for 132 seconds of stillness.
Written for the Wii balance board, whose timestamps are unsorted, 4.7 per cent duplicated, and arrive at a median 61.8 Hz that varies between files.
Source code in src/micromotion/resample.py
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | |
interpolate_gaps ¶
interpolate_gaps(x, max_gap: int = 200)
Bridge short runs of NaN, leave long ones alone.
Filters cannot run across missing samples, so gaps have to be handled before anything else. Bridging a dropped frame is reconstruction; bridging a 469-second hole is invention, and the difference is only a matter of degree, which is why the threshold is explicit and the long gaps stay NaN for the caller to exclude.
Works column by column. Leading and trailing gaps are never filled, since there is nothing on one side to interpolate from.
Source code in src/micromotion/resample.py
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 | |
gap_report ¶
gap_report(x, fs: float) -> dict
Where the missing data is, and how it is distributed.
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 in the middle is two recordings.
Source code in src/micromotion/resample.py
162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | |
Reading files¶
micromotion.io ¶
Readers for the layouts this corpus actually uses.
Each returns a :class:~micromotion.record.MotionRecord. Gap sentinels become NaN, units
are recorded rather than assumed, and the sampling rate is measured wherever the file
carries a timebase.
:func:read dispatches on content, not on the extension, because the extension lies: the
balance-board dumps are headerless and space-delimited whatever they are called, and the
Qualisys family puts three different header shapes behind one name.
Y_UP_COLLECTIONS
module-attribute
¶
Y_UP_COLLECTIONS = ('Standstill2019',)
Editions whose vertical axis is Y.
The 2019 championship was recorded on OptiTrack and converted, and the conversion left the vertical on Y where every other optical file in the corpus is Z-up. Nothing in the file says so; only the data dictionary does.
BOARD_MM
module-attribute
¶
BOARD_MM = (433.0, 238.0)
Wii Balance Board sensing area, width by depth, in millimetres.
Multiplying the normalised centre of pressure by these gives millimetres, which is what makes balance data comparable with the optical collections.
read_qualisys ¶
read_qualisys(path: str, drop_gaps: bool = True) -> MotionRecord
Qualisys or Qualisys-style TSV, in all three header shapes found in the corpus.
The shapes differ in what follows the ten KEY<TAB>value metadata lines:
- a
<marker> Xcolumn-name row, then data (2012, 2015, 2017, 2018, 2019, HpSp); - nothing, data begins immediately (2022, Bishop 2020);
- a
Frame/Timecolumn-name row, then data (MocapNoiseFloor, Solberg 2016).
The shape is detected by trying to parse the eleventh line as numbers, so a file that was exported differently still reads correctly.
Source code in src/micromotion/io.py
50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | |
read_sverm ¶
read_sverm(path: str) -> MotionRecord
Sverm plain-header export: Time then s<n>_head_{x,y,z}.
A separate reader because it shares nothing with the Qualisys export but the units. It silently returned zero series to a corpus analysis until one was written for it.
Source code in src/micromotion/io.py
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 | |
read_ax3 ¶
read_ax3(path: str) -> MotionRecord
Axivity AX3 export with a ts,x,y,z header: Standstill2024 and Taqasim.
The rate is measured from the timestamps and differs meaningfully between files: it is a property of the physical logger, spanning 191.3-207.7 Hz across the 2024 units, and it differs between the two Taqasim sites in the same direction as the effect that record's headline claim reports.
Source code in src/micromotion/io.py
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 | |
read_phone ¶
read_phone(path: str, trim_clap_s: float = 0.0) -> MotionRecord
Physics Toolbox phone log, cleaned tab-separated form.
ax/ay/az are linear acceleration in m/s^2. They are not in g, whatever an
older version of the data dictionary said; reading them as g inflated every quantity of
motion in this project by 9.80665 until 2026-07-28. Only gF* is in g.
trim_clap_s drops that many seconds from each end. The StillStanding365 recordings
open and close with a synchronisation clap which is a timing marker, not movement, and
the deposited pipeline trims 12 s.
Source code in src/micromotion/io.py
184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 | |
read_equivital ¶
read_equivital(path: str) -> MotionRecord
Equivital physiology CSV: Stillness2025 accelerometer, ECG, respiration or RR.
Four of the five files per participant are delimited by comma-and-space and the fifth by a bare comma, so the separator is handled rather than assumed. The accelerometer is in raw counts, calibrated to g by the median vector magnitude, since a person standing still averages one g.
The rate is measured from the timestamps by span over count. Taking the median interval instead returns exactly 250 Hz for a recording that runs at 256, because the timestamps are rounded to whole milliseconds; that error was live in the deposited quantity of motion until 2026-07-28.
Source code in src/micromotion/io.py
221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 | |
read_balance_board ¶
read_balance_board(path: str) -> MotionRecord
Wii balance board dump: headerless, space-delimited, irregularly sampled.
Eight columns: timestamp in milliseconds, four corner load cells, total load, and centre of pressure in x and y, all normalised to 0-1.
Samples with no load carry a centre of pressure of exactly (0.5, 0.5), the midpoint of the board, which is a plausible-looking value for nobody standing on it. Those are returned as NaN. They are 13.8 per cent of the HpSp balance data.
Source code in src/micromotion/io.py
265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 | |
read_fnirs ¶
read_fnirs(path: str) -> MotionRecord
Artinis Brite wide export: 96 haemoglobin channels plus two IMUs.
Only the accelerometer and gyroscope are returned as data; the haemoglobin channels
are kept in meta because they are not motion. The accelerometer is in milli-g.
Column 0 is a sample index, not time, so the rate comes from the header. The absolute
start can be decoded from the TimeStampHi/Lo pair if it is needed.
Source code in src/micromotion/io.py
302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 | |
sniff ¶
sniff(path: str) -> str
Identify a file's layout from its first lines.
Source code in src/micromotion/io.py
353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 | |
read ¶
read(path: str, **kw) -> MotionRecord
Read any corpus motion file, dispatching on content.
Source code in src/micromotion/io.py
374 375 376 | |
micromotion.record ¶
The common structure every reader returns.
The corpus holds sixteen distinct file layouts across fourteen collections, and each one has already cost an analysis at least once: an axis convention that differs in a single edition, a rate that three documents disagree about, a gap code that looks like a valid measurement. A reader's job is to absorb all of that and hand back the same object regardless.
MotionRecord
dataclass
¶
One recording, in a form the rest of the package can use without special cases.
Attributes:
| Name | Type | Description |
|---|---|---|
data |
ndarray
|
(n_samples, n_channels) float array. Gaps are NaN, never a sentinel. |
fs |
float
|
Sampling rate in Hz, measured from the file where a timebase exists and taken from the header only where it does not. |
channels |
list[str]
|
Column names, one per column of |
kind |
str
|
|
unit |
str
|
|
vertical |
str
|
Which axis letter is up. Not always Z: the 2019 championship is Y-up, alone in the corpus. |
t |
ndarray | None
|
Timestamps in seconds where the file carries them, otherwise None. |
t0 |
object | None
|
Absolute start time where the file carries one, otherwise None. |
source |
str
|
Path the record was read from. |
meta |
dict
|
Anything else the header held, unmodified. |
Source code in src/micromotion/record.py
16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 | |
markers
property
¶
markers: list[str]
Marker names, for records whose channels are <marker> X/Y/Z triplets.
marker ¶
marker(name: str) -> np.ndarray
The (n_samples, 3) block for one marker, by name.
Reading marker columns by name rather than by position is not a nicety. Six HpSp files break the documented 22-marker order, and the README's positional quick-start mis-assigned markers in every one of them until it was rewritten.
Source code in src/micromotion/record.py
87 88 89 90 91 92 93 94 95 96 97 | |
missing_fraction ¶
missing_fraction() -> float
Proportion of the array that is NaN.
Source code in src/micromotion/record.py
99 100 101 | |
qom ¶
qom(**kw)
Quantity of motion for this record, with kind and unit already filled in.
Passing a whole record computes across every channel at once, which is rarely what you want for a multi-marker file; select a marker first.
Source code in src/micromotion/record.py
103 104 105 106 107 108 109 110 111 112 113 | |
Spectral¶
micromotion.spectral ¶
Spectral helpers: finding the physiological peaks in a motion signal.
A body-worn accelerometer on someone standing still picks up two rhythms that are not movement in the intentional sense. Respiration sits at roughly 0.2-0.4 Hz and the ballistocardiac impulse, the recoil of the heart ejecting blood, at roughly 0.8-1.8 Hz. Both are inside or adjacent to the micromotion band, so isolating postural motion means locating them first.
CARDIAC_BAND
module-attribute
¶
CARDIAC_BAND = (0.7, 2.2)
Hz. 42-132 bpm, which covers rest through mild exertion.
cardiac_peak ¶
cardiac_peak(x, fs: float, window_s: float = 60.0) -> float
Dominant frequency in the cardiac band, in Hz.
Pass the acceleration magnitude. Multiply by 60 for beats per minute. Returns NaN if the recording is too short for the band to be resolved.
Source code in src/micromotion/spectral.py
32 33 34 35 36 37 38 | |
respiratory_peak ¶
respiratory_peak(x, fs: float, window_s: float = 120.0) -> float
Dominant frequency in the respiratory band, in Hz.
Needs a long window: at 0.2 Hz, a 60-second segment holds twelve cycles, which is few enough that the estimate wanders.
Source code in src/micromotion/spectral.py
41 42 43 44 45 46 47 | |
band_power ¶
band_power(x, fs: float, band: tuple[float, float], window_s: float = 60.0) -> float
Integrated power between two frequencies.
Source code in src/micromotion/spectral.py
50 51 52 53 54 55 56 | |
spectral_peak ¶
spectral_peak(x, fs: float, band: tuple[float, float], window_s: float = 60.0) -> dict
Peak frequency in a band, with a signal-to-noise ratio against the band's own median.
The ratio is what makes the peak reportable. Every spectrum has a maximum somewhere inside any band you choose; whether it rises above the surrounding noise decides whether a rhythm is present at all. Below about 3, treat the peak as absent rather than weak.
Source code in src/micromotion/spectral.py
59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 | |
band_rms ¶
band_rms(x, fs: float, band: tuple[float, float]) -> float
Root-mean-square amplitude within a band, in the input units.
Rate-independent, unlike an integrated measure, which makes it the right thing to quote when comparing instruments that sample at different rates.
Source code in src/micromotion/spectral.py
81 82 83 84 85 86 87 88 89 | |
band_power_fraction ¶
band_power_fraction(x, fs: float, bands: dict, window_s: float = 60.0) -> dict
Proportion of total power falling in each named band.
Pass something like {"respiratory": (0.1, 0.5), "cardiac": (0.7, 2.2)}. This is how
the finding that roughly 38 per cent of the chest-phone signal power sits at the heart
rate was established, which is in turn why the compensated quantity-of-motion variant
exists.
Source code in src/micromotion/spectral.py
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 | |
mean_frequency ¶
mean_frequency(x, fs: float, band: tuple[float, float] = (0.1, 5.0), window_s: float = 60.0) -> float
Power-weighted mean frequency within a band, in Hz.
A different statistic from the peak: it moves when energy shifts between frequencies even if the dominant one does not change, so it is the more sensitive of the two to a gradual change in how a person is standing.
Source code in src/micromotion/spectral.py
112 113 114 115 116 117 118 119 120 121 122 123 124 | |
detect_breaths ¶
detect_breaths(x, fs: float, band: tuple[float, float] = RESPIRATORY_BAND, prominence_sd: float = 0.4, min_period_s: float = 2.0) -> dict
Breath timing from a respiration belt or a band-limited motion signal.
Returns peak and trough times and the cycle durations between them. Peaks and troughs are both returned because inhale and exhale are not symmetric, and a rate alone hides that.
Source code in src/micromotion/spectral.py
127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 | |
detect_breaths_adaptive ¶
detect_breaths_adaptive(x, fs: float, band: tuple[float, float] = RESPIRATORY_BAND, vel_frac: float = 0.55, baseline_hz: float = 0.2) -> dict
Breath detection that rejects chest movement which is not breathing.
Peak-and-trough detection, which :func:detect_breaths does, counts any sufficiently
prominent bump. On a belt worn by someone standing that includes postural sway, weight
shifts and swallows, all of which look like small breaths.
This asks a different question. A breath is a sustained rise in chest expansion whose velocity exceeds a threshold set from the signal's own positive-derivative mean, and which crosses an adaptive baseline -- a heavily low-passed copy of the signal rather than zero. A rise that never crosses that baseline did not start from an exhaled state and is discarded. That rejection step is what removes the sway.
Returns inspiration and expiration onsets, cycle durations and the rate. Expiration onset is taken as the end of the rise, which assumes passive expiration.
After Finn Upham's respiration work, reimplemented and used with permission.
Do not reach for this by default, on the evidence available here. It was added on the
expectation that rejecting non-breath rises would beat plain peak detection, and measured
against it that expectation did not hold. On twelve HpSp respiration-belt recordings the
two agree: median error against the spectral estimate 1.82 breaths per minute for both.
On eight Stillness2025 chest accelerometers, which is the case the rejection step was
supposed to help, it is markedly worse -- median error against the same participant's belt
10.3 breaths per minute against 3.6 for :func:detect_breaths, over-counting throughout.
That may be the parameters rather than the idea; the velocity threshold is derived from a belt's amplitude distribution and an accelerometer's is not the same shape. It is kept because the approach is sound in its original setting and because someone should be able to tune it, not because it is currently the better detector.
Source code in src/micromotion/spectral.py
147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | |
Dynamics¶
micromotion.dynamics ¶
Scaling and nonlinearity measures for postural time series.
These describe how movement is structured in time rather than how much of it there is. They
are the part of this package with reuse value beyond the standstill corpus, and they are
also the part where a subtly wrong implementation produces a plausible number rather than an
error. Every function here is covered by a test against a process whose answer is known in
advance; see tests/test_dynamics.py.
One warning carried over from the corpus. These methods read across scales, so they are the
ones that upsampling corrupts. Resample downwards only, and use
:func:micromotion.resample.to_rate, which refuses to do otherwise.
iaaft ¶
iaaft(x, iters: int = 100, rng=None) -> np.ndarray
Iterative amplitude-adjusted Fourier transform surrogate.
Preserves both the amplitude distribution and the power spectrum while destroying any nonlinear structure, so it is the null hypothesis "this series is a linear Gaussian process observed through a monotonic transform".
Source code in src/micromotion/dynamics.py
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | |
phase_surrogate ¶
phase_surrogate(x, rng=None) -> np.ndarray
Phase-randomised surrogate: preserves the spectrum, not the distribution.
Cheaper than :func:iaaft and a weaker null, since a non-Gaussian amplitude
distribution alone can make a linear series look nonlinear against it.
Source code in src/micromotion/dynamics.py
43 44 45 46 47 48 49 50 51 52 53 54 55 56 | |
circular_shift_surrogate ¶
circular_shift_surrogate(x, rng=None, margin: float = 0.1) -> np.ndarray
Rotate a series in time.
Preserves everything about the series itself and destroys only its alignment with another, so it is the right null for a correlation between two recordings and the wrong one for a property of a single series.
Source code in src/micromotion/dynamics.py
59 60 61 62 63 64 65 66 67 68 69 70 | |
surrogate_test ¶
surrogate_test(x, statistic, n: int = 99, method=iaaft, rng=None) -> dict
Compare a statistic against its surrogate distribution.
Returns the observed value, the surrogate mean and standard deviation, a z score and a
two-sided p value. The p value uses the standard (count + 1) / (n + 1) form, which
cannot return zero.
Source code in src/micromotion/dynamics.py
73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 | |
trev ¶
trev(x, tau: int = 1) -> float
Time-reversal asymmetry.
A linear Gaussian process looks the same run backwards; most nonlinear ones do not. The
statistic is the skew of the lag-tau increments, normalised by their variance.
Source code in src/micromotion/dynamics.py
92 93 94 95 96 97 98 99 100 | |
dfa ¶
dfa(x, smin: int | None = None, smax: int | None = None, nsc: int = 18, order: int = 1) -> dict
Detrended fluctuation analysis.
Returns the scaling exponent alpha. For reference, 0.5 is white noise, 1.0 is pink
noise, and 1.5 is Brownian motion. Values above 0.5 mean the series persists: a
deviation tends to be followed by more of the same.
Source code in src/micromotion/dynamics.py
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | |
mfdfa ¶
mfdfa(x, qs=None, smin: int = 16, smax: int | None = None, nsc: int = 20, order: int = 1) -> dict | None
Multifractal detrended fluctuation analysis.
Returns h(q), the singularity spectrum, the generalised Hurst exponent h2 and
the spectrum width. A width near zero means one scaling exponent describes the whole
series; a wide spectrum means different parts of it scale differently.
A width above about 2 for postural data is a signal to check the preprocessing rather than to celebrate: widths up to 6.6 were once produced entirely by upsampling.
Source code in src/micromotion/dynamics.py
140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 | |
sda ¶
sda(x, y=None, fs: float = 25.0, maxlag: float = 10.0, nlags: int = 60) -> dict
Stabilogram diffusion analysis, after Collins and De Luca.
Postural sway behaves like two different processes at two timescales: over short intervals it drifts away from where it was, and over longer ones it is pulled back. The crossover between them is found by fitting two lines to the log-log mean-square displacement and taking their intersection.
Returns short- and long-term Hurst exponents and diffusion coefficients, and the
critical time and displacement. Hs above 0.5 with Hl below it is the normal
pattern: open-loop drift, then closed-loop correction.
Source code in src/micromotion/dynamics.py
196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 | |
fit_two_region ¶
fit_two_region(dt, msd) -> dict
Fit two straight lines to a log-log curve and locate their crossover.
Generic: any measure with a scaling break can use it.
Source code in src/micromotion/dynamics.py
222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 | |
sampen ¶
sampen(x, m: int = 2, r: float | None = None) -> float
Sample entropy: how unpredictable the series is.
The negative log probability that two segments which match for m samples still match
for m + 1. Higher is less regular. r defaults to 0.2 standard deviations.
Source code in src/micromotion/dynamics.py
244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 | |
ami ¶
ami(x, maxlag: int = 100, bins: int = 32) -> np.ndarray
Average mutual information against lag, for choosing an embedding delay.
Source code in src/micromotion/dynamics.py
270 271 272 273 274 275 276 277 278 279 280 | |
first_ami_minimum ¶
first_ami_minimum(x, maxlag: int = 100, smooth: int = 9) -> int
The conventional embedding delay: the first local minimum of mutual information.
The curve is smoothed first. Estimated from a histogram it is noisy enough that the first local minimum of the raw curve is usually spurious: on a clean sine, whose true answer is a quarter period, the unsmoothed rule returns a delay of 2.
Source code in src/micromotion/dynamics.py
283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 | |
embed ¶
embed(x, dim: int, tau: int) -> np.ndarray
Time-delay embedding. x may be one series or several columns.
Source code in src/micromotion/dynamics.py
302 303 304 305 306 307 308 309 310 | |
rqa ¶
rqa(x, dim: int = 3, tau: int | None = None, rr: float = 0.05, lmin: int = 2) -> dict
Recurrence quantification, at fixed recurrence rate.
The threshold is solved per plot so that exactly rr of pairs count as recurrent.
That matters: with a fixed absolute threshold, determinism partly measures how tightly
packed the trajectory is, so two recordings of different amplitude are not comparable.
Pass several columns to get multidimensional recurrence quantification, which is the form used for between-body coupling.
Leave tau unset unless you have a reason. A delay of 1 makes consecutive embedding
vectors share all but one coordinate, so recurrences chain into diagonals that reflect
the embedding rather than the dynamics: white noise embedded at tau=1 reports a
determinism of 0.57, against 0.08 at a properly chosen delay.
Source code in src/micromotion/dynamics.py
313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 | |
plv ¶
plv(a, b, fs: float, band: tuple[float, float] | None = None) -> dict
Phase-locking value between two signals.
Returns the locking strength from 0 to 1 and the preferred phase difference in radians. Band-pass first if the signals are broadband; locking is only meaningful within a band where each signal has a well-defined phase.
Source code in src/micromotion/dynamics.py
373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 | |
apen ¶
apen(x, m: int = 2, r: float | None = None) -> float
Approximate entropy.
The older sibling of :func:sampen, and biased: it counts each template as matching
itself, which pulls the estimate towards regularity, and the bias grows as the series
gets shorter. Provided because the balance literature reports it and comparisons need
it. For new work prefer :func:sampen, which drops the self-match.
Source code in src/micromotion/dynamics.py
392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 | |
dcca ¶
dcca(a, b, scales=None, order: int = 1) -> dict
Detrended cross-correlation between two non-stationary series.
An ordinary correlation between two signals that each wander is dominated by the wandering. This detrends both inside windows of many sizes and correlates what is left, giving a coefficient per timescale.
That per-scale answer is the point: two bodies can be uncorrelated second to second and
correlated over a minute, and a single number cannot express it. Returns rho against
scales, running from -1 to 1.
Source code in src/micromotion/dynamics.py
418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 | |
Postural geometry¶
micromotion.posture ¶
The shape and extent of standing still.
Quantity of motion says how fast a body part moved. These say where it went: how far it strayed, what area it covered, whether it swayed along one line or wandered in all directions.
The pair is more informative than either alone. Two people with the same quantity of motion can occupy regions differing several-fold, because speed and extent are close to independent in this data — how much someone moves predicts only about a quarter of how large a region they occupy.
sway_geometry ¶
sway_geometry(xy) -> dict
Principal axis, anisotropy and dispersion of a two-dimensional trace.
Give it the horizontal coordinates of a marker, or a centre-of-pressure track.
anisotropy runs from 0 to 1: zero when the sway is equally wide in every direction,
approaching one when it collapses onto a line. There are two other definitions of this
quantity in circulation in this project — one where 1 means isotropic and one reporting
only the angle — so the definition is stated rather than assumed. axis_deg is the
direction of greatest sway, and is axial: 10 degrees and 190 degrees describe the same
posture. Test it with :func:micromotion.circular.rayleigh_axial, never with the
ordinary Rayleigh test.
Source code in src/micromotion/posture.py
19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | |
ellipse_area_95 ¶
ellipse_area_95(xy) -> float
Area of the 95 per cent confidence ellipse, in the input units squared.
The standard summary of postural extent in the balance literature, which makes it the number to report when comparing against clinical work.
Source code in src/micromotion/posture.py
51 52 53 54 55 56 57 58 59 60 61 62 | |
path_length ¶
path_length(xy, fs: float | None = None) -> dict
Total distance travelled, and the rate at which it accumulated.
Unfiltered and undifferentiated, so it is not a quantity of motion and is not comparable with one: sensor noise adds to path length monotonically, which means a noisier instrument reports a longer path for an identical movement. Reported because the balance literature uses it, and because it correlates with head quantity of motion at 0.61 in this corpus, which is worth knowing but is not an equivalence.
Source code in src/micromotion/posture.py
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 | |
dispersion_radius ¶
dispersion_radius(xy, quantile: float = 0.95) -> float
Radius containing a given proportion of the samples, about the mean position.
A robust alternative to the ellipse area when the trace has excursions: a single lean that lasts two seconds changes the ellipse considerably and this hardly at all.
Source code in src/micromotion/posture.py
82 83 84 85 86 87 88 89 90 91 92 93 | |
Circular statistics¶
micromotion.circular ¶
Statistics for angles.
Sway direction, the phase of a breath, the time of day a session happened: all are circular, and ordinary statistics quietly give wrong answers on them. The mean of 350 degrees and 10 degrees is 0, not 180.
Sway direction needs a further distinction. A body swaying forwards and backwards along one line has no preferred direction, only a preferred axis — 10 degrees and 190 degrees are the same posture. Statistics that treat those as opposite will report a person with a strong front-back sway as having no directional preference at all. The axial variants here double the angles before averaging, which is the standard fix, and they are the ones to use for sway.
circ_mean ¶
circ_mean(angles, weights=None) -> dict
Mean direction and concentration.
Returns the mean angle in radians and R, the resultant length, which runs from 0 for
angles spread uniformly around the circle to 1 for angles all identical. R is the
circular answer to "how consistent is this", and there is no separate standard deviation
worth reporting beside it.
Source code in src/micromotion/circular.py
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | |
rayleigh ¶
rayleigh(angles) -> dict
Test whether angles are spread uniformly around the circle.
The null hypothesis is no preferred direction. A small p rejects it. Uses the standard approximation, which is accurate for n above about 10.
Note what this cannot detect: a perfectly bidirectional distribution, with half the
angles at 0 and half at 180, has a resultant length of zero and passes as uniform. Use
:func:rayleigh_axial when the data are axial, which sway is.
Source code in src/micromotion/circular.py
38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | |
rayleigh_axial ¶
rayleigh_axial(angles) -> dict
Rayleigh test for axial data, where an angle and its opposite are the same.
Doubles the angles, tests those, and halves the resulting mean direction back. This is the correct test for sway direction, marker orientation, and anything else defined on a line rather than a ray.
Source code in src/micromotion/circular.py
59 60 61 62 63 64 65 66 67 68 69 70 71 | |
axial_dispersion ¶
axial_dispersion(angles) -> float
Circular standard deviation of axial data, in radians.
Zero for a body swaying along one fixed line, rising towards the isotropic case.
Source code in src/micromotion/circular.py
74 75 76 77 78 79 80 81 82 83 84 | |
circ_corr ¶
circ_corr(a, b) -> dict
Correlation between two circular variables, after Jammalamadaka and Sengupta.
Source code in src/micromotion/circular.py
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 | |
circ_corr_linear ¶
circ_corr_linear(angles, x) -> dict
Correlation between a circular variable and a linear one.
The question behind "does quantity of motion depend on the time of day", or on the day of the year, where the predictor wraps and the outcome does not.
Source code in src/micromotion/circular.py
105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 | |
vtest ¶
vtest(angles, mu: float) -> dict
Test for a preferred direction at a specified angle.
More powerful than :func:rayleigh when there is a prior expectation of where the
preference lies — a hypothesis that sway is front-to-back, for instance, rather than a
search for whichever direction happens to win.
Source code in src/micromotion/circular.py
127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 | |
Alignment¶
micromotion.align ¶
Aligning recordings that share no clock.
The recurring problem in this corpus: two instruments recorded the same person at the same time and neither knows what time it was. A phone counts seconds from when its app started, an fNIRS headband from when its acquisition began, an audio recorder from when someone pressed the button. Putting them on one timeline means recovering the offset from the signals themselves.
Two methods, for two situations. When both signals carry the same physiological rhythm, track
that rhythm in each and cross-correlate the resulting curves: :func:instantaneous_rate then
:func:xcorr_lag. When the signals are of different kinds, different lengths, or sampled far
apart, search integer offsets directly with :func:search_lag, which tolerates all three.
Both report how confident they are, and neither should be used without looking. A cross-correlation always has a maximum; that the maximum means anything is a separate claim.
instantaneous_rate ¶
instantaneous_rate(x, fs: float, band: tuple[float, float] = CARDIAC_BAND, win_s: float = 20.0, step_s: float = 1.0, per_minute: bool = True)
Track a rhythm's frequency over time, by sliding a Welch estimate along it.
Returns (times, rate). With per_minute the rate is in beats or breaths per
minute; otherwise in Hz.
This turns a raw signal into something two instruments can be compared on even when they measure entirely different quantities. A haemoglobin trace and a chest accelerometer have no common units, but both carry a heartbeat, and the way that heart rate wanders over ten minutes is a signature specific enough to align them.
Source code in src/micromotion/align.py
26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | |
xcorr_lag ¶
xcorr_lag(a, b, fs: float = 1.0, max_lag_s: float | None = None, min_r: float = 0.5, difference: bool = True) -> dict
Offset between two equally sampled signals, by cross-correlation.
Returns the lag in seconds and samples, the correlation at that lag, and confident,
which is whether that correlation reaches min_r. A positive lag means b starts
later than a.
Both series are differenced first, and that default is load-bearing rather than a
stylistic choice. Correlating two drifting series and taking the best lag is the classic
spurious-regression trap: over 200 pairs of independent random walks, the best-lag
correlation had a median of 0.47, exceeded 0.5 forty per cent of the time, and reached
0.98 at worst. No threshold can survive that. Differencing the same pairs gave a median
of 0.11 and never exceeded 0.16. Differencing is shift-equivariant, so the lag itself is
unaffected — it removes the drift, not the alignment. Pass difference=False only if
the inputs are already stationary and you know it.
The confidence test is a threshold on the correlation and deliberately not a measure of how sharply the peak stands out from the rest of the curve. Two such measures were tried and both get it backwards: a drifting series correlates highly with itself at every nearby lag, so its true peak looks unremarkable against the curve, while white noise gives a flat curve whose maximum looks strikingly sharp. A genuine alignment scored 0.98 and pure noise 0.07, but the noise had the higher peak-to-background ratio of the two.
Source code in src/micromotion/align.py
56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 | |
search_lag ¶
search_lag(t_a, x_a, t_b, x_b, max_lag_s: float = 300.0, step_s: float = 1.0, min_overlap_s: float = 120.0, min_r: float = 0.5) -> dict
Offset between two irregular or unequal-length signals, by direct search.
Both series are put on a common grid, then every integer offset within max_lag_s is
scored by Pearson correlation over whatever the two share at that offset. Slower than
:func:xcorr_lag and far more tolerant: the series may differ in length, in sampling,
and in how much of the session they cover.
confident is True only when the best correlation reaches min_r and the overlap
reaches min_overlap_s. This recovered the audio time origin for 331 of 356
StillStanding365 days; the remaining 25 failed this test and were left blank rather than
given a number nobody could trust.
Source code in src/micromotion/align.py
105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 | |
find_transient ¶
find_transient(x, fs: float, threshold: float = 8.0, search_s: float | None = None, min_separation_s: float = 1.0)
Locate impulsive events: a hand clap, a tap on a sensor, a heel strike.
Returns their times in seconds. threshold is in robust standard deviations above the
median of the signal envelope, using the median absolute deviation so that the events
themselves do not inflate the scale they are measured against.
The StillStanding365 sessions open and close with a synchronisation clap, and the deposited pipeline handles it by trimming a fixed twelve seconds from each end. Detecting it instead gives the offset rather than discarding it, which is what turns a clap into an alignment rather than a nuisance.
Source code in src/micromotion/align.py
153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | |
apply_lag ¶
apply_lag(t, lag_s: float)
Shift a timebase onto another recording's origin.
Source code in src/micromotion/align.py
191 192 193 | |