Skip to content

API reference

Every public function, grouped by the question being asked of a signal. The pages below are generated from the docstrings in the source, so a signature here is the signature the code has.

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.2-5 Hz, bring it to velocity, band-limit again, take the Euclidean norm across axes, and report the MEDIAN in mm/s.

The statistic is part of the definition rather than a presentation choice. The two are not close: on accelerometer data the mean-to-median speed ratio is about 2, and one corpus record carries a deposited mean of 12.79 mm/s beside a report quoting 11.12 for the same recordings at the same band, the whole difference being this choice with neither document saying which it had made. speed_from_position and speed_from_acceleration return the speed SERIES and take no statistic, so the caller decides. Decide explicitly, and say which one a published figure used.

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 is then 9.80665x too large.

BANDS module-attribute

BANDS = {'micromotion': filters.BAND, 'wideband': filters.WIDEBAND, 'noresp': filters.NORESP_BAND, 'optical_legacy': filters.OPTICAL_LEGACY_BAND}

The four conventions in use, by name.

micromotion is 0.2-5 Hz and is the only one every device in the corpus can deliver, so it is the one a cross-collection comparison must use.

wideband is 0.2-10 Hz, for jerk and other high-derivative measures that need the octave the canonical band gives up. Only on collections sampled fast enough to reach it; check with :func:effective_band, and do not infer the rate from a file's grid, which may be an upsample.

noresp is 0.45-5 Hz, the canonical band with its lower edge above respiration. On a chest-worn sensor the 0.15-0.45 Hz stretch is dominated by respiratory chest tilt -- gravity re-projected by the breathing ribcage, a rotation rather than a translation -- so the choice between this and micromotion is a purpose decision: keep the respiratory term or exclude it. See :data:~micromotion.filters.NORESP_BAND.

optical_legacy is the 10 Hz low-pass behind the published championship figures. It retains sub-0.2 Hz postural drift and reads about 15 per cent higher. It is kept because those numbers are in print, not because it is interchangeable with the others.

QomResult dataclass

Quantity of motion, with the series it was reduced from.

Source code in src/micromotion/qom.py
 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
@dataclass
class QomResult:
    """Quantity of motion, with the series it was reduced from."""

    mean_mm_s: float
    median_mm_s: float
    speed: np.ndarray = field(repr=False)
    fs: float = 0.0
    variant: str = "raw"
    cardiac_hz: float = float("nan")
    n_samples: int = 0
    edge_samples: int = 0

    def binned(self, 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.
        """
        import pandas as pd

        idx = (np.arange(self.n_samples) / self.fs // bin_s).astype(int)
        counts = np.bincount(idx)
        means = np.bincount(idx, self.speed) / counts
        full = int(round(bin_s * self.fs))
        edge = np.where(counts < full, "partial", "ok").astype(object)
        n_edge = max(1, int(np.ceil(self.edge_samples / (bin_s * self.fs))))
        for i in list(range(n_edge)) + list(range(len(counts) - n_edge, len(counts))):
            if 0 <= i < len(counts) and edge[i] == "ok":
                edge[i] = "filter_transient"
        return pd.DataFrame(
            {
                "time_s": np.arange(len(counts)) * bin_s,
                "qom_mm_s": means,
                "n_samples": counts,
                "edge": edge,
            }
        )
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
 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
def binned(self, 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.
    """
    import pandas as pd

    idx = (np.arange(self.n_samples) / self.fs // bin_s).astype(int)
    counts = np.bincount(idx)
    means = np.bincount(idx, self.speed) / counts
    full = int(round(bin_s * self.fs))
    edge = np.where(counts < full, "partial", "ok").astype(object)
    n_edge = max(1, int(np.ceil(self.edge_samples / (bin_s * self.fs))))
    for i in list(range(n_edge)) + list(range(len(counts) - n_edge, len(counts))):
        if 0 <= i < len(counts) and edge[i] == "ok":
            edge[i] = "filter_transient"
    return pd.DataFrame(
        {
            "time_s": np.arange(len(counts)) * bin_s,
            "qom_mm_s": means,
            "n_samples": counts,
            "edge": edge,
        }
    )

identify_acceleration_unit

identify_acceleration_unit(acc, tol: float = 0.25) -> str

Which unit an accelerometer file is in, from its own values: "g", "mg" or "m/s^2".

A device that is mostly stationary is mostly measuring gravity, so the median vector norm of a resting recording sits near 1, 981 or 9.81 depending on the convention. Those are three orders of magnitude apart, and no plausible unit lies between them, so the identification is unambiguous whenever the recording is dominated by gravity.

THIS EXISTS BECAUSE NO FILE FORMAT IN THIS FIELD DECLARES ITS UNITS. Four accelerometers recorded simultaneously on one body in the Oslo corpus stored their values in three different conventions, none of them stated anywhere in the files. The method is Finn Upham's, from the analysis those recordings were made for: take the mean total acceleration while the participant lies still, and read off which constant it is near.

A UNIT ERROR IS THE HARDEST KIND TO NOTICE. It scales one recording by 9.8 or 981 and leaves every correlation, every rank statistic and every reliability estimate untouched, so nothing downstream complains. It shows up as a suspiciously ROUND ratio against a known value -- a real disagreement is ragged and a unit error is a constant.

tol is the fractional distance from a candidate at which the answer is still accepted. Raises if the norm is near none of them, which means either the recording is not gravity-dominated or the units are something this does not know about; in both cases guessing would be worse than stopping.

identify_acceleration_unit(np.full((100, 3), [0.0, 0.0, 1.0])) 'g'

Source code in src/micromotion/qom.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
def identify_acceleration_unit(acc, tol: float = 0.25) -> str:
    """Which unit an accelerometer file is in, from its own values: ``"g"``, ``"mg"`` or ``"m/s^2"``.

    A device that is mostly stationary is mostly measuring gravity, so the median vector norm of a
    resting recording sits near 1, 981 or 9.81 depending on the convention. Those are three orders
    of magnitude apart, and no plausible unit lies between them, so the identification is
    unambiguous whenever the recording is dominated by gravity.

    THIS EXISTS BECAUSE NO FILE FORMAT IN THIS FIELD DECLARES ITS UNITS. Four accelerometers
    recorded simultaneously on one body in the Oslo corpus stored their values in three different
    conventions, none of them stated anywhere in the files. The method is Finn Upham's, from the
    analysis those recordings were made for: take the mean total acceleration while the participant
    lies still, and read off which constant it is near.

    A UNIT ERROR IS THE HARDEST KIND TO NOTICE. It scales one recording by 9.8 or 981 and leaves
    every correlation, every rank statistic and every reliability estimate untouched, so nothing
    downstream complains. It shows up as a suspiciously ROUND ratio against a known value -- a real
    disagreement is ragged and a unit error is a constant.

    ``tol`` is the fractional distance from a candidate at which the answer is still accepted.
    Raises if the norm is near none of them, which means either the recording is not
    gravity-dominated or the units are something this does not know about; in both cases guessing
    would be worse than stopping.

    >>> identify_acceleration_unit(np.full((100, 3), [0.0, 0.0, 1.0]))
    'g'
    """
    a = _to_2d(np.asarray(acc, float))
    if a.shape[1] != 3:
        raise ValueError(f"need three axes to take a vector norm, got {a.shape[1]}")
    n = float(np.nanmedian(np.linalg.norm(a, axis=1)))
    if not np.isfinite(n) or n <= 0:
        raise ValueError("the median vector norm is not a positive finite number")
    for unit, expect in (("g", 1.0), ("mg", 1000.0), ("m/s^2", G)):
        if abs(n - expect) / expect <= tol:
            return unit
    raise ValueError(
        f"median vector norm {n:.4g} is not near 1 (g), 981 (mg) or {G:.4g} (m/s^2). "
        "Either this recording is not dominated by gravity -- a stationary stretch is what the "
        "method needs -- or the units are not one of these three.")

velocity_from_acceleration

velocity_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 velocity, per axis, in mm/s, from acceleration.

acc is (n_samples, n_axes). unit is "m/s^2" or "g". Returns an array of the same shape; :func:speed_from_acceleration is its Euclidean norm.

Use this when a descriptor needs the velocity vector rather than its magnitude -- jerk, spectral measures per axis, anything directional. Computing it from the speed alone is not equivalent, and integrating by hand invites a pipeline that differs from the rest of the corpus in the filter order or the quadrature rule.

integrate selects the quadrature rule, and the choice is not cosmetic. Both are in common use and they differ by about 0.26 per cent on real phone data -- small, but a systematic bias rather than noise, since the rectangle rule lags the signal by half a sample.

Neither is universally right, so the default is "rectangle", which is what this package's own reference numbers were computed with. Pass integrate="trapezoid" to reproduce a pipeline that used the trapezoid rule; on one deposited value the two give 93.140 and 93.405 mm/s against a published 93.091.

Which rule the project should standardise on is an open question, deliberately not settled by this default.

Source code in src/micromotion/qom.py
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
def velocity_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 velocity, per axis, in mm/s, from acceleration.

    ``acc`` is (n_samples, n_axes). ``unit`` is ``"m/s^2"`` or ``"g"``. Returns an array of
    the same shape; :func:`speed_from_acceleration` is its Euclidean norm.

    Use this when a descriptor needs the velocity *vector* rather than its magnitude --
    jerk, spectral measures per axis, anything directional. Computing it from the speed
    alone is not equivalent, and integrating by hand invites a pipeline that differs from
    the rest of the corpus in the filter order or the quadrature rule.

    ``integrate`` selects the quadrature rule, and the choice is not cosmetic. Both are in
    common use and they differ by about 0.26 per cent on real phone data -- small, but a
    systematic bias rather than noise, since the rectangle rule lags the signal by half a
    sample.

    Neither is universally right, so the default is ``"rectangle"``, which is what this
    package's own reference numbers were computed with. Pass ``integrate="trapezoid"`` to
    reproduce a pipeline that used the trapezoid rule; on one deposited value the two give
    93.140 and 93.405 mm/s against a published 93.091.

    Which rule the project should standardise on is an open question, deliberately not
    settled by this default.
    """
    a = _to_2d(acc)
    if unit == "g":
        a = a * G
    elif unit != "m/s^2":
        raise ValueError(f"unknown acceleration unit {unit!r}; use 'm/s^2' or 'g'")
    a = filters.bandpass(a, fs, lo, hi)
    if notch_hz:
        a = filters.notch(a, fs, notch_hz)
    if integrate == "trapezoid":
        v = cumulative_trapezoid(a, dx=1.0 / fs, initial=0, axis=0)
    elif integrate == "rectangle":
        v = np.cumsum(a, axis=0) / fs
    else:
        raise ValueError(f"unknown rule {integrate!r}; use 'trapezoid' or 'rectangle'")
    return filters.bandpass(v, fs, lo, hi) * MM_PER_M

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". This is the norm of :func:velocity_from_acceleration; see that function for the integration options.

Source code in src/micromotion/qom.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
def 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"``. This is the norm of
    :func:`velocity_from_acceleration`; see that function for the integration options.
    """
    v = velocity_from_acceleration(acc, fs, unit, lo, hi, notch_hz, integrate)
    return np.linalg.norm(v, axis=1)

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
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
def 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.
    """
    x = _to_2d(x)
    if len(x) < 5:
        return np.gradient(x, 1.0 / fs, axis=0)
    v = np.empty_like(x)
    v[2:-2] = (x[:-4] - 8 * x[1:-3] + 8 * x[3:-1] - x[4:]) * (fs / 12.0)
    v[:2], v[-2:] = v[2], v[-3]
    return v

velocity_from_position

velocity_from_position(pos, fs: float, unit: str = 'mm', lo: float = filters.BAND[0], hi: float = filters.BAND[1]) -> np.ndarray

Band-limited velocity, per axis, in mm/s, from position.

pos is (n_samples, n_axes), normally the three coordinates of one optical marker. unit is "mm" or "m". Returns an array of the same shape; :func:speed_from_position is its Euclidean norm.

Pair with :func:velocity_from_acceleration when a descriptor must be computed the same way across optical and accelerometer collections: take the velocity from whichever function matches the recorded quantity, and everything downstream is identical.

Source code in src/micromotion/qom.py
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
def velocity_from_position(
    pos,
    fs: float,
    unit: str = "mm",
    lo: float = filters.BAND[0],
    hi: float = filters.BAND[1],
) -> np.ndarray:
    """Band-limited velocity, per axis, in mm/s, from position.

    ``pos`` is (n_samples, n_axes), normally the three coordinates of one optical marker.
    ``unit`` is ``"mm"`` or ``"m"``. Returns an array of the same shape;
    :func:`speed_from_position` is its Euclidean norm.

    Pair with :func:`velocity_from_acceleration` when a descriptor must be computed the same
    way across optical and accelerometer collections: take the velocity from whichever
    function matches the recorded quantity, and everything downstream is identical.
    """
    p = _to_2d(pos)
    if unit == "m":
        p = p * MM_PER_M
    elif unit != "mm":
        raise ValueError(f"unknown position unit {unit!r}; use 'mm' or 'm'")
    p = filters.bandpass(p, fs, lo, hi)
    return filters.bandpass(derivative(p, fs), fs, lo, hi)

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". This is the norm of :func:velocity_from_position.

Source code in src/micromotion/qom.py
269
270
271
272
273
274
275
276
277
278
279
280
281
def 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"``. This is the norm of :func:`velocity_from_position`.
    """
    return np.linalg.norm(velocity_from_position(pos, fs, unit, lo, hi), axis=1)

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 ndarray

(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:micromotion.resample.measured_rate.

required
kind str

"acceleration" or "position".

'acceleration'
unit str

Defaults to "m/s^2" for acceleration and "mm" for position.

None
variant str

"raw", "compensated" or "tilt_corrected". Defaults to "raw".

'raw'
band str

"micromotion", "wideband", "noresp" or "optical_legacy". See :data:BANDS. Defaults to "micromotion".

'micromotion'
gyro ndarray

(n_samples, 3) angular velocity in rad/s. Required by tilt_corrected.

None
integrate str

"rectangle" or "trapezoid", the quadrature rule used to bring acceleration to velocity. The two differ by a systematic fraction of a per cent, so the choice is the caller's and belongs in the record of the analysis. Defaults to "rectangle".

'rectangle'

Returns:

Name Type Description
QomResult QomResult

The mean and median speed in mm/s, the full speed series, the rate it was computed at, the variant, the notched cardiac frequency where one was found, and edge_samples, the length of the filter transient at each end.

Raises:

Type Description
ValueError

If band or variant is unknown, if optical_legacy is asked for on acceleration, or if tilt_corrected is asked for without a gyroscope.

Source code in src/micromotion/qom.py
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
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
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
def 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.

    Args:
        data (np.ndarray): (n_samples, n_axes) acceleration or position.
        fs (float): Measured sampling rate. Use the rate measured from the timestamps,
            not the nominal one; see :func:`micromotion.resample.measured_rate`.
        kind (str): ``"acceleration"`` or ``"position"``.
        unit (str, optional): Defaults to ``"m/s^2"`` for acceleration and ``"mm"`` for
            position.
        variant (str, optional): ``"raw"``, ``"compensated"`` or ``"tilt_corrected"``.
            Defaults to ``"raw"``.
        band (str, optional): ``"micromotion"``, ``"wideband"``, ``"noresp"`` or
            ``"optical_legacy"``. See :data:`BANDS`. Defaults to ``"micromotion"``.
        gyro (np.ndarray, optional): (n_samples, 3) angular velocity in rad/s. Required by
            ``tilt_corrected``.
        integrate (str, optional): ``"rectangle"`` or ``"trapezoid"``, the quadrature rule
            used to bring acceleration to velocity. The two differ by a systematic fraction
            of a per cent, so the choice is the caller's and belongs in the record of the
            analysis. Defaults to ``"rectangle"``.

    Returns:
        QomResult: The mean and median speed in mm/s, the full speed series, the rate it was
            computed at, the variant, the notched cardiac frequency where one was found, and
            ``edge_samples``, the length of the filter transient at each end.

    Raises:
        ValueError: If ``band`` or ``variant`` is unknown, if ``optical_legacy`` is asked for
            on acceleration, or if ``tilt_corrected`` is asked for without a gyroscope.
    """
    x = _to_2d(data)
    if band not in BANDS:
        raise ValueError(f"unknown band {band!r}; use one of {sorted(BANDS)}")
    lo, hi = BANDS[band]
    hz = float("nan")

    if lo is None and kind == "acceleration":
        raise ValueError(
            "the 'optical_legacy' band has no lower edge, and an accelerometer cannot be "
            "integrated without one: gravity is a DC term and any residual offset becomes "
            "a ramp. Use band='micromotion' for accelerometer data."
        )

    if kind == "acceleration":
        unit = unit or "m/s^2"
        if variant == "compensated":
            mag = np.linalg.norm(x, axis=1)
            hz = cardiac_peak(mag, fs)
            speed = speed_from_acceleration(x, fs, unit, lo=0.5, hi=hi, notch_hz=hz,
                                            integrate=integrate)
        elif variant == "tilt_corrected":
            if gyro is None:
                raise ValueError("variant 'tilt_corrected' needs a gyroscope signal")
            speed = speed_from_acceleration(
                remove_tilt(x, gyro, fs, unit), fs, "m/s^2", lo, hi
            )
        elif variant == "raw":
            speed = speed_from_acceleration(x, fs, unit, lo, hi, integrate=integrate)
        else:
            raise ValueError(f"unknown variant {variant!r}")
    elif kind == "position":
        if variant != "raw":
            raise ValueError(
                f"variant {variant!r} applies to accelerometers only; optical position "
                "measures displacement directly, so there is no tilt ambiguity and no "
                "integration drift to compensate"
            )
        speed = speed_from_position(x, fs, unit or "mm", lo, hi)
    else:
        raise ValueError(f"unknown kind {kind!r}; use 'acceleration' or 'position'")

    return QomResult(
        mean_mm_s=float(np.mean(speed)),
        median_mm_s=float(np.median(speed)),
        speed=speed,
        fs=fs,
        variant=variant,
        cardiac_hz=hz,
        n_samples=len(speed),
        edge_samples=filters.edge_transient_samples(fs, lo),
    )

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
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
def 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.
    """
    a = _to_2d(acc) * (G if unit == "g" else 1.0)
    w = _to_2d(gyro)
    if w.shape[0] != a.shape[0]:
        raise ValueError("gyroscope and accelerometer must have the same length")

    g_hat = np.zeros_like(a)
    g_hat[0] = a[0] / (np.linalg.norm(a[0]) + 1e-12)
    dt = 1.0 / fs
    tau = 1.0                       # s; trust the accelerometer beyond this
    alpha = tau / (tau + dt)
    for i in range(1, len(a)):
        # rotate the previous estimate by -omega*dt (the frame turns, the vector does not)
        gyro_step = g_hat[i - 1] - np.cross(w[i], g_hat[i - 1]) * dt
        meas = a[i] / (np.linalg.norm(a[i]) + 1e-12)
        g = alpha * gyro_step + (1 - alpha) * meas
        g_hat[i] = g / (np.linalg.norm(g) + 1e-12)

    g_mag = float(np.median(np.linalg.norm(a, axis=1)))
    return a - g_hat * g_mag

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
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
def 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.
    """
    raw = qom(acc, fs, kind="acceleration", unit=unit).mean_mm_s
    corrected = qom(remove_tilt(acc, gyro, fs, unit), fs,
                    kind="acceleration", unit="m/s^2").mean_mm_s
    return {"raw_mm_s": raw, "translation_mm_s": corrected,
            "inflation": raw / corrected if corrected else float("nan"),
            "tilt_fraction": 1 - corrected / raw if raw else float("nan")}

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).

Parameters:

Name Type Description Default
x ndarray

Input 1-D signal.

required
fs float

Sampling rate of the signal (Hz).

required
smooth float

Smoothing window in seconds. None or 0 disables smoothing. Defaults to 1.0.

1.0
normalize bool

If True, z-score the result. Defaults to True.

True

Returns:

Type Description

np.ndarray: The smoothed (and optionally z-scored) envelope, same length as the input.

Source code in src/micromotion/qom.py
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
def 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.
    """
    from scipy.signal import savgol_filter
    x = np.asarray(x, float)
    if smooth:
        w = max(3, int(smooth * fs) | 1)
        if len(x) > w:
            x = savgol_filter(x, w, 2)
    if normalize:
        x = (x - x.mean()) / (x.std() + 1e-9)
    return x

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.

Parameters:

Name Type Description Default
x ndarray

Input 1-D signal.

required
fs float

Sampling rate of the signal (Hz).

required
bin_s float

Bin length in seconds. Defaults to 1.0.

1.0

Returns:

Type Description

np.ndarray: One mean value per bin (empty if the signal is shorter than two bins).

Source code in src/micromotion/qom.py
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
def 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).
    """
    x = np.asarray(x, float)
    step = max(1, int(round(fs * bin_s)))
    n = len(x) // step
    if n < 2:
        return np.array([])
    return x[:n * step].reshape(n, step).mean(axis=1)

band_limited_qom

band_limited_qom(pos, fs, lo=filters.BAND[0], hi=filters.BAND[1], 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.

.. note::

Defaults follow filters.BAND, like everything else in the package, so a number cannot be quoted from a second band by accident. :func:pose_qom pins its upper edge at 5 Hz because image-space landmark jitter dominates above that. Since the band became 0.2-5 Hz the two coincide, so that pin currently changes nothing; it is kept because it is a statement about pose data rather than about the band.

Use :func:speed_from_position for new work, which band-limits again after differentiating; this one does not, and reads high as a result. See the interop guide for the comparison against musicalgestures, which carries the same function.

.. warning::

The name overstates what this does, and it reads high as a result. The position is band-limited; the speed derived from it is not. Differentiation amplifies the high end, so the velocity carries energy above hi that the stated band excludes, and none of it is removed.

Measured against :func:speed_from_position, which band-limits again after differentiating, on a 200 Hz optical recording at a matched band: this returns 3.1475 mm/s against 2.9754, i.e. 5.5 per cent high. The decomposition is one-sided -- the differentiation rule (first difference here, central difference there) accounts for 0.05 per cent, and the missing second band-pass for the remaining 5.5. At the respective defaults the gap is larger still, because this function band-limits only the position, not the speed derived from it.

Prefer :func:qom or :func:speed_from_position for new work. This is kept, unchanged, so that figures computed with musicalgestures continue to reproduce -- not because it is the better measure. Whichever you use, say which.

Parameters:

Name Type Description Default
pos 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.

required
fs float

Sampling rate of the trajectory (Hz).

required
lo float

Lower band edge (Hz). Defaults to filters.BAND[0] (0.2 Hz).

BAND[0]
hi float

Upper band edge (Hz), clipped to 0.9 x Nyquist. Defaults to filters.BAND[1] (5.0 Hz).

BAND[1]
order int

Butterworth order of the direct band-pass. Defaults to 4.

4
auto_decimate bool

Enable the decimate+SOS low-band regime. Defaults to True.

True

Returns:

Name Type Description
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.

lo=None gives a pure low-pass with no lower edge, which is the :data:~micromotion.OPTICAL_LEGACY_BAND convention and what the pre-2020 optical standstill studies used. It retains the slow postural drift the corpus band removes, so a value computed that way is not comparable with one computed at BAND.

Raises:

Type Description
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). With lo=None only the upper edge is checked.

Source code in src/micromotion/qom.py
531
532
533
534
535
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
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
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
629
630
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
def band_limited_qom(pos, fs, lo=filters.BAND[0], hi=filters.BAND[1], 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.

    .. note::

       Defaults follow ``filters.BAND``, like everything else in the package,
       so a number cannot be quoted from a second band by accident.
       :func:`pose_qom` pins its upper edge at 5 Hz because image-space
       landmark jitter dominates above that. Since the band became 0.2-5 Hz
       the two coincide, so that pin currently changes nothing; it is kept
       because it is a statement about pose data rather than about the band.

       **Use** :func:`speed_from_position` **for new work**, which band-limits
       again after differentiating; this one does not, and reads high as a
       result. See the interop guide for the comparison against
       ``musicalgestures``, which carries the same function.

    .. warning::

       The name overstates what this does, and it reads high as a result.
       The *position* is band-limited; the speed derived from it is not.
       Differentiation amplifies the high end, so the velocity carries energy
       above ``hi`` that the stated band excludes, and none of it is removed.

       Measured against :func:`speed_from_position`, which band-limits again
       after differentiating, on a 200 Hz optical recording at a matched
       band: this returns 3.1475 mm/s against 2.9754, i.e. **5.5 per
       cent high**. The decomposition is one-sided -- the differentiation rule
       (first difference here, central difference there) accounts for 0.05 per
       cent, and the missing second band-pass for the remaining 5.5. At the
       respective defaults the gap is larger still, because this function
       band-limits only the position, not the speed derived from it.

       Prefer :func:`qom` or :func:`speed_from_position` for new work. This is
       kept, unchanged, so that figures computed with ``musicalgestures``
       continue to reproduce -- not because it is the better measure. Whichever
       you use, say which.

    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 ``filters.BAND[0]`` (0.2 Hz).
        hi (float, optional): Upper band edge (Hz), clipped to 0.9 x Nyquist.
            Defaults to ``filters.BAND[1]`` (5.0 Hz).
        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.

    ``lo=None`` gives a pure low-pass with no lower edge, which is the
    :data:`~micromotion.OPTICAL_LEGACY_BAND` convention and what the pre-2020 optical
    standstill studies used. It retains the slow postural drift the corpus band removes, so a
    value computed that way is not comparable with one computed at ``BAND``.

    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).
            With `lo=None` only the upper edge is checked.
    """
    from scipy import signal
    pos = np.asarray(pos, float)
    if pos.ndim == 1:
        pos = pos[:, None]
    pos = np.column_stack([_interp_nans(pos[:, i]) for i in range(pos.shape[1])])

    hi_eff = min(hi, 0.9 * fs / 2)
    # `lo=None` is a pure low-pass with no lower edge. That is the package's own
    # OPTICAL_LEGACY_BAND convention, which `filters.bandpass` has always honoured and this
    # function used to reject with a TypeError -- so `band_limited_qom(x, fs, *OPTICAL_LEGACY_BAND)`
    # failed on a constant the package itself exports. Several analyses in the source corpus
    # deliberately work at that band, because it is what the older optical studies used, and they
    # each had to reimplement the filter to do it.
    if lo is None:
        if not (0 < hi_eff <= 0.45 * fs + 1e-9):
            raise ValueError("band must satisfy 0 < hi <= 0.45*fs")
    elif not (0 < lo < hi_eff <= 0.45 * fs + 1e-9):
        raise ValueError("band must satisfy 0 < lo < hi <= 0.45*fs")

    if len(pos) < int(fs) + 5 or not np.isfinite(pos).all():
        return np.array([]), fs

    if auto_decimate and fs / hi_eff >= 40:
        q = int(min(13, fs // (20 * hi_eff)))
        pos = signal.decimate(pos, q, axis=0, zero_phase=True)
        fs_out = fs / q
        if len(pos) < 30:
            return np.array([]), fs_out
        if lo is None:
            sos = signal.butter(2, hi_eff / (fs_out / 2), btype="low", output="sos")
        else:
            sos = signal.butter(2, [lo / (fs_out / 2), hi_eff / (fs_out / 2)],
                                btype="band", output="sos")
        filtered = signal.sosfiltfilt(sos, pos, axis=0)
    else:
        fs_out = fs
        if lo is None:
            b, a = signal.butter(order, hi_eff / (fs / 2), btype="low")
        else:
            b, a = signal.butter(order, [lo / (fs / 2), hi_eff / (fs / 2)], btype="band")
        filtered = signal.filtfilt(b, a, pos, axis=0)
    speed = np.linalg.norm(np.diff(filtered, axis=0), axis=1) * fs_out
    return speed, fs_out

accel_to_speed

accel_to_speed(acc, fs, highpass=filters.BAND[0], 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.

Parameters:

Name Type Description Default
acc ndarray

Acceleration of shape (N, 3) in m/s^2 (or raw counts with normalize_gravity=True).

required
fs float

Sampling rate (Hz).

required
highpass float

High-pass cutoff (Hz) used both before and after integration. Defaults to filters.BAND[0] (0.2 Hz).

BAND[0]
order int

Butterworth order of the high-pass filters. Defaults to 2.

2
normalize_gravity bool

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.

False

Returns:

Type Description

np.ndarray: Speed series of length N (m/s).

Source code in src/micromotion/qom.py
665
666
667
668
669
670
671
672
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
def accel_to_speed(acc, fs, highpass=filters.BAND[0], 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 ``filters.BAND[0]`` (0.2 Hz).
        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).
    """
    from scipy.signal import butter, filtfilt
    G = 9.80665
    acc = np.asarray(acc, float)
    if normalize_gravity:
        norm = np.linalg.norm(acc, axis=1)
        acc = acc / np.median(norm) * G
    b, a = butter(order, highpass / (fs / 2), btype="high")
    acc = filtfilt(b, a, acc, axis=0)
    vel = np.cumsum(acc, axis=0) / fs
    vel = filtfilt(b, a, vel, axis=0)
    return np.linalg.norm(vel, axis=1)

group_qom

group_qom(points, fs, lo=filters.BAND[0], hi=filters.BAND[1], normalize='visible', **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.

.. warning::

normalize decides what the divisor is, and the number this returns moves with it. Say which was used.

normalize="visible", the default, excludes each marker at the frames where it was absent and averages over the rest. On twelve markers with a realistic dropout pattern, a median of eight visible, it lands within 0.8 per cent of the unoccluded truth.

normalize="worn" averages over every marker at every frame instead. Since band_limited_qom interpolates gaps, an occluded marker then contributes a near-zero speed while still counting in the divisor, and the result tracks how much the cameras saw: on the same data it reads 16 to 17 per cent low and its speed series correlates +0.25 to +0.70 with the per-frame count of visible markers. It is kept so that a figure computed that way keeps reproducing; it is bit-for-bit identical on one machine and agrees to about 1 part in 10^7 across platforms, since filtfilt is not bit-reproducible between scipy builds.

Parameters:

Name Type Description Default
points ndarray

Trajectories of shape (N, M, D): N frames, M markers/landmarks, D spatial dimensions.

required
fs float

Sampling rate (Hz).

required
lo float

Lower band edge (Hz). Defaults to filters.BAND[0] (0.2 Hz).

BAND[0]
hi float

Upper band edge (Hz). Defaults to filters.BAND[1] (5.0 Hz).

BAND[1]
normalize str

"visible" averages over the markers present in each frame; "worn" averages over every marker that produced a series, which is the pre-1.0 behaviour. Defaults to "visible".

'visible'
**kwargs

Passed on to band_limited_qom.

{}

Returns:

Name Type Description
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. With normalize="visible" a frame in which no marker was present is NaN in speed, since nothing was measured there.

Source code in src/micromotion/qom.py
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
def group_qom(points, fs, lo=filters.BAND[0], hi=filters.BAND[1], normalize="visible",
               **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.

    .. warning::

       ``normalize`` decides what the divisor is, and the number this returns
       moves with it. Say which was used.

       ``normalize="visible"``, the default, excludes each marker at the frames
       where it was absent and averages over the rest. On twelve markers with a
       realistic dropout pattern, a median of eight visible, it lands within
       0.8 per cent of the unoccluded truth.

       ``normalize="worn"`` averages over every marker at every frame instead.
       Since ``band_limited_qom`` interpolates gaps, an occluded marker then
       contributes a near-zero speed while still counting in the divisor, and
       the result tracks how much the cameras saw: on the same data it reads
       16 to 17 per cent low and its speed series correlates +0.25 to +0.70
       with the per-frame count of visible markers. It is kept so that a figure
       computed that way keeps reproducing; it is bit-for-bit identical on one
       machine and agrees to about 1 part in 10^7 across platforms, since
       ``filtfilt`` is not bit-reproducible between scipy builds.

    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 ``filters.BAND[0]`` (0.2 Hz).
        hi (float, optional): Upper band edge (Hz). Defaults to ``filters.BAND[1]`` (5.0 Hz).
        normalize (str, optional): ``"visible"`` averages over the markers
            present in each frame; ``"worn"`` averages over every marker that
            produced a series, which is the pre-1.0 behaviour. Defaults to
            ``"visible"``.
        **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. With ``normalize="visible"`` a frame in which no
            marker was present is NaN in `speed`, since nothing was measured
            there.
    """
    import warnings

    if normalize not in ("visible", "worn"):
        raise ValueError("normalize must be 'visible' or 'worn', not %r" % (normalize,))
    points = np.asarray(points, float)
    present = np.isfinite(points).all(axis=2)
    speeds, masks, fs_out = [], [], fs
    for m in range(points.shape[1]):
        sp, fs_out = band_limited_qom(points[:, m, :], fs, lo=lo, hi=hi, **kwargs)
        if len(sp):
            speeds.append(sp)
            masks.append(_presence_at_output_rate(present[:, m], len(sp), fs, fs_out))
    if not speeds:
        return np.nan, np.array([]), fs_out

    if normalize == "worn":
        # Bit-for-bit the pre-1.0 computation, so a published figure still reproduces.
        L = min(len(s) for s in speeds)
        mean_speed = np.mean([s[:L] for s in speeds], axis=0)
        return float(np.mean([s.mean() for s in speeds])), mean_speed, fs_out

    L = min(len(s) for s in speeds)
    stacked = np.where(np.array([m[:L] for m in masks]),
                       np.array([s[:L] for s in speeds]), np.nan)
    with warnings.catch_warnings():
        # A frame in which no marker was visible is legitimately empty, not an error.
        warnings.simplefilter("ignore", RuntimeWarning)
        mean_speed = np.nanmean(stacked, axis=0)
        qom = float(np.nanmean(stacked))
    return qom, mean_speed, fs_out

pose_qom

pose_qom(landmarks, fs, lo=filters.BAND[0], 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.2-5 Hz), where higher bands are dominated by landmark jitter rather than motion.

Source: Westney-comparisons study (Jensenius).

Parameters:

Name Type Description Default
landmarks ndarray

Landmark trajectories of shape (N, L, 2) in pixels (a single landmark of shape (N, 2) is also accepted).

required
fs float

Sampling rate (Hz, e.g. video frame rate).

required
lo float

Lower band edge (Hz). Defaults to filters.BAND[0] (0.2 Hz).

BAND[0]
hi float

Upper band edge (Hz). Defaults to 5.0.

5.0
**kwargs

Passed on to band_limited_qom.

{}

Returns:

Name Type Description
tuple

(qom, speed, fs_out) as in group_qom.

Source code in src/micromotion/qom.py
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
def pose_qom(landmarks, fs, lo=filters.BAND[0], 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.2-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 ``filters.BAND[0]`` (0.2 Hz).
        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`.
    """
    landmarks = np.asarray(landmarks, float)
    if landmarks.ndim == 2:
        landmarks = landmarks[:, None, :]
    return group_qom(landmarks, fs, lo=lo, hi=hi, **kwargs)

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).

Parameters:

Name Type Description Default
landmarks ndarray

Landmark trajectories of shape (N, L, C) with C >= 2; only the first two coordinates are used.

required
upper tuple

Indices of the two shoulder landmarks. Defaults to (11, 12).

(11, 12)
lower tuple

Indices of the two hip landmarks. Defaults to (23, 24).

(23, 24)

Returns:

Name Type Description
float

Median torso length (NaN if no finite frames).

Source code in src/micromotion/qom.py
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
def 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).
    """
    landmarks = np.asarray(landmarks, float)
    um = (landmarks[:, upper[0], :2] + landmarks[:, upper[1], :2]) / 2
    lm = (landmarks[:, lower[0], :2] + landmarks[:, lower[1], :2]) / 2
    d = np.linalg.norm(um - lm, axis=1)
    d = d[np.isfinite(d)]
    return float(np.median(d)) if len(d) else np.nan

normalized_qom

normalized_qom(landmarks, fs, scale=None, lo=filters.BAND[0], 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.

Parameters:

Name Type Description Default
landmarks ndarray

Landmark trajectories of shape (N, L, 2).

required
fs float

Sampling rate (Hz).

required
scale float

Precomputed body scale. Defaults to None (which computes body_scale(landmarks, upper, lower)).

None
lo float

Lower band edge (Hz). Defaults to filters.BAND[0] (0.2 Hz).

BAND[0]
hi float

Upper band edge (Hz). Defaults to 5.0.

5.0
upper tuple

Shoulder landmark indices for body_scale. Defaults to (11, 12).

(11, 12)
lower tuple

Hip landmark indices for body_scale. Defaults to (23, 24).

(23, 24)
**kwargs

Passed on to band_limited_qom.

{}

Returns:

Name Type Description
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
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
def normalized_qom(landmarks, fs, scale=None, lo=filters.BAND[0], 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 ``filters.BAND[0]`` (0.2 Hz).
        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.
    """
    landmarks = np.asarray(landmarks, float)
    if scale is None:
        scale = body_scale(landmarks, upper=upper, lower=lower)
    qom, speed, fs_out = pose_qom(landmarks, fs, lo=lo, hi=hi, **kwargs)
    if not np.isfinite(scale) or scale <= 0:
        return float("nan"), np.full_like(speed, np.nan), fs_out
    return qom / scale, speed / scale, fs_out

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.

Parameters:

Name Type Description Default
frames ndarray

Grayscale frames of shape (T, H, W).

required
grid tuple

Grid size (columns, rows). Defaults to (6, 4).

(6, 4)
region tuple

Region of interest as fractions (x0, x1, y0, y1) of the frame. Defaults to the full frame.

(0.0, 1.0, 0.0, 1.0)
threshold float

Absolute-difference threshold below which pixel changes are zeroed (0-255 scale). Defaults to 8.0.

8.0

Returns:

Name Type Description
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:

Type Description
ValueError

If frames is not 3-D (T, H, W), as in _motionanalysis.motiongram_data.

Source code in src/micromotion/qom.py
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
def 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`.
    """
    frames = np.asarray(frames, dtype=np.float32)
    if frames.ndim != 3:
        raise ValueError("grid_qom expects frames of shape (T, H, W)")
    T, H, W = frames.shape
    gx, gy = grid
    x0, x1, y0, y1 = region
    xs = np.linspace(int(x0 * W), int(x1 * W), gx + 1).astype(int)
    ys = np.linspace(int(y0 * H), int(y1 * H), gy + 1).astype(int)
    d = np.abs(np.diff(frames, axis=0))
    d[d < threshold] = 0.0
    series = np.empty((T - 1, gy * gx), dtype=np.float32)
    for r in range(gy):
        for c in range(gx):
            cell = d[:, ys[r]:ys[r + 1], xs[c]:xs[c + 1]]
            series[:, r * gx + c] = cell.mean(axis=(1, 2))
    heat = series.mean(axis=0).reshape(gy, gx)
    return series, heat

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.2-5 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 set by what the slowest device in the corpus can actually deliver.

BAND module-attribute

BAND = (0.2, 5.0)

The micromotion band, in Hz.

The lower edge was chosen by a sweep across seven optical datasets and 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.

An edge this close to DC has to be checked against integration drift, since that is what it 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.

The upper edge is set by deliverability, not by taste. A band above Nyquist is not a convention but a defect that returns a plausible number, and the ceiling must therefore be one that every device in the corpus can support:

============================ ================== ========== ========== collection sampling Nyquist 5 Hz? ============================ ================== ========== ========== phone, fused linear accel ~15 Hz 7.5 yes optical, 20 Hz subset 20 Hz 10 yes collaborators' audience data 10 Hz 5 at Nyquist optical and inertial, rest 100-256 Hz 50-128 yes ============================ ================== ========== ==========

A 10 Hz ceiling fails the first row on 354 of 355 days and sits exactly on Nyquist for the second. 5 Hz clears every one of them.

The first row is worth stating precisely, because the obvious reading is wrong. The phone's accelerometer runs at about 50 Hz. What runs at 15 Hz is the linear acceleration, which the logging app derives by fusing the accelerometer with the gyroscope and magnetometer to remove gravity -- and a fusion cannot output faster than its slowest input, which is the 15 Hz gyroscope. The deposited files carry that fused channel, so their usable Nyquist really is 7.5 Hz. The limit is the channel chosen, not the hardware.

What it costs. Band-limited speed mostly does not notice: measured over 466 person-recordings, 5 Hz against 10 differs by a median of 1.3 per cent, and 95 per cent of quiet-standing sway power lies below 1 Hz anyway. That is a distribution rather than a bound. Its ninetieth percentile is 3.2 per cent and its maximum 9.1, largest on optical collections sampled at 100 to 200 Hz, which genuinely resolve the octave between 5 and 10 Hz. Rankings survive: on one 199-recording optical collection the change moves the median 0.8 per cent and leaves the ranking at Spearman 0.996.

What it costs that matters. Jerk is two derivatives higher and lives in the discarded region: at 5 Hz it is 37 to 66 per cent of its 10 Hz value, and the ranking shifts too. So jerk must not be computed at this band on data that could support a wider one -- use :data:WIDEBAND and say so. On the phone collection the wider jerk was never real: the 15 Hz sensor cannot produce it, and computing it there inflated jerk 18 to 27 per cent with interpolation.

WIDEBAND module-attribute

WIDEBAND = (0.2, 10.0)

0.2-10 Hz: the band for measures that need the octave :data:BAND gives up.

Jerk and other high-derivative quantities live between 5 and 10 Hz, where band-limited speed does not. This band is for them, on collections sampled fast enough to deliver it -- check with :func:effective_band first, and never assume it from a file's grid, which may be an upsample of a much slower sensor.

It is deliberately not the default. A quantity computed here is not comparable with one computed at :data:BAND, and it is not computable at all on the slower collections.

NORESP_BAND module-attribute

NORESP_BAND = (0.45, 5.0)

0.45-5 Hz: the micromotion band with its lower edge raised above respiration.

The 0.15-0.45 Hz stretch that :data:BAND keeps is, on a chest-worn sensor, dominated by respiratory chest tilt. The ribcage turns the sensor as it expands, so what the accelerometer reads there is gravity re-projected between axes -- a rotation, not the sensor travelling. Measured across the year-long chest-phone record, 94 to 99 per cent of the power in that band lies perpendicular to that day's gravity vector, implying a tilt of 0.05 to 0.14 degrees. This band excludes it; a quantity computed here is micromotion above respiration.

The choice between :data:BAND and this one is a purpose decision, not a correctness one. :data:BAND keeps the respiratory term, which makes it the cardiorespiratory torso measure and the band every cross-collection comparison must use; this one drops the term, which makes it the band for a postural question on a chest-worn sensor. The StillStanding365 record deposits quantity of motion at both, as qom_mm_s and qom_045_5hz_mm_s, each with its band stated -- which is the practice to copy, because a value at either band with the band unstated will sooner or later be compared against one at the other.

It is not the compensated variant of :func:~micromotion.qom.qom, which raises the edge further, to 0.5 Hz, and also notches the recording's own cardiac peak. This constant changes the band and nothing else: the ballistocardiac impulse is still inside it.

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.2 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 = 1000.0

Warn when the sampling rate exceeds the lower band edge by more than this.

The lower edge is what drives the conditioning, which is why the ratio is taken against it. The worked example below fails at 0.15 Hz, near the bottom of its band, not near the top; a test keyed on the upper edge measures the wrong thing and moves whenever the ceiling moves. It did: halving the canonical ceiling from 10 Hz to 5 doubled an upper-edge ratio while the conditioning was unchanged, and the warning began firing on every high-rate call.

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
220
221
222
223
224
225
226
227
228
229
230
def 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.
    """
    if lo is None:
        return lowpass(x, fs, hi, order, margin)
    wl, wh = _edges(fs, lo, hi, margin)
    sos = signal.butter(order, [wl, wh], btype="band", output="sos")
    return signal.sosfiltfilt(sos, _checked(x, "bandpass"), axis=0)

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
233
234
235
236
237
238
239
240
241
242
def 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`.
    """
    ny = fs / 2.0
    fc = min(fc, ny * margin)
    sos = signal.butter(order, fc / ny, btype="low", output="sos")
    return signal.sosfiltfilt(sos, _checked(x, "lowpass"), axis=0)

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
245
246
247
248
249
250
251
252
253
254
255
def 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.
    """
    ny = fs / 2.0
    if fc <= 0 or fc >= ny:
        raise ValueError(f"cutoff {fc} Hz is not below Nyquist {ny} Hz")
    sos = signal.butter(order, fc / ny, btype="high", output="sos")
    return signal.sosfiltfilt(sos, _checked(x, "highpass"), axis=0)

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
258
259
260
261
262
263
264
265
266
267
def 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`.
    """
    if not np.isfinite(f0) or f0 <= 0 or f0 >= fs / 2:
        return np.asarray(x, float)
    b, a = signal.iirnotch(f0 / (fs / 2), Q=q)
    return signal.filtfilt(b, a, np.asarray(x, float), axis=0)

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
270
271
272
273
274
275
276
277
def 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.
    """
    return int(np.ceil(2 * order * fs / (lo if lo else BAND[1])))

effective_band

effective_band(fs: float, lo: float | None = BAND[0], hi: float = BAND[1], margin: float = NYQUIST_MARGIN) -> tuple[float | None, float]

The band that will actually be applied at this sampling rate.

The requested upper edge is clamped to just below Nyquist, so a rate below twice hi silently narrows the band. Ask before comparing two results computed at different rates: at 8 Hz the canonical 0.2-5 Hz band becomes 0.2-3.96, which is a different measurement.

effective_band(100.0) (0.2, 5.0) effective_band(10.0) (0.2, 4.95)

Source code in src/micromotion/filters.py
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
def effective_band(fs: float, lo: float | None = BAND[0], hi: float = BAND[1],
                   margin: float = NYQUIST_MARGIN) -> tuple[float | None, float]:
    """The band that will actually be applied at this sampling rate.

    The requested upper edge is clamped to just below Nyquist, so a rate below twice ``hi``
    silently narrows the band. Ask before comparing two results computed at different rates:
    at 8 Hz the canonical 0.2-5 Hz band becomes 0.2-3.96, which is a different measurement.

    >>> effective_band(100.0)
    (0.2, 5.0)
    >>> effective_band(10.0)
    (0.2, 4.95)
    """
    ny = fs / 2.0
    return lo, float(min(hi, ny * margin))

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. 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 rate at which every collection can be compared, including the slowest.

It is the greatest common divisor of the corpus's optical rates -- 20, 100, 120 and 200 Hz -- so every recording reaches it by an integer decimation and none requires upsampling. That is its one virtue, and it is a real one: it is the only rate at which the natively-20 Hz origin study can be placed beside the rest at all.

It is a lossy rate, not a free one. The argument that the band stops at 10 Hz so 20 Hz discards nothing does not survive measurement, because a 10 Hz upper edge cannot be realised at 20 Hz: Nyquist sits exactly on it, the margin rule pulls the edge inside, and the anti-alias filter is already rolling off below it. Decimating 34 natively-200 Hz person-recordings and re-measuring moves quantity of motion by -2.09 per cent at the median, and between -0.30 and -10.57 per cent across recordings. A per-recording spread that wide is a distortion rather than a bias: it cannot be measured once and corrected away.

Prefer :data:HARMONISED_RATE where every series in the comparison can reach it. Use this one when the comparison must include a 20 Hz collection, and say in the output that it is the lossy view.

HARMONISED_RATE module-attribute

HARMONISED_RATE = 100.0

Hz. The preferred rate for a comparison whose series can all reach it.

Native for several collections, an exact halving from 200 Hz, and a 6-to-5 polyphase step from 120 Hz. Measured against native 200 Hz values it costs +0.02 per cent at the median and stays within +/-0.85 per cent, against -2.09 and up to -10.57 at 20 Hz. It also leaves the 0.2-5 Hz band comfortably inside Nyquist rather than sitting on it.

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 one dataset 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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def 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 one dataset 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.
    """
    t = np.asarray(t, float)
    if len(t) < 2:
        raise ValueError("need at least two timestamps")
    span = t[-1] - t[0]
    if span <= 0:
        raise ValueError("timestamps do not increase")
    return (len(t) - 1) / span

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def 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.
    """
    t = np.asarray(t, float)
    d = np.diff(t)
    fs = measured_rate(t)
    nominal = 1.0 / fs
    return {
        "measured_rate_hz": fs,
        "n_samples": len(t),
        "duration_s": float(t[-1] - t[0]),
        "jitter_cv": float(np.std(d) / np.mean(d)) if np.mean(d) else float("nan"),
        "max_gap_s": float(d.max()) if len(d) else float("nan"),
        "gap_ratio": float(d.max() / nominal) if len(d) and nominal else float("nan"),
        "n_duplicate_t": int((d == 0).sum()),
        "n_backward_t": int((d < 0).sum()),
        "coverage": float(d[d <= 5 * nominal].sum() / (t[-1] - t[0])) if len(d) else 1.0,
    }

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
 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
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
def 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.
    """
    if fs_out > fs_in + 1e-9:
        raise ValueError(
            f"refusing to upsample {fs_in:.4g} Hz to {fs_out:.4g} Hz. "
            "Upsampling invents structure between samples; exclude this series instead."
        )
    x = np.asarray(x, float)
    if abs(fs_in - fs_out) < 1e-9:
        return x

    # WHAT A BARE RESAMPLE COSTS, measured rather than asserted. On optical head-marker position
    # from the Oslo Standstill corpus, `scipy.signal.resample_poly` called directly leaves the first
    # difference with a lag-one autocorrelation of -0.70 -- successive samples zigzagging against
    # each other -- where this function leaves +0.95, which is what a slowly drifting position looks
    # like. The zigzag is near-Nyquist tracking noise folded into the band.
    #
    # It matters most where an estimator reads the SHORTEST lags. On 2026-08-12 it reversed a
    # published result: a Collins-De Luca short-term Hurst exponent read 0.107 through the bare
    # call and 0.908 through this one, turning "a head marker cannot show the two regions of
    # postural control" into "it shows them in 619 of 626 recordings". A multifractal width, read
    # across a range of scales rather than the bottom of it, moved by 0.002 under the same
    # substitution -- so the risk is not resampling, it is resampling plus a short-lag estimator.
    #
        # Polyphase FIR, not signal.resample. The FFT resampler treats the series as periodic,
    # so on optical position data -- which carries a large offset and does not begin and end
    # at the same value -- it rings across the whole recording. That moved quantity of
    # motion by up to 8 per cent and did so non-monotonically in the rate, which is exactly
    # the kind of silent error a harmonised table cannot carry.
    up, down = Fraction(fs_out / fs_in).limit_denominator(1000).as_integer_ratio()
    if up == 0:
        raise ValueError(f"rate ratio {fs_out}/{fs_in} is too extreme to resample")

    # Centre before resampling and restore after. When up > 1 the polyphase filter interpolates,
    # and interpolation of a signal sitting on a large constant leaks that constant into the
    # stopband: the filter's finite attenuation is applied to the OFFSET as well as to the signal.
    # Optical position is the case that matters, since a marker sits 1-2 metres from the origin
    # while its motion is a fraction of a millimetre, so the leakage arrives far above the motion.
    #
    # Measured on Standstill2019, 120 -> 100 Hz, a real head marker at (-1383, 350, 1718) mm: the
    # share of 0.02-20 Hz velocity power above 5 Hz went from 5.4 per cent at the native rate to
    # 92.8 per cent after resampling, and back to 5.4 with this centring. A synthetic test isolates
    # the two conditions: the artefact needs up > 1 AND a large offset, and pure decimation
    # (up == 1, as in 200 -> 100 Hz) is immune to the offset entirely.
    #
    # This does not touch the band the corpus measures. The leakage lands above 5 Hz, so
    # band-limited quantity of motion was unaffected and the published figures stand; what was
    # affected is any analysis of the harmonised series ABOVE the band.
    centre = x.mean(axis=0, keepdims=True) if x.ndim > 1 else x.mean()
    return signal.resample_poly(x - centre, up, down, axis=0, padtype="line") + centre

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
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
def 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.
    """
    t = np.asarray(t, float)
    x = np.asarray(x, float)
    x2 = x[:, None] if x.ndim == 1 else x

    order = np.argsort(t, kind="stable")
    t, x2 = t[order], x2[order]
    keep = np.concatenate([[True], np.diff(t) > 0])
    t, x2 = t[keep], x2[keep]
    if len(t) < 2:
        raise ValueError("fewer than two usable timestamps after cleaning")

    fs_out = fs_out or measured_rate(t)
    grid = np.arange(t[0], t[-1], 1.0 / fs_out)
    out = np.column_stack([np.interp(grid, t, x2[:, i]) for i in range(x2.shape[1])])

    if max_gap_s is not None:
        d = np.diff(t)
        for i in np.flatnonzero(d > max_gap_s):
            out[(grid > t[i]) & (grid < t[i + 1])] = np.nan

    return grid, (out[:, 0] if x.ndim == 1 else out)

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
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
def 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.
    """
    a = np.array(x, float)
    one_d = a.ndim == 1
    if one_d:
        a = a[:, None]
    for j in range(a.shape[1]):
        v = a[:, j]
        bad = np.isnan(v)
        if not bad.any() or bad.all():
            continue
        good = np.flatnonzero(~bad)
        idx = np.flatnonzero(bad)
        for run in np.split(idx, np.flatnonzero(np.diff(idx) != 1) + 1):
            if len(run) <= max_gap and run[0] > 0 and run[-1] < len(v) - 1:
                v[run] = np.interp(run, good, v[good])
    return a[:, 0] if one_d else a

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
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
def 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.
    """
    a = np.asarray(x, float)
    bad = np.isnan(a)
    if a.ndim > 1:
        bad = bad.any(axis=1)
    if not bad.any():
        return {"missing_frac": 0.0, "n_gaps": 0, "longest_gap_s": 0.0}
    idx = np.flatnonzero(bad)
    runs = np.split(idx, np.flatnonzero(np.diff(idx) != 1) + 1)
    return {
        "missing_frac": float(bad.mean()),
        "n_gaps": len(runs),
        "longest_gap_s": float(max(len(r) for r in runs) / fs),
        "median_gap_s": float(np.median([len(r) for r in runs]) / fs),
    }

Validation

micromotion.validate

Checks that fail loudly on the errors this corpus makes silently.

Every check here exists because the failure it catches happened, went unnoticed, and produced a plausible number that someone used. None of them raised anything at the time. That is the common thread and the reason for the module: the corpus's characteristic failure is not a crash but a believable wrong answer, and the only defence is a check that runs on every build.

The intended use is a gate, not a report. Run :func:validate_series over everything a harmonised table is about to be built from, and refuse to build if anything comes back at "error". A finding at "warning" is something to record in the manifest beside the number it affects.

Each check names the incident that motivated it, with its numbers, so that the tolerance is arguable rather than magic.

HUMAN_FLOOR_MM_S module-attribute

HUMAN_FLOOR_MM_S = 1.0

Below this band-limited speed, an optical trace is equipment rather than a person.

Calibrated on the Oslo Standstill Database rather than chosen: across 649 championship person-recordings the people span 2.326 to 17.705 mm/s and the 84 known tripod and reference-marker traces span 0.024 to 0.306, with an order of magnitude of empty space between them. 1.0 sits in that gap.

Finding dataclass

One thing wrong with one series or file.

Source code in src/micromotion/validate.py
29
30
31
32
33
34
35
36
37
38
39
40
@dataclass(frozen=True)
class Finding:
    """One thing wrong with one series or file."""

    check: str
    severity: str
    message: str
    where: str = ""

    def __str__(self) -> str:
        loc = f"{self.where}: " if self.where else ""
        return f"[{self.severity}] {loc}{self.check}{self.message}"

zero_triplets

zero_triplets(x, where: str = '', max_fraction: float = 0.0) -> list[Finding]

Rows where every coordinate is exactly zero, which are gaps and not positions.

Qualisys writes a dropped frame as 0.000 0.000 0.000. That is a point on the laboratory floor about a metre and a half below a standing head, so a reader that takes it literally sees the head leave and return. A median-based measure barely notices; anything that sums or integrates does. On one 2021 recording, 93 such frames out of 118698 gave the head a path length of 119.8 m where the true figure is 11.0 m.

Exact zeros in all axes at once do not occur in real optical data, so the default tolerance is zero. Raise max_fraction only for a sensor whose true output can sit at the origin.

Source code in src/micromotion/validate.py
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
def zero_triplets(x, where: str = "", max_fraction: float = 0.0) -> list[Finding]:
    """Rows where every coordinate is exactly zero, which are gaps and not positions.

    Qualisys writes a dropped frame as ``0.000 0.000 0.000``. That is a point on the laboratory
    floor about a metre and a half below a standing head, so a reader that takes it literally
    sees the head leave and return. A median-based measure barely notices; anything that sums or
    integrates does. On one 2021 recording, 93 such frames out of 118698 gave the head a path
    length of 119.8 m where the true figure is 11.0 m.

    Exact zeros in all axes at once do not occur in real optical data, so the default tolerance
    is zero. Raise ``max_fraction`` only for a sensor whose true output can sit at the origin.
    """
    x = np.atleast_2d(np.asarray(x, float).T).T
    if x.shape[1] < 2:
        return []
    gap = (x == 0.0).all(axis=1)
    n = int(gap.sum())
    if n == 0 or n / len(x) <= max_fraction:
        return []
    runs = [len(list(g)) for k, g in itertools.groupby(gap) if k]
    return [_finding(
        "zero_triplets", "error",
        f"{n} of {len(x)} samples ({100 * n / len(x):.2f} %) are exactly zero in every axis, "
        f"longest run {max(runs)}. These are gaps; convert them to NaN before filtering",
        where)]

marker_average

marker_average(markers, where: str = '', max_gap_fraction: float = 0.5) -> list[Finding]

Check a set of markers before averaging them into one position.

Averaging several markers into a single "head" or "trunk" position is routine and looks harmless. It is not, if the gaps have not been repaired first, because the usual repair happens at the end of a pipeline and an average destroys the evidence on the way in: the mean of two real coordinates and one zero triplet is a perfectly finite point that no later gap check will flag.

The damage is a clean multiplicative bias. Markers on one rigid segment move together, so with n markers of which k are dead, the averaged position moves at about (n - k) / n of the true amplitude -- and a speed derived from it is understated by the same factor. One marker dead out of three is exactly two thirds, which reads as a third less motion.

This happened. Four recordings in one 86-session collection carried a head marker that was never tracked, and their quantity of motion was reported 33.3 per cent low for as long as the collection existed, looking like unusually still standing rather than like a fault.

Pass a mapping of name to (n, 3) array. Repair each marker with :func:micromotion.validate.zero_triplets and NaN before averaging, then use nanmean.

The same bias appears one level up in :func:micromotion.qom.group_qom, whose normalize argument decides whether markers that were not visible count in the divisor. Where positions are averaged here, check how speeds are averaged there.

Source code in src/micromotion/validate.py
 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
135
136
137
138
139
140
141
142
143
144
145
146
147
148
def marker_average(markers, where: str = "", max_gap_fraction: float = 0.5) -> list[Finding]:
    """Check a set of markers before averaging them into one position.

    Averaging several markers into a single "head" or "trunk" position is routine and looks
    harmless. It is not, if the gaps have not been repaired first, because the usual repair
    happens at the *end* of a pipeline and an average destroys the evidence on the way in: the
    mean of two real coordinates and one zero triplet is a perfectly finite point that no later
    gap check will flag.

    The damage is a clean multiplicative bias. Markers on one rigid segment move together, so
    with ``n`` markers of which ``k`` are dead, the averaged position moves at about
    ``(n - k) / n`` of the true amplitude -- and a speed derived from it is understated by the
    same factor. One marker dead out of three is exactly two thirds, which reads as a third
    less motion.

    This happened. Four recordings in one 86-session collection carried a head marker that was
    never tracked, and their quantity of motion was reported 33.3 per cent low for as long as
    the collection existed, looking like unusually still standing rather than like a fault.

    Pass a mapping of name to (n, 3) array. Repair each marker with
    :func:`micromotion.validate.zero_triplets` and NaN before averaging, then use ``nanmean``.


    The same bias appears one level up in :func:`micromotion.qom.group_qom`, whose
    ``normalize`` argument decides whether markers that were not visible count in the
    divisor. Where positions are averaged here, check how speeds are averaged there.
    """
    findings: list[Finding] = []
    if not markers:
        return findings
    dead, partial = [], []
    for name, arr in dict(markers).items():
        x = np.atleast_2d(np.asarray(arr, float).T).T
        if x.shape[1] < 2 or not len(x):
            continue
        bad = (x == 0.0).all(axis=1) | ~np.isfinite(x).all(axis=1)
        frac = float(bad.mean())
        if frac >= 1.0:
            dead.append(name)
        elif frac > max_gap_fraction:
            partial.append((name, frac))

    n = len(markers)
    if dead:
        bias = (n - len(dead)) / n if n else 0.0
        findings.append(_finding(
            "marker_average", "error",
            f"{len(dead)} of {n} markers carry no data at all ({', '.join(sorted(dead))}). "
            f"Averaging as-is understates the amplitude by about {100 * (1 - bias):.1f} % "
            f"(factor {bias:.3f}). Repair gaps to NaN per marker and use nanmean",
            where))
    for name, frac in partial:
        findings.append(_finding(
            "marker_average", "warning",
            f"marker {name} is {100 * frac:.1f} % gaps; averaging it in biases the result "
            f"toward the origin over that stretch",
            where))
    return findings

implausible_position

implausible_position(x, where: str = '', axis: int = 2, min_fraction: float = 0.3, max_fraction: float = 2.5) -> list[Finding]

Samples that put a marker somewhere a body cannot be.

:func:zero_triplets catches a dropped frame written as three exact zeros. It cannot catch the near miss: a reconstruction that lands close to the laboratory origin without being exactly on it. Those samples pass every finiteness and sentinel test, because they are ordinary numbers, and they are not rare enough to ignore -- a corpus of 1018 optical person-recordings held two, one of which placed a head marker 139 mm below the floor.

The test is physical rather than statistical: a marker on a standing body stays within a band around its own median height. Anything below min_fraction of that median, or above max_fraction, is a tracking artefact rather than a posture.

The damage is uneven, which is why this is worth checking separately. A median-based measure barely notices 0.5 per cent of samples. Anything spatial is destroyed by them: on that Sverm recording the sway extent read 977 mm where the true figure is about 48.

Only meaningful for a marker whose median height is a real standing height, so recordings whose median falls below 500 in the array's own units are skipped.

Source code in src/micromotion/validate.py
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
def implausible_position(x, where: str = "", axis: int = 2, min_fraction: float = 0.3,
                         max_fraction: float = 2.5) -> list[Finding]:
    """Samples that put a marker somewhere a body cannot be.

    :func:`zero_triplets` catches a dropped frame written as three exact zeros. It cannot catch the
    near miss: a reconstruction that lands *close* to the laboratory origin without being exactly
    on it. Those samples pass every finiteness and sentinel test, because they are ordinary
    numbers, and they are not rare enough to ignore -- a corpus of 1018 optical person-recordings
    held two, one of which placed a head marker 139 mm *below the floor*.

    The test is physical rather than statistical: a marker on a standing body stays within a band
    around its own median height. Anything below ``min_fraction`` of that median, or above
    ``max_fraction``, is a tracking artefact rather than a posture.

    The damage is uneven, which is why this is worth checking separately. A median-based measure
    barely notices 0.5 per cent of samples. Anything spatial is destroyed by them: on that Sverm
    recording the sway extent read 977 mm where the true figure is about 48.

    Only meaningful for a marker whose median height is a real standing height, so recordings
    whose median falls below ``500`` in the array's own units are skipped.
    """
    x = np.atleast_2d(np.asarray(x, float).T).T
    if x.shape[1] <= axis:
        return []
    z = x[:, axis]
    z = z[np.isfinite(z)]
    if len(z) < 100:
        return []
    med = float(np.median(z))
    if med < 500:                       # not a standing-height marker; no expectation to test
        return []
    bad = (z < med * min_fraction) | (z > med * max_fraction)
    n = int(bad.sum())
    if n == 0:
        return []
    return [_finding(
        "implausible_position", "error",
        f"{n} of {len(z)} samples ({100 * n / len(z):.2f} %) place the marker outside "
        f"{min_fraction:g}-{max_fraction:g} times its median height of {med:.0f}, reaching "
        f"{z.min():.0f}. These are tracking artefacts, not postures; they survive a zero-triplet "
        f"check because they are not exactly zero, and they wreck spatial measures while barely "
        f"moving a median",
        where)]

marker_noise

marker_noise(x, fs: float, where: str = '', max_ratio: float = 5.0) -> list[Finding]

A marker that neither jumps nor drops out, but jitters.

:func:zero_triplets catches the dropped frame and :func:implausible_position catches the reconstruction that lands near the laboratory origin. Neither can see the third failure, which is a marker whose every sample is plausible and whose sample-to-sample noise is several times what the body contributes. Nothing about such a trace looks wrong: it stays at head height, it never leaps, and its median-based quantity of motion is perfectly ordinary.

It is destroyed only by measures that SUM. On one Sverm recording the band-limited quantity of motion is 4.95 mm/s -- the corpus median, an unremarkable standstill -- while the raw sample-to-sample path length runs at 79.18 mm/s, sixteen times higher. Plotted as cumulative distance beside 190 other recordings it was the obvious outlier, and it is not a person who moved.

The test compares the two. Raw path speed is the mean sample-to-sample displacement per second, which counts everything including the sensor's own jitter; band-limited speed keeps only the frequencies a standing body moves in. Their ratio is therefore how much of the measured path lies outside the band, and it is bounded below by 1 rather than by 0. Over 195 Sverm person-recordings it has a median of 1.39 and a 95th percentile of 2.37, then a gap to 4.5, 5.9, 10.8 and 16.2 -- so the default threshold sits in empty space rather than on a shoulder.

THE DENOMINATOR IS THE MEDIAN, and that was checked rather than assumed. Re-measured on 2026-08-15 both ways over the same recordings: against the median band-limited speed, which is what this function uses, the ratio reproduces the figures above; against the MEAN it gives a median of 1.22, a 95th percentile of 2.10 and a tail of 3.8, 5.2, 9.7 and 11.0, which is not what is quoted here. So the calibration and the code agree about which statistic they are dividing by. _curation/marker_jitter_sweep.py in the standstill corpus reports the MEAN-based ratio in its own table, so its numbers are the second set and are not comparable with these.

Sampling rate matters and is already accounted for: a faster recording accumulates more raw path for identical behaviour, but it accumulates the same band-limited speed, so the ratio rises with rate. That is the point. It is asking how much of what a summing measure would count is not the body, and the answer legitimately depends on how often the sensor was asked.

Requires positions in millimetres. Returns nothing for a series too short to filter.

A GAP DOES NOT SILENCE IT, since 1.12.2. Until then a single non-finite sample anywhere in the trace returned no finding at all, which is the failure this library exists to prevent: no finding is what a clean recording returns, so one NaN made a jittering marker read as checked and sound. The guard was there for a real reason -- a NaN propagates through diff into the sum and through the filter into every sample of the band-limited series, so neither number survives it -- but the answer is to measure what can be measured and say so, not to fall silent. The check now runs on the longest contiguous finite run, reports the ratio for that span, and says in the message that it is a span rather than the recording. Where no run is long enough to filter it returns a WARNING saying the check could not run, so the gap is visible in the same list as everything else.

This changes no verdict in the corpus it was written for: of 934 optical position recordings swept, 10 carry any gap at all and none of the 10 has a ratio anywhere near the threshold. It is a latent defect rather than one that has fired, and it is fixed because a check that cannot fail reads as coverage.

Source code in src/micromotion/validate.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
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
def marker_noise(x, fs: float, where: str = "", max_ratio: float = 5.0) -> list[Finding]:
    """A marker that neither jumps nor drops out, but jitters.

    :func:`zero_triplets` catches the dropped frame and :func:`implausible_position` catches the
    reconstruction that lands near the laboratory origin. Neither can see the third failure, which
    is a marker whose every sample is plausible and whose sample-to-sample noise is several times
    what the body contributes. Nothing about such a trace looks wrong: it stays at head height, it
    never leaps, and its median-based quantity of motion is perfectly ordinary.

    It is destroyed only by measures that SUM. On one Sverm recording the band-limited quantity of
    motion is 4.95 mm/s -- the corpus median, an unremarkable standstill -- while the raw
    sample-to-sample path length runs at 79.18 mm/s, sixteen times higher. Plotted as cumulative
    distance beside 190 other recordings it was the obvious outlier, and it is not a person who
    moved.

    The test compares the two. Raw path speed is the mean sample-to-sample displacement per second,
    which counts everything including the sensor's own jitter; band-limited speed keeps only the
    frequencies a standing body moves in. Their ratio is therefore how much of the measured path
    lies outside the band, and it is bounded below by 1 rather than by 0. Over 195 Sverm
    person-recordings it has a median of 1.39 and a 95th percentile of 2.37, then a gap to 4.5,
    5.9, 10.8 and 16.2 -- so the default threshold sits in empty space rather than on a shoulder.

    THE DENOMINATOR IS THE MEDIAN, and that was checked rather than assumed. Re-measured on
    2026-08-15 both ways over the same recordings: against the median band-limited speed, which is
    what this function uses, the ratio reproduces the figures above; against the MEAN it gives a
    median of 1.22, a 95th percentile of 2.10 and a tail of 3.8, 5.2, 9.7 and 11.0, which is not
    what is quoted here. So the calibration and the code agree about which statistic they are
    dividing by. `_curation/marker_jitter_sweep.py` in the standstill corpus reports the MEAN-based
    ratio in its own table, so its numbers are the second set and are not comparable with these.

    Sampling rate matters and is already accounted for: a faster recording accumulates more raw
    path for identical behaviour, but it accumulates the same band-limited speed, so the ratio
    rises with rate. That is the point. It is asking how much of what a summing measure would count
    is not the body, and the answer legitimately depends on how often the sensor was asked.

    Requires positions in millimetres. Returns nothing for a series too short to filter.

    A GAP DOES NOT SILENCE IT, since 1.12.2. Until then a single non-finite sample anywhere in the
    trace returned no finding at all, which is the failure this library exists to prevent: no
    finding is what a clean recording returns, so one NaN made a jittering marker read as checked
    and sound. The guard was there for a real reason -- a NaN propagates through ``diff`` into the
    sum and through the filter into every sample of the band-limited series, so neither number
    survives it -- but the answer is to measure what can be measured and say so, not to fall
    silent. The check now runs on the longest contiguous finite run, reports the ratio for that
    span, and says in the message that it is a span rather than the recording. Where no run is long
    enough to filter it returns a WARNING saying the check could not run, so the gap is visible in
    the same list as everything else.

    This changes no verdict in the corpus it was written for: of 934 optical position recordings
    swept, 10 carry any gap at all and none of the 10 has a ratio anywhere near the threshold. It is
    a latent defect rather than one that has fired, and it is fixed because a check that cannot fail
    reads as coverage.
    """
    from .qom import speed_from_position          # local: qom imports filters, not this module

    x = np.atleast_2d(np.asarray(x, float).T).T
    if x.shape[1] < 2 or len(x) < 50:
        return []

    finite = np.isfinite(x).all(axis=1)
    span_note = ""
    if not finite.all():
        lo, hi = _longest_run(finite)
        if hi - lo < 50:
            return [_finding(
                "marker_noise", "warning",
                f"the jitter check could not run: {100*(1-finite.mean()):.1f} per cent of the "
                f"series is non-finite and its longest unbroken run is {hi-lo} samples, fewer than "
                f"the 50 a filter needs. This is not a clean recording, it is an unchecked one",
                where)]
        x = x[lo:hi]
        span_note = (f" Measured on the longest unbroken run, {hi-lo} of {len(finite)} samples, "
                     f"because {100*(1-finite.mean()):.1f} per cent of the series is non-finite.")

    step = np.linalg.norm(np.diff(x, axis=0), axis=1)
    raw = float(step.sum()) * fs / len(step)
    band = float(np.median(speed_from_position(x, fs, unit="mm")))
    if band <= 0:
        return []
    ratio = raw / band
    if ratio <= max_ratio:
        return []
    return [_finding(
        "marker_noise", "error",
        f"raw path length runs at {raw:.1f} mm/s against a band-limited {band:.2f} mm/s, a ratio "
        f"of {ratio:.1f} where this corpus sits at a median of 1.4. Most of what a cumulative or "
        f"summing measure would count here is marker jitter rather than the body; median-based "
        f"measures are unaffected." + span_note,
        where)]

finite_fraction

finite_fraction(x, where: str = '', min_finite: float = 0.8) -> list[Finding]

Whether enough of a series survived to measure, and whether any of it did.

A gap that runs off the start or end of a series cannot be interpolated — there is nothing on the far side — and a band-pass then spreads the surviving NaN across the whole recording. The result is an all-NaN series that is indistinguishable from an absent marker unless something looks. One Sverm 2012 recording lost a marker 548 s in and never regained it; the deposited value for it had been computed straight across the hole.

An emptied series is an error. A merely thin one is a warning, because the caller may legitimately be measuring the longest clean span instead.

Source code in src/micromotion/validate.py
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
def finite_fraction(x, where: str = "", min_finite: float = 0.8) -> list[Finding]:
    """Whether enough of a series survived to measure, and whether any of it did.

    A gap that runs off the start or end of a series cannot be interpolated — there is nothing
    on the far side — and a band-pass then spreads the surviving NaN across the whole recording.
    The result is an all-NaN series that is indistinguishable from an absent marker unless
    something looks. One Sverm 2012 recording lost a marker 548 s in and never regained it; the
    deposited value for it had been computed straight across the hole.

    An emptied series is an error. A merely thin one is a warning, because the caller may
    legitimately be measuring the longest clean span instead.
    """
    x = np.asarray(x, float)
    bad = np.isnan(x).any(axis=1) if x.ndim > 1 else np.isnan(x)
    finite = 1.0 - float(bad.mean())
    if finite == 0.0:
        return [_finding("finite_fraction", "error",
                         "the series is entirely NaN. A band-pass across an unbridgeable gap "
                         "does this, and the result looks like an absent sensor", where)]
    if finite < min_finite:
        return [_finding("finite_fraction", "warning",
                         f"only {100 * finite:.1f} % of samples are finite; measure the longest "
                         f"clean span rather than the whole series", where)]
    return []

longest_finite_span

longest_finite_span(x) -> tuple[int, int]

Start index and length of the longest run with no missing sample.

What to measure over when a gap cannot be bridged. Filtering across the gap is not an option and dropping the samples silently closes it, which is worse: the series then claims a duration it does not have.

Source code in src/micromotion/validate.py
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
def longest_finite_span(x) -> tuple[int, int]:
    """Start index and length of the longest run with no missing sample.

    What to measure over when a gap cannot be bridged. Filtering across the gap is not an
    option and dropping the samples silently closes it, which is worse: the series then claims
    a duration it does not have.
    """
    x = np.asarray(x, float)
    bad = np.isnan(x).any(axis=1) if x.ndim > 1 else np.isnan(x)
    best_start, best_len, i = 0, 0, 0
    for is_bad, group in itertools.groupby(bad):
        n = len(list(group))
        if not is_bad and n > best_len:
            best_start, best_len = i, n
        i += n
    return best_start, best_len

timestamps

timestamps(t, where: str = '') -> list[Finding]

Whether a timestamp column is usable as a clock.

Sorting a timestamp column into order is the tempting repair and the wrong one: it destroys the evidence that the clock misbehaved while leaving the samples in an order the sensor never produced. One balance-board collection carries 123 111 duplicate timestamps and 83 that step backwards, which is a device fault to be recorded, not a sort key to be fixed.

Source code in src/micromotion/validate.py
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
def timestamps(t, where: str = "") -> list[Finding]:
    """Whether a timestamp column is usable as a clock.

    Sorting a timestamp column into order is the tempting repair and the wrong one: it destroys
    the evidence that the clock misbehaved while leaving the samples in an order the sensor
    never produced. One balance-board collection carries 123 111 duplicate timestamps and 83
    that step backwards, which is a device fault to be recorded, not a sort key to be fixed.
    """
    t = np.asarray(t, float)
    out = []
    if len(t) < 2:
        return [_finding("timestamps", "error", f"only {len(t)} samples", where)]
    d = np.diff(t)
    back = int((d < 0).sum())
    dup = int((d == 0).sum())
    if back:
        out.append(_finding("timestamps", "error",
                            f"{back} timestamps step backwards; the clock is not monotonic and "
                            f"sorting would hide it", where))
    if dup:
        out.append(_finding("timestamps", "warning",
                            f"{dup} of {len(d)} intervals ({100 * dup / len(d):.2f} %) are zero, "
                            f"so samples share a timestamp", where))
    pos = d[d > 0]
    if len(pos) and pos.max() > 20 * np.median(pos):
        out.append(_finding("timestamps", "warning",
                            f"largest interval {pos.max():.4g} s against a median of "
                            f"{np.median(pos):.4g} s; the series has holes", where))
    return out

rate_agreement

rate_agreement(t, documented_hz: float, where: str = '', tolerance: float = 0.02) -> list[Finding]

Whether the rate written down matches the rate the timestamps imply.

Measure the rate, do not read it. Documented rates in this corpus are wrong by up to 4.4 per cent, one record's was out by a factor of 37, and one championship's accelerometers turn out to run at 191.29–207.73 Hz against a nominal 200 — with the true rate a property of the individual device rather than the protocol, so three participants sharing a unit share a clock and everyone else does not.

A disagreement is an error rather than a warning because every frequency-domain measure downstream scales with it, and nothing further along can detect it.

Source code in src/micromotion/validate.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
def rate_agreement(t, documented_hz: float, where: str = "",
                   tolerance: float = 0.02) -> list[Finding]:
    """Whether the rate written down matches the rate the timestamps imply.

    Measure the rate, do not read it. Documented rates in this corpus are wrong by up to 4.4 per
    cent, one record's was out by a factor of 37, and one championship's accelerometers turn out
    to run at 191.29–207.73 Hz against a nominal 200 — with the true rate a property of the
    individual device rather than the protocol, so three participants sharing a unit share a
    clock and everyone else does not.

    A disagreement is an error rather than a warning because every frequency-domain measure
    downstream scales with it, and nothing further along can detect it.
    """
    fs = measured_rate(t)
    if not np.isfinite(fs) or fs <= 0:
        return [_finding("rate_agreement", "error", "no rate could be measured", where)]
    if documented_hz is None or not np.isfinite(documented_hz) or documented_hz <= 0:
        return []
    rel = abs(fs - documented_hz) / documented_hz
    if rel > tolerance:
        return [_finding("rate_agreement", "error",
                         f"measured {fs:.4g} Hz against a documented {documented_hz:.4g} Hz, "
                         f"a difference of {100 * rel:.1f} %. Use the measured rate", where)]
    return []

held_samples

held_samples(x, where: str = '', max_run: int = 50) -> list[Finding]

Long runs of an identical value, which mean a hold rather than a measurement.

A sensor sampled below the rate it is stored at is written out with each value repeated, and the file then claims a rate the data does not carry. The Delsys accelerometers in this corpus are stored at 2000 Hz and repeat every value, so their real rate is far lower and any spectrum computed at the stored rate is wrong above the true Nyquist.

Source code in src/micromotion/validate.py
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
def held_samples(x, where: str = "", max_run: int = 50) -> list[Finding]:
    """Long runs of an identical value, which mean a hold rather than a measurement.

    A sensor sampled below the rate it is stored at is written out with each value repeated, and
    the file then claims a rate the data does not carry. The Delsys accelerometers in this corpus
    are stored at 2000 Hz and repeat every value, so their real rate is far lower and any
    spectrum computed at the stored rate is wrong above the true Nyquist.
    """
    x = np.asarray(x, float)
    col = x[:, 0] if x.ndim > 1 else x
    col = col[np.isfinite(col)]
    if len(col) < max_run + 1:
        return []
    longest = max((len(list(g)) for _, g in itertools.groupby(col)), default=0)
    if longest > max_run:
        return [_finding("held_samples", "warning",
                         f"{longest} consecutive identical values; the stored rate is probably "
                         f"higher than the rate actually sampled", where)]
    return []

frame_count

frame_count(n: int, where: str = '') -> list[Finding]

A sample count sitting exactly on a 16-bit boundary, which means a silent truncation.

C3D stores its frame count in sixteen bits, so a conversion through it stops at 65535 and says nothing. Seven sessions in this corpus were nearly deposited that way: 327.7 s of a 360 s recording, complete-looking, with the last thirty-two seconds gone.

Source code in src/micromotion/validate.py
409
410
411
412
413
414
415
416
417
418
419
420
def frame_count(n: int, where: str = "") -> list[Finding]:
    """A sample count sitting exactly on a 16-bit boundary, which means a silent truncation.

    C3D stores its frame count in sixteen bits, so a conversion through it stops at 65535 and
    says nothing. Seven sessions in this corpus were nearly deposited that way: 327.7 s of a
    360 s recording, complete-looking, with the last thirty-two seconds gone.
    """
    if n in (65535, 65536):
        return [_finding("frame_count", "error",
                         f"exactly {n} samples, which is the 16-bit ceiling. A C3D conversion "
                         f"truncates here silently; re-export from the source", where)]
    return []

edge_motion

edge_motion(speed, fs: float, where: str = '', edge_s: float = 10.0, baseline_s: tuple[float, float] = (60.0, 300.0), factor: float = 2.0) -> list[Finding]

Whether a recording opens or closes with movement rather than standstill.

A deposited standstill recording should contain standstill and nothing else. In practice exports are trimmed by hand, or not trimmed at all, and what survives at the edges is people walking into position, settling, or being told the recording has ended. That inflates anything computed over a short window and is invisible in a whole-recording median.

speed is a band-limited speed series, whatever sensor it came from. The comparison is against the recording's own settled interior rather than an absolute threshold, because the quantity varies by two orders of magnitude across sensors in this corpus.

Returns one finding per affected end, at "warning": settling is a fact about the recording to be recorded, not necessarily a fault to be fixed.

Source code in src/micromotion/validate.py
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
def edge_motion(speed, fs: float, where: str = "", edge_s: float = 10.0,
                baseline_s: tuple[float, float] = (60.0, 300.0),
                factor: float = 2.0) -> list[Finding]:
    """Whether a recording opens or closes with movement rather than standstill.

    A deposited standstill recording should contain standstill and nothing else. In practice
    exports are trimmed by hand, or not trimmed at all, and what survives at the edges is people
    walking into position, settling, or being told the recording has ended. That inflates
    anything computed over a short window and is invisible in a whole-recording median.

    ``speed`` is a band-limited speed series, whatever sensor it came from. The comparison is
    against the recording's own settled interior rather than an absolute threshold, because the
    quantity varies by two orders of magnitude across sensors in this corpus.

    Returns one finding per affected end, at ``"warning"``: settling is a fact about the
    recording to be recorded, not necessarily a fault to be fixed.
    """
    speed = np.asarray(speed, float)
    n = len(speed)
    lo, hi = int(baseline_s[0] * fs), int(min(baseline_s[1] * fs, n))
    if n < int(2 * edge_s * fs) + 1 or hi - lo < int(10 * fs):
        return []
    base = float(np.nanmedian(speed[lo:hi]))
    if not np.isfinite(base) or base <= 0:
        return []
    k = int(edge_s * fs)
    out = []
    for end, seg in (("start", speed[:k]), ("end", speed[-k:])):
        v = float(np.nanmedian(seg))
        if np.isfinite(v) and v > factor * base:
            out.append(_finding(
                "edge_motion", "warning",
                f"the first {edge_s:.0f} s move at {v:.3g} against a settled {base:.3g} "
                f"({v / base:.1f}x)" if end == "start" else
                f"the last {edge_s:.0f} s move at {v:.3g} against a settled {base:.3g} "
                f"({v / base:.1f}x)", where))
    return out

settling_time

settling_time(speed, fs: float, baseline_s: tuple[float, float] = (60.0, 300.0), factor: float = 1.5, window_s: float = 5.0, max_s: float = 120.0) -> tuple[float, float]

How long each end of a recording takes to reach its settled level, in seconds.

Returns (head, tail): the time to trim from the start and from the end so that what remains is within factor of the recording's own settled interior. Zero means that end is already settled. Use it to choose a trim rather than guessing one: a fixed twelve seconds was not enough for any recording in the collection it was chosen for.

The search stops at max_s and returns that value, which should be read as "still moving when the search stopped" rather than as a measurement.

Source code in src/micromotion/validate.py
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
def settling_time(speed, fs: float, baseline_s: tuple[float, float] = (60.0, 300.0),
                  factor: float = 1.5, window_s: float = 5.0,
                  max_s: float = 120.0) -> tuple[float, float]:
    """How long each end of a recording takes to reach its settled level, in seconds.

    Returns ``(head, tail)``: the time to trim from the start and from the end so that what
    remains is within ``factor`` of the recording's own settled interior. Zero means that end is
    already settled. Use it to choose a trim rather than guessing one: a fixed twelve seconds
    was not enough for any recording in the collection it was chosen for.

    The search stops at ``max_s`` and returns that value, which should be read as "still moving
    when the search stopped" rather than as a measurement.
    """
    speed = np.asarray(speed, float)
    n = len(speed)
    lo, hi = int(baseline_s[0] * fs), int(min(baseline_s[1] * fs, n))
    if hi - lo < int(10 * fs):
        return 0.0, 0.0
    base = float(np.nanmedian(speed[lo:hi]))
    if not np.isfinite(base) or base <= 0:
        return 0.0, 0.0
    w = max(1, int(window_s * fs))
    limit = min(int(max_s * fs), n // 2)

    def scan(x):
        t = 0
        while t + w <= limit:
            if np.nanmedian(x[t:t + w]) <= factor * base:
                return t / fs
            t += w
        return limit / fs

    return scan(speed), scan(speed[::-1])

duplicate_files

duplicate_files(paths, where: str = '') -> list[Finding]

Files in a set that are byte-identical.

A record shipped with the same recording under two names, one of them in no manifest, left over from a rename. It inflated the record and would have inflated any count taken by listing the directory.

Source code in src/micromotion/validate.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
def duplicate_files(paths, where: str = "") -> list[Finding]:
    """Files in a set that are byte-identical.

    A record shipped with the same recording under two names, one of them in no manifest, left
    over from a rename. It inflated the record and would have inflated any count taken by
    listing the directory.
    """
    by_hash: dict[str, list[str]] = {}
    for p in paths:
        try:
            h = hashlib.sha256()
            with open(p, "rb") as fh:
                for block in iter(lambda: fh.read(1 << 20), b""):
                    h.update(block)
        except OSError:
            continue
        by_hash.setdefault(h.hexdigest(), []).append(str(p))
    out = []
    for group in by_hash.values():
        if len(group) > 1:
            names = ", ".join(os.path.basename(p) for p in sorted(group))
            out.append(_finding("duplicate_files", "error",
                                f"byte-identical: {names}", where))
    return out

too_still

too_still(x, fs: float, where: str = '', floor_mm_s: float = HUMAN_FLOOR_MM_S, band: tuple[float, float] | None = None) -> list[Finding]

A trace too still to be a body: a tripod, a floor marker, a mount.

Every other check here asks whether a recording moved WRONGLY. This one asks whether it moved at all, and it exists because a name list cannot be trusted to be complete.

THE CASE THAT MOTIVATES IT, twice over. A corpus of optical recordings carried 84 tripod traces as participants for months, because the label pattern matching equipment missed St01, ST1 and Tripod. Fixing the pattern was not enough: on 2026-08-19 four more entered the same corpus as static1 to static4, because the pattern allowed a bare static and st1 but not a trailing digit on the word it already contained. Both times the numbers looked ordinary -- a median is robust to a tripod, so nothing downstream complained -- and both times what found them was a check on the PHYSICS rather than on the name.

Use it that way. Run it over everything, and treat what it returns as a list of labels to add to whatever pattern you filter by, not as a filter itself: a check that quietly removes data reads as coverage.

Requires positions in millimetres. Returns nothing for a series too short to filter, which is deliberate -- a short recording is a different complaint, and :func:frame_count makes it.

Source code in src/micromotion/validate.py
533
534
535
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
571
572
573
574
575
576
577
578
579
def too_still(x, fs: float, where: str = "", floor_mm_s: float = HUMAN_FLOOR_MM_S,
              band: tuple[float, float] | None = None) -> list[Finding]:
    """A trace too still to be a body: a tripod, a floor marker, a mount.

    Every other check here asks whether a recording moved WRONGLY. This one asks whether
    it moved at all, and it exists because a name list cannot be trusted to be complete.

    THE CASE THAT MOTIVATES IT, twice over. A corpus of optical recordings carried 84
    tripod traces as participants for months, because the label pattern matching
    equipment missed `St01`, `ST1` and `Tripod`. Fixing the pattern was not enough: on
    2026-08-19 four more entered the same corpus as `static1` to `static4`, because the
    pattern allowed a bare `static` and `st1` but not a trailing digit on the word it
    already contained. Both times the numbers looked ordinary -- a median is robust to a
    tripod, so nothing downstream complained -- and both times what found them was a
    check on the PHYSICS rather than on the name.

    Use it that way. Run it over everything, and treat what it returns as a list of
    labels to add to whatever pattern you filter by, not as a filter itself: a check that
    quietly removes data reads as coverage.

    Requires positions in millimetres. Returns nothing for a series too short to filter,
    which is deliberate -- a short recording is a different complaint, and
    :func:`frame_count` makes it.
    """
    from .qom import qom as _qom_of

    x = np.asarray(x, float)
    if x.ndim == 1:
        x = x[:, None]
    finite = np.isfinite(x).all(axis=1)
    if finite.sum() < 50:
        return []
    # No try/except around the measurement. An early draft of this function wrapped it
    # in a bare `except Exception: return []`, which swallowed an AttributeError and made
    # the check silently return "nothing wrong" for every input -- the exact failure this
    # module exists to prevent, inside this module. If the speed cannot be computed, the
    # caller should see why.
    speed = _qom_of(x[finite], fs, kind="position", unit="mm").median_mm_s
    if not np.isfinite(speed) or speed >= floor_mm_s:
        return []
    return [Finding(
        check="too_still", severity="error", where=where,
        message=(
        f"band-limited speed is {speed:.3f} mm/s, below the {floor_mm_s} mm/s floor for a "
        f"human body. This is almost certainly equipment -- a tripod, a floor marker or a "
        f"mount -- rather than a person. Add its label to whatever pattern excludes "
        f"reference markers rather than dropping it here."))]

validate_series

validate_series(x, t=None, documented_hz: float | None = None, where: str = '', min_finite: float = 0.8, expect_positions: bool = True) -> list[Finding]

Run every applicable check on one series and return what is wrong with it.

x is (n_samples, n_axes) or one-dimensional. Pass t to check the clock, and documented_hz to check it against what the record claims. Set expect_positions False for a sensor whose output may legitimately be zero in every axis at once.

Source code in src/micromotion/validate.py
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
def validate_series(x, t=None, documented_hz: float | None = None, where: str = "",
                    min_finite: float = 0.8, expect_positions: bool = True) -> list[Finding]:
    """Run every applicable check on one series and return what is wrong with it.

    ``x`` is (n_samples, n_axes) or one-dimensional. Pass ``t`` to check the clock, and
    ``documented_hz`` to check it against what the record claims. Set ``expect_positions``
    False for a sensor whose output may legitimately be zero in every axis at once.
    """
    out: list[Finding] = []
    x = np.asarray(x, float)
    out += frame_count(len(x), where)
    if expect_positions:
        out += zero_triplets(x, where)
    out += finite_fraction(x, where, min_finite=min_finite)
    out += held_samples(x, where)
    if t is not None:
        out += timestamps(t, where)
        if documented_hz is not None:
            out += rate_agreement(t, documented_hz, where)
    return out

errors

errors(findings) -> list[Finding]

Just the findings that should stop a build.

Source code in src/micromotion/validate.py
604
605
606
def errors(findings) -> list[Finding]:
    """Just the findings that should stop a build."""
    return [f for f in findings if f.severity == "error"]

raise_on_error

raise_on_error(findings) -> None

Raise if anything in findings is an error, listing all of them.

A build that continues past a finding produces a plausible wrong number, which is the exact failure this module exists to prevent.

Source code in src/micromotion/validate.py
609
610
611
612
613
614
615
616
617
def raise_on_error(findings) -> None:
    """Raise if anything in ``findings`` is an error, listing all of them.

    A build that continues past a finding produces a plausible wrong number, which is the exact
    failure this module exists to prevent.
    """
    bad = errors(findings)
    if bad:
        raise ValueError("validation failed:\n  " + "\n  ".join(str(f) for f in bad))

Comparing over equal windows

micromotion.windows

Comparing conditions over windows of the same length.

A paired contrast between two conditions is only about the conditions if the two were measured over comparable stretches of time. That is easy to assume and easy to get wrong, because segment duration usually follows the STIMULUS rather than the design: a running order plays a track for as long as the track lasts and leaves whatever gap it leaves, so "music" and "silence" end up different lengths without anyone deciding they should be.

It matters because a quantity averaged over a longer window settles further toward its middle. Two conditions measured over unequal windows are estimated with unequal smoothing, so the difference between them is changed by the schedule rather than by the participants. In the corpus this package was written for, the effect was to SUPPRESS: equalising the windows raised a music-versus-silence contrast at 0.5-1 Hz from +1.70 to +2.10 per cent and moved two further frequency bands from null to significant. Elsewhere in the same corpus it worked the other way and produced a false positive below 0.5 Hz. The direction is not predictable, which is the argument for checking rather than reasoning about it.

Two functions, in the order they should be used. :func:balance asks whether the windows differ by condition at all, and is the one-line check that would have caught this years earlier than it was caught. :func:equalise truncates every segment to a common length so that they do not.

>>> import numpy as np
>>> onset  = np.array([0.,  60., 120., 180.])
>>> offset = np.array([45., 105., 165., 240.])      # silences 45 s, music 60 s
>>> cond   = np.array(["s", "s", "s", "m"])
>>> b = balance(onset, offset, cond)
>>> b.balanced
False
>>> new_offset = equalise(onset, offset)
>>> (new_offset - onset).tolist()
[45.0, 45.0, 45.0, 45.0]

CAP PER GROUP, NOT ACROSS THE STUDY. Where segments come from several recordings, editions or sessions, pass by= so each group is equalised against its own shortest segment. A single cap across the whole study equalises the conditions AND shortens every window, and those pull in opposite directions: applied to six recording sessions whose segments ran from 20 to 180 s, a flat cap made a real effect look like it had collapsed, purely by discarding the signal of the sessions that had recorded longest.

Balance dataclass

What :func:balance found. Truthy when the windows are comparable.

by_condition maps each condition to (n, median, min, max) in seconds. ratio is the longest condition median over the shortest, so 1.0 is perfect balance. balanced applies the tolerance the caller asked for.

Source code in src/micromotion/windows.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
@dataclass
class Balance:
    """What :func:`balance` found. Truthy when the windows are comparable.

    ``by_condition`` maps each condition to ``(n, median, min, max)`` in seconds. ``ratio`` is the
    longest condition median over the shortest, so 1.0 is perfect balance. ``balanced`` applies the
    tolerance the caller asked for.
    """

    by_condition: dict = field(default_factory=dict)
    ratio: float = 1.0
    balanced: bool = True
    tol: float = 0.05
    groups: dict = field(default_factory=dict)

    def __bool__(self) -> bool:
        return self.balanced

    def __str__(self) -> str:
        if self.groups:
            worst = max(self.groups, key=lambda g: self.groups[g].ratio)
            rows = "\n".join(
                f"    {g!s:<12} ratio {b.ratio:5.2f}   "
                f"{'balanced' if b.balanced else 'CONFOUNDED WITH DURATION'}"
                for g, b in sorted(self.groups.items(), key=lambda kv: str(kv[0]))
            )
            return (f"{rows}\n    worst group is {worst!s} at {self.ratio:.2f}x. "
                    f"Read these and not a pooled figure, which averages them away.")
        rows = "\n".join(
            f"    {c!s:<12} n={n:<5d} median {med:7.1f} s   range {lo:.1f}-{hi:.1f}"
            for c, (n, med, lo, hi) in sorted(self.by_condition.items())
        )
        verdict = (
            "windows are comparable"
            if self.balanced
            else f"WINDOWS DIFFER BY CONDITION: longest median is {self.ratio:.2f}x the shortest. "
            "A contrast computed on these is confounded with duration; call equalise() first."
        )
        return f"{rows}\n    {verdict}"

balance

balance(onset_s, offset_s, condition, by=None, tol: float = 0.05) -> Balance

Do the conditions being compared occupy windows of the same length?

Returns a :class:Balance, which is falsy when they do not. tol is how far the ratio of condition medians may sit from 1.0 before the answer is no; the default of 0.05 allows the few per cent that rounding a segment table to whole seconds produces.

PASS by= WHENEVER THE SEGMENTS COME FROM MORE THAN ONE RECORDING, SESSION OR EDITION, and read the per-group result rather than the pooled one. Pooling averages the imbalances and can hide a severe one almost completely. On the corpus this was written for, the pooled ratio over six editions is 1.07 — a few per cent, easy to wave away — while one edition inside it sits at 9.00 and three others between 1.33 and 1.60. The pooled figure is the one that reassures, and it is the wrong one. With by, ratio is the worst group's and groups holds each.

Run this before any paired contrast between conditions. It costs one line and answers a question that is otherwise invisible: nothing about a table of onsets and offsets announces that one condition is systematically longer than another, and no amount of checking the analysis code will reveal it, because the fault is in the design of the running order rather than in the arithmetic.

Source code in src/micromotion/windows.py
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
def balance(onset_s, offset_s, condition, by=None, tol: float = 0.05) -> Balance:
    """Do the conditions being compared occupy windows of the same length?

    Returns a :class:`Balance`, which is falsy when they do not. ``tol`` is how far the ratio of
    condition medians may sit from 1.0 before the answer is no; the default of 0.05 allows the few
    per cent that rounding a segment table to whole seconds produces.

    PASS ``by=`` WHENEVER THE SEGMENTS COME FROM MORE THAN ONE RECORDING, SESSION OR EDITION, and
    read the per-group result rather than the pooled one. Pooling averages the imbalances and can
    hide a severe one almost completely. On the corpus this was written for, the pooled ratio over
    six editions is 1.07 — a few per cent, easy to wave away — while one edition inside it sits at
    9.00 and three others between 1.33 and 1.60. The pooled figure is the one that reassures, and
    it is the wrong one. With ``by``, ``ratio`` is the worst group's and ``groups`` holds each.

    Run this before any paired contrast between conditions. It costs one line and answers a
    question that is otherwise invisible: nothing about a table of onsets and offsets announces
    that one condition is systematically longer than another, and no amount of checking the
    analysis code will reveal it, because the fault is in the design of the running order rather
    than in the arithmetic.
    """
    _, _, d = _durations(onset_s, offset_s)
    cond = np.asarray(condition)
    if cond.shape != d.shape:
        raise ValueError(f"condition has shape {cond.shape}, expected {d.shape}")
    if by is None:
        return _balance_one(d, cond, tol)

    groups = np.asarray(by)
    if groups.shape != d.shape:
        raise ValueError(f"by has shape {groups.shape}, expected {d.shape}")
    per = {}
    for g in np.unique(groups):
        sel = groups == g
        per[g.item() if hasattr(g, "item") else g] = _balance_one(d[sel], cond[sel], tol)
    worst = max(per.values(), key=lambda b: b.ratio)
    return Balance(by_condition=worst.by_condition, ratio=worst.ratio,
                   balanced=all(b.balanced for b in per.values()), tol=tol, groups=per)

equalise

equalise(onset_s, offset_s, cap_s: float | None = None, by=None)

Truncate every segment to a common length, returning new offsets.

Each segment keeps its own onset and is cut from the start, so the equalised window is the beginning of the segment rather than a slice from its middle. That is deliberate: the start is the part every segment has.

cap_s sets the length; by default it is the shortest segment present, which keeps as much of every segment as can be kept while making them equal. by groups the segments, so each group is capped against its own shortest segment rather than against the study's — see the module docstring for why a single cap across groups is the wrong tool.

Only offsets are returned. The onsets are unchanged, so the caller's other columns stay aligned and nothing needs reordering.

Source code in src/micromotion/windows.py
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
def equalise(onset_s, offset_s, cap_s: float | None = None, by=None):
    """Truncate every segment to a common length, returning new offsets.

    Each segment keeps its own onset and is cut from the start, so the equalised window is the
    beginning of the segment rather than a slice from its middle. That is deliberate: the start is
    the part every segment has.

    ``cap_s`` sets the length; by default it is the shortest segment present, which keeps as much
    of every segment as can be kept while making them equal. ``by`` groups the segments, so each
    group is capped against its own shortest segment rather than against the study's — see the
    module docstring for why a single cap across groups is the wrong tool.

    Only offsets are returned. The onsets are unchanged, so the caller's other columns stay aligned
    and nothing needs reordering.
    """
    onset, offset, d = _durations(onset_s, offset_s)

    if by is None:
        cap = float(d.min()) if cap_s is None else float(cap_s)
        if cap <= 0:
            raise ValueError("cap_s must be positive")
        return onset + np.minimum(d, cap)

    groups = np.asarray(by)
    if groups.shape != d.shape:
        raise ValueError(f"by has shape {groups.shape}, expected {d.shape}")
    out = offset.copy()
    for g in np.unique(groups):
        sel = groups == g
        cap = float(d[sel].min()) if cap_s is None else float(cap_s)
        if cap <= 0:
            raise ValueError("cap_s must be positive")
        out[sel] = onset[sel] + np.minimum(d[sel], cap)
    return out

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: tuple[str, ...] = ()

Datasets whose vertical axis is Y rather than Z. Empty by default.

A system's frame is a property of how it was configured for a session, not of the system: the same OptiTrack rig can produce Y-up files for one study and Z-up for another. Where a dataset is known to be Y-up, name it here rather than compensating in downstream analysis.

Prefer rotating the data at source (X -> X, Y -> -Z_old, Z -> Y_old — a rotation, not an axis swap). If you do, empty this in the same change: a reader that still claims Y for rotated files hands every caller a horizontal axis as the vertical one, silently.

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> X column-name row, then data (2012, 2015, 2017, 2018, 2019, HpSp);
  • nothing, data begins immediately (2022, Bishop 2020);
  • a Frame/Time column-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
 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
135
136
137
138
139
140
141
142
143
144
145
146
147
148
def 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> X`` column-name row, then data (2012, 2015, 2017, 2018, 2019, HpSp);
    * nothing, data begins immediately (2022, Bishop 2020);
    * a ``Frame``/``Time`` column-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.
    """
    lines = _decode(path)
    meta, n_header = {}, 0
    for i, ln in enumerate(lines[:16]):
        parts = ln.rstrip("\n").split("\t")
        key = parts[0].strip()
        if key in QUALISYS_KEYS:
            meta[key] = [p for p in parts[1:] if p.strip() != ""]
            n_header = i + 1
        elif _METADATA_KEY.match(key):
            # An unknown metadata line, not the end of the header. QTM adds fields between
            # versions -- exports since 2.0.0 open with FILE_VERSION -- and a parser that
            # stops at the first key it does not recognise reads those files as having no
            # header at all. Recorded but unused; the shape test below still finds the data.
            meta.setdefault(key, [p for p in parts[1:] if p.strip() != ""])
            n_header = i + 1
        else:
            break

    if "MARKER_NAMES" not in meta:
        raise ValueError(f"{path} has no MARKER_NAMES line; not a Qualisys export")
    markers = [m.strip() for m in meta["MARKER_NAMES"]]
    fs_header = float(meta["FREQUENCY"][0])

    def is_numeric(line: str) -> bool:
        try:
            float(line.split("\t")[0])
            return True
        except (ValueError, IndexError):
            return False

    has_frame_time = False
    if is_numeric(lines[n_header]):
        skip = n_header                       # shape 2: no column-name row
    else:
        skip = n_header + 1                   # shape 1 or 3
        first = lines[n_header].split("\t")[0].strip()
        has_frame_time = first.lower() == "frame"

    df = pd.read_csv(path, sep="\t", skiprows=skip, header=None, encoding="latin-1")
    if df.iloc[:, -1].isna().all():
        df = df.iloc[:, :-1]                  # the trailing tab's phantom column
    arr = df.to_numpy(float)

    t = None
    if has_frame_time:
        t = arr[:, 1].copy()
        arr = arr[:, 2:]

    n_expected = 3 * len(markers)
    if arr.shape[1] != n_expected:
        # Trust the columns, not the count. One HpSp file declares 22 markers and carries 7.
        n_actual = arr.shape[1] // 3
        markers = markers[:n_actual] if n_actual <= len(markers) else [
            *markers, *[f"unnamed{i}" for i in range(len(markers), n_actual)]
        ]
        arr = arr[:, : 3 * len(markers)]

    if drop_gaps:
        # A zero triplet is the Qualisys gap code, not a marker at the origin. In HpSp that
        # is 878 948 lines; read as data it would report a marker teleporting to (0,0,0).
        block = arr.reshape(len(arr), -1, 3)
        gap = np.all(block == 0.0, axis=2)
        block[gap] = np.nan
        arr = block.reshape(len(arr), -1)

    channels = [f"{m} {ax}" for m in markers for ax in "XYZ"]
    vertical = "Y" if any(c in path for c in Y_UP_COLLECTIONS) else "Z"
    fs = measured_rate(t) if t is not None and len(t) > 1 else fs_header

    return MotionRecord(
        data=arr,
        fs=fs,
        channels=channels,
        kind="position",
        unit="mm",
        vertical=vertical,
        t=t,
        source=path,
        meta={"header": meta, "nominal_fs": fs_header, "n_markers": len(markers)},
    )

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
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
def 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.
    """
    df = pd.read_csv(path, sep="\t")
    if "Time" not in df.columns:
        raise ValueError(f"{path} has no Time column; not a Sverm export")
    t = df["Time"].to_numpy(float)
    cols = [c for c in df.columns if c != "Time"]
    return MotionRecord(
        data=df[cols].to_numpy(float),
        fs=measured_rate(t),
        channels=[c.replace("_", " ") for c in cols],
        kind="position",
        unit="mm",
        t=t,
        source=path,
        meta={"subjects": sorted({c.split("_")[0] for c in cols})},
    )

read_ax3

read_ax3(path: str) -> MotionRecord

Axivity AX3 export with a ts,x,y,z header.

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 can differ between recording sites in the same direction as the effect a study record's headline claim reports.

Source code in src/micromotion/io.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
def read_ax3(path: str) -> MotionRecord:
    """Axivity AX3 export with a ``ts,x,y,z`` header.

    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 can differ between recording sites in the same direction as the effect a study
    record's headline claim reports.
    """
    df = pd.read_csv(path, sep="\t")
    ts = pd.to_datetime(df["ts"])
    t = (ts - ts.iloc[0]).dt.total_seconds().to_numpy()
    return MotionRecord(
        data=df[["x", "y", "z"]].to_numpy(float),
        fs=measured_rate(t),
        channels=["x", "y", "z"],
        kind="acceleration",
        unit="g",
        t=t,
        t0=ts.iloc[0],
        source=path,
        meta={"device_slot": (re.match(r"[A-Z]+(\d+)", os.path.basename(path)[:-4]) or [None, None])[1]},
    )

read_cwa

read_cwa(path: str) -> MotionRecord

Axivity AX3 raw .cwa, the file the logger itself writes.

:func:read_ax3 reads the ts,x,y,z export made from one of these. Studies deposit the raw file instead often enough that a reader is worth having: the Solberg 2015 dance record ships twenty .cwa and no export, so without this every reuser writes their own decoder or installs a second toolbox to get at deposited data.

THE TIMEBASE IS THE POINT, and it is why this returns wall-clock seconds rather than seconds from the start. Each 512-byte block carries its own clock reading and a timestampOffset saying which sample inside the block that reading applies to, so the time axis is rebuilt block by block: a dropped block then leaves a gap in t instead of silently shifting every sample after it forward. t is seconds from midnight on the recording's own day, which is the form a session log tends to be written in; t0 carries that day.

TWO PACKINGS EXIST AND BOTH ARE IN THE WILD. The unpacked one stores three int16 per sample; the packed one stores three 10-bit values and a shared 2-bit exponent in four bytes, and its values must be scaled by 2**exponent to land on the same scale as the unpacked form. Getting that exponent wrong is not obvious from the numbers --- it scales the whole recording by a constant, which leaves every correlation and every rank statistic untouched. Check a decode with :func:identify_acceleration_unit, which reads gravity off a still stretch and is the test this reader was verified with: the twenty deposited Solberg files read 0.96 to 1.06 g.

Returns:

Name Type Description
MotionRecord MotionRecord

kind="acceleration", unit="g", three channels, with the logger's

MotionRecord

measured rate in fs and its configured rate and range in meta.

Source code in src/micromotion/io.py
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
290
291
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
320
321
322
323
324
325
326
327
328
329
330
def read_cwa(path: str) -> MotionRecord:
    """Axivity AX3 raw ``.cwa``, the file the logger itself writes.

    :func:`read_ax3` reads the ``ts,x,y,z`` export made from one of these. Studies deposit the raw
    file instead often enough that a reader is worth having: the Solberg 2015 dance record ships
    twenty ``.cwa`` and no export, so without this every reuser writes their own decoder or installs
    a second toolbox to get at deposited data.

    THE TIMEBASE IS THE POINT, and it is why this returns wall-clock seconds rather than seconds
    from the start. Each 512-byte block carries its own clock reading and a ``timestampOffset``
    saying which sample inside the block that reading applies to, so the time axis is rebuilt block
    by block: a dropped block then leaves a gap in ``t`` instead of silently shifting every sample
    after it forward. ``t`` is seconds from midnight on the recording's own day, which is the form a
    session log tends to be written in; ``t0`` carries that day.

    TWO PACKINGS EXIST AND BOTH ARE IN THE WILD. The unpacked one stores three ``int16`` per sample;
    the packed one stores three 10-bit values and a shared 2-bit exponent in four bytes, and its
    values must be scaled by ``2**exponent`` to land on the same scale as the unpacked form. Getting
    that exponent wrong is not obvious from the numbers --- it scales the whole recording by a
    constant, which leaves every correlation and every rank statistic untouched. Check a decode with
    :func:`identify_acceleration_unit`, which reads gravity off a still stretch and is the test this
    reader was verified with: the twenty deposited Solberg files read 0.96 to 1.06 g.

    Returns:
        MotionRecord: ``kind="acceleration"``, ``unit="g"``, three channels, with the logger's
        measured rate in ``fs`` and its configured rate and range in ``meta``.
    """
    import struct

    raw = open(path, "rb").read()
    if len(raw) <= _CWA_HEADER:
        raise ValueError(f"{path}: header only, no data blocks")

    times: list[float] = []
    chunks: list[np.ndarray] = []
    counts: list[int] = []
    rate = None
    day = None
    rng_g = None
    for i in range((len(raw) - _CWA_HEADER) // _CWA_BLOCK):
        b = raw[_CWA_HEADER + i * _CWA_BLOCK: _CWA_HEADER + (i + 1) * _CWA_BLOCK]
        if len(b) < _CWA_BLOCK or b[0:2] != b"AX":
            continue
        stamp = _cwa_time(struct.unpack("<I", b[14:18])[0])
        if stamp is None:
            continue
        light_scale = struct.unpack("<H", b[18:20])[0]
        sr_code = b[24]
        axes_bps = b[25]
        offset = struct.unpack("<h", b[26:28])[0]
        count = struct.unpack("<H", b[28:30])[0]
        block_rate = 3200 / (1 << (15 - (sr_code & 15)))
        if rate is None:
            rate, day, rng_g = block_rate, stamp[:3], 16 >> (light_scale >> 13)
        n_axes = (axes_bps >> 4) & 0x0F
        packing = axes_bps & 0x0F

        if packing == 2:                                  # three int16 per sample
            need = count * n_axes * 2
            if len(b) < 30 + need:
                continue
            v = np.frombuffer(b[30:30 + need], dtype="<i2").astype(np.float64)
            v = v.reshape(count, n_axes)[:, :3] / 256.0
        elif packing == 0:                                # 3 x 10 bit + 2 bit exponent
            need = count * 4
            if len(b) < 30 + need:
                continue
            w = np.frombuffer(b[30:30 + need], dtype="<u4")
            ex = ((w >> 30) & 0x03).astype(np.float64)

            def _axis(shift: int) -> np.ndarray:
                a = ((w >> shift) & 0x3FF).astype(np.int32)
                return np.where(a > 511, a - 1024, a)

            v = np.stack([_axis(0), _axis(10), _axis(20)], axis=1).astype(np.float64)
            v = v * (2.0 ** ex)[:, None] / 256.0
        else:
            continue

        # The block's clock reading applies at sample `timestampOffset` within the block.
        t_sec = stamp[3] * 3600 + stamp[4] * 60 + stamp[5]
        times.append(t_sec - offset / block_rate)
        chunks.append(v)
        counts.append(len(v))

    if not chunks:
        raise ValueError(f"{path}: no decodable data blocks")

    # Sample times come from the block BOUNDARIES, not from the configured rate. The AX3's true
    # rate is a property of the physical logger and is not the number it was configured with --- the
    # Solberg units run about 97.3 Hz against a configured 100 --- so spacing samples at 1/configured
    # inside each block and jumping to the next block's clock leaves a sawtooth at every boundary.
    # Interpolating between consecutive block starts removes it and makes `fs` the measured rate.
    # Where a block is missing, the implied spacing is absurd; those fall back to the nominal rate
    # so one dropped block cannot stretch the samples around it.
    nominal = 1.0 / rate
    starts = np.asarray(times, dtype=float)
    spacing = np.full(len(counts), nominal)
    if len(counts) > 1:
        implied = np.diff(starts) / np.asarray(counts[:-1], dtype=float)
        ok = (implied > 0.5 * nominal) & (implied < 2.0 * nominal)
        spacing[:-1] = np.where(ok, implied, nominal)
        spacing[-1] = spacing[-2]
    t = np.concatenate([starts[i] + np.arange(counts[i]) * spacing[i] for i in range(len(counts))])
    return MotionRecord(
        data=np.concatenate(chunks, axis=0),
        fs=measured_rate(t),
        channels=["x", "y", "z"],
        kind="acceleration",
        unit="g",
        t=t,
        t0=day,
        source=path,
        meta={"configured_rate_hz": rate, "range_g": rng_g, "n_blocks": len(counts),
              "clock": "seconds from midnight on the logger's own clock"},
    )

channel_rate

channel_rate(t: ndarray, x: ndarray) -> float

How often a channel actually advances, not how often the file has a row for it.

A multi-sensor log is usually an interleaved union of streams that update at different rates, so the spacing of its rows belongs to no device. On one Physics Toolbox file the rows arrive at about 426 Hz while the accelerometer updates at 51 Hz and the fused channel at 15 Hz. Taking the row rate as the sensor rate is how a 100 Hz resampling grid and a 12.5 Hz decimation both came to be quoted as sampling rates in this project.

Counted as value changes over the elapsed span, which is robust to a channel repeating a value and to occasional dropouts. The tempting alternative, 1 / median(diff(t)) at the change points, is not: where updates arrive in bursts it returns the within-burst spacing, which on that same file gives 680 Hz.

x may be one channel or an (n, k) block; the first column is used.

Source code in src/micromotion/io.py
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
def channel_rate(t: np.ndarray, x: np.ndarray) -> float:
    """How often a channel actually advances, not how often the file has a row for it.

    A multi-sensor log is usually an interleaved union of streams that update at different
    rates, so the spacing of its rows belongs to no device. On one Physics Toolbox file the
    rows arrive at about 426 Hz while the accelerometer updates at 51 Hz and the fused channel
    at 15 Hz. Taking the row rate as the sensor rate is how a 100 Hz resampling grid and a
    12.5 Hz decimation both came to be quoted as sampling rates in this project.

    Counted as value changes over the elapsed span, which is robust to a channel repeating a
    value and to occasional dropouts. The tempting alternative, ``1 / median(diff(t))`` at the
    change points, is not: where updates arrive in bursts it returns the within-burst spacing,
    which on that same file gives 680 Hz.

    ``x`` may be one channel or an (n, k) block; the first column is used.
    """
    x = np.asarray(x, float)
    if x.ndim > 1:
        x = x[:, 0]
    t = np.asarray(t, float)
    if len(t) < 2 or t[-1] <= t[0]:
        raise ValueError("need at least two samples spanning a positive duration")
    changed = np.r_[True, np.diff(x) != 0]
    return float(changed.sum() / (t[-1] - t[0]))

channel_resolution

channel_resolution(x, need: float | None = None) -> dict

The quantisation step of a channel, against the amplitude you mean to measure.

The neighbour of :func:channel_rate, and the same class of mistake. That one asks whether a channel updates fast enough to carry your band; this one asks whether it resolves finely enough to carry your amplitude. A channel can satisfy either and fail the other.

The case it was written for. Delsys EMG sensors carry a three-axis accelerometer alongside the muscle channel, and in a 2017 standstill recording those accelerometers step by 0.0395 m/s², identically on all twelve axes. Each axis therefore holds between eight and forty-nine distinct values across 720000 samples, where the muscle channel beside it holds about fifty thousand. The band-limited head acceleration being measured has a median of 0.033 m/s², so one quantisation step was larger than the entire signal. Correlating those accelerometers against anything returned about 0.03, which reads exactly like a real null and is not one.

An accelerometer bundled with another sensor is specified for that sensor's purpose. These are there to tell a standing muscle from a walking one and they do that well. Check the step against the amplitude you need before planning an analysis on a secondary channel.

Returns step, the modal spacing between adjacent distinct values, levels, how many distinct values the channel holds, span, and ratio where need is given: the amplitude you asked for divided by the step. A ratio below about 10 means the quantisation is a visible part of your measurement; below 1 the signal is inside one step and cannot be recovered.

x may be one channel or an (n, k) block, in which case each column is reported.

Source code in src/micromotion/io.py
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
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
def channel_resolution(x, need: float | None = None) -> dict:
    """The quantisation step of a channel, against the amplitude you mean to measure.

    The neighbour of :func:`channel_rate`, and the same class of mistake. That one asks whether a
    channel updates fast enough to carry your band; this one asks whether it resolves finely enough
    to carry your amplitude. A channel can satisfy either and fail the other.

    The case it was written for. Delsys EMG sensors carry a three-axis accelerometer alongside the
    muscle channel, and in a 2017 standstill recording those accelerometers step by 0.0395 m/s²,
    identically on all twelve axes. Each axis therefore holds between eight and forty-nine distinct
    values across 720000 samples, where the muscle channel beside it holds about fifty thousand. The
    band-limited head acceleration being measured has a median of 0.033 m/s², so one quantisation
    step was larger than the entire signal. Correlating those accelerometers against anything
    returned about 0.03, which reads exactly like a real null and is not one.

    An accelerometer bundled with another sensor is specified for that sensor's purpose. These are
    there to tell a standing muscle from a walking one and they do that well. Check the step against
    the amplitude you need before planning an analysis on a secondary channel.

    Returns ``step``, the modal spacing between adjacent distinct values, ``levels``, how many
    distinct values the channel holds, ``span``, and ``ratio`` where ``need`` is given: the
    amplitude you asked for divided by the step. A ratio below about 10 means the quantisation is a
    visible part of your measurement; below 1 the signal is inside one step and cannot be recovered.

    ``x`` may be one channel or an (n, k) block, in which case each column is reported.
    """
    x = np.asarray(x, float)
    if x.ndim > 1:
        return {f"col{i}": channel_resolution(x[:, i], need) for i in range(x.shape[1])}
    x = x[np.isfinite(x)]
    if x.size < 2:
        raise ValueError("need at least two finite samples")
    u = np.unique(x)
    if u.size < 2:
        return dict(step=0.0, levels=int(u.size), span=0.0,
                    ratio=float("inf") if need else None)
    d = np.diff(u)
    # The modal gap, not the median: a quantised channel with a few outliers has most gaps at the
    # step and a handful much larger, and the median survives that where a mean does not.
    counts, edges = np.histogram(d, bins=min(64, max(4, d.size)))
    step = float(np.median(d[(d >= edges[counts.argmax()]) & (d <= edges[counts.argmax() + 1])]))
    out = dict(step=step, levels=int(u.size), span=float(u[-1] - u[0]))
    if need is not None:
        out["ratio"] = float(need / step) if step > 0 else float("inf")
    return out

read_phone

read_phone(path: str, trim_clap_s: float = 0.0, *, channel: str = 'accel', trim_start_s: float | None = None, trim_end_s: float | None = None) -> MotionRecord

Physics Toolbox phone log, raw app export or cleaned tab-separated form.

The variant is detected from the first two lines, so both the app's own semicolon/ decimal-comma CSV and the cleaned TSV used by the deposited pipeline read correctly. See _read_physics_toolbox_raw for what the raw format does that plain CSV readers get wrong.

Which channel. A Physics Toolbox log carries two accelerations and they are not interchangeable.

channel="accel" (the default) reads gFx/gFy/gFz: the accelerometer itself, total specific force including gravity, written in g and converted to m/s^2 here. Its magnitude sits at about 1 g on a phone at rest. It updates at the accelerometer's own rate, commonly 50 Hz but anywhere from 15 to 455 Hz depending on how the app was configured.

channel="fused" reads ax/ay/az: linear acceleration, already in m/s^2, with gravity removed by fusing the accelerometer with the gyroscope and magnetometer. They are not in g, whatever an older data dictionary said; reading them as g inflates every quantity of motion by 9.80665.

Use accel for anything about how much something moved. The fusion cannot output faster than its slowest input, so it 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 sits 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. That is why accel is the default: on one session the fused channel put a chest sensor at 1.65 mm/s against the accelerometer's 6.74, and made a head sensor look 4.6 times more active than the chest when the true ratio is 1.12.

Use fused for the fusion's tilt correction, accepting the ceiling. Tilt is real, since rotating a sensor swings gravity across its axes and no high-pass can remove that, but measure it before assuming it dominates: reconstructed from a gyroscope on one chest recording it accounted for 6 per cent of the accelerometer's band-limited content, and removing it did not move that channel toward the fused one.

Whichever you choose, the other is in meta["extra"] and both are in the same unit, m/s^2. meta["channel_rates"] gives each sensor's own rate, which is not the rate of the file's rows: an interleaved log's row spacing belongs to no device. See channel_rate.

The rate is neither constant nor the nominal one. Physics Toolbox delivers whatever the Android sensor stack gives it, so a log requested at 100 Hz arrives between roughly 100 and 170 Hz with millisecond-scale jitter, and differs between handsets recording the same event. fs here is the measured mean rate over the span. Long dropouts are common: logging stops when the app is backgrounded or the screen sleeps, and resumes silently, leaving gaps of tens of seconds inside a file that otherwise looks continuous. Check meta["gaps"] before treating a file as one recording; resample onto a uniform grid before filtering.

trim_clap_s drops that many seconds from each end. trim_start_s and trim_end_s override it per end, so an opening clap can be removed without discarding good data at the close: pass trim_start_s=35, trim_end_s=0.

Trim before plotting or transforming. A recording that opens with a synchronisation clap carries a transient that is a timing marker, not movement, and it can be two orders of magnitude above the standstill it precedes, reaching 10.29 m/s² in one recording against a body maximum of 0.155. Left in, it sets the y-axis of any plot, dominates any spectrum, and gives a peak detector a transient that is not a breath.

The settling that follows is slower and also worth removing. Measure it rather than guessing: both posture and heart rate can still be moving well beyond ten seconds.

Source code in src/micromotion/io.py
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
495
496
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
534
535
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
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
def read_phone(path: str, trim_clap_s: float = 0.0, *,
               channel: str = "accel",
               trim_start_s: float | None = None,
               trim_end_s: float | None = None) -> MotionRecord:
    """Physics Toolbox phone log, raw app export or cleaned tab-separated form.

    The variant is detected from the first two lines, so both the app's own semicolon/
    decimal-comma CSV and the cleaned TSV used by the deposited pipeline read correctly.
    See ``_read_physics_toolbox_raw`` for what the raw format does that plain CSV readers
    get wrong.

    **Which channel.** A Physics Toolbox log carries two accelerations and they are not
    interchangeable.

    ``channel="accel"`` (the default) reads ``gFx``/``gFy``/``gFz``: the accelerometer itself,
    total specific force *including* gravity, written in g and converted to m/s^2 here. Its
    magnitude sits at about 1 g on a phone at rest. It updates at the accelerometer's own rate,
    commonly 50 Hz but anywhere from 15 to 455 Hz depending on how the app was configured.

    ``channel="fused"`` reads ``ax``/``ay``/``az``: linear acceleration, already in m/s^2, with
    gravity removed by fusing the accelerometer with the gyroscope and magnetometer. They are
    not in g, whatever an older data dictionary said; reading them as g inflates every quantity
    of motion by 9.80665.

    Use ``accel`` for anything about how much something moved. The fusion cannot output faster
    than its slowest input, so it 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 sits 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. That is why ``accel`` is the default: on one
    session the fused channel put a chest sensor at 1.65 mm/s against the accelerometer's 6.74,
    and made a head sensor look 4.6 times more active than the chest when the true ratio is
    1.12.

    Use ``fused`` for the fusion's tilt correction, accepting the ceiling. Tilt is real, since
    rotating a sensor swings gravity across its axes and no high-pass can remove that, but
    measure it before assuming it dominates: reconstructed from a gyroscope on one chest
    recording it accounted for 6 per cent of the accelerometer's band-limited content, and
    removing it did not move that channel toward the fused one.

    Whichever you choose, the other is in ``meta["extra"]`` and both are in the same unit, m/s^2.
    ``meta["channel_rates"]`` gives each sensor's own rate, which is not the rate of the file's
    rows: an interleaved log's row spacing belongs to no device. See ``channel_rate``.

    **The rate is neither constant nor the nominal one.** Physics Toolbox delivers whatever the
    Android sensor stack gives it, so a log requested at 100 Hz arrives between roughly 100 and
    170 Hz with millisecond-scale jitter, and differs between handsets recording the same event.
    ``fs`` here is the measured mean rate over the span. **Long dropouts are common**: logging
    stops when the app is backgrounded or the screen sleeps, and resumes silently, leaving gaps
    of tens of seconds inside a file that otherwise looks continuous. Check ``meta["gaps"]``
    before treating a file as one recording; resample onto a uniform grid before filtering.

    ``trim_clap_s`` drops that many seconds from **each** end. ``trim_start_s`` and
    ``trim_end_s`` override it per end, so an opening clap can be removed without discarding
    good data at the close: pass ``trim_start_s=35, trim_end_s=0``.

    **Trim before plotting or transforming.** A recording that opens with a synchronisation clap
    carries a transient that is a timing marker, not movement, and it can be two orders of
    magnitude above the standstill it precedes, reaching 10.29 m/s² in one recording against a
    body maximum of 0.155. Left in, it sets the y-axis of any plot, dominates any spectrum, and gives
    a peak detector a transient that is not a breath.

    The settling that follows is slower and also worth removing. Measure it rather than guessing:
    both posture and heart rate can still be moving well beyond ten seconds.
    """
    with open(path, encoding="utf-8", errors="replace") as fh:
        head = [fh.readline() for _ in range(2)]
    raw = ";" in head[1] and "\t" not in head[1]
    df = _read_physics_toolbox_raw(path) if raw else pd.read_csv(path, sep="\t")
    t = df["time"].to_numpy(float)
    head_s = trim_clap_s if trim_start_s is None else trim_start_s
    tail_s = trim_clap_s if trim_end_s is None else trim_end_s
    m = np.ones(len(t), bool)
    if head_s or tail_s:
        m = (t >= t[0] + head_s) & (t <= t[-1] - tail_s)
        if not m.any():
            raise ValueError(f"trimming {head_s} s from the start and {tail_s} s from the end "
                             f"leaves nothing of a {t[-1] - t[0]:.1f} s recording")
    # Which channel. The fused ax/ay/az is the wrong default for this package's own measure:
    # the fusion cannot output faster than its
    # slowest input, so it advances at the gyroscope's ~15 Hz and most of a 0.2-5 Hz band is its
    # noise floor rather than the body. At standstill amplitudes the body is below that floor,
    # and the floor differs between handsets, which is how a phantom device factor and a phantom
    # fourfold head-over-chest gradient both arose in the Oslo Standstill Database.
    if channel not in ("accel", "fused"):
        raise ValueError(f"channel must be 'accel' or 'fused', not {channel!r}")
    want = ("gFx", "gFy", "gFz") if channel == "accel" else ("ax", "ay", "az")
    cols = [c for c in want if c in df.columns]
    if len(cols) != 3:
        other = ("ax", "ay", "az") if channel == "accel" else ("gFx", "gFy", "gFz")
        have = [c for c in other if c in df.columns]
        # Built without a backslash inside an f-string expression: that is a SyntaxError on
        # Python 3.10 and 3.11, which this package supports, and PEP 701 only relaxed it in 3.12.
        # It passed local tests on 3.12 and was caught by CI on the older interpreters.
        alt = "fused" if channel == "accel" else "accel"
        msg = (f"{path} carries no complete {channel} channel "
               f"(wanted {want}, found {cols or 'none'}).")
        if have:
            msg += (f" It does carry {have}; pass channel={alt!r} to read that instead, but see "
                    f"the docstring first: the two are not interchangeable.")
        raise ValueError(msg)
    data = df[cols].to_numpy(float)[m]
    # gF* is total specific force in g, including gravity. Convert to m/s^2 so that both channels
    # leave this function in the same unit and a caller cannot mix them by accident.
    if channel == "accel":
        data = data * G

    # Missing samples are written as exact zeros, not NaN. Every file opens with a few.
    zero_rows = np.all(data == 0.0, axis=1)
    data[zero_rows] = np.nan

    # Dropouts are silent and common: report them rather than letting a caller average across
    # a 40 s hole as though it were one continuous recording.
    tm = t[m]
    dt = np.diff(tm)
    gap_idx = np.where(dt > 1.0)[0]
    gaps = [(float(tm[i]), float(tm[i + 1])) for i in gap_idx]
    bounds = [0, *(gap_idx + 1), len(tm)]
    segments = [(int(a), int(b)) for a, b in zip(bounds[:-1], bounds[1:]) if b - a >= 2]
    longest = max((tm[b - 1] - tm[a] for a, b in segments), default=0.0)

    # A span-averaged rate is meaningless once there are dropouts: one 108 s hole in a file
    # sampled at 120 Hz returns 3.8 Hz, which would then be handed to a 0.2-5 Hz filter as
    # though it were the truth. Report the rate of the longest continuous run instead, and keep
    # the span average in meta for anyone who wants it.
    fs_span = measured_rate(t)
    if segments:
        a, b = max(segments, key=lambda ab: tm[ab[1] - 1] - tm[ab[0]])
        fs = measured_rate(tm[a:b])
    else:
        fs = fs_span

    return MotionRecord(
        data=data,
        fs=fs,
        channels=cols,
        kind="acceleration",
        unit="m/s^2",
        t=tm,
        source=path,
        meta={"trimmed_s": trim_clap_s, "trimmed_start_s": head_s, "trimmed_end_s": tail_s, "n_zero_rows": int(zero_rows.sum()),
              "raw_export": raw, "fs_span_average": float(fs_span),
              "gaps": gaps,
              "segments": segments,
              "longest_continuous_s": float(longest),
              "channel": channel,
              # What each sensor's rate actually is, as opposed to the file's row rate. A caller
              # comparing two recordings should check these agree before comparing the numbers.
              "channel_rates": {name: channel_rate(tm, df[c].to_numpy(float)[m])
                                for name, c in (("accel", "gFx"), ("fused", "ax"),
                                                ("gyro", "wx"), ("magnetometer", "Bx"))
                                if c in df.columns},
              "extra": {c: df[c].to_numpy(float)[m] for c in df.columns
                        if c in ("wx", "wy", "wz", "gFx", "gFy", "gFz", "ax", "ay", "az")
                        and c not in cols}},
    )

read_equivital

read_equivital(path: str) -> MotionRecord

Equivital physiology CSV: 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.

Source code in src/micromotion/io.py
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
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
def read_equivital(path: str) -> MotionRecord:
    """Equivital physiology CSV: 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.
    """
    df = pd.read_csv(path, skipinitialspace=True)
    tcol = df.columns[0]
    ts = pd.to_datetime(df[tcol], format="mixed", utc=True)
    t = (ts - ts.iloc[0]).dt.total_seconds().to_numpy()
    cols = list(df.columns[1:])

    # copy(): pandas returns a read-only view under copy-on-write, and the rail masking
    # below writes in place. Without this, reading a respiration file raises.
    data = df[cols].to_numpy(float).copy()
    unit, kind = "counts", "acceleration"
    if any("Accelerometer" in c for c in cols):
        mag = np.median(np.linalg.norm(data, axis=1))
        data = data / mag                      # counts -> g
        unit = "g"
    elif "Breathing" in cols:
        kind, unit = "respiration", "adc"
    elif any("Interbeat" in c or c.strip().upper() in ("RR", "IBI") for c in cols):
        # Inter-beat intervals, one row per heartbeat, in milliseconds. Falling through to the
        # default would label a beat series as acceleration in counts -- and while ``qom`` then
        # refuses on the unit, anything inspecting ``kind`` to decide what a record is would be
        # told it was motion. The sampling "rate" is also nominal here: rows are beats, not
        # samples on a clock, so ``fs`` is the mean beat rate rather than a sampling frequency.
        kind, unit = "interbeat_interval", "ms"
    elif any(c.startswith("Lead") for c in cols):
        kind, unit = "ecg", "mV"
        # 0 and 1023 are the rails of a 10-bit converter, not waveform.
        data[(data <= 0) | (data >= 1023)] = np.nan

    return MotionRecord(
        data=data,
        fs=measured_rate(t),
        channels=cols,
        kind=kind,
        unit=unit,
        t=t,
        t0=ts.iloc[0],
        source=path,
    )

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
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
def 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.
    """
    arr = np.loadtxt(path)
    if arr.ndim != 2 or arr.shape[1] < 8:
        raise ValueError(f"{path} is not an 8-column balance dump")
    t = arr[:, 0] / 1000.0
    data = arr[:, 6:8].copy()
    load = arr[:, 5]
    data[(load <= 0) | ((data[:, 0] == 0.5) & (data[:, 1] == 0.5))] = np.nan
    return MotionRecord(
        data=data,
        fs=measured_rate(t) if len(t) > 1 else float("nan"),
        channels=["cop_x", "cop_y"],
        kind="position",
        unit="normalised",
        t=t,
        source=path,
        meta={"load": load, "cells": arr[:, 1:5], "irregular": True},
    )

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
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
718
719
720
721
722
723
724
725
726
727
728
729
730
def 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.
    """
    df = pd.read_csv(path, sep="\t")
    acc = [c for c in df.columns if c.startswith("ACC_")]
    gyr = [c for c in df.columns if c.startswith("GYR_")]
    if not acc:
        raise ValueError(f"{path} has no ACC_ columns; not an fNIRS export")
    dev = acc[0].split("_")[-2]
    acc = [c for c in acc if dev in c][:3]
    gyr = [c for c in gyr if dev in c][:3]

    legend = os.path.join(os.path.dirname(path), "channel_legend.txt")
    fs = 75.0
    if os.path.exists(legend):
        m = re.search(r"sample rate:\s*([\d.]+)", _io.open(legend, encoding="latin-1").read())
        if m:
            fs = float(m.group(1))

    return MotionRecord(
        data=df[acc].to_numpy(float) / 1000.0,     # milli-g -> g
        fs=fs,
        channels=acc,
        kind="acceleration",
        unit="g",
        source=path,
        meta={
            "device": dev,
            "gyro_deg_s": df[gyr].to_numpy(float) if gyr else None,
            "haemoglobin": [c for c in df.columns if c.endswith(("O2Hb", "HHb"))],
        },
    )

sniff

sniff(path: str) -> str

Identify a file's layout from its first lines.

Source code in src/micromotion/io.py
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
def sniff(path: str) -> str:
    """Identify a file's layout from its first lines."""
    head = "".join(_decode(path)[:3])
    first = head.split("\n")[0]
    if first.startswith("NO_OF_FRAMES"):
        return "qualisys"
    if first.startswith("Time\t"):
        # Any Time column followed by whole <name>_x/_y/_z triples. This used to require "_head_"
        # in the header, which missed every Sverm file that has no head marker -- the 2011 sessions
        # deposit c7, rfoot and static and nothing else, so `sniff` raised on them and `read` could
        # not dispatch. Those are exactly the plain-header files a private `load_qtm` returns None
        # for before silently falling back to reading by column position.
        cols = first.rstrip("\n").split("\t")[1:]
        if cols and len(cols) % 3 == 0 and all(
                c.endswith(s) for c, s in zip(cols, ("_x", "_y", "_z") * (len(cols) // 3))):
            return "sverm"
    if first.startswith("ts\tx\ty\tz"):
        return "ax3"
    if first.startswith("time\t") and "\tax\t" in first:
        return "phone"
    if first.startswith("sample_index\t"):
        return "fnirs"
    if first.startswith("DateTime"):
        return "equivital"
    if re.fullmatch(r"[\d.\s eE+-]+", first) and len(first.split()) == 8:
        return "balance"
    raise ValueError(f"cannot identify the layout of {path}")

read

read(path: str, **kw) -> MotionRecord

Read any corpus motion file, dispatching on content.

Source code in src/micromotion/io.py
773
774
775
def read(path: str, **kw) -> MotionRecord:
    """Read any corpus motion file, dispatching on content."""
    return _READERS[sniff(path)](path, **kw)

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 data.

kind str

"position" or "acceleration": what the numbers are, which decides whether quantity of motion differentiates or integrates.

unit str

"mm", "m", "g", "m/s^2" or "counts".

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.

t0 object | None

Absolute start time where the file carries one.

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
@dataclass
class MotionRecord:
    """One recording, in a form the rest of the package can use without special cases.

    Attributes:
        data (np.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 ``data``.
        kind (str): ``"position"`` or ``"acceleration"``: what the numbers are, which decides
            whether quantity of motion differentiates or integrates.
        unit (str): ``"mm"``, ``"m"``, ``"g"``, ``"m/s^2"`` or ``"counts"``.
        vertical (str): Which axis letter is up. Not always Z: the 2019 championship is Y-up,
            alone in the corpus.
        t (np.ndarray | None): Timestamps in seconds where the file carries them.
        t0: Absolute start time where the file carries one.
        source (str): Path the record was read from.
        meta (dict): Anything else the header held, unmodified.
    """

    data: np.ndarray = field(repr=False)
    fs: float
    channels: list[str]
    kind: str = "position"
    unit: str = "mm"
    vertical: str = "Z"
    t: np.ndarray | None = field(default=None, repr=False)
    t0: object | None = None
    source: str = ""
    meta: dict = field(default_factory=dict)

    def __post_init__(self):
        self.data = np.asarray(self.data, float)
        if self.data.ndim == 1:
            self.data = self.data[:, None]
        if len(self.channels) != self.data.shape[1]:
            raise ValueError(
                f"{len(self.channels)} channel names for {self.data.shape[1]} columns "
                f"in {self.source}"
            )

    @property
    def n_samples(self) -> int:
        return self.data.shape[0]

    @property
    def duration_s(self) -> float:
        return self.n_samples / self.fs

    @property
    def markers(self) -> list[str]:
        """Marker names, for records whose channels are ``<marker> X/Y/Z`` triplets."""
        seen, out = set(), []
        for c in self.channels:
            name = c.rsplit(" ", 1)[0]
            if name not in seen:
                seen.add(name)
                out.append(name)
        return out

    def marker(self, 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.
        """
        idx = [i for i, c in enumerate(self.channels) if c.rsplit(" ", 1)[0] == name]
        if not idx:
            raise KeyError(f"no marker {name!r} in {self.source}; have {self.markers}")
        return self.data[:, idx]

    def missing_fraction(self) -> float:
        """Proportion of the array that is NaN."""
        return float(np.isnan(self.data).mean())

    def qom(self, **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.
        """
        from .qom import qom as _qom

        kw.setdefault("kind", self.kind)
        kw.setdefault("unit", self.unit)
        return _qom(self.data, self.fs, **kw)
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
77
78
79
80
81
82
83
84
85
86
87
def marker(self, 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.
    """
    idx = [i for i, c in enumerate(self.channels) if c.rsplit(" ", 1)[0] == name]
    if not idx:
        raise KeyError(f"no marker {name!r} in {self.source}; have {self.markers}")
    return self.data[:, idx]
missing_fraction
missing_fraction() -> float

Proportion of the array that is NaN.

Source code in src/micromotion/record.py
89
90
91
def missing_fraction(self) -> float:
    """Proportion of the array that is NaN."""
    return float(np.isnan(self.data).mean())
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
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
def qom(self, **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.
    """
    from .qom import qom as _qom

    kw.setdefault("kind", self.kind)
    kw.setdefault("unit", self.unit)
    return _qom(self.data, self.fs, **kw)

Motion capture

micromotion.mocap

Motion-capture I/O and cross-modality utilities.

Pure numpy/scipy helpers ported from the "still standing" and Westney-comparisons studies:

  • :func:read_qtm_tsv -- a single robust reader for Qualisys Track Manager (QTM) TSV exports, consolidating the four/five near-duplicate loaders that were copy-pasted across the study scripts.
  • :func:compare_modality_envelopes -- resample two motion envelopes onto a common per-second grid and correlate them (e.g. video-pose vs mocap validation).
  • :func:dominant_frequency -- the dominant spectral peak of a signal within a band, via Welch.

.. note:: :func:compare_modality_envelopes deliberately takes precomputed 1-D motion envelopes rather than computing quantity-of-motion internally, so this module stays independent of the QoM machinery. The natural producer of such envelopes is musicalgestures._qom.band_limited_qom followed by a per-second binning (envelope / bin_series), arriving in a sibling PR.

Source: still standing study and Westney-comparisons study (Jensenius).

read_qtm_tsv

read_qtm_tsv(path)

Read a Qualisys Track Manager (QTM) TSV motion-capture export.

Consolidates the several near-duplicate loaders used across the studies into one robust reader. It locates the MARKER_NAMES header row to recover marker labels, autodetects where the numeric data block starts (the first row whose first field parses as a float), drops a trailing all-empty column produced by a trailing tab, converts exact-zero XYZ triples (Qualisys gap fills) to NaN, and falls back from UTF-8 to latin-1 encoding. When a FREQUENCY header field is present the frame rate is returned as well.

Source: still standing study and Westney-comparisons study (Jensenius) -- unifies the load_qtm variants in the balance/dynamics/circular/ spatial-range reports and the latin-1 variant in compare_mp_mocap.

Parameters:

Name Type Description Default
path str

Path to the .tsv file.

required

Returns:

Name Type Description
tuple

(marker_names, data, fs) where marker_names is a list of M strings (empty if no header was found), data is a float array of shape (T, M, 3) with gaps as NaN, and fs is the frame rate in Hz or None if not derivable from the header.

Source code in src/micromotion/mocap.py
 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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
def read_qtm_tsv(path):
    """
    Read a Qualisys Track Manager (QTM) TSV motion-capture export.

    Consolidates the several near-duplicate loaders used across the studies
    into one robust reader. It locates the ``MARKER_NAMES`` header row to
    recover marker labels, autodetects where the numeric data block starts
    (the first row whose first field parses as a float), drops a trailing
    all-empty column produced by a trailing tab, converts exact-zero XYZ
    triples (Qualisys gap fills) to ``NaN``, and falls back from UTF-8 to
    latin-1 encoding. When a ``FREQUENCY`` header field is present the frame
    rate is returned as well.

    Source: still standing study and Westney-comparisons study (Jensenius) --
    unifies the ``load_qtm`` variants in the balance/dynamics/circular/
    spatial-range reports and the latin-1 variant in ``compare_mp_mocap``.

    Args:
        path (str): Path to the ``.tsv`` file.

    Returns:
        tuple: ``(marker_names, data, fs)`` where ``marker_names`` is a list
            of ``M`` strings (empty if no header was found), ``data`` is a
            float array of shape ``(T, M, 3)`` with gaps as ``NaN``, and
            ``fs`` is the frame rate in Hz or ``None`` if not derivable from
            the header.
    """
    import io

    def _read_lines(enc):
        with io.open(path, encoding=enc) as fh:
            return fh.readlines()

    try:
        lines = _read_lines("utf-8")
    except (UnicodeDecodeError, UnicodeError):
        lines = _read_lines("latin-1")

    marker_names = []
    fs = None
    data_start = None
    for i, ln in enumerate(lines):
        parts = ln.rstrip("\n").split("\t")
        key = parts[0].strip().upper()
        if key == "MARKER_NAMES":
            marker_names = [p.strip() for p in parts[1:] if p.strip()]
            continue
        if key == "FREQUENCY" and len(parts) > 1:
            try:
                fs = float(parts[1])
            except ValueError:
                pass
            continue
        # first row whose leading field is numeric marks the data block
        try:
            float(parts[0])
            data_start = i
            break
        except ValueError:
            continue

    if data_start is None:
        raise ValueError(f"No numeric data block found in {path!r}")

    rows = []
    for ln in lines[data_start:]:
        parts = ln.rstrip("\n").split("\t")
        try:
            rows.append([float(p) for p in parts if p != ""])
        except ValueError:
            continue
    if not rows:
        raise ValueError(f"No parseable data rows in {path!r}")

    ncol = min(len(r) for r in rows)
    arr = np.array([r[:ncol] for r in rows], dtype=float)

    # A QTM data row may carry a leading time/frame column(s); the marker
    # block is the trailing 3*M columns. Prefer the marker count from the
    # header; otherwise infer the largest multiple of 3.
    if marker_names:
        M = len(marker_names)
    else:
        M = ncol // 3
    offset = arr.shape[1] - 3 * M
    if offset < 0:
        # header lists more markers than columns present; fall back
        M = arr.shape[1] // 3
        offset = arr.shape[1] - 3 * M
        marker_names = marker_names[:M]
    block = arr[:, offset:offset + 3 * M]
    data = block.reshape(block.shape[0], M, 3)

    # exact-zero triples are Qualisys gap fills -> NaN
    gap = np.all(data == 0, axis=2)
    data[gap] = np.nan

    return marker_names, data, fs

dominant_frequency

dominant_frequency(x, fs, band=(0.3, 4.0))

Dominant frequency of a signal within a band, via a Welch spectrum.

Returns the frequency of the largest Welch power-spectral-density peak inside band -- e.g. the dominant oscillation rate of a body-part speed or vertical-position signal.

A bare maximum, and it stays one, because published figures were computed with it. It now warns when the band holds no peak at all, by :func:~micromotion.spectral.is_band_floor, and returns the value unchanged. Take that warning seriously on position and speed signals, whose spectra fall steeply: on synthetic 1/f series with nothing in a 0.7-2.2 Hz band this returns a median 1.07 times the lower edge and lands exactly on that edge on none of forty of them, so an audit looking for values that sit on a band boundary would not have found one. Use :func:~micromotion.spectral.spectral_peak where a NaN is preferable to a number, and :func:~micromotion.spectral.band_edge_sweep to test an estimate that has already been computed.

Source: Westney-comparisons study (Jensenius), extended motion-feature analysis (motion dominant frequency of vertical trunk position).

Parameters:

Name Type Description Default
x ndarray

1-D input signal.

required
fs float

Sampling rate in Hz.

required
band tuple

(low, high) search band in Hz. Defaults to (0.3, 4.0).

(0.3, 4.0)

Returns:

Name Type Description
float

The dominant frequency in Hz, or nan if the band is empty.

Source code in src/micromotion/mocap.py
132
133
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
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
def dominant_frequency(x, fs, band=(0.3, 4.0)):
    """
    Dominant frequency of a signal within a band, via a Welch spectrum.

    Returns the frequency of the largest Welch power-spectral-density peak
    inside ``band`` -- e.g. the dominant oscillation rate of a body-part
    speed or vertical-position signal.

    A bare maximum, and it stays one, because published figures were computed
    with it. It now warns when the band holds no peak at all, by
    :func:`~micromotion.spectral.is_band_floor`, and returns the value
    unchanged. Take that warning seriously on position and speed signals, whose
    spectra fall steeply: on synthetic 1/f series with nothing in a 0.7-2.2 Hz
    band this returns a median 1.07 times the lower edge and lands exactly on
    that edge on none of forty of them, so an audit looking for values that sit
    on a band boundary would not have found one. Use
    :func:`~micromotion.spectral.spectral_peak` where a NaN is preferable to a
    number, and :func:`~micromotion.spectral.band_edge_sweep` to test an
    estimate that has already been computed.

    Source: Westney-comparisons study (Jensenius), extended motion-feature
    analysis (motion dominant frequency of vertical trunk position).

    Args:
        x (np.ndarray): 1-D input signal.
        fs (float): Sampling rate in Hz.
        band (tuple, optional): ``(low, high)`` search band in Hz. Defaults
            to ``(0.3, 4.0)``.

    Returns:
        float: The dominant frequency in Hz, or ``nan`` if the band is empty.
    """
    from scipy.signal import welch

    x = np.asarray(x, dtype=float)
    x = x[np.isfinite(x)]
    if len(x) < 8:
        return np.nan
    lo, hi = band
    f, P = welch(x - x.mean(), fs, nperseg=min(2048, len(x)))
    mask = (f >= lo) & (f <= hi)
    if not mask.any():
        return np.nan
    out = float(f[mask][np.argmax(P[mask])])
    if is_band_floor(*_floor_spectrum(x, fs, (lo, hi)), (lo, hi)):
        _warn_band_floor("dominant_frequency()", out, (lo, hi))
    return out

compare_modality_envelopes

compare_modality_envelopes(env_a, env_b, fs_a, fs_b)

Correlate two motion envelopes after resampling to a common grid.

Resamples both 1-D envelopes onto a shared one-sample-per-second grid (by averaging within each second), truncates to the common length, and returns the Pearson correlation -- the video-vs-mocap (or view-vs-view) agreement measure. Both inputs are treated as already-computed motion envelopes (e.g. per-frame band-limited quantity-of-motion), keeping this function decoupled from the QoM computation itself. The per-second binning uses an integer-rounded step, so non-integer frame rates (e.g. 29.97 fps) drift slightly over long signals; this function is intended for validation rather than precise alignment.

Source: still standing / Westney-comparisons study (Jensenius), MediaPipe-vs-mocap validation (compare_mp_mocap).

Parameters:

Name Type Description Default
env_a ndarray

First 1-D motion envelope.

required
env_b ndarray

Second 1-D motion envelope.

required
fs_a float

Sampling rate of env_a in Hz.

required
fs_b float

Sampling rate of env_b in Hz.

required

Returns:

Name Type Description
dict

{"r", "n"} where r is the Pearson correlation of the two per-second envelopes and n the number of common seconds (r is nan if fewer than three overlapping seconds or if either resampled envelope is constant).

Source code in src/micromotion/mocap.py
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
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
def compare_modality_envelopes(env_a, env_b, fs_a, fs_b):
    """
    Correlate two motion envelopes after resampling to a common grid.

    Resamples both 1-D envelopes onto a shared one-sample-per-second grid
    (by averaging within each second), truncates to the common length, and
    returns the Pearson correlation -- the video-vs-mocap (or view-vs-view)
    agreement measure. Both inputs are treated as already-computed motion
    envelopes (e.g. per-frame band-limited quantity-of-motion), keeping this
    function decoupled from the QoM computation itself. The per-second binning
    uses an integer-rounded step, so non-integer frame rates (e.g. 29.97 fps)
    drift slightly over long signals; this function is intended for validation
    rather than precise alignment.

    Source: still standing / Westney-comparisons study (Jensenius),
    MediaPipe-vs-mocap validation (``compare_mp_mocap``).

    Args:
        env_a (np.ndarray): First 1-D motion envelope.
        env_b (np.ndarray): Second 1-D motion envelope.
        fs_a (float): Sampling rate of ``env_a`` in Hz.
        fs_b (float): Sampling rate of ``env_b`` in Hz.

    Returns:
        dict: ``{"r", "n"}`` where ``r`` is the Pearson correlation of the
            two per-second envelopes and ``n`` the number of common seconds
            (``r`` is ``nan`` if fewer than three overlapping seconds or if
            either resampled envelope is constant).
    """
    def _per_second(env, fs):
        env = np.asarray(env, dtype=float)
        nsec = int(len(env) / fs)
        if nsec < 1:
            return np.array([])
        step = int(round(fs))
        step = max(step, 1)
        return np.array([np.nanmean(env[i * step:(i + 1) * step])
                         for i in range(nsec)])

    a = _per_second(env_a, fs_a)
    b = _per_second(env_b, fs_b)
    n = min(len(a), len(b))
    if n < 3:
        return dict(r=np.nan, n=n)
    a = a[:n]
    b = b[:n]
    ok = np.isfinite(a) & np.isfinite(b)
    if ok.sum() < 3 or a[ok].std() == 0 or b[ok].std() == 0:
        return dict(r=np.nan, n=int(ok.sum()))
    r = float(np.corrcoef(a[ok], b[ok])[0, 1])
    return dict(r=r, n=int(ok.sum()))

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.

RESPIRATORY_BAND module-attribute

RESPIRATORY_BAND = (0.1, 0.5)

Hz. 6-30 breaths per minute.

DEFAULT_EDGE_FACTORS module-attribute

DEFAULT_EDGE_FACTORS = (0.7, 0.85, 1.0, 1.15, 1.3)

What :func:band_edge_sweep multiplies a band's lower edge by when given no edges.

SHARE_RULES module-attribute

SHARE_RULES = ('trapezoid', 'sum')

The quadrature rules a share can be taken under. See :func:band_share.

SHARE_INTERVALS module-attribute

SHARE_INTERVALS = ('closed', 'half_open')

The band-edge conventions a share can be taken under. See :func:band_share.

is_band_floor

is_band_floor(f, p, band: tuple[float, float]) -> bool

Whether the largest value in band is where the band starts rather than a rhythm.

The test is :func:peak_from_spectrum's, so the package holds one peak rule and not two: is there an interior local maximum of the spectrum divided by a log-log straight-line fit across the band. True means there is not, and that whatever a bare argmax returned is a property of the search band rather than of the body.

GIVE THIS AN UNFILTERED SPECTRUM. A band-pass applied before the transform builds its own rising skirt inside the passband, and that skirt survives dividing out the log-log slope. On twenty synthetic 1/f series with nothing in the band, the rule called the filtered spectrum a peak 16 times and the raw spectrum once at a single-segment Welch, and 17 against 6 at the averaging :func:_floor_spectrum uses. Every caller in this package therefore runs it on the raw segment even where the estimate itself is taken from a filtered one. It is a difference of sensitivity rather than of verdict: at the call sites here, where a warning follows if ANY window is flagged, both choices still warn on all twenty series.

GIVE IT AN AVERAGED SPECTRUM TOO, which is the less obvious half. The rule asks whether any bin stands a factor above a fitted slope, and in a lightly averaged Welch some bin always does, by chance. On the same twenty 1/f series over a 0.7-2.2 Hz band this correctly returns True on 20 of 20 at about thirty Welch averages, on 14 of 20 at fifteen, and on 0 of 20 at the five that mocap.dominant_frequency's own nperseg=2048 leaves — which is why every caller here recomputes its own diagnostic spectrum instead of reusing the one the estimate came from. The failure is one-sided: too few averages makes this MISS a band floor, never invent one. A short record, or a band narrow enough that resolving it uses up the record, therefore gets a weaker test rather than a wrong one.

Source code in src/micromotion/spectral.py
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
def is_band_floor(f, p, band: tuple[float, float]) -> bool:
    """Whether the largest value in ``band`` is where the band starts rather than a rhythm.

    The test is :func:`peak_from_spectrum`'s, so the package holds one peak rule and not two: is
    there an interior local maximum of the spectrum divided by a log-log straight-line fit across
    the band. ``True`` means there is not, and that whatever a bare argmax returned is a property
    of the search band rather than of the body.

    GIVE THIS AN UNFILTERED SPECTRUM. A band-pass applied before the transform builds its own
    rising skirt inside the passband, and that skirt survives dividing out the log-log slope. On
    twenty synthetic 1/f series with nothing in the band, the rule called the filtered spectrum a
    peak 16 times and the raw spectrum once at a single-segment Welch, and 17 against 6 at the
    averaging :func:`_floor_spectrum` uses. Every caller in this package therefore runs it on the
    raw segment even where the estimate itself is taken from a filtered one. It is a difference of
    sensitivity rather than of verdict: at the call sites here, where a warning follows if ANY
    window is flagged, both choices still warn on all twenty series.

    GIVE IT AN AVERAGED SPECTRUM TOO, which is the less obvious half. The rule asks whether any bin
    stands a factor above a fitted slope, and in a lightly averaged Welch some bin always does, by
    chance. On the same twenty 1/f series over a 0.7-2.2 Hz band this correctly returns True on
    20 of 20 at about thirty Welch averages, on 14 of 20 at fifteen, and on 0 of 20 at the five
    that `mocap.dominant_frequency`'s own `nperseg=2048` leaves — which is why every caller here
    recomputes its own diagnostic spectrum instead of reusing the one the estimate came from. The
    failure is one-sided: too few averages makes this MISS a band floor, never invent one. A short
    record, or a band narrow enough that resolving it uses up the record, therefore gets a weaker
    test rather than a wrong one.
    """
    return not peak_from_spectrum(np.asarray(f, float), np.asarray(p, float), band)["is_peak"]

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.

This is a bare maximum inside the band, unlike :func:spectral_peak, and it stays one because published beats-per-minute figures in this corpus were computed with it. What it gained instead is a warning: when the band holds no peak it says so and returns the value anyway. On synthetic 1/f series with nothing in the cardiac band it returns a median 1.05 times the 0.7 Hz lower edge, and lands exactly on that edge on only a quarter of them, so a check for values sitting on the boundary would have passed most of them. Use :func:spectral_peak where a NaN is preferable to a number, and :func:band_edge_sweep to settle whether an estimate already computed is following its own band edge.

Source code in src/micromotion/spectral.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
def 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.

    This is a bare maximum inside the band, unlike :func:`spectral_peak`, and it stays one
    because published beats-per-minute figures in this corpus were computed with it. What it
    gained instead is a warning: when the band holds no peak it says so and returns the value
    anyway. On synthetic 1/f series with nothing in the cardiac band it returns a median 1.05
    times the 0.7 Hz lower edge, and lands exactly on that edge on only a quarter of them, so a
    check for values sitting on the boundary would have passed most of them. Use
    :func:`spectral_peak` where a NaN is preferable to a number, and :func:`band_edge_sweep` to
    settle whether an estimate already computed is following its own band edge.
    """
    return _peak(x, fs, CARDIAC_BAND, window_s)

respiratory_peak

respiratory_peak(x, fs: float, window_s: float = 120.0) -> float

Dominant respiratory frequency, in Hz. Multiply by 60 for breaths per minute.

This is measured in the time domain, from :func:detect_breaths, and NOT as a peak in a periodogram. It used to be the latter and the change corrects a wrong number rather than trading one convention for another.

Why, in short: a periodogram of belt or body motion is red, so the breathing bump sits on a much larger downward slope and never becomes the global maximum inside the band. Measured on the one dataset in this corpus with a ground truth -- Stillness2025's sixteen thoracic belts at 25.6 Hz -- the old version returned a median 7.5 breaths per minute where the belts' own breath timing gives 16.8 and where a resting adult breathes 12-20. It was not a calibration offset: it ranked those sixteen participants at Spearman -0.32 against their own breath timing, so it carried no usable information about who was breathing faster.

Four repairs were measured and all four rejected, which is why the spectral approach was abandoned rather than patched. Raising the band floor to 0.20 Hz leaves a 3.4 breaths-per-minute gap. Band-passing before the periodogram does not rescue it. The most prominent local maximum instead of the global one reaches Spearman +0.22. Dividing out a fitted power law before taking the maximum reaches +0.26 and biases the median high, to 21.5.

ONE REASON GIVEN FOR THE SECOND OF THOSE WAS WRONG, and it matters because it is the reason people go on proposing it. This docstring used to say that band-passing "changes nothing whatever, and cannot, because the maximum inside a band is unaffected by filtering inside that same band". The maximum inside a band is NOT unaffected: a Butterworth is not flat inside its own passband, its rising skirt reaches in, and multiplying a falling spectrum by that skirt moves the maximum up. Measured on twenty synthetic 1/f series over a 0.12-0.40 Hz band, the unfiltered maximum sits at 1.11 times the lower edge, a fourth-order zero-phase band-pass over the same band moves it to 1.25 and a second-order one to 1.39, and the filtered and unfiltered answers agree on 6 and 4 of the twenty. So band-passing moves the number by about a fifth and still returns the edge; the repair fails because the answer is the edge either way, not because filtering is a no-op. See :func:band_edge_sweep.

:func:cardiac_peak still uses the periodogram and is right to: its band sits above the slope and the ballistocardiac impulse is a genuinely prominent peak, giving a median 75 bpm with an interquartile range of 70-82 on a year of chest-phone data.

window_s is accepted for backward compatibility and is unused; breath detection does not need a spectral window.

Source code in src/micromotion/spectral.py
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
151
152
153
154
155
156
157
158
159
def respiratory_peak(x, fs: float, window_s: float = 120.0) -> float:
    """Dominant respiratory frequency, in Hz. Multiply by 60 for breaths per minute.

    This is measured in the time domain, from :func:`detect_breaths`, and NOT as a peak in a
    periodogram. It used to be the latter and the change corrects a wrong number rather than
    trading one convention for another.

    Why, in short: a periodogram of belt or body motion is red, so the breathing bump sits on a
    much larger downward slope and never becomes the global maximum inside the band. Measured on
    the one dataset in this corpus with a ground truth -- Stillness2025's sixteen thoracic belts at
    25.6 Hz -- the old version returned a median 7.5 breaths per minute where the belts' own breath
    timing gives 16.8 and where a resting adult breathes 12-20. It was not a calibration offset: it
    ranked those sixteen participants at Spearman -0.32 against their own breath timing, so it
    carried no usable information about who was breathing faster.

    Four repairs were measured and all four rejected, which is why the spectral approach was
    abandoned rather than patched. Raising the band floor to 0.20 Hz leaves a 3.4 breaths-per-minute
    gap. Band-passing before the periodogram does not rescue it. The most prominent local
    maximum instead of the global one reaches Spearman +0.22. Dividing out a fitted power law before
    taking the maximum reaches +0.26 and biases the median high, to 21.5.

    ONE REASON GIVEN FOR THE SECOND OF THOSE WAS WRONG, and it matters because it is the reason
    people go on proposing it. This docstring used to say that band-passing "changes nothing
    whatever, and cannot, because the maximum inside a band is unaffected by filtering inside that
    same band". The maximum inside a band is NOT unaffected: a Butterworth is not flat inside its
    own passband, its rising skirt reaches in, and multiplying a falling spectrum by that skirt
    moves the maximum up. Measured on twenty synthetic 1/f series over a 0.12-0.40 Hz band, the
    unfiltered maximum sits at 1.11 times the lower edge, a fourth-order zero-phase band-pass over
    the same band moves it to 1.25 and a second-order one to 1.39, and the filtered and unfiltered
    answers agree on 6 and 4 of the twenty. So band-passing moves the number by about a fifth and
    still returns the edge; the repair fails because the answer is the edge either way, not
    because filtering is a no-op. See :func:`band_edge_sweep`.

    :func:`cardiac_peak` still uses the periodogram and is right to: its band sits above the slope
    and the ballistocardiac impulse is a genuinely prominent peak, giving a median 75 bpm with an
    interquartile range of 70-82 on a year of chest-phone data.

    ``window_s`` is accepted for backward compatibility and is unused; breath detection does not
    need a spectral window.
    """
    rate = detect_breaths(x, fs)["rate_per_min"]
    return float(rate / 60.0) if np.isfinite(rate) else float("nan")

band_power

band_power(x, fs: float, band: tuple[float, float], window_s: float = 60.0) -> float

Integrated power between two frequencies.

Trapezoid quadrature over a closed interval, [lo, hi]. A ratio of two of these is not the same number as a ratio computed by summing bins over [lo, hi), which is what :func:~micromotion.physio.spectral_band_fractions does; :func:band_share names the convention it uses and can express either.

Source code in src/micromotion/spectral.py
162
163
164
165
166
167
168
169
170
171
172
173
174
def band_power(x, fs: float, band: tuple[float, float], window_s: float = 60.0) -> float:
    """Integrated power between two frequencies.

    Trapezoid quadrature over a closed interval, ``[lo, hi]``. A ratio of two of these is not
    the same number as a ratio computed by summing bins over ``[lo, hi)``, which is what
    :func:`~micromotion.physio.spectral_band_fractions` does; :func:`band_share` names the
    convention it uses and can express either.
    """
    x = np.asarray(x, float)
    nper = int(min(len(x), fs * window_s))
    f, p = signal.welch(signal.detrend(x), fs, nperseg=nper)
    m = (f >= band[0]) & (f <= band[1])
    return float(np.trapezoid(p[m], f[m])) if m.sum() > 1 else float("nan")

peak_from_spectrum

peak_from_spectrum(f, p, band: tuple[float, float], require_peak: bool = True, min_excess: float = 2.0) -> dict

The peak rule, for a caller that already has a spectrum.

spectral_peak is this with a Welch in front of it. It exists separately because several analyses compute one spectrum and read three bands off it, and recomputing the transform per band to get at the rule would be both wasteful and an invitation to reimplement it locally. A rule that is easier to copy than to import gets copied; that is how the reference markers got into one of these analyses twice.

See spectral_peak for what the rule is and why.

Source code in src/micromotion/spectral.py
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
217
218
219
220
221
def peak_from_spectrum(f, p, band: tuple[float, float], require_peak: bool = True,
                       min_excess: float = 2.0) -> dict:
    """The peak rule, for a caller that already has a spectrum.

    `spectral_peak` is this with a Welch in front of it. It exists separately because several
    analyses compute one spectrum and read three bands off it, and recomputing the transform per
    band to get at the rule would be both wasteful and an invitation to reimplement it locally.
    A rule that is easier to copy than to import gets copied; that is how the reference markers
    got into one of these analyses twice.

    See `spectral_peak` for what the rule is and why.
    """
    f = np.asarray(f, float)
    p = np.asarray(p, float)
    nan = {"freq": float("nan"), "power": float("nan"), "snr": float("nan"),
           "excess": float("nan"), "is_peak": False}
    m = (f >= band[0]) & (f <= band[1]) & np.isfinite(p)
    if not m.any():
        return nan
    fb, pb = f[m], p[m]
    med = float(np.median(pb))

    def result(k, excess, is_peak):
        return {"freq": float(fb[k]), "power": float(pb[k]),
                "snr": float(pb[k] / med) if med > 0 else float("nan"),
                "excess": float(excess), "is_peak": bool(is_peak)}

    if not require_peak:
        return result(int(np.argmax(pb)), float("nan"), False)

    ok = (fb > 0) & (pb > 0)
    if ok.sum() < 5:
        return nan
    lf, lp = np.log(fb[ok]), np.log(pb[ok])
    slope, intercept = np.polyfit(lf, lp, 1)
    ratio = np.full(len(pb), np.nan)
    ratio[ok] = pb[ok] / np.exp(slope * lf + intercept)
    if np.all(np.isnan(ratio)):
        return nan
    k = int(np.nanargmax(ratio))
    if not (0 < k < len(pb) - 1 and ratio[k] > ratio[k - 1] and ratio[k] > ratio[k + 1]):
        return nan
    if not ratio[k] >= min_excess:
        return nan
    return result(k, ratio[k], True)

spectral_peak

spectral_peak(x, fs: float, band: tuple[float, float], window_s: float = 60.0, require_peak: bool = True, min_excess: float = 2.0) -> dict

Peak frequency in a band, or NaN when the band contains no peak.

Every spectrum has a maximum somewhere inside any band you choose, and on a falling spectrum that maximum is the lowest bin. It is not a rhythm, it is the slope. With require_peak the result is NaN unless the band holds something that stands above its own background: an interior local maximum of the spectrum divided by a log-log straight-line fit across the band, exceeding min_excess. is_peak and excess in the returned dict say which happened and by how much.

WHY THE BASELINE IS A FITTED SLOPE AND NOT THE BAND MEDIAN. Measuring a peak against the median of its own band assumes the band is flat. Over a 1/f spectrum it is not: the median is dragged down by the high-frequency end, so the low bins clear any threshold without being peaks. On plain 1/f noise the band's largest value scores 3.7 against the median and 1.3 to 1.7 against the fitted slope, where a real rhythm scores 5 and up against either.

THE SIGNAL-TO-NOISE RATIO DOES NOT CATCH THIS, and an earlier version of this docstring advised using it that way. It is wrong in the one case that matters. The lowest bin of a 1/f spectrum has both the most power and the highest power-over-band-median of anything inside a band drawn above the knee, so raising an SNR threshold SELECTS the artefact rather than excluding it. In a year of daily standstill recordings, tightening the threshold from nothing to 5 took the share of days whose "respiration rate" sat exactly on the band floor from 21 per cent to 32, and moved the median from 10.5 breaths a minute to 9.0. Four analyses in the Oslo Standstill corpus reported a band edge as a measurement before this was found, one of them on 662 of 930 values, and one of those numbers had reached a book.

REJECTING THE EDGE BIN IS NOT ENOUGH EITHER. On a monotone slope, refusing the first bin moves the maximum to the second: the same 662 of 930 became 198 of 268, one bin along. Only asking whether the thing is a peak at all separates the two cases.

IT IS MORE ACCURATE AND NOT ONLY MORE CAUTIOUS. Dividing out the slope finds peaks the raw maximum misses: on 1/f noise plus a modest 0.25 Hz tone, the old answer is the band floor and this returns 0.25.

WHAT IT COSTS. It is conservative, and a rhythm weaker than about half that returns NaN even though it is really there. That is the right direction to fail in -- a missing value rather than a wrong one -- but it is a false negative, so a rate aggregated over many recordings will be missing its weakest cases and the count of NaNs is part of the result rather than a nuisance. Lower min_excess to trade the other way, knowingly.

snr is still the peak over the band median, unchanged, because callers report it. require_peak=False restores the old behaviour exactly, for a caller who wants the largest value in a band and knows that is what they are asking for.

Source code in src/micromotion/spectral.py
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
def spectral_peak(x, fs: float, band: tuple[float, float],
                  window_s: float = 60.0, require_peak: bool = True,
                  min_excess: float = 2.0) -> dict:
    """Peak frequency in a band, or NaN when the band contains no peak.

    Every spectrum has a maximum somewhere inside any band you choose, and on a falling
    spectrum that maximum is the lowest bin. It is not a rhythm, it is the slope. With
    ``require_peak`` the result is NaN unless the band holds something that stands above its own
    background: an interior local maximum of the spectrum divided by a log-log straight-line fit
    across the band, exceeding ``min_excess``. ``is_peak`` and ``excess`` in the returned dict say
    which happened and by how much.

    WHY THE BASELINE IS A FITTED SLOPE AND NOT THE BAND MEDIAN. Measuring a peak against the
    median of its own band assumes the band is flat. Over a 1/f spectrum it is not: the median is
    dragged down by the high-frequency end, so the low bins clear any threshold without being
    peaks. On plain 1/f noise the band's largest value scores 3.7 against the median and 1.3 to
    1.7 against the fitted slope, where a real rhythm scores 5 and up against either.

    THE SIGNAL-TO-NOISE RATIO DOES NOT CATCH THIS, and an earlier version of this docstring
    advised using it that way. It is wrong in the one case that matters. The lowest bin of a 1/f
    spectrum has both the most power and the highest power-over-band-median of anything inside a
    band drawn above the knee, so raising an SNR threshold SELECTS the artefact rather than
    excluding it. In a year of daily standstill recordings, tightening the threshold from nothing
    to 5 took the share of days whose "respiration rate" sat exactly on the band floor from 21 per
    cent to 32, and moved the median from 10.5 breaths a minute to 9.0. Four analyses in the Oslo
    Standstill corpus reported a band edge as a measurement before this was found, one of them on
    662 of 930 values, and one of those numbers had reached a book.

    REJECTING THE EDGE BIN IS NOT ENOUGH EITHER. On a monotone slope, refusing the first bin moves
    the maximum to the second: the same 662 of 930 became 198 of 268, one bin along. Only asking
    whether the thing is a peak at all separates the two cases.

    IT IS MORE ACCURATE AND NOT ONLY MORE CAUTIOUS. Dividing out the slope finds peaks the raw
    maximum misses: on 1/f noise plus a modest 0.25 Hz tone, the old answer is the band floor and
    this returns 0.25.

    WHAT IT COSTS. It is conservative, and a rhythm weaker than about half that returns NaN even
    though it is really there. That is the right direction to fail in -- a missing value rather
    than a wrong one -- but it is a false negative, so a rate aggregated over many recordings will
    be missing its weakest cases and the count of NaNs is part of the result rather than a
    nuisance. Lower ``min_excess`` to trade the other way, knowingly.

    ``snr`` is still the peak over the band median, unchanged, because callers report it.
    ``require_peak=False`` restores the old behaviour exactly, for a caller who wants the largest
    value in a band and knows that is what they are asking for.
    """
    x = np.asarray(x, float)
    if len(x) < fs * 10:
        return {"freq": float("nan"), "power": float("nan"), "snr": float("nan"),
                "excess": float("nan"), "is_peak": False}
    nper = int(min(len(x), fs * window_s))
    f, p = signal.welch(signal.detrend(x), fs, nperseg=nper)
    return peak_from_spectrum(f, p, band, require_peak=require_peak, min_excess=min_excess)

band_edge_sweep

band_edge_sweep(signals, fs: float, band: tuple[float, float], *, estimator=None, edges=None, reference=None) -> dict

Move the lower edge of a search band and see whether the answer follows it.

The question this answers is not "did the estimate land on the boundary" but "is the estimate the boundary". Those are different, and the second is the one that catches things. An estimator that reports the largest peak inside a band, run on a spectrum that falls steeply, returns a number that MOVES WITH the band edge at a near-constant multiple of it -- not the edge itself, a plausible-looking interior value a fifth or a third above it. Checking whether values sit ON a boundary passes that clean. Moving the boundary does not.

Pass one signal, or a sequence of signals from a collection. estimator is called as estimator(item, fs, (lo, hi)) and must return a frequency in Hz; it defaults to the bare in-band maximum, which is the thing usually under suspicion, and it can be any callable, so an estimator from outside this package -- a whole pipeline, reading video -- can be audited by the same rule. Items are passed through untouched, so they need only be whatever that callable accepts. Warnings raised inside it are suppressed, since the sweep deliberately calls it in the regime where it complains.

edges are the lower edges to try, defaulting to :data:DEFAULT_EDGE_FACTORS times band[0]. The upper edge is held fixed throughout: this tests one boundary at a time.

Returns a dict:

  • edges, and freq, the answer at each edge (the median across signals if there are several), with freq_by_signal shaped (n_edges, n_signals)
  • ratio, freq / edges, which is flat for an estimate that is the edge
  • factor, the least-squares multiple of the edge through the origin
  • rss_edge and rss_constant, how well "the answer is c times the edge" and "the answer is a fixed frequency" each fit the sweep
  • follows, which is rss_edge < rss_constant: the edge explains the answers better than a rhythm does
  • r against reference at each edge, and r_max, when a reference frequency per signal is given

WHY THE VERDICT COMPARES TWO FITS rather than thresholding a slope. A genuine rhythm returns the same frequency at every edge below it, so a constant fits perfectly and the edge fits badly. An estimate that is the edge fits c * edge and not a constant. The comparison needs no threshold, and it covers both shapes of the failure at once: an answer sitting exactly on the boundary is this with factor at 1.0.

WHAT IT CANNOT DO. Push the edge above the rhythm and every estimator follows it, correctly, because the rhythm is no longer in the band. So keep the swept edges below the frequency you expect; the default range spans 0.7 to 1.3 times a band edge that was presumably chosen to sit below it. A follows verdict from edges that straddle the answer says nothing.

A single sweep is a strong test of one estimator on one collection. reference makes it conclusive: if the estimate carries information about the body, it correlates with an independent measurement of the same quantity at SOME edge. The case this was written from -- a heart rate read from a year of video -- reported 1.24 to 1.33 times its own 0.7 Hz lower edge, moved from 40 to 116 beats a minute as the edge moved from 0.5 to 1.5 Hz, and never correlated with a worn reference above 0.21 at any setting.

import numpy as np t = np.arange(0, 300, 0.1) rng = np.random.default_rng(0) tone = np.sin(2 * np.pi * 0.9 * t) + 0.5 * rng.normal(size=len(t)) band_edge_sweep(tone, 10.0, (0.5, 2.0))["follows"] False

Source code in src/micromotion/spectral.py
283
284
285
286
287
288
289
290
291
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
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
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
def band_edge_sweep(signals, fs: float, band: tuple[float, float], *,
                    estimator=None, edges=None, reference=None) -> dict:
    """Move the lower edge of a search band and see whether the answer follows it.

    The question this answers is not "did the estimate land on the boundary" but "is the estimate
    the boundary". Those are different, and the second is the one that catches things. An estimator
    that reports the largest peak inside a band, run on a spectrum that falls steeply, returns a
    number that MOVES WITH the band edge at a near-constant multiple of it -- not the edge itself,
    a plausible-looking interior value a fifth or a third above it. Checking whether values sit ON
    a boundary passes that clean. Moving the boundary does not.

    Pass one signal, or a sequence of signals from a collection. ``estimator`` is called as
    ``estimator(item, fs, (lo, hi))`` and must return a frequency in Hz; it defaults to the bare
    in-band maximum, which is the thing usually under suspicion, and it can be any callable, so an
    estimator from outside this package -- a whole pipeline, reading video -- can be audited by the
    same rule. Items are passed through untouched, so they need only be whatever that callable
    accepts. Warnings raised inside it are suppressed, since the sweep deliberately calls it in the
    regime where it complains.

    ``edges`` are the lower edges to try, defaulting to :data:`DEFAULT_EDGE_FACTORS` times
    ``band[0]``. The upper edge is held fixed throughout: this tests one boundary at a time.

    Returns a dict:

    - ``edges``, and ``freq``, the answer at each edge (the median across signals if there are
      several), with ``freq_by_signal`` shaped ``(n_edges, n_signals)``
    - ``ratio``, ``freq / edges``, which is flat for an estimate that is the edge
    - ``factor``, the least-squares multiple of the edge through the origin
    - ``rss_edge`` and ``rss_constant``, how well "the answer is c times the edge" and "the answer
      is a fixed frequency" each fit the sweep
    - ``follows``, which is ``rss_edge < rss_constant``: the edge explains the answers better than
      a rhythm does
    - ``r`` against ``reference`` at each edge, and ``r_max``, when a reference frequency per
      signal is given

    WHY THE VERDICT COMPARES TWO FITS rather than thresholding a slope. A genuine rhythm returns
    the same frequency at every edge below it, so a constant fits perfectly and the edge fits
    badly. An estimate that is the edge fits ``c * edge`` and not a constant. The comparison needs
    no threshold, and it covers both shapes of the failure at once: an answer sitting exactly on
    the boundary is this with ``factor`` at 1.0.

    WHAT IT CANNOT DO. Push the edge above the rhythm and every estimator follows it, correctly,
    because the rhythm is no longer in the band. So keep the swept edges below the frequency you
    expect; the default range spans 0.7 to 1.3 times a band edge that was presumably chosen to sit
    below it. A ``follows`` verdict from edges that straddle the answer says nothing.

    A single sweep is a strong test of one estimator on one collection. ``reference`` makes it
    conclusive: if the estimate carries information about the body, it correlates with an
    independent measurement of the same quantity at SOME edge. The case this was written from --
    a heart rate read from a year of video -- reported 1.24 to 1.33 times its own 0.7 Hz lower
    edge, moved from 40 to 116 beats a minute as the edge moved from 0.5 to 1.5 Hz, and never
    correlated with a worn reference above 0.21 at any setting.

    >>> import numpy as np
    >>> t = np.arange(0, 300, 0.1)
    >>> rng = np.random.default_rng(0)
    >>> tone = np.sin(2 * np.pi * 0.9 * t) + 0.5 * rng.normal(size=len(t))
    >>> band_edge_sweep(tone, 10.0, (0.5, 2.0))["follows"]
    False
    """
    lo, hi = float(band[0]), float(band[1])
    if not (np.isfinite(lo) and np.isfinite(hi)) or lo <= 0 or hi <= lo:
        raise ValueError(f"band {lo}-{hi} Hz is not a band; need 0 < lo < hi")
    e = (np.asarray(DEFAULT_EDGE_FACTORS, float) * lo if edges is None
         else np.asarray(edges, float))
    if len(e) < 3 or not np.all(np.diff(e) > 0) or e[0] <= 0 or e[-1] >= hi:
        raise ValueError(
            f"edges must be at least three increasing frequencies in (0, {hi:g}) Hz; got {e}")

    if isinstance(signals, np.ndarray) and signals.ndim == 1 and signals.dtype != object:
        items = [signals]
    elif (isinstance(signals, (list, tuple)) and len(signals)
          and isinstance(signals[0], (int, float, np.number))):
        items = [np.asarray(signals, float)]      # a plain list of samples, i.e. one signal
    else:
        items = list(signals)
    if not items:
        raise ValueError("band_edge_sweep() needs at least one signal")

    est = estimator if estimator is not None else (
        lambda x, rate, b: _peak(x, rate, b, 60.0, where=""))

    by_signal = np.full((len(e), len(items)), np.nan)
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        for i, edge in enumerate(e):
            for j, item in enumerate(items):
                by_signal[i, j] = float(est(item, fs, (float(edge), hi)))

    freq = np.nanmedian(by_signal, axis=1) if len(items) > 1 else by_signal[:, 0]
    ok = np.isfinite(freq)
    if ok.sum() < 3:
        rss_edge = rss_const = factor = float("nan")
        follows = False
    else:
        fe, ff = e[ok], freq[ok]
        factor = float(np.sum(ff * fe) / np.sum(fe ** 2))
        rss_edge = float(np.sum((ff - factor * fe) ** 2))
        rss_const = float(np.sum((ff - ff.mean()) ** 2))
        follows = bool(rss_edge < rss_const)

    r = np.full(len(e), np.nan)
    if reference is not None:
        ref = np.asarray(reference, float)
        if len(ref) != len(items):
            raise ValueError(
                f"reference has {len(ref)} values for {len(items)} signals; one each or none")
        for i in range(len(e)):
            good = np.isfinite(by_signal[i]) & np.isfinite(ref)
            if good.sum() >= 3 and np.std(by_signal[i][good]) > 0 and np.std(ref[good]) > 0:
                r[i] = float(np.corrcoef(by_signal[i][good], ref[good])[0, 1])

    return {"edges": e, "freq": freq, "freq_by_signal": by_signal,
            "ratio": freq / e, "factor": factor, "follows": follows,
            "rss_edge": rss_edge, "rss_constant": rss_const,
            "r": r, "r_max": float(np.nanmax(np.abs(r))) if np.isfinite(r).any() else float("nan")}

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
401
402
403
404
405
406
407
408
409
def 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.
    """
    from .filters import bandpass

    return float(np.sqrt(np.mean(bandpass(np.asarray(x, float), fs, *band) ** 2)))

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 chest-phone cardiac share that motivated the compensated quantity-of-motion variant was established. The number that finding long circulated as -- 38 per cent -- did not survive re-measurement under stated bands; the figure that reproduces is 25 per cent of 0.2-5 Hz acceleration power at 0.8-2.5 Hz, on the raw accelerometer channel. That history is why :func:band_share exists and makes both bands mandatory; prefer it whenever the result is going to be quoted.

WHICH CONVENTION THIS IS. Trapezoid quadrature over a closed interval, [lo, hi], with the whole spectrum as the denominator rather than a named band -- the same arithmetic as :func:band_power and as :func:band_share at its defaults, and NOT the same arithmetic as :func:~micromotion.physio.spectral_band_fractions, which sums bins over [lo, hi) and divides by a named band. The two are not interchangeable: on chest-accelerometer standstill recordings the quadrature rule alone moves a respiratory share by up to 0.034 absolute on a share of about 0.16, and the closure by up to 0.025. Do not compare a fraction from here with one from there; :func:band_share can express either, and says which it used.

Source code in src/micromotion/spectral.py
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
def 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 chest-phone cardiac share that motivated the compensated quantity-of-motion variant
    was established. The number that finding long circulated as -- 38 per cent -- did not
    survive re-measurement under stated bands; the figure that reproduces is 25 per cent of
    0.2-5 Hz acceleration power at 0.8-2.5 Hz, on the raw accelerometer channel. That history
    is why :func:`band_share` exists and makes both bands mandatory; prefer it whenever the
    result is going to be quoted.

    WHICH CONVENTION THIS IS. Trapezoid quadrature over a closed interval, ``[lo, hi]``, with the
    whole spectrum as the denominator rather than a named band -- the same arithmetic as
    :func:`band_power` and as :func:`band_share` at its defaults, and NOT the same arithmetic as
    :func:`~micromotion.physio.spectral_band_fractions`, which sums bins over ``[lo, hi)`` and
    divides by a named band. The two are not interchangeable: on chest-accelerometer standstill
    recordings the quadrature rule alone moves a respiratory share by up to 0.034 absolute on a
    share of about 0.16, and the closure by up to 0.025. Do not compare a fraction from here with
    one from there; :func:`band_share` can express either, and says which it used.
    """
    x = np.asarray(x, float)
    nper = int(min(len(x), fs * window_s))
    f, p = signal.welch(signal.detrend(x), fs, nperseg=nper)
    total = float(np.trapezoid(p, f))
    out = {}
    for name, (lo, hi) in bands.items():
        m = (f >= lo) & (f <= hi)
        out[name] = (float(np.trapezoid(p[m], f[m]) / total)
                     if m.sum() > 1 and total > 0 else float("nan"))
    return out

band_share_from_spectrum

band_share_from_spectrum(f, p, *, num_band: tuple[float, float], den_band: tuple[float, float], integrate: str = 'trapezoid', interval: str = 'closed') -> float

The share rule, for a caller that already has a spectrum.

:func:band_share is this with a Welch in front of it, and its docstring states the rule and the incident behind it. The split mirrors :func:peak_from_spectrum: several analyses compute one spectrum and read more than one share off it, and a rule that is easier to copy than to import gets copied.

The deliverability check here is against the spectrum itself: a denominator edge above the highest frequency the spectrum reaches means the share is taken over a truncated denominator, and the warning says so. Everything else -- the mandatory bands, the containment rule, the non-finite warning, and both convention parameters -- is the same.

integrate is "trapezoid" or "sum" and interval is "closed" or "half_open", exactly as in :func:band_share, which states what the four combinations mean and what they cost. integrate="sum", interval="half_open" is the convention :func:~micromotion.physio.spectral_band_fractions implements, and on the same spectrum the two then agree to floating point; the defaults here are the convention :func:band_power, :func:band_power_fraction and :func:band_share use.

Source code in src/micromotion/spectral.py
496
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
534
535
536
537
538
def band_share_from_spectrum(f, p, *, num_band: tuple[float, float],
                             den_band: tuple[float, float],
                             integrate: str = "trapezoid",
                             interval: str = "closed") -> float:
    """The share rule, for a caller that already has a spectrum.

    :func:`band_share` is this with a Welch in front of it, and its docstring states the rule and
    the incident behind it. The split mirrors :func:`peak_from_spectrum`: several analyses compute
    one spectrum and read more than one share off it, and a rule that is easier to copy than to
    import gets copied.

    The deliverability check here is against the spectrum itself: a denominator edge above the
    highest frequency the spectrum reaches means the share is taken over a truncated denominator,
    and the warning says so. Everything else -- the mandatory bands, the containment rule, the
    non-finite warning, and both convention parameters -- is the same.

    ``integrate`` is ``"trapezoid"`` or ``"sum"`` and ``interval`` is ``"closed"`` or
    ``"half_open"``, exactly as in :func:`band_share`, which states what the four combinations
    mean and what they cost. ``integrate="sum", interval="half_open"`` is the convention
    :func:`~micromotion.physio.spectral_band_fractions` implements, and on the same spectrum the
    two then agree to floating point; the defaults here are the convention
    :func:`band_power`, :func:`band_power_fraction` and :func:`band_share` use.
    """
    _share_bands_checked(num_band, den_band)
    _share_rule_checked(integrate, interval)
    f = np.asarray(f, float)
    p = np.asarray(p, float)
    n_bad = int((~np.isfinite(p)).sum())
    if n_bad:
        warnings.warn(
            f"band_share_from_spectrum() was given {n_bad} non-finite spectrum value(s); "
            "the integrals will be NaN. A Welch spectrum of a series with any non-finite "
            "sample is entirely NaN -- clean the series before the transform.",
            RuntimeWarning, stacklevel=2)
    top = float(f[np.isfinite(f)].max()) if np.isfinite(f).any() else float("nan")
    if den_band[1] > top:
        warnings.warn(
            f"denominator band reaches {den_band[1]} Hz but the spectrum ends at {top:.4g} Hz. "
            f"The result is a share over {den_band[0]}-{top:.4g} Hz, not "
            f"{den_band[0]}-{den_band[1]} Hz, and is not comparable with a share computed over "
            "the full denominator.",
            RuntimeWarning, stacklevel=2)
    return _share(f, p, num_band, den_band, integrate, interval)

band_share

band_share(x, fs: float, *, num_band: tuple[float, float], den_band: tuple[float, float], window_s: float = 60.0, integrate: str = 'trapezoid', interval: str = 'closed') -> float

Fraction of spectral power in one band over another. Both bands are mandatory.

Returns numerator-band power over denominator-band power, integrated on a Welch spectrum, as a number in 0 to 1. The numerator band must lie inside the denominator band, and neither has a default.

THE TWO BANDS ARE NOT THE WHOLE LABEL: THE ARITHMETIC IS PART OF IT. Two conventions for turning a Welch spectrum into a band power are in use in this field, and this package used both before it said so. integrate selects the quadrature rule -- "trapezoid", which weights the two edge bins by a half, or "sum", which adds the bins in the mask (the rectangle rule; the bin width cancels in a ratio, so a bin-summed fraction is exactly this). interval selects what the band edges mean -- "closed", [lo, hi], or "half_open", [lo, hi). The defaults, "trapezoid" and "closed", are what :func:band_power, :func:band_power_fraction and this function have always computed, and what the 25 per cent chest-phone cardiac share was measured under. integrate="sum", interval="half_open" is the other convention in this package, implemented by :func:~micromotion.physio.spectral_band_fractions, and it is what the older cardiac and respiratory composition figures for chest-accelerometer standstill were taken under; pass it to reproduce those rather than silently restating them.

BOTH CHOICES MOVE REAL SHARES BY MORE THAN THEY LOOK LIKE THEY SHOULD. Measured on chest-accelerometer standstill recordings with the mask held fixed so that only the quadrature rule changed, the respiratory share moved by up to 0.034 absolute on a share of about 0.16 -- over a fifth of the value. Closure costs up to 0.025 absolute on the same recordings, and it costs that much because analysts choose round band edges: at the conventional 60 s window the bin spacing is 1/60 Hz, so 0.40, 0.70, 2.20, 3.0, 5.0 and 8.0 Hz all land exactly on a bin, and closing the interval adds a whole bin at the numerator's upper edge and at the denominator's, which do not cancel. Four analysis scripts in one corpus were carried from the bin-sum convention onto the defaults here, so that every share in that corpus is taken the same way and each is comparable with the rest. The move republished one share from 58 to 59 per cent, another from 16.6 to 15.1, a fold from 3.1 to 3.2, and 18 of the 24 numbers in one table. That is a re-measurement rather than a refactor, and it is why these parameters exist: a convention has to be nameable before a corpus can decide to hold one.

THE TWO AGREE EXACTLY ON A FLAT BAND, which is why they look interchangeable. Where the spectrum is flat, the trapezoid's half-weighted end bins remove precisely one bin's worth, so "trapezoid", "closed" equals "sum", "half_open" to floating point. The disagreement is driven by the slope inside the band, so it is largest at the low end of a red spectrum -- the respiratory band, on every body-worn sensor here.

A share is comparable only with a share taken the same way. Record the rule and the closure beside the two bands whenever the number is going to be quoted.

WHY THERE ARE NO DEFAULTS. A share is a ratio of two integrals and it moves when either band moves. Four published-looking figures for the share of standstill motion -- 38, 43, 45 and 58 per cent -- circulated in one project and were quoted against one another as though they measured the same thing. Traced to their origins, each came from a hand-rolled fraction with a different, sometimes unstated, denominator; the 58 traces only to its own 0.10-3.0 Hz denominator, and the 45 is untraceable to any measurement at all. A share whose two bands are not stated beside it is not a reportable number. Report the domain (power of what quantity), the site (sensor and placement) and both bands, every time: acceleration power and position power weight the spectrum by a factor of frequency to the fourth relative to each other, so a share of one is not even approximately a share of the other from the same sensor on the same body.

WHAT IT REFUSES AND WHAT IT WARNS ABOUT. A numerator band wider than or outside the denominator raises, because the result would not be a share. A denominator edge above what fs can deliver warns, the way :func:~micromotion.filters.bandpass warns, because the share silently becomes one over a narrower band -- and fs must be the channel's own rate, not the file's row rate or a resampled grid's; measure it with :func:~micromotion.io.channel_rate on an interleaved log. A non-finite input warns, as the filters have since 1.7.0: one NaN makes the whole Welch spectrum NaN, so the share is NaN rather than mostly right.

IF TWO CHANNELS MUST MEET AT ONE RATE FIRST, resample with :func:~micromotion.resample.to_rate, which is an anti-aliased polyphase FIR resampler and refuses to upsample. Plain interpolation onto a slower clock is not a resampler: measured in the vest decomposition work, interpolating 256 Hz accelerometer axes to a belt's 25.6 Hz folded high-frequency sensor noise into the cardiac band, which inflates exactly the kind of share this function computes.

For a spectrum already in hand, :func:band_share_from_spectrum applies the same rule without recomputing the transform. For several named bands over one common total, :func:band_power_fraction remains the convenience -- it computes the default convention here, over the whole spectrum rather than a named denominator. For the bin-sum convention with a named denominator band, :func:~micromotion.physio.spectral_band_fractions is where it already lives. This function is the one whose result is meant to be quoted, which is why it makes the denominator explicit and lets the convention be named rather than assumed.

Source code in src/micromotion/spectral.py
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
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
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
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
def band_share(x, fs: float, *, num_band: tuple[float, float],
               den_band: tuple[float, float], window_s: float = 60.0,
               integrate: str = "trapezoid", interval: str = "closed") -> float:
    """Fraction of spectral power in one band over another. Both bands are mandatory.

    Returns numerator-band power over denominator-band power, integrated on a Welch spectrum,
    as a number in 0 to 1. The numerator band must lie inside the denominator band, and neither
    has a default.

    THE TWO BANDS ARE NOT THE WHOLE LABEL: THE ARITHMETIC IS PART OF IT. Two conventions for
    turning a Welch spectrum into a band power are in use in this field, and this package used
    both before it said so. ``integrate`` selects the quadrature rule -- ``"trapezoid"``, which
    weights the two edge bins by a half, or ``"sum"``, which adds the bins in the mask (the
    rectangle rule; the bin width cancels in a ratio, so a bin-summed fraction is exactly this).
    ``interval`` selects what the band edges mean -- ``"closed"``, ``[lo, hi]``, or
    ``"half_open"``, ``[lo, hi)``. The defaults, ``"trapezoid"`` and ``"closed"``, are what
    :func:`band_power`, :func:`band_power_fraction` and this function have always computed, and
    what the 25 per cent chest-phone cardiac share was measured under.
    ``integrate="sum", interval="half_open"`` is the other convention in this package,
    implemented by :func:`~micromotion.physio.spectral_band_fractions`, and it is what the
    older cardiac and respiratory composition figures for chest-accelerometer standstill were
    taken under; pass it to reproduce those rather than silently restating them.

    BOTH CHOICES MOVE REAL SHARES BY MORE THAN THEY LOOK LIKE THEY SHOULD. Measured on
    chest-accelerometer standstill recordings with the mask held fixed so that only the
    quadrature rule changed, the respiratory share moved by up to 0.034 absolute on a share of
    about 0.16 -- over a fifth of the value. Closure costs up to 0.025 absolute on the same
    recordings, and it costs that much because analysts choose round band edges: at the
    conventional 60 s window the bin spacing is 1/60 Hz, so 0.40, 0.70, 2.20, 3.0, 5.0 and
    8.0 Hz all land exactly on a bin, and closing the interval adds a whole bin at the
    numerator's upper edge and at the denominator's, which do not cancel. Four analysis scripts
    in one corpus were carried from the bin-sum convention onto the defaults here, so that every
    share in that corpus is taken the same way and each is comparable with the rest. The move
    republished one share from 58 to 59 per cent, another from 16.6 to 15.1, a fold from 3.1 to
    3.2, and 18 of the 24 numbers in one table. That is a re-measurement rather than a refactor,
    and it is why these parameters exist: a convention has to be nameable before a corpus can
    decide to hold one.

    THE TWO AGREE EXACTLY ON A FLAT BAND, which is why they look interchangeable. Where the
    spectrum is flat, the trapezoid's half-weighted end bins remove precisely one bin's worth,
    so ``"trapezoid", "closed"`` equals ``"sum", "half_open"`` to floating point. The
    disagreement is driven by the slope inside the band, so it is largest at the low end of a
    red spectrum -- the respiratory band, on every body-worn sensor here.

    A share is comparable only with a share taken the same way. Record the rule and the closure
    beside the two bands whenever the number is going to be quoted.

    WHY THERE ARE NO DEFAULTS. A share is a ratio of two integrals and it moves when either band
    moves. Four published-looking figures for the share of standstill motion -- 38, 43, 45 and 58
    per cent -- circulated in one project and were quoted against one another as though they
    measured the same thing. Traced to their origins, each came from a hand-rolled fraction with a
    different, sometimes unstated, denominator; the 58 traces only to its own 0.10-3.0 Hz
    denominator, and the 45 is untraceable to any measurement at all. A share whose two bands are
    not stated beside it is not a reportable number. Report the domain (power of what quantity),
    the site (sensor and placement) and both bands, every time: acceleration power and position
    power weight the spectrum by a factor of frequency to the fourth relative to each other, so a
    share of one is not even approximately a share of the other from the same sensor on the same
    body.

    WHAT IT REFUSES AND WHAT IT WARNS ABOUT. A numerator band wider than or outside the
    denominator raises, because the result would not be a share. A denominator edge above what
    ``fs`` can deliver warns, the way :func:`~micromotion.filters.bandpass` warns, because the
    share silently becomes one over a narrower band -- and ``fs`` must be the channel's own rate,
    not the file's row rate or a resampled grid's; measure it with
    :func:`~micromotion.io.channel_rate` on an interleaved log. A non-finite input warns, as the
    filters have since 1.7.0: one NaN makes the whole Welch spectrum NaN, so the share is NaN
    rather than mostly right.

    IF TWO CHANNELS MUST MEET AT ONE RATE FIRST, resample with
    :func:`~micromotion.resample.to_rate`, which is an anti-aliased polyphase FIR resampler and
    refuses to upsample. Plain interpolation onto a slower clock is not a resampler: measured in
    the vest decomposition work, interpolating 256 Hz accelerometer axes to a belt's 25.6 Hz
    folded high-frequency sensor noise into the cardiac band, which inflates exactly the kind of
    share this function computes.

    For a spectrum already in hand, :func:`band_share_from_spectrum` applies the same rule
    without recomputing the transform. For several named bands over one common total,
    :func:`band_power_fraction` remains the convenience -- it computes the default convention
    here, over the whole spectrum rather than a named denominator. For the bin-sum convention
    with a named denominator band, :func:`~micromotion.physio.spectral_band_fractions` is where
    it already lives. This function is the one whose result is meant to be quoted, which is why
    it makes the denominator explicit and lets the convention be named rather than assumed.
    """
    _share_bands_checked(num_band, den_band)
    _share_rule_checked(integrate, interval)
    x = np.asarray(x, float)
    from .filters import NYQUIST_MARGIN

    deliverable = fs / 2.0 * NYQUIST_MARGIN
    if den_band[1] > deliverable:
        warnings.warn(
            f"denominator band reaches {den_band[1]} Hz but {fs} Hz sampling delivers only "
            f"{deliverable:.4g} Hz. The result is a share over a truncated denominator and is "
            "not comparable with one computed at a rate that carries the full band. Note also "
            "that fs must be the channel's own rate, not the file's row rate or a resampled "
            "grid's -- see channel_rate().",
            RuntimeWarning, stacklevel=2)
    n_bad = int((~np.isfinite(x)).sum())
    if n_bad:
        warnings.warn(
            f"band_share() was given {n_bad} non-finite sample(s); a spectrum of a series with "
            "any non-finite sample is entirely NaN, so the share is NaN rather than mostly "
            "right. Interpolate short gaps or split the series before calling.",
            RuntimeWarning, stacklevel=2)
        return float("nan")
    nper = int(min(len(x), fs * window_s))
    f, p = signal.welch(signal.detrend(x), fs, nperseg=nper)
    return _share(f, p, num_band, den_band, integrate, interval)

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
651
652
653
654
655
656
657
658
659
660
661
662
663
def 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.
    """
    x = np.asarray(x, float)
    nper = int(min(len(x), fs * window_s))
    f, p = signal.welch(signal.detrend(x), fs, nperseg=nper)
    m = (f >= band[0]) & (f <= band[1])
    return float(np.sum(f[m] * p[m]) / np.sum(p[m])) if p[m].sum() > 0 else float("nan")

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
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
def 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.
    """
    from .filters import bandpass

    y = bandpass(np.asarray(x, float), fs, *band)
    kw = dict(distance=max(1, int(min_period_s * fs)),
              prominence=prominence_sd * np.std(y))
    peaks, _ = signal.find_peaks(y, **kw)
    troughs, _ = signal.find_peaks(-y, **kw)
    cycles = np.diff(peaks) / fs if len(peaks) > 1 else np.array([])
    return {"peaks_s": peaks / fs, "troughs_s": troughs / fs, "cycle_s": cycles,
            "rate_per_min": float(60.0 / np.median(cycles)) if len(cycles) else float("nan"),
            "n_breaths": len(peaks)}

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 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
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
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
def 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 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.
    """
    from .filters import bandpass, lowpass

    x = np.asarray(x, float)
    y = bandpass(x, fs, *band)
    d = np.gradient(y, 1.0 / fs)

    pos = d[d > 0]
    if not len(pos):
        return {"inspiration_s": np.array([]), "expiration_s": np.array([]),
                "cycle_s": np.array([]), "rate_per_min": float("nan"), "n_breaths": 0}
    thresh = pos.mean() * vel_frac

    # Crossings of an adaptive baseline, not of zero: the belt drifts.
    base = lowpass(y, fs, min(baseline_hz, fs / 2 * 0.4), order=2)
    above = y > base

    rising = d > thresh
    edges = np.diff(rising.astype(int))
    starts = np.flatnonzero(edges == 1) + 1
    ends = np.flatnonzero(edges == -1) + 1
    if len(ends) and len(starts) and ends[0] < starts[0]:
        ends = ends[1:]
    n = min(len(starts), len(ends))
    starts, ends = starts[:n], ends[:n]

    insp, expi = [], []
    for s, e in zip(starts, ends):
        # A genuine breath rises through the baseline; a sway bump does not.
        if e > s and above[s:e].any() and not above[s:e].all():
            insp.append(s / fs)
            expi.append(e / fs)
    insp, expi = np.asarray(insp), np.asarray(expi)
    cycles = np.diff(insp) if len(insp) > 1 else np.array([])
    return {
        "inspiration_s": insp, "expiration_s": expi, "cycle_s": cycles,
        "rate_per_min": float(60.0 / np.median(cycles)) if len(cycles) else float("nan"),
        "n_breaths": len(insp),
    }

Physiology

micromotion.physio

Physiology signal features for standstill / micromotion studies.

Two pure numpy/scipy surfaces ported from the "still standing" study:

  • :func:respiration_rate -- windowed breathing rate (breaths per minute) from a respiration waveform, via band-pass filtering and a Welch spectral peak per window.
  • :func:spectral_band_fractions -- the fraction of a signal's Welch power falling in each of a set of caller-supplied named frequency bands. This is the generic "cardiorespiratory QoM" spectral-composition diagnostic with the heart-rate/respiration bands supplied by the caller, so the function carries no dependency on any particular physiological sensor. It sums bins over half-open bands, which is not the convention :mod:~micromotion.spectral computes shares under; its docstring says what the difference costs and how to ask for either.

Source: still standing study (Jensenius) -- Deichman / Equivital physiology analyses.

DEFAULT_TOTAL_BAND module-attribute

DEFAULT_TOTAL_BAND = (0.1, 8.0)

Hz. The denominator :func:spectral_band_fractions falls back on when none is given.

It is a fallback and not a standard. Published shares in this corpus rest on it, so it cannot move; a caller who does not name a denominator is warned rather than quietly given this one.

respiration_rate

respiration_rate(waveform, fs, *, band=(0.1, 0.6), window_s=30, step_s=30)

Windowed respiration rate (breaths per minute) from a breathing waveform.

Each analysis window is band-pass filtered to the respiration band and its dominant frequency is taken as the Welch spectral peak inside that band; the rate is that frequency times 60. Windows advance by step_s seconds. The default band (0.1, 0.6) Hz corresponds to about 6-36 breaths/min. Each window must contain at least 15 seconds of valid samples for spectral estimation.

THIS IS THE SHAPE THAT FAILS WORST ON A FALLING SPECTRUM, and the rate is unchanged but the function now says so. Band-passing to the same band the maximum is then searched in does not leave the maximum where it was: the filter's own rising skirt reaches into the passband and multiplies the falling spectrum by it, so the largest surviving value sits a fixed fraction above the lower edge. Measured on synthetic 1/f series with nothing in a 0.7-2.2 Hz band, the bare maximum returns a median 1.05 times the lower edge and this returns 1.41 times it, and neither lands on the edge itself. Sweeping the lower edge from 0.3 to 1.5 Hz drags this function's answer along at 1.17 to 1.61 times whatever it is set to.

So a window whose band holds no peak returns a plausible interior frequency, not a boundary value, and an audit for values sitting on a boundary passes it. Every window is therefore tested with :func:~micromotion.spectral.is_band_floor on its own UNFILTERED spectrum, which is the more sensitive of the two: on the filtered one the skirt reads as a genuine peak on 17 of 20 such windows against 6 of 20 raw. The function then warns once, naming how many windows failed. At this function's own defaults, a 1/f series with no breathing in it returns 8 to 11 breaths a minute -- which is where this corpus was reading respiration rates before any of this was found -- and warns on every one. Use :func:~micromotion.spectral.spectral_peak where NaN is preferable to a number, and :func:~micromotion.spectral.band_edge_sweep on a rate already computed.

Source: still standing study (Jensenius), Deichman respiration analysis (compute_qom_resp).

Parameters:

Name Type Description Default
waveform ndarray

1-D respiration/breathing waveform.

required
fs float

Sampling rate in Hz.

required
band tuple

(low, high) respiration band in Hz. Defaults to (0.1, 0.6).

(0.1, 0.6)
window_s float

Window length in seconds. Defaults to 30.

30
step_s float

Hop between windows in seconds. Defaults to 30.

30

Returns:

Name Type Description
dict

{"rate_bpm", "times_s", "median_bpm"} where rate_bpm is the per-window rate (breaths/min, nan for windows without a clear peak), times_s the window centre times in seconds, and median_bpm the median across valid windows.

Source code in src/micromotion/physio.py
 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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
def respiration_rate(waveform, fs, *, band=(0.1, 0.6), window_s=30,
                     step_s=30):
    """
    Windowed respiration rate (breaths per minute) from a breathing waveform.

    Each analysis window is band-pass filtered to the respiration band and
    its dominant frequency is taken as the Welch spectral peak inside that
    band; the rate is that frequency times 60. Windows advance by ``step_s``
    seconds. The default band ``(0.1, 0.6)`` Hz corresponds to about
    6-36 breaths/min. Each window must contain at least 15 seconds of valid
    samples for spectral estimation.

    THIS IS THE SHAPE THAT FAILS WORST ON A FALLING SPECTRUM, and the rate is
    unchanged but the function now says so. Band-passing to the same band the
    maximum is then searched in does not leave the maximum where it was: the
    filter's own rising skirt reaches into the passband and multiplies the
    falling spectrum by it, so the largest surviving value sits a fixed
    fraction above the lower edge. Measured on synthetic 1/f series with
    nothing in a 0.7-2.2 Hz band, the bare maximum returns a median 1.05 times
    the lower edge and this returns 1.41 times it, and neither lands on the
    edge itself. Sweeping the lower edge from 0.3 to 1.5 Hz drags this
    function's answer along at 1.17 to 1.61 times whatever it is set to.

    So a window whose band holds no peak returns a plausible interior
    frequency, not a boundary value, and an audit for values sitting on a
    boundary passes it. Every window is therefore tested with
    :func:`~micromotion.spectral.is_band_floor` on its own UNFILTERED
    spectrum, which is the more sensitive of the two: on the filtered one the
    skirt reads as a genuine peak on 17 of 20 such windows against 6 of 20 raw.
    The function then warns once, naming how many windows failed. At this
    function's own defaults, a 1/f series with no breathing in it returns 8 to
    11 breaths a minute -- which is where this corpus was reading respiration
    rates before any of this was found -- and warns on every one. Use
    :func:`~micromotion.spectral.spectral_peak` where NaN is preferable to a
    number, and :func:`~micromotion.spectral.band_edge_sweep` on a rate
    already computed.

    Source: still standing study (Jensenius), Deichman respiration analysis
    (``compute_qom_resp``).

    Args:
        waveform (np.ndarray): 1-D respiration/breathing waveform.
        fs (float): Sampling rate in Hz.
        band (tuple, optional): ``(low, high)`` respiration band in Hz.
            Defaults to ``(0.1, 0.6)``.
        window_s (float, optional): Window length in seconds. Defaults to 30.
        step_s (float, optional): Hop between windows in seconds. Defaults to
            30.

    Returns:
        dict: ``{"rate_bpm", "times_s", "median_bpm"}`` where ``rate_bpm`` is
            the per-window rate (breaths/min, ``nan`` for windows without a
            clear peak), ``times_s`` the window centre times in seconds, and
            ``median_bpm`` the median across valid windows.
    """
    from scipy.signal import butter, filtfilt, welch

    x = np.asarray(waveform, dtype=float)
    x = x[np.isfinite(x)]
    lo, hi = band
    nyq = fs / 2.0
    if len(x) < int(fs * window_s):
        # single short window: still attempt one estimate
        window_s = max(1.0, len(x) / fs)

    b, a = butter(2, [lo / nyq, hi / nyq], btype="band")
    xf = filtfilt(b, a, x - x.mean())

    win = int(fs * window_s)
    step = int(fs * step_s)
    win = max(win, 1)
    step = max(step, 1)

    rates = []
    times = []
    n_floor = n_tested = 0
    for start in range(0, max(len(xf) - win + 1, 1), step):
        seg = xf[start:start + win]
        if len(seg) < int(fs * 15):  # need at least 15 s for spectral estimation
            rates.append(np.nan)
            times.append((start + win / 2) / fs)
            continue
        nperseg = min(len(seg), int(fs * window_s))
        f, P = welch(seg, fs, nperseg=nperseg)
        mask = (f >= lo) & (f <= hi)
        if mask.any() and P[mask].sum() > 0:
            fpk = f[mask][np.argmax(P[mask])]
            rates.append(float(fpk * 60.0))
            # On the raw segment, not the filtered one: see the docstring.
            n_tested += 1
            n_floor += is_band_floor(
                *_floor_spectrum(x[start:start + win], fs, (lo, hi)), (lo, hi))
        else:
            rates.append(np.nan)
        times.append((start + win / 2) / fs)

    rate_bpm = np.array(rates, dtype=float)
    if n_floor:
        _warn_band_floor("respiration_rate()", float(np.nanmedian(rate_bpm)) / 60.0,
                         (lo, hi), n_floor, n_tested)
    return dict(rate_bpm=rate_bpm, times_s=np.array(times, dtype=float),
                median_bpm=float(np.nanmedian(rate_bpm))
                if np.isfinite(rate_bpm).any() else np.nan)

spectral_band_fractions

spectral_band_fractions(signal, fs, bands, *, total_band=None, nperseg_s=20)

Fraction of a signal's power in each of a set of named frequency bands.

Estimates the Welch power spectrum and, for each named band in bands, returns that band's summed power divided by the summed power in total_band. This is the generic spectral-composition diagnostic used for the "cardiorespiratory QoM artifact" analysis (e.g. how much of a chest-accelerometer QoM signal sits in a cardiac vs a respiration band), with the bands supplied by the caller so there is no built-in dependence on a heart-rate or respiration sensor.

WHICH CONVENTION THIS IS, AND WHAT ELSE IS IN THIS PACKAGE. Power is bin-summed on the Welch grid over a half-open interval, [lo, hi), at both the numerator and the denominator. That is one of the two spectral-share conventions micromotion contains, and the other one is the default everywhere else: :func:~micromotion.spectral.band_power, :func:~micromotion.spectral.band_power_fraction and :func:~micromotion.spectral.band_share integrate with the trapezoid rule over a closed [lo, hi]. A share from here and a share from there are not comparable, and until 1.11.0 neither docstring said so.

An earlier version of this docstring claimed the two "yield nearly identical results on the uniform frequency spacing of Welch". They do not. Measured on chest-accelerometer standstill recordings, holding the mask fixed so that only the quadrature rule changed, the respiratory share moved by up to 0.034 absolute on a share of about 0.16 -- over a fifth of the value. Interval closure costs a further 0.025 absolute on the same recordings, because analysts pick round band edges and at the conventional 60 s window (1/60 Hz bins) 0.40, 0.70, 2.20, 3.0, 5.0 and 8.0 Hz all land exactly on a bin, so closing the interval adds a whole bin at both the numerator's and the denominator's upper edge and the two do not cancel. The two conventions agree only where the band is flat, which the low end of a body-worn sensor's spectrum never is.

Nothing here is deprecated and no default has moved. This is a named convention with a stated arithmetic, and the standstill composition figures the corpus carried before it settled on the trapezoid-and-closed rule were computed by exactly it, so this is where they reproduce. To compute a share under the other convention, or to state which convention a number was taken under, call band_share(..., integrate="sum", interval="half_open"), which reproduces this function on the same spectrum, or leave those parameters at their defaults to get the trapezoid-and-closed convention. New work that will quote a number should prefer :func:~micromotion.spectral.band_share: it makes the denominator mandatory and the convention explicit.

total_band has no silent default. Left unset it falls back to :data:DEFAULT_TOTAL_BAND, 0.1-8.0 Hz, and warns, because a share is a ratio of two integrals and a denominator nobody wrote down is the failure that put four incompatible standstill shares -- 38, 43, 45 and 58 per cent -- into one project's writing. Passing the band explicitly, including passing (0.1, 8.0), silences the warning and changes no number.

Source: still standing study (Jensenius), Deichman chest-QoM cardiorespiratory spectral-composition analysis (deichman_full).

Parameters:

Name Type Description Default
signal ndarray

1-D input signal.

required
fs float

Sampling rate in Hz.

required
bands dict

Mapping of band name to (low, high) in Hz, e.g. {"cardiac": (0.9, 1.3), "resp": (0.12, 0.5)}.

required
total_band tuple

(low, high) reference band whose power is the denominator. Unset falls back to :data:DEFAULT_TOTAL_BAND, (0.1, 8.0), with a warning.

None
nperseg_s float

Welch segment length in seconds. Defaults to 20.

20

Returns:

Name Type Description
dict

Mapping of each band name to its power fraction in [0, 1] (nan if the total band contains no power).

Source code in src/micromotion/physio.py
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
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
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
def spectral_band_fractions(signal, fs, bands, *, total_band=None,
                            nperseg_s=20):
    """
    Fraction of a signal's power in each of a set of named frequency bands.

    Estimates the Welch power spectrum and, for each named band in ``bands``,
    returns that band's summed power divided by the summed power in
    ``total_band``. This is the generic spectral-composition diagnostic used
    for the "cardiorespiratory QoM artifact" analysis (e.g. how much of a
    chest-accelerometer QoM signal sits in a cardiac vs a respiration band),
    with the bands supplied by the caller so there is no built-in dependence
    on a heart-rate or respiration sensor.

    WHICH CONVENTION THIS IS, AND WHAT ELSE IS IN THIS PACKAGE. Power is
    bin-summed on the Welch grid over a half-open interval, ``[lo, hi)``, at
    both the numerator and the denominator. That is one of the two spectral-share
    conventions micromotion contains, and the other one is the default
    everywhere else: :func:`~micromotion.spectral.band_power`,
    :func:`~micromotion.spectral.band_power_fraction` and
    :func:`~micromotion.spectral.band_share` integrate with the trapezoid rule
    over a closed ``[lo, hi]``. A share from here and a share from there are
    not comparable, and until 1.11.0 neither docstring said so.

    An earlier version of this docstring claimed the two "yield nearly
    identical results on the uniform frequency spacing of Welch". They do not.
    Measured on chest-accelerometer standstill recordings, holding the mask
    fixed so that only the quadrature rule changed, the respiratory share moved
    by up to 0.034 absolute on a share of about 0.16 -- over a fifth of the
    value. Interval closure costs a further 0.025 absolute on the same
    recordings, because analysts pick round band edges and at the conventional
    60 s window (1/60 Hz bins) 0.40, 0.70, 2.20, 3.0, 5.0 and 8.0 Hz all land
    exactly on a bin, so closing the interval adds a whole bin at both the
    numerator's and the denominator's upper edge and the two do not cancel. The
    two conventions agree only where the band is flat, which the low end of a
    body-worn sensor's spectrum never is.

    Nothing here is deprecated and no default has moved. This is a named
    convention with a stated arithmetic, and the standstill composition figures
    the corpus carried before it settled on the trapezoid-and-closed rule were
    computed by exactly it, so this is where they reproduce. To compute a share
    under the other convention, or to state
    which convention a number was taken under, call
    ``band_share(..., integrate="sum", interval="half_open")``, which reproduces
    this function on the same spectrum, or leave those parameters at their
    defaults to get the trapezoid-and-closed convention. New work that will
    quote a number should prefer :func:`~micromotion.spectral.band_share`: it
    makes the denominator mandatory and the convention explicit.

    ``total_band`` has no silent default. Left unset it falls back to
    :data:`DEFAULT_TOTAL_BAND`, 0.1-8.0 Hz, and warns, because a share is a
    ratio of two integrals and a denominator nobody wrote down is the failure
    that put four incompatible standstill shares -- 38, 43, 45 and 58 per cent
    -- into one project's writing. Passing the band explicitly, including
    passing ``(0.1, 8.0)``, silences the warning and changes no number.

    Source: still standing study (Jensenius), Deichman chest-QoM
    cardiorespiratory spectral-composition analysis (``deichman_full``).

    Args:
        signal (np.ndarray): 1-D input signal.
        fs (float): Sampling rate in Hz.
        bands (dict): Mapping of band name to ``(low, high)`` in Hz, e.g.
            ``{"cardiac": (0.9, 1.3), "resp": (0.12, 0.5)}``.
        total_band (tuple, optional): ``(low, high)`` reference band whose
            power is the denominator. Unset falls back to
            :data:`DEFAULT_TOTAL_BAND`, ``(0.1, 8.0)``, with a warning.
        nperseg_s (float, optional): Welch segment length in seconds.
            Defaults to 20.

    Returns:
        dict: Mapping of each band name to its power fraction in ``[0, 1]``
            (``nan`` if the total band contains no power).
    """
    from scipy.signal import welch

    if total_band is None:
        total_band = DEFAULT_TOTAL_BAND
        warnings.warn(
            "spectral_band_fractions() was not given a total_band, so the fractions are over "
            f"{DEFAULT_TOTAL_BAND[0]}-{DEFAULT_TOTAL_BAND[1]} Hz. A share whose denominator is "
            "not stated beside it is not a reportable number -- four shares of standstill "
            "motion were quoted against one another in this corpus while each rested on a "
            "different, unstated denominator. Pass total_band explicitly; passing "
            f"{DEFAULT_TOTAL_BAND} changes nothing but the warning. Note also that this "
            "function sums bins over a half-open interval, which is not what band_share() "
            "computes by default; see its docstring.",
            RuntimeWarning, stacklevel=2)

    x = np.asarray(signal, dtype=float)
    x = x[np.isfinite(x)]
    if len(x) < 8:
        return {name: np.nan for name in bands}
    nperseg = min(len(x), max(8, int(fs * nperseg_s)))
    f, P = welch(x - x.mean(), fs, nperseg=nperseg)
    tlo, thi = total_band
    total = P[(f >= tlo) & (f < thi)].sum()
    if total <= 0:
        return {name: np.nan for name in bands}
    out = {}
    for name, (lo, hi) in bands.items():
        out[name] = float(P[(f >= lo) & (f < hi)].sum() / total)
    return out

respiration_onsets

respiration_onsets(x, fs: float, *, lowpass_hz: float = 1.0, onset_frac: float = 0.55, baseline_hz: float = 0.2) -> dict

Inspiration and expiration onset times from a chest-expansion recording.

Inspiration onset is defined by chest-expansion velocity crossing a threshold rather than by a local minimum, which is what makes this robust on quiet standing: a belt on a standing body carries sway and weight shifts that produce local minima with no breath behind them. Expiration onset is the end of that rise, which assumes passive expiration.

The threshold is taken from the signal's own distribution -- onset_frac times the mean positive velocity -- and candidate rises are then required to contain an upward crossing of a heavily low-passed copy of the signal, so a rise that never returns to an exhaled baseline is discarded.

Zero-crossing technique after Matsuda et al. and Upham (2018). Ported from Finn Upham's respy (MIT, 2023) and reimplemented on numpy; see :func:respiratory_phases for why.

Returns inspiration_s, expiration_s, the normalised signal, and its velocity.

Source code in src/micromotion/physio.py
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
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
320
321
322
323
324
def respiration_onsets(x, fs: float, *, lowpass_hz: float = 1.0,
                       onset_frac: float = 0.55, baseline_hz: float = 0.2) -> dict:
    """Inspiration and expiration onset times from a chest-expansion recording.

    Inspiration onset is defined by chest-expansion *velocity* crossing a threshold rather than
    by a local minimum, which is what makes this robust on quiet standing: a belt on a standing
    body carries sway and weight shifts that produce local minima with no breath behind them.
    Expiration onset is the end of that rise, which assumes passive expiration.

    The threshold is taken from the signal's own distribution -- ``onset_frac`` times the mean
    positive velocity -- and candidate rises are then required to contain an upward crossing of
    a heavily low-passed copy of the signal, so a rise that never returns to an exhaled baseline
    is discarded.

    Zero-crossing technique after Matsuda et al. and Upham (2018). Ported from Finn Upham's
    ``respy`` (MIT, 2023) and reimplemented on numpy; see :func:`respiratory_phases` for why.

    Returns ``inspiration_s``, ``expiration_s``, the normalised signal, and its velocity.
    """
    y = np.asarray(x, float)
    if y.ndim != 1:
        raise ValueError("respiration_onsets expects a 1-D waveform")
    finite = np.isfinite(y)
    if finite.sum() < 3:
        raise ValueError("respiration_onsets needs at least three finite samples")
    if not finite.all():                       # NaNs sneak in; filtfilt would spread them
        y = np.interp(np.arange(len(y)), np.flatnonzero(finite), y[finite])
    y = y - y.mean()

    filt = _butter_zero_phase(y, fs, [lowpass_hz], "lowpass")
    scale = fs * np.median(np.abs(np.diff(filt)))
    norm = filt / scale if scale > 0 else filt
    vel = _centred_diff(norm)

    thresh = vel[vel > 0].mean() * onset_frac if (vel > 0).any() else 0.0

    # candidate rises: contiguous runs where velocity exceeds the threshold
    flat = _butter_zero_phase(norm, fs, [baseline_hz], "lowpass")
    crossings = np.diff(np.sign(norm - flat), prepend=np.nan)   # +2 marks an upward crossing
    up = np.flatnonzero(crossings == 2)

    V = np.where(vel < thresh, 0.0, vel)
    a = np.diff(np.sign(V), prepend=np.nan)
    seg_in = np.flatnonzero(a > 0.5) - 2        # respy backs the onset off two samples
    seg_out = np.flatnonzero(a < -0.5)
    seg_in, seg_out = _trim_segments(seg_in, seg_out)

    # drop rises that never cross the baseline: chest movement that is not a breath
    for lo, hi in zip(seg_in, seg_out):
        if not np.any((up >= lo) & (up <= hi)):
            V[max(lo, 0):hi + 1] = 0.0
    a = np.diff(np.sign(V), prepend=np.nan)
    seg_in, seg_out = _trim_segments(np.flatnonzero(a > 0.5), np.flatnonzero(a < -0.5))

    return {"inspiration_s": seg_in / fs, "expiration_s": seg_out / fs,
            "inspiration_i": seg_in, "expiration_i": seg_out,
            "normalised": norm, "velocity": vel, "n_breaths": len(seg_in)}

respiratory_phases

respiratory_phases(x, fs: float, *, scale_high: float = 0.7, scale_low: float = 0.3, **kw) -> dict

Decompose a respiration recording into the phases of the breath cycle.

A breathing rate says how often; this says where in each cycle the body is. That matters here specifically, because the post-expiration pause is the moment in the cycle when the body is most nearly still, so relating breathing to micromotion wants phases rather than a rate.

Returns boolean masks over the input samples:

inspiration, expiration the two half-cycles. inspiration_high, expiration_high high-flow moments judged against the whole recording -- the scale_high quantile of velocity within each phase. inspiration_v, expiration_v high-flow moments judged within each breath, against that breath's own peak velocity. Use these when breath size varies across the recording, which it does during settling. post_expiration the pause after expiration has slowed to scale_low of its own peak rate.

The defaults come from the coordination analysis in Upham (2018).

Ported from Finn Upham's respy (MIT, 2023) with permission, and reimplemented on numpy. The port is not gratuitous: respy.Resp_phases assigns through df[col].loc[idx], which under pandas copy-on-write silently does not write, so on pandas 2 and later it returns all twelve of its phase columns empty without raising. Verified against respy 0.1.1 on pandas 3.0.3, where every phase column came back 0.0 per cent populated.

References

Upham, F. (2018). Detecting the Adaptation of Listeners' Respiration to Heard Music. PhD thesis, New York University.

Source code in src/micromotion/physio.py
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
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
def respiratory_phases(x, fs: float, *, scale_high: float = 0.7, scale_low: float = 0.3,
                       **kw) -> dict:
    """Decompose a respiration recording into the phases of the breath cycle.

    A breathing *rate* says how often; this says where in each cycle the body is. That matters
    here specifically, because the post-expiration pause is the moment in the cycle when the
    body is most nearly still, so relating breathing to micromotion wants phases rather than a
    rate.

    Returns boolean masks over the input samples:

    ``inspiration``, ``expiration``
        the two half-cycles.
    ``inspiration_high``, ``expiration_high``
        high-flow moments judged against the whole recording -- the ``scale_high`` quantile of
        velocity within each phase.
    ``inspiration_v``, ``expiration_v``
        high-flow moments judged *within each breath*, against that breath's own peak velocity.
        Use these when breath size varies across the recording, which it does during settling.
    ``post_expiration``
        the pause after expiration has slowed to ``scale_low`` of its own peak rate.

    The defaults come from the coordination analysis in Upham (2018).

    Ported from Finn Upham's ``respy`` (MIT, 2023) with permission, and reimplemented on numpy.
    The port is not gratuitous: ``respy.Resp_phases`` assigns through ``df[col].loc[idx]``, which
    under pandas copy-on-write silently does not write, so on pandas 2 and later it returns all
    twelve of its phase columns empty without raising. Verified against ``respy`` 0.1.1 on
    pandas 3.0.3, where every phase column came back 0.0 per cent populated.

    References
    ----------
    Upham, F. (2018). *Detecting the Adaptation of Listeners' Respiration to Heard Music*.
    PhD thesis, New York University.
    """
    o = respiration_onsets(x, fs, **kw)
    n = len(o["normalised"])
    norm, vel = o["normalised"], o["velocity"]
    ins, exp = o["inspiration_i"], o["expiration_i"]

    out = {k: np.zeros(n, bool) for k in
           ("inspiration", "expiration", "inspiration_high", "expiration_high",
            "inspiration_v", "expiration_v", "post_expiration")}

    for lo, hi in zip(ins, exp):                       # inspiration: onset to offset
        out["inspiration"][lo:hi + 1] = True
    for hi, lo in zip(exp[:-1], ins[1:]):              # expiration: offset to the next onset
        out["expiration"][hi:lo + 1] = True

    # sequence-wise: one threshold for the whole recording, per phase
    if out["inspiration"].any():
        t = np.quantile(vel[out["inspiration"]], scale_high)
        out["inspiration_high"] = out["inspiration"] & (vel >= t)
    if out["expiration"].any():
        t = np.quantile(vel[out["expiration"]], 1 - scale_high)
        out["expiration_high"] = out["expiration"] & (vel <= t)

    # breath-wise: each half-cycle against its own peak rate
    for lo, hi in zip(ins, exp):
        seg = vel[lo:hi + 1]
        if len(seg) and seg.max() > 0:
            out["inspiration_v"][lo:hi + 1] = seg > seg.max() * scale_high
    for hi, lo in zip(exp[:-1], ins[1:]):
        seg = vel[hi:lo + 1]
        if not len(seg):
            continue
        if seg.min() < 0:
            out["expiration_v"][hi:lo + 1] = seg < seg.min() * scale_high
            # the pause: after the steepest point, once the rate has fallen below scale_low of it
            k = int(np.argmin(seg))
            slowed = seg.copy()
            slowed[:k] = seg.min()                     # never call the run-up a pause
            out["post_expiration"][hi:lo + 1] = slowed > seg.min() * scale_low

    out["inspiration_onset_s"] = o["inspiration_s"]
    out["expiration_onset_s"] = o["expiration_s"]
    out["normalised"] = norm
    out["velocity"] = vel
    out["n_breaths"] = o["n_breaths"]
    return out

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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
def 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".
    """
    rng = rng or np.random.default_rng()
    x = np.asarray(x, float)
    n = len(x)
    amp = np.abs(np.fft.rfft(x))
    srt = np.sort(x)
    y = rng.permutation(x)
    for _ in range(iters):
        Y = np.fft.rfft(y)
        y = np.fft.irfft(amp * np.exp(1j * np.angle(Y)), n=n)
        y = srt[np.argsort(np.argsort(y))]
    return y

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
def 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.
    """
    rng = rng or np.random.default_rng()
    x = np.asarray(x, float)
    X = np.fft.rfft(x)
    ph = rng.uniform(0, 2 * np.pi, len(X))
    ph[0] = 0
    if len(x) % 2 == 0:
        ph[-1] = 0
    return np.fft.irfft(np.abs(X) * np.exp(1j * ph), n=len(x))

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
61
62
63
64
65
66
67
68
69
70
71
72
def 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.
    """
    rng = rng or np.random.default_rng()
    x = np.asarray(x)
    n = len(x)
    k = rng.integers(int(margin * n), int((1 - margin) * n))
    return np.roll(x, k)

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
def 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.
    """
    rng = rng or np.random.default_rng()
    obs = float(statistic(np.asarray(x, float)))
    null = np.array([statistic(method(x, rng=rng)) for _ in range(n)])
    sd = null.std()
    z = (obs - null.mean()) / (sd + 1e-30)
    p = (np.sum(np.abs(null - null.mean()) >= abs(obs - null.mean())) + 1) / (n + 1)
    return {"observed": obs, "null_mean": float(null.mean()), "null_sd": float(sd),
            "z": float(z), "p": float(p), "n_surrogates": n}

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
 94
 95
 96
 97
 98
 99
100
101
102
def 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.
    """
    x = np.asarray(x, float)
    d = x[tau:] - x[:-tau]
    return float(np.mean(d**3) / (np.mean(d**2) ** 1.5 + 1e-30))

dfa

dfa(x, smin: int | None = None, smax: int | None = None, nsc: int = 18, order: int = 1, *, fs: float | None = None, min_scale_s: float | None = None) -> 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.

THE SCALE FLOOR MATTERS MORE THAN IT LOOKS, and it is the argument most worth setting. The shortest scales of a real recording are dominated by measurement noise, which is white, so including them pulls the exponent toward 0.5. On standstill head-marker series the exponent moves by up to 0.15 between a floor of 8 samples and one of 0.3 s.

GIVE IT IN SECONDS WHERE YOU CAN. smin is a sample count, so the same call measures different physical scales at different rates: 8 samples is 0.16 s at 50 Hz and 0.08 s at 100 Hz. Pass fs and min_scale_s instead and the floor is the same stretch of time whatever the recording rate, which is what a comparison across a corpus needs.

A SINGLE EXPONENT MAY NOT DESCRIBE THE SERIES AT ALL. Where the scaling is multifractal the answer depends on which scales are included, and the sensitivity above is a symptom of that rather than of a bad estimator. Check with a multifractal width before quoting one number.

Source code in src/micromotion/dynamics.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
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
def dfa(x, smin: int | None = None, smax: int | None = None, nsc: int = 18,
        order: int = 1, *, fs: float | None = None,
        min_scale_s: float | None = None) -> 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.

    THE SCALE FLOOR MATTERS MORE THAN IT LOOKS, and it is the argument most worth setting.
    The shortest scales of a real recording are dominated by measurement noise, which is
    white, so including them pulls the exponent toward 0.5. On standstill head-marker series
    the exponent moves by up to 0.15 between a floor of 8 samples and one of 0.3 s.

    GIVE IT IN SECONDS WHERE YOU CAN. ``smin`` is a sample count, so the same call measures
    different physical scales at different rates: 8 samples is 0.16 s at 50 Hz and 0.08 s at
    100 Hz. Pass ``fs`` and ``min_scale_s`` instead and the floor is the same stretch of time
    whatever the recording rate, which is what a comparison across a corpus needs.

    A SINGLE EXPONENT MAY NOT DESCRIBE THE SERIES AT ALL. Where the scaling is multifractal
    the answer depends on which scales are included, and the sensitivity above is a symptom of
    that rather than of a bad estimator. Check with a multifractal width before quoting one
    number.
    """
    x = np.asarray(x, float)
    x = x[np.isfinite(x)]
    n = len(x)
    if smin is None and min_scale_s is None and fs is not None:
        # A caller who knows the rate but does not say what the floor should be is almost always
        # taking the default without meaning to, and the default is a sample count: 8 samples is
        # 0.16 s at 50 Hz and 0.08 s at 100. Across a corpus recorded at several rates that is not
        # one analysis, it is several. Warn rather than raise, because a single-rate caller is
        # entitled to the default.
        import warnings as _w
        _w.warn(
            "dfa() is using its default scale floor of 8 samples, which is "
            f"{8 / fs:.3g} s at {fs:g} Hz. The floor changes the exponent by up to 0.15 on real "
            "standstill data and a sample count is not comparable across rates. Pass "
            "min_scale_s to state it in seconds.", RuntimeWarning, stacklevel=2)
    if min_scale_s is not None:
        if fs is None:
            raise ValueError("min_scale_s needs fs: a scale in seconds is meaningless "
                             "without a sampling rate")
        if smin is not None:
            raise ValueError("give the scale floor once, as smin (samples) or min_scale_s "
                             "(seconds), not both")
        smin = max(8, int(round(min_scale_s * fs)))
    smin = smin or 8
    smax = smax or n // 4
    if n < 100 or smax <= smin:
        return {"alpha": float("nan"), "scales": np.array([]), "F": np.array([])}
    Y = np.cumsum(x - x.mean())
    scales = np.unique(np.round(np.logspace(np.log10(smin), np.log10(smax), nsc)).astype(int))
    F = []
    for s in scales:
        nseg = n // s
        if nseg < 4:
            F.append(np.nan)
            continue
        seg = Y[: nseg * s].reshape(nseg, s)
        t = np.arange(s)
        V = np.polynomial.polynomial.polyvander(t, order)
        coef, *_ = np.linalg.lstsq(V, seg.T, rcond=None)
        resid = seg.T - V @ coef
        F.append(np.sqrt(np.mean(resid**2)))
    F = np.array(F, float)
    m = np.isfinite(F) & (F > 0)
    if m.sum() < 5:
        return {"alpha": float("nan"), "scales": scales, "F": F}
    alpha = float(np.polyfit(np.log(scales[m]), np.log(F[m]), 1)[0])
    return {"alpha": alpha, "scales": scales, "F": F}

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
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
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
def 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.
    """
    qs = np.arange(-5, 5.1, 0.5) if qs is None else np.asarray(qs, float)
    x = np.asarray(x, float)
    x = x[np.isfinite(x)]
    N = len(x)
    smax = smax or N // 8
    if smax <= smin:
        return None
    Y = np.cumsum(x - x.mean())
    scales = np.unique(np.round(np.logspace(np.log10(smin), np.log10(smax), nsc)).astype(int))
    Fq = np.full((len(qs), len(scales)), np.nan)
    for si, s in enumerate(scales):
        nseg = N // s
        if nseg < 4:
            continue
        seg = Y[: nseg * s].reshape(nseg, s)
        t = np.arange(s)
        V = np.polynomial.polynomial.polyvander(t, order)
        coef, *_ = np.linalg.lstsq(V, seg.T, rcond=None)
        F2 = np.mean((seg.T - V @ coef) ** 2, axis=0)
        F2 = F2[F2 > 0]
        if len(F2) < 4:
            continue
        for qi, q in enumerate(qs):
            if abs(q) < 1e-9:
                Fq[qi, si] = np.exp(0.5 * np.mean(np.log(F2)))
            else:
                Fq[qi, si] = np.mean(F2 ** (q / 2)) ** (1 / q)
    h = np.full(len(qs), np.nan)
    for qi in range(len(qs)):
        m = np.isfinite(Fq[qi])
        if m.sum() >= 5:
            h[qi] = np.polyfit(np.log(scales[m]), np.log(Fq[qi][m]), 1)[0]
    ok = np.isfinite(h)
    if ok.sum() < 5:
        return None
    q, hh = qs[ok], h[ok]
    tau = q * hh - 1
    alpha = np.gradient(tau, q)
    return {
        "qs": q, "h": hh, "alpha": alpha, "f": q * alpha - tau,
        "h2": float(np.interp(2, q, hh)),
        "width": float(np.nanmax(alpha) - np.nanmin(alpha)),
    }

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
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
def 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.
    """
    x = np.asarray(x, float)
    y = np.zeros_like(x) if y is None else np.asarray(y, float)
    n = len(x)
    L = min(int(maxlag * fs), n - 5)
    if L < 10:
        return {"Hs": float("nan"), "Hl": float("nan")}
    lags = np.unique(np.round(np.logspace(0, np.log10(L), nlags)).astype(int))
    msd = np.array([np.nanmean((x[k:] - x[:-k]) ** 2 + (y[k:] - y[:-k]) ** 2) for k in lags])
    ok = np.isfinite(msd) & (msd > 0)
    out = fit_two_region(lags[ok] / fs, msd[ok])
    out["lags_s"], out["msd"] = lags[ok] / fs, msd[ok]
    return out

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
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
def 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.
    """
    ld, lm = np.log10(dt), np.log10(msd)
    best = None
    for i in range(4, len(dt) - 4):
        s1 = np.polyfit(ld[: i + 1], lm[: i + 1], 1)
        s2 = np.polyfit(ld[i:], lm[i:], 1)
        r = (np.sum((lm[: i + 1] - np.polyval(s1, ld[: i + 1])) ** 2)
             + np.sum((lm[i:] - np.polyval(s2, ld[i:])) ** 2))
        if best is None or r < best[0]:
            best = (r, i, s1, s2)
    if best is None:
        return {"Hs": float("nan"), "Hl": float("nan")}
    _, i, s1, s2 = best
    xc = (s2[1] - s1[1]) / (s1[0] - s2[0])
    return {"Hs": s1[0] / 2, "Hl": s2[0] / 2, "Ds": 10 ** s1[1] / 2, "Dl": 10 ** s2[1] / 2,
            "dtc": 10**xc, "msdc": 10 ** np.polyval(s1, xc), "idx": int(i)}

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
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
def 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.
    """
    x = np.asarray(x, float)
    x = x[np.isfinite(x)]
    n = len(x)
    if n < m + 20:
        return float("nan")
    r = 0.2 * np.std(x) if r is None else r
    if r <= 0:
        return float("nan")

    def count(mm):
        emb = np.lib.stride_tricks.sliding_window_view(x, mm)[: n - m]
        tree = cKDTree(emb)
        return tree.count_neighbors(tree, r, p=np.inf) - len(emb)

    a, b = count(m + 1), count(m)
    return float(-np.log(a / b)) if a > 0 and b > 0 else float("nan")

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
308
309
310
311
312
313
314
315
316
317
318
def ami(x, maxlag: int = 100, bins: int = 32) -> np.ndarray:
    """Average mutual information against lag, for choosing an embedding delay."""
    x = np.asarray(x, float)
    out = []
    for k in range(1, maxlag + 1):
        c, _, _ = np.histogram2d(x[:-k], x[k:], bins=bins)
        p = c / c.sum()
        px, py = p.sum(1, keepdims=True), p.sum(0, keepdims=True)
        m = p > 0
        out.append(float(np.sum(p[m] * np.log(p[m] / (px @ py)[m]))))
    return np.array(out)

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.

When the curve has no local minimum within maxlag the answer is its smallest value, which on a monotone curve is the end of the search and not a property of the signal. This is a bounded search returning its own boundary, and it warns rather than passing a plausible integer on silently: on synthetic 1/f² series it returns 96 with the default maxlag=100 every time, four short of the boundary only because the smoothing flattens the tail. Raise maxlag until a minimum appears, or accept that this signal does not have one.

Source code in src/micromotion/dynamics.py
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
def 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.

    When the curve has no local minimum within ``maxlag`` the answer is its smallest value,
    which on a monotone curve is the end of the search and not a property of the signal. This
    is a bounded search returning its own boundary, and it warns rather than passing a plausible
    integer on silently: on synthetic 1/f² series it returns 96 with the default ``maxlag=100``
    every time, four short of the boundary only because the smoothing flattens the tail. Raise
    ``maxlag`` until a minimum appears, or accept that this signal does not have one.
    """
    a = ami(x, maxlag)
    if smooth > 1 and len(a) > smooth:
        k = np.ones(smooth) / smooth
        a = np.convolve(a, k, mode="same")
        a[: smooth // 2] = a[smooth // 2]
        a[-(smooth // 2):] = a[-(smooth // 2) - 1]
    for i in range(1, len(a) - 1):
        if a[i] < a[i - 1] and a[i] < a[i + 1]:
            return i + 1
    out = int(np.argmin(a)) + 1
    warnings.warn(
        f"first_ami_minimum() found no local minimum of mutual information within maxlag="
        f"{maxlag}, so it returns {out}, which is where the search stopped rather than where "
        "the curve turns. A bounded search returns its own boundary when it finds nothing. "
        "Raise maxlag until a minimum appears, or treat this signal as having no embedding "
        "delay by this rule.",
        RuntimeWarning, stacklevel=2)
    return out

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
355
356
357
358
359
360
361
362
363
def embed(x, dim: int, tau: int) -> np.ndarray:
    """Time-delay embedding. ``x`` may be one series or several columns."""
    x = np.asarray(x, float)
    if x.ndim == 1:
        x = x[:, None]
    n = len(x) - (dim - 1) * tau
    if n <= 0:
        raise ValueError("series is too short for this embedding")
    return np.concatenate([x[i * tau: i * tau + n] for i in range(dim)], axis=1)

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
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
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
def 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.
    """
    x = np.asarray(x, float)
    if x.ndim == 1:
        tau = tau or first_ami_minimum(x, min(100, len(x) // 10))
        emb = embed(x, dim, tau)
    else:
        emb = x
        tau = tau or 1
    d = np.linalg.norm(emb[:, None, :] - emb[None, :, :], axis=2)
    eps = np.quantile(d[np.triu_indices_from(d, 1)], rr)
    R = d <= eps

    n = len(R)
    lengths = []
    for k in range(-(n - lmin), n - lmin + 1):
        if k == 0:
            continue
        diag = np.diagonal(R, k)
        run = 0
        for v in diag:
            if v:
                run += 1
            else:
                if run >= lmin:
                    lengths.append(run)
                run = 0
        if run >= lmin:
            lengths.append(run)
    lengths = np.array(lengths)
    n_rec = R.sum() - n
    det = lengths.sum() / n_rec if n_rec and len(lengths) else 0.0
    p = np.bincount(lengths)[lmin:] if len(lengths) else np.array([])
    p = p[p > 0] / p.sum() if p.sum() else np.array([])
    return {
        "RR": float(n_rec / (n * n - n)),
        "DET": float(det),
        "Lmax": int(lengths.max()) if len(lengths) else 0,
        "Lmean": float(lengths.mean()) if len(lengths) else 0.0,
        "ENTR": float(-np.sum(p * np.log(p))) if len(p) else 0.0,
        "eps": float(eps), "dim": dim, "tau": int(tau),
    }

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
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
def 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.
    """
    from .filters import bandpass

    a = np.asarray(a, float)
    b = np.asarray(b, float)
    if band:
        a, b = bandpass(a, fs, *band), bandpass(b, fs, *band)
    pa = np.angle(_signal.hilbert(a - a.mean()))
    pb = np.angle(_signal.hilbert(b - b.mean()))
    z = np.exp(1j * (pa - pb))
    return {"plv": float(np.abs(z.mean())), "preferred_phase": float(np.angle(z.mean()))}

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
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
def 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.
    """
    x = np.asarray(x, float)
    x = x[np.isfinite(x)]
    n = len(x)
    if n < m + 20:
        return float("nan")
    r = 0.2 * np.std(x) if r is None else r
    if r <= 0:
        return float("nan")

    def phi(mm):
        emb = np.lib.stride_tricks.sliding_window_view(x, mm)
        tree = cKDTree(emb)
        counts = np.array(tree.query_ball_point(emb, r, p=np.inf, return_length=True))
        return float(np.mean(np.log(counts / len(emb))))

    return float(phi(m) - phi(m + 1))

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
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
def 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.
    """
    a = np.asarray(a, float)
    b = np.asarray(b, float)
    n = min(len(a), len(b))
    a, b = a[:n], b[:n]
    m = np.isfinite(a) & np.isfinite(b)
    if m.sum() < 100:
        return {"scales": np.array([]), "rho": np.array([])}
    a, b = a[m], b[m]
    n = len(a)
    A, B = np.cumsum(a - a.mean()), np.cumsum(b - b.mean())
    if scales is None:
        scales = np.unique(np.round(np.logspace(np.log10(10), np.log10(n // 4), 16))
                           ).astype(int)
    rho = []
    for s in scales:
        nseg = n // s
        if nseg < 4:
            rho.append(np.nan)
            continue
        t = np.arange(s)
        V = np.polynomial.polynomial.polyvander(t, order)
        sa = A[: nseg * s].reshape(nseg, s).T
        sb = B[: nseg * s].reshape(nseg, s).T
        ra = sa - V @ np.linalg.lstsq(V, sa, rcond=None)[0]
        rb = sb - V @ np.linalg.lstsq(V, sb, rcond=None)[0]
        fab = np.mean(ra * rb)
        faa, fbb = np.mean(ra * ra), np.mean(rb * rb)
        rho.append(fab / np.sqrt(faa * fbb) if faa > 0 and fbb > 0 else np.nan)
    return {"scales": np.asarray(scales), "rho": np.asarray(rho, float)}

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.

axis_deg IS IN THE RECORDING'S OWN FRAME AND IS NOT COMPARABLE ACROSS RECORDINGS unless you know that the frames agree. Two things have to hold: the laboratory coordinate convention, and which way the body was facing. A sway axis is anatomical -- it is front-to-back -- so turning the person turns this number without anything about their posture changing.

Both failed in one corpus on one afternoon, which is why this paragraph exists. One championship edition is stored with its horizontal axes about 90 degrees from every other, at the same concentration, so a comparison of mean axes across editions measured the laboratories rather than the bodies. And a three-performer collection recorded many sessions with the performers standing in a circle facing each other, so within-person axial concentration came out at R = 0.23 to 0.48 with axes spread over nearly 180 degrees, against 0.75 to 0.85 in a group all facing one way -- and an analysis read that as a weak personal trait when varying facing predicts it exactly.

anisotropy and dispersion are rotation-invariant and carry neither problem. So is the CONCENTRATION of a set of axes, which is why a clustering result can survive where a mean angle cannot. If you need the angle itself across recordings, establish the facing geometry first; if you cannot, use the invariant quantities and say so.

Source code in src/micromotion/posture.py
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
def 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.

    ``axis_deg`` IS IN THE RECORDING'S OWN FRAME AND IS NOT COMPARABLE ACROSS RECORDINGS
    unless you know that the frames agree. Two things have to hold: the laboratory
    coordinate convention, and which way the body was facing. A sway axis is anatomical --
    it is front-to-back -- so turning the person turns this number without anything about
    their posture changing.

    Both failed in one corpus on one afternoon, which is why this paragraph exists. One
    championship edition is stored with its horizontal axes about 90 degrees from every
    other, at the same concentration, so a comparison of mean axes across editions
    measured the laboratories rather than the bodies. And a three-performer collection
    recorded many sessions with the performers standing in a circle facing each other, so
    within-person axial concentration came out at R = 0.23 to 0.48 with axes spread over
    nearly 180 degrees, against 0.75 to 0.85 in a group all facing one way -- and an
    analysis read that as a weak personal trait when varying facing predicts it exactly.

    ``anisotropy`` and ``dispersion`` are rotation-invariant and carry neither problem.
    So is the CONCENTRATION of a set of axes, which is why a clustering result can survive
    where a mean angle cannot. If you need the angle itself across recordings, establish
    the facing geometry first; if you cannot, use the invariant quantities and say so.
    """
    p = np.asarray(xy, float)
    p = p[np.isfinite(p).all(axis=1)][:, :2]
    if len(p) < 3:
        return {"anisotropy": float("nan"), "axis_deg": float("nan")}
    c = p - p.mean(axis=0)
    _, s, vt = np.linalg.svd(c, full_matrices=False)
    lam = s**2 / max(len(c) - 1, 1)
    major, minor = float(lam[0]), float(lam[1])
    return {
        "anisotropy": float(1 - minor / major) if major > 0 else float("nan"),
        "axis_deg": float(np.degrees(np.arctan2(vt[0, 1], vt[0, 0])) % 180.0),
        "sd_major": float(np.sqrt(major)),
        "sd_minor": float(np.sqrt(minor)),
        "rms_radius": float(np.sqrt(np.mean(np.sum(c**2, axis=1)))),
        "range_major": float(np.ptp(c @ vt[0])),
        "range_minor": float(np.ptp(c @ vt[1])),
    }

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
73
74
75
76
77
78
79
80
81
82
83
84
def 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.
    """
    p = np.asarray(xy, float)
    p = p[np.isfinite(p).all(axis=1)][:, :2]
    if len(p) < 3:
        return float("nan")
    cov = np.cov(p.T)
    return float(np.pi * _stats.chi2.ppf(0.95, 2) * np.sqrt(max(np.linalg.det(cov), 0)))

heading_persistence

heading_persistence(xy, speed_percentile: float = 20.0) -> dict

Does the trace reverse along a line, wander at random, or loop?

Takes the heading of each step, and averages the cosine of the change in heading from one step to the next. It measures smoothness, not shape: +1 is a trace whose direction barely changes between samples, 0 is a random walk, and -1 is a trace that reverses at every single step.

A back-and-forth sway along one line reads near +1, not -1, which is worth saying because the intuition runs the other way. Such a trace holds its heading for the whole of each excursion and reverses only at the turning points, so the reversals are a handful of steps among hundreds. Quiet standing in the championship corpus reads about 0.95. Reaching -1 takes a zigzag that flips direction at the sampling rate, which is a signature of noise rather than of movement.

straightness is the net displacement over the distance walked to achieve it, so it is near 0 for someone who stays put however much they move, and near 1 for someone who walks away in a straight line.

The slowest steps are dropped before averaging, speed_percentile of them by default. A heading is the direction of a step, and the direction of a step that barely happened is mostly noise; including them pulls the mean towards the 0 of a random walk.

.. warning::

This descriptor is unusually sensitive to how the series was brought to its sampling rate, because a change of heading between consecutive samples is a different question at every sampling interval. In the standstill corpus a bare polyphase resample read 0.15 here for the two 120 Hz editions --- which need a 5:12 conversion, where the anti-alias filter has least room --- against about 0.95 for the others, and the split was very nearly published as a difference between tracking systems. Through :func:~micromotion.to_rate every edition reads 0.949 to 0.964. Bring series to a common rate with that, and compare only series that share one.

Parameters:

Name Type Description Default
xy

Positions, shape (N, 2). Non-finite rows are dropped.

required
speed_percentile float

Percentile of step speeds below which steps are excluded from the heading average. Defaults to 20.

20.0

Returns:

Name Type Description
dict dict

persistence in [-1, 1] as described above, and straightness in [0, 1]. Both are NaN when fewer than three finite samples remain.

Source code in src/micromotion/posture.py
 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
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
def heading_persistence(xy, speed_percentile: float = 20.0) -> dict:
    """Does the trace reverse along a line, wander at random, or loop?

    Takes the heading of each step, and averages the cosine of the change in heading from
    one step to the next. It measures smoothness, not shape: +1 is a trace whose direction
    barely changes between samples, 0 is a random walk, and -1 is a trace that reverses at
    every single step.

    A back-and-forth sway along one line reads near +1, not -1, which is worth saying
    because the intuition runs the other way. Such a trace holds its heading for the whole
    of each excursion and reverses only at the turning points, so the reversals are a
    handful of steps among hundreds. Quiet standing in the championship corpus reads about
    0.95. Reaching -1 takes a zigzag that flips direction at the sampling rate, which is a
    signature of noise rather than of movement.

    ``straightness`` is the net displacement over the distance walked to achieve it, so it
    is near 0 for someone who stays put however much they move, and near 1 for someone who
    walks away in a straight line.

    The slowest steps are dropped before averaging, ``speed_percentile`` of them by default.
    A heading is the direction of a step, and the direction of a step that barely happened
    is mostly noise; including them pulls the mean towards the 0 of a random walk.

    .. warning::

       This descriptor is unusually sensitive to how the series was brought to its sampling
       rate, because a change of heading between consecutive samples is a different question
       at every sampling interval. In the standstill corpus a bare polyphase resample read
       0.15 here for the two 120 Hz editions --- which need a 5:12 conversion, where the
       anti-alias filter has least room --- against about 0.95 for the others, and the split
       was very nearly published as a difference between tracking systems. Through
       :func:`~micromotion.to_rate` every edition reads 0.949 to 0.964. Bring series to a
       common rate with that, and compare only series that share one.

    Args:
        xy: Positions, shape (N, 2). Non-finite rows are dropped.
        speed_percentile (float): Percentile of step speeds below which steps are excluded
            from the heading average. Defaults to 20.

    Returns:
        dict: ``persistence`` in [-1, 1] as described above, and ``straightness`` in [0, 1].
            Both are NaN when fewer than three finite samples remain.
    """
    p = np.asarray(xy, float)
    p = p[np.isfinite(p).all(axis=1)]
    if len(p) < 3:
        return {"persistence": float("nan"), "straightness": float("nan")}

    v = np.diff(p, axis=0)
    speed = np.hypot(v[:, 0], v[:, 1])
    path = float(np.sum(speed))
    straightness = float(np.hypot(*(p[-1] - p[0])) / path) if path > 0 else float("nan")

    if len(v) < 2:
        return {"persistence": float("nan"), "straightness": straightness}

    heading = np.arctan2(v[:, 1], v[:, 0])
    turn = np.diff(heading)
    turn = (turn + np.pi) % (2 * np.pi) - np.pi
    fast = speed >= np.percentile(speed, speed_percentile)
    keep = fast[:-1]
    persistence = float(np.mean(np.cos(turn[keep]))) if keep.any() else float("nan")
    return {"persistence": persistence, "straightness": straightness}

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 device 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
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
def 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
    device 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.
    """
    p = np.asarray(xy, float)
    p = p[np.isfinite(p).all(axis=1)]
    if len(p) < 2:
        return {"path": float("nan"), "path_rate": float("nan")}
    d = float(np.sum(np.linalg.norm(np.diff(p, axis=0), axis=1)))
    return {"path": d, "path_rate": d / (len(p) / fs) if fs else float("nan")}

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
169
170
171
172
173
174
175
176
177
178
179
180
def 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.
    """
    p = np.asarray(xy, float)
    p = p[np.isfinite(p).all(axis=1)][:, :2]
    if len(p) < 3:
        return float("nan")
    c = p - p.mean(axis=0)
    return float(np.quantile(np.linalg.norm(c, axis=1), quantile))

shared_axis_projection

shared_axis_projection(markers, reference, mask: str = 'reference') -> dict

Project several markers onto ONE axis, the reference marker's principal axis.

This is the difference between asking "does this marker sway along a line" and asking "do these segments sway along the SAME line". :func:micromotion.principal_axis_projection answers the first, per marker, giving each its own axis -- and two markers swaying at right angles, each on its own axis, then correlate perfectly while sharing no direction of motion. Correlating segments only means something once they are on a common axis, which is what this builds.

The axis is anatomical -- front-to-back for a standing person -- so it is taken from one named marker rather than from the pooled cloud, which would be dominated by whichever segment moved most. axis_deg carries the same warning as :func:sway_geometry's: it is in the recording's own frame and is not comparable across recordings unless the laboratory convention and the direction the body faced are both known.

Parameters:

Name Type Description Default
markers

Mapping of name -> horizontal coordinates, each (T, 2). Extra columns are ignored, so a full (T, 3) marker may be passed.

required
reference

Key naming the marker whose axis every other is projected onto.

required
mask str

Which samples count. "reference" (the default) keeps the frames where the reference marker is finite, and centres and projects every marker over exactly those -- the convention of the still-standing coordination analysis, kept as the default so its results reproduce. "own" gives each marker its own finite samples, which is more defensible per marker but makes the projections rest on different frames.

'reference'

Returns:

Name Type Description
dict dict

projection (name -> (T,) array, NaN where masked out), axis (the

dict

unit vector), axis_deg (its direction, axial, 0-180), mask (the boolean frame

dict

mask used when mask="reference", else None), and n_finite (name -> count of

dict

finite samples in that marker's projection).

Raises:

Type Description
KeyError

If reference is not among the markers.

ValueError

If mask is neither "reference" nor "own", or the reference marker has fewer than three usable frames.

Source code in src/micromotion/posture.py
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
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
def shared_axis_projection(markers, reference, mask: str = "reference") -> dict:
    """Project several markers onto ONE axis, the reference marker's principal axis.

    This is the difference between asking "does this marker sway along a line" and asking
    "do these segments sway along the SAME line". :func:`micromotion.principal_axis_projection`
    answers the first, per marker, giving each its own axis -- and two markers swaying at right
    angles, each on its own axis, then correlate perfectly while sharing no direction of motion.
    Correlating segments only means something once they are on a common axis, which is what
    this builds.

    The axis is anatomical -- front-to-back for a standing person -- so it is taken from one
    named marker rather than from the pooled cloud, which would be dominated by whichever
    segment moved most. ``axis_deg`` carries the same warning as
    :func:`sway_geometry`'s: it is in the recording's own frame and is not comparable across
    recordings unless the laboratory convention and the direction the body faced are both known.

    Args:
        markers: Mapping of name -> horizontal coordinates, each ``(T, 2)``. Extra columns
            are ignored, so a full ``(T, 3)`` marker may be passed.
        reference: Key naming the marker whose axis every other is projected onto.
        mask (str, optional): Which samples count. ``"reference"`` (the default) keeps the
            frames where the reference marker is finite, and centres and projects every marker
            over exactly those -- the convention of the still-standing coordination analysis,
            kept as the default so its results reproduce. ``"own"`` gives each marker its own
            finite samples, which is more defensible per marker but makes the projections rest
            on different frames.

    Returns:
        dict: ``projection`` (name -> ``(T,)`` array, NaN where masked out), ``axis`` (the
        unit vector), ``axis_deg`` (its direction, axial, 0-180), ``mask`` (the boolean frame
        mask used when ``mask="reference"``, else None), and ``n_finite`` (name -> count of
        finite samples in that marker's projection).

    Raises:
        KeyError: If ``reference`` is not among the markers.
        ValueError: If ``mask`` is neither "reference" nor "own", or the reference marker has
            fewer than three usable frames.
    """
    if mask not in ("reference", "own"):
        raise ValueError(
            f"mask must be 'reference' or 'own', not {mask!r}. 'reference' keeps the frames "
            "where the reference marker is finite; 'own' gives each marker its own.")
    if reference not in markers:
        raise KeyError(
            f"reference marker {reference!r} is not among the markers "
            f"({sorted(markers)!r}). The axis has to come from a named marker, because it is "
            "anatomical rather than a property of the pooled cloud.")

    ref = np.asarray(markers[reference], float)[:, :2]
    ok = np.isfinite(ref).all(axis=1)
    if ok.sum() < 3:
        raise ValueError(
            f"reference marker {reference!r} has {int(ok.sum())} usable frames; "
            "need at least three to define an axis.")

    centred = ref[ok] - ref[ok].mean(axis=0)
    cov = np.cov(centred.T)
    _, vecs = np.linalg.eigh(cov)
    axis = vecs[:, -1]

    projection: dict = {}
    n_finite: dict = {}
    for name, xy in markers.items():
        p = np.asarray(xy, float)[:, :2]
        keep = ok if mask == "reference" else np.isfinite(p).all(axis=1)
        s = np.full(len(p), np.nan)
        if keep.any():
            good = keep & np.isfinite(p).all(axis=1)
            s[good] = (p[good] - p[good].mean(axis=0)) @ axis
        projection[name] = s
        n_finite[name] = int(np.isfinite(s).sum())

    return {
        "projection": projection,
        "axis": axis,
        "axis_deg": float(np.degrees(np.arctan2(axis[1], axis[0])) % 180.0),
        "mask": ok if mask == "reference" else None,
        "n_finite": n_finite,
    }

segmental_coordination

segmental_coordination(markers, reference, ratios=None, mask: str = 'reference', min_finite: float = 0.8, reduce=None) -> dict

Do a body's segments sway as one rigid link, or as several?

Projects every marker onto the reference marker's axis with :func:shared_axis_projection, then reduces the result three ways: how strongly each pair of segments agrees, how many independent axes the set spans, and which of a named pair sways further. A body rocking at the ankles as a single inverted pendulum gives high correlations, an effective degrees-of-freedom near one, and a head-to-hip amplitude ratio above one -- the head is further from the ankle, so the same rotation carries it further.

The effective degrees of freedom is :func:micromotion.effective_dimensionality called on the projections with rank=False, not a second implementation of the participation ratio. Ranking is right for heavy-tailed descriptors and wrong here, where the columns are sway signals and their actual covariance is the quantity of interest.

The correlations are pairwise-complete and each carries its own n. np.corrcoef returns NaN if a single sample is missing, which drops whole sessions from a pair without saying so -- one or two per pair in the corpus this comes from, under a heading that reported one N for every row. Read n beside any correlation.

No body-part taxonomy is built in: markers is whatever the caller names, and "inverted pendulum" is an interpretation of a ratio above one rather than something this computes. Group membership stays with the study.

Parameters:

Name Type Description Default
markers

Mapping of name -> horizontal coordinates, each (T, 2).

required
reference

Key naming the marker whose axis defines the shared direction.

required
ratios optional

Pairs (a, b) to report an amplitude ratio for, as std(a) / std(b) over their shared finite samples. Defaults to none.

None
mask str

Passed to :func:shared_axis_projection. Defaults to "reference".

'reference'
reduce optional

Which markers enter the dimensionality reduction, by name. Defaults to all of them. Pass an explicit list when markers carries a derived marker -- a midpoint of two others, say, wanted only for an amplitude ratio -- because such a marker is a linear combination of segments already present and entering it as a ninth segment changes the effective degrees of freedom.

None
min_finite float

Markers finite on a smaller fraction of the usable frames than this -- the reference-valid frames under mask="reference", the whole recording under mask="own" -- are excluded from the effective-dimensionality reduction, which needs a complete matrix. They still appear in the pairwise correlations. Defaults to 0.8.

0.8

Returns:

Name Type Description
dict dict

correlation and n (both keyed by the (a, b) pair, a and b sorted as

dict

given), pc1_fraction and effective_dof, amplitude_ratio (keyed by the

dict

requested pairs), axis_deg, n_finite per marker, used (the markers that

dict

entered the reduction) and n_excluded.

Raises:

Type Description
KeyError

If reference, or either member of a requested ratio, is not a marker.

ValueError

If mask is invalid, or fewer than two markers survive min_finite.

Source code in src/micromotion/posture.py
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
290
291
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
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
369
370
371
372
373
374
375
376
def segmental_coordination(markers, reference, ratios=None, mask: str = "reference",
                           min_finite: float = 0.8, reduce=None) -> dict:
    """Do a body's segments sway as one rigid link, or as several?

    Projects every marker onto the reference marker's axis with
    :func:`shared_axis_projection`, then reduces the result three ways: how strongly each pair
    of segments agrees, how many independent axes the set spans, and which of a named pair
    sways further. A body rocking at the ankles as a single inverted pendulum gives high
    correlations, an effective degrees-of-freedom near one, and a head-to-hip amplitude ratio
    above one -- the head is further from the ankle, so the same rotation carries it further.

    The effective degrees of freedom is :func:`micromotion.effective_dimensionality` called on
    the projections with ``rank=False``, not a second implementation of the participation
    ratio. Ranking is right for heavy-tailed descriptors and wrong here, where the columns are
    sway signals and their actual covariance is the quantity of interest.

    **The correlations are pairwise-complete and each carries its own n.** ``np.corrcoef``
    returns NaN if a single sample is missing, which drops whole sessions from a pair without
    saying so -- one or two per pair in the corpus this comes from, under a heading that
    reported one N for every row. Read ``n`` beside any correlation.

    No body-part taxonomy is built in: ``markers`` is whatever the caller names, and
    "inverted pendulum" is an interpretation of a ratio above one rather than something this
    computes. Group membership stays with the study.

    Args:
        markers: Mapping of name -> horizontal coordinates, each ``(T, 2)``.
        reference: Key naming the marker whose axis defines the shared direction.
        ratios (optional): Pairs ``(a, b)`` to report an amplitude ratio for, as
            ``std(a) / std(b)`` over their shared finite samples. Defaults to none.
        mask (str, optional): Passed to :func:`shared_axis_projection`. Defaults to
            ``"reference"``.
        reduce (optional): Which markers enter the dimensionality reduction, by name.
            Defaults to all of them. Pass an explicit list when ``markers`` carries a derived
            marker -- a midpoint of two others, say, wanted only for an amplitude ratio --
            because such a marker is a linear combination of segments already present and
            entering it as a ninth segment changes the effective degrees of freedom.
        min_finite (float, optional): Markers finite on a smaller fraction of the *usable*
            frames than this -- the reference-valid frames under ``mask="reference"``, the
            whole recording under ``mask="own"`` --
            are excluded from the effective-dimensionality reduction, which needs a complete
            matrix. They still appear in the pairwise correlations. Defaults to 0.8.

    Returns:
        dict: ``correlation`` and ``n`` (both keyed by the ``(a, b)`` pair, a and b sorted as
        given), ``pc1_fraction`` and ``effective_dof``, ``amplitude_ratio`` (keyed by the
        requested pairs), ``axis_deg``, ``n_finite`` per marker, ``used`` (the markers that
        entered the reduction) and ``n_excluded``.

    Raises:
        KeyError: If ``reference``, or either member of a requested ratio, is not a marker.
        ValueError: If ``mask`` is invalid, or fewer than two markers survive ``min_finite``.
    """
    proj = shared_axis_projection(markers, reference, mask=mask)
    sig = proj["projection"]
    names = list(sig)
    n_frames = len(sig[reference])

    correlation: dict = {}
    n_pairs: dict = {}
    for i, a in enumerate(names):
        for b in names[i + 1:]:
            both = np.isfinite(sig[a]) & np.isfinite(sig[b])
            n_pairs[(a, b)] = int(both.sum())
            if both.sum() < 3:
                correlation[(a, b)] = float("nan")
                continue
            x, y = sig[a][both], sig[b][both]
            if x.std() == 0 or y.std() == 0:
                correlation[(a, b)] = float("nan")
                continue
            correlation[(a, b)] = float(np.corrcoef(x, y)[0, 1])

    # The denominator is the frames that COULD have been used, not the length of the
    # recording: under mask="reference" a marker cannot be finite where the reference is not,
    # so dividing by the full length would penalise every marker for the reference's dropouts.
    usable = int(proj["mask"].sum()) if proj["mask"] is not None else n_frames
    candidates = list(names) if reduce is None else list(reduce)
    missing = [n for n in candidates if n not in sig]
    if missing:
        raise KeyError(f"reduce names markers that were not given: {missing!r}")
    used = [n for n in candidates
            if usable and proj["n_finite"][n] / usable >= min_finite]
    if len(used) < 2:
        raise ValueError(
            f"only {len(used)} marker(s) are finite on at least {min_finite:.0%} of frames, "
            "so there is nothing to reduce. Lower min_finite or check the markers.")

    matrix = np.column_stack([sig[n] for n in used])
    matrix = matrix[np.isfinite(matrix).all(axis=1)]
    dims = effective_dimensionality(matrix, rank=False)

    amplitude_ratio: dict = {}
    for a, b in (ratios or []):
        for key in (a, b):
            if key not in sig:
                raise KeyError(f"amplitude ratio asked for {key!r}, which is not a marker.")
        both = np.isfinite(sig[a]) & np.isfinite(sig[b])
        denom = sig[b][both].std() if both.sum() >= 2 else 0.0
        amplitude_ratio[(a, b)] = (float(sig[a][both].std() / denom) if denom > 0
                                   else float("nan"))

    return {
        "correlation": correlation,
        "n": n_pairs,
        "pc1_fraction": float(dims["variance_fraction"][0]),
        "effective_dof": float(dims["participation_ratio"]),
        "amplitude_ratio": amplitude_ratio,
        "axis_deg": proj["axis_deg"],
        "n_finite": proj["n_finite"],
        "used": used,
        "n_excluded": len(candidates) - len(used),
    }

Balance and centre of pressure

micromotion.balance

Posturography and standstill-sway metrics for centre-of-pressure (CoP) and head/marker position signals.

This module ports the "still standing" study's posturography stack into pure numpy/scipy surfaces that operate on plain arrays -- no study-specific loaders, axis conventions, or marker loops. Three families of measures are provided:

  • Sway amount / geometry -- :func:cop_sway_metrics, :func:confidence_ellipse_area, :func:convex_hull_area.
  • Control dynamics / complexity -- :func:stabilogram_diffusion (Collins-De Luca SDA), :func:dfa (detrended fluctuation analysis), :func:sample_entropy, :func:spectral_edges, :func:sway_texture, :func:principal_axis_projection.
  • Direction / extent -- :func:sway_orientation, :func:axial_rayleigh, :func:spatial_extent.

The from-scratch SDA / DFA / sample-entropy implementations are validated in the test-suite against known-answer synthetic signals (white noise -> DFA alpha ~= 0.5 and SDA Hurst ~= 0.5; a sine -> low sample entropy relative to its shuffle).

Source: still standing study (Jensenius) -- posturography and micromotion analyses of the international "standstill" championships and related datasets.

confidence_ellipse_area

confidence_ellipse_area(xy, conf=0.95)

Area of the confidence ellipse of a 2-D point cloud (e.g. a centre-of-pressure trace).

The ellipse is the standard bivariate-Gaussian confidence region area = pi * chi2_conf,2df * sqrt(det Cov) where Cov is the 2x2 covariance of the (mean-removed) points. For a CoP sway path this is the classic 95% "sway-ellipse area".

Source: still standing study (Jensenius), HpSp balance analysis.

Parameters:

Name Type Description Default
xy ndarray

Point cloud of shape (T, 2).

required
conf float

Confidence level in (0, 1). Defaults to 0.95.

0.95

Returns:

Name Type Description
float

Ellipse area in squared position units (e.g. mm^2), or nan if fewer than three finite points are available.

Source code in src/micromotion/balance.py
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
def confidence_ellipse_area(xy, conf=0.95):
    """
    Area of the confidence ellipse of a 2-D point cloud (e.g. a
    centre-of-pressure trace).

    The ellipse is the standard bivariate-Gaussian confidence region
    ``area = pi * chi2_conf,2df * sqrt(det Cov)`` where ``Cov`` is the
    2x2 covariance of the (mean-removed) points. For a CoP sway path this
    is the classic 95% "sway-ellipse area".

    Source: still standing study (Jensenius), HpSp balance analysis.

    Args:
        xy (np.ndarray): Point cloud of shape ``(T, 2)``.
        conf (float, optional): Confidence level in ``(0, 1)``. Defaults to
            0.95.

    Returns:
        float: Ellipse area in squared position units (e.g. mm^2), or
            ``nan`` if fewer than three finite points are available.
    """
    from scipy.stats import chi2

    xy = np.asarray(xy, dtype=float)
    xy = xy[np.isfinite(xy).all(axis=1)]
    if len(xy) < 3:
        return np.nan
    cov = np.cov(xy.T)
    return float(np.pi * chi2.ppf(conf, 2) * np.sqrt(max(np.linalg.det(cov), 0.0)))

convex_hull_area

convex_hull_area(xy)

Area of the 2-D convex hull of a point cloud.

A non-parametric alternative to :func:confidence_ellipse_area for the region occupied by a sway path: it makes no Gaussian assumption and is driven by the outermost excursions.

Source: still standing study (Jensenius); complements the confidence ellipse used in the balance reports.

Parameters:

Name Type Description Default
xy ndarray

Point cloud of shape (T, 2).

required

Returns:

Name Type Description
float

Convex-hull area in squared position units, or nan if fewer than three non-collinear finite points are available.

Source code in src/micromotion/balance.py
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
def convex_hull_area(xy):
    """
    Area of the 2-D convex hull of a point cloud.

    A non-parametric alternative to :func:`confidence_ellipse_area` for the
    region occupied by a sway path: it makes no Gaussian assumption and is
    driven by the outermost excursions.

    Source: still standing study (Jensenius); complements the confidence
    ellipse used in the balance reports.

    Args:
        xy (np.ndarray): Point cloud of shape ``(T, 2)``.

    Returns:
        float: Convex-hull area in squared position units, or ``nan`` if
            fewer than three non-collinear finite points are available.
    """
    from scipy.spatial import ConvexHull, QhullError

    xy = np.asarray(xy, dtype=float)
    xy = xy[np.isfinite(xy).all(axis=1)]
    if len(xy) < 3:
        return np.nan
    try:
        return float(ConvexHull(xy).volume)  # 2-D "volume" == area
    except QhullError:
        return np.nan

cop_sway_metrics

cop_sway_metrics(xy, t=None, fs=None, *, freq_band=(0.1, 5.0), resample_fs=50.0)

Standard centre-of-pressure (CoP) sway metrics from a 2-D sway path.

Computes the classic posturographic descriptors: CoP path length and path rate, the 95% confidence-ellipse area, medio-lateral (ML) and antero-posterior (AP) ranges and standard deviations, the AP/ML range and SD ratios, and the mean sway frequency of each axis (the power-weighted mean frequency of a Welch spectrum inside freq_band, computed on a uniform grid at resample_fs).

The first column of xy is treated as ML and the second as AP, matching the study convention. Sampling time may be given either as an explicit time vector t (seconds; may be irregular) or a constant rate fs (Hz); if neither is supplied a rate of 1 Hz is assumed.

Source: still standing study (Jensenius), HpSp balance analysis (analyze_balance).

Parameters:

Name Type Description Default
xy ndarray

CoP path of shape (T, 2) as [ML, AP] in position units (e.g. mm).

required
t ndarray

Per-sample timestamps in seconds. May be irregular. Defaults to None.

None
fs float

Constant sampling rate in Hz, used when t is not given. Defaults to None (interpreted as 1 Hz).

None
freq_band tuple

(low, high) band in Hz for the mean sway frequency. Defaults to (0.1, 5.0).

(0.1, 5.0)
resample_fs float

Uniform rate in Hz onto which the path is interpolated before the spectral estimate. Defaults to 50.0.

50.0

Returns:

Name Type Description
dict

Metrics with keys n, dur, fs_mean, path_len, path_rate, area95, ml_range, ap_range, ml_sd, ap_sd, ap_ml_range_ratio, ap_ml_sd_ratio, mf_ml, mf_ap and mf_mean.

Source code in src/micromotion/balance.py
 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
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
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
def cop_sway_metrics(xy, t=None, fs=None, *, freq_band=(0.1, 5.0),
                     resample_fs=50.0):
    """
    Standard centre-of-pressure (CoP) sway metrics from a 2-D sway path.

    Computes the classic posturographic descriptors: CoP path length and
    path rate, the 95% confidence-ellipse area, medio-lateral (ML) and
    antero-posterior (AP) ranges and standard deviations, the AP/ML range
    and SD ratios, and the mean sway frequency of each axis (the
    power-weighted mean frequency of a Welch spectrum inside ``freq_band``,
    computed on a uniform grid at ``resample_fs``).

    The first column of ``xy`` is treated as ML and the second as AP,
    matching the study convention. Sampling time may be given either as an
    explicit time vector ``t`` (seconds; may be irregular) or a constant
    rate ``fs`` (Hz); if neither is supplied a rate of 1 Hz is assumed.

    Source: still standing study (Jensenius), HpSp balance analysis
    (``analyze_balance``).

    Args:
        xy (np.ndarray): CoP path of shape ``(T, 2)`` as ``[ML, AP]`` in
            position units (e.g. mm).
        t (np.ndarray, optional): Per-sample timestamps in seconds. May be
            irregular. Defaults to None.
        fs (float, optional): Constant sampling rate in Hz, used when ``t``
            is not given. Defaults to None (interpreted as 1 Hz).
        freq_band (tuple, optional): ``(low, high)`` band in Hz for the mean
            sway frequency. Defaults to ``(0.1, 5.0)``.
        resample_fs (float, optional): Uniform rate in Hz onto which the
            path is interpolated before the spectral estimate. Defaults to
            50.0.

    Returns:
        dict: Metrics with keys ``n``, ``dur``, ``fs_mean``, ``path_len``,
            ``path_rate``, ``area95``, ``ml_range``, ``ap_range``,
            ``ml_sd``, ``ap_sd``, ``ap_ml_range_ratio``,
            ``ap_ml_sd_ratio``, ``mf_ml``, ``mf_ap`` and ``mf_mean``.
    """
    from scipy import signal as _sig

    xy = np.asarray(xy, dtype=float)
    if xy.ndim != 2 or xy.shape[1] != 2:
        raise ValueError("xy must have shape (T, 2)")
    ml = xy[:, 0]
    ap = xy[:, 1]
    n = len(xy)

    if t is not None:
        t = np.asarray(t, dtype=float)
        t = t - t[0]
    elif fs is not None:
        t = np.arange(n) / float(fs)
    else:
        t = np.arange(n, dtype=float)
    dur = float(t[-1]) if n > 1 else 0.0
    fs_mean = (n - 1) / dur if dur > 0 else np.nan

    ml_c = ml - np.nanmean(ml)
    ap_c = ap - np.nanmean(ap)

    dpath = np.sqrt(np.diff(ml) ** 2 + np.diff(ap) ** 2)
    path_len = float(np.nansum(dpath))
    path_rate = path_len / dur if dur > 0 else np.nan

    ml_range = float(np.nanmax(ml) - np.nanmin(ml))
    ap_range = float(np.nanmax(ap) - np.nanmin(ap))
    ml_sd = float(np.nanstd(ml, ddof=1))
    ap_sd = float(np.nanstd(ap, ddof=1))

    area95 = confidence_ellipse_area(np.column_stack([ml_c, ap_c]), conf=0.95)

    lo, hi = freq_band
    tu = np.arange(0, dur, 1.0 / resample_fs) if dur > 0 else np.array([])

    def _mean_freq(x):
        if len(tu) < 4:
            return np.nan
        xu = np.interp(tu, t, x)
        xu = _sig.detrend(xu)
        nperseg = min(len(xu), int(resample_fs * 20))
        if nperseg < 4:
            return np.nan
        f, P = _sig.welch(xu, fs=resample_fs, nperseg=nperseg)
        band = (f >= lo) & (f <= hi)
        if P[band].sum() <= 0:
            return np.nan
        return float(np.sum(f[band] * P[band]) / np.sum(P[band]))

    mf_ml = _mean_freq(ml_c)
    mf_ap = _mean_freq(ap_c)

    return dict(
        n=n, dur=dur, fs_mean=fs_mean,
        path_len=path_len, path_rate=path_rate, area95=area95,
        ml_range=ml_range, ap_range=ap_range, ml_sd=ml_sd, ap_sd=ap_sd,
        ap_ml_range_ratio=ap_range / ml_range if ml_range else np.nan,
        ap_ml_sd_ratio=ap_sd / ml_sd if ml_sd else np.nan,
        mf_ml=mf_ml, mf_ap=mf_ap, mf_mean=float(np.nanmean([mf_ml, mf_ap])),
    )

principal_axis_projection

principal_axis_projection(xy)

Project a 2-D (or N-D) point cloud onto its principal axis.

Runs a PCA on the mean-removed points and returns the 1-D coordinate along the direction of greatest variance -- the natural 1-D reduction of a sway path used by the dynamics/complexity measures. The PCA eigenvector sign is arbitrary; the projection may be globally flipped across calls or datasets.

Source: still standing study (Jensenius), sway-dynamics analysis.

Parameters:

Name Type Description Default
xy ndarray

Point cloud of shape (T, D) (typically D == 2).

required

Returns:

Type Description

np.ndarray: 1-D projection of shape (T,).

Source code in src/micromotion/balance.py
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
def principal_axis_projection(xy):
    """
    Project a 2-D (or N-D) point cloud onto its principal axis.

    Runs a PCA on the mean-removed points and returns the 1-D coordinate
    along the direction of greatest variance -- the natural 1-D reduction of
    a sway path used by the dynamics/complexity measures. The PCA eigenvector
    sign is arbitrary; the projection may be globally flipped across calls or
    datasets.

    Source: still standing study (Jensenius), sway-dynamics analysis.

    Args:
        xy (np.ndarray): Point cloud of shape ``(T, D)`` (typically
            ``D == 2``).

    Returns:
        np.ndarray: 1-D projection of shape ``(T,)``.
    """
    xy = np.asarray(xy, dtype=float)
    xy = xy - xy.mean(axis=0)
    cov = np.cov(xy.T)
    w, v = np.linalg.eigh(cov)
    return xy @ v[:, -1]  # eigvecs ascending -> last is major axis

stabilogram_diffusion

stabilogram_diffusion(xy, fs, *, short_max_s=0.6, long_min_s=1.5, n_lags=40)

Collins-De Luca stabilogram-diffusion analysis (SDA) of a sway path.

Fits the mean-square-displacement (MSD) curve <[r(t+dt) - r(t)]^2> versus time-lag dt in log-log space and reports a short-term and a long-term Hurst exponent (each slope / 2) plus the critical crossover time where the two regression lines intersect. Persistent (open-loop) drift gives a short-term Hurst above 0.5; anti-persistent (closed-loop correction) gives a long-term Hurst below 0.5.

Source: still standing study (Jensenius), sway-dynamics analysis; method of Collins & De Luca (1993).

Parameters:

Name Type Description Default
xy ndarray

Sway path of shape (T, D) (D >= 1). A 1-D input of shape (T,) is accepted and treated as a single axis.

required
fs float

Sampling rate in Hz.

required
short_max_s float

Upper bound (s) of the short-term fitting window. Defaults to 0.6.

0.6
long_min_s float

Lower bound (s) of the long-term fitting window. Defaults to 1.5.

1.5
n_lags int

Number of log-spaced lags at which the MSD is evaluated. Defaults to 40.

40

Returns:

Name Type Description
dict

{"H_short", "H_long", "crossover_s"}. Entries are nan when a window contains fewer than three usable lags.

Source code in src/micromotion/balance.py
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
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
def stabilogram_diffusion(xy, fs, *, short_max_s=0.6, long_min_s=1.5,
                          n_lags=40):
    """
    Collins-De Luca stabilogram-diffusion analysis (SDA) of a sway path.

    Fits the mean-square-displacement (MSD) curve
    ``<[r(t+dt) - r(t)]^2>`` versus time-lag ``dt`` in log-log space and
    reports a short-term and a long-term Hurst exponent (each ``slope / 2``)
    plus the critical crossover time where the two regression lines
    intersect. Persistent (open-loop) drift gives a short-term Hurst above
    0.5; anti-persistent (closed-loop correction) gives a long-term Hurst
    below 0.5.

    Source: still standing study (Jensenius), sway-dynamics analysis;
    method of Collins & De Luca (1993).

    Args:
        xy (np.ndarray): Sway path of shape ``(T, D)`` (``D >= 1``). A 1-D
            input of shape ``(T,)`` is accepted and treated as a single
            axis.
        fs (float): Sampling rate in Hz.
        short_max_s (float, optional): Upper bound (s) of the short-term
            fitting window. Defaults to 0.6.
        long_min_s (float, optional): Lower bound (s) of the long-term
            fitting window. Defaults to 1.5.
        n_lags (int, optional): Number of log-spaced lags at which the MSD
            is evaluated. Defaults to 40.

    Returns:
        dict: ``{"H_short", "H_long", "crossover_s"}``. Entries are ``nan``
            when a window contains fewer than three usable lags.
    """
    xy = np.asarray(xy, dtype=float)
    if xy.ndim == 1:
        xy = xy[:, None]
    n = len(xy)
    if n < 8:
        return dict(H_short=np.nan, H_long=np.nan, crossover_s=np.nan)

    lags = np.unique(
        np.round(np.logspace(0, np.log10(max(n // 4, 2)), n_lags)).astype(int))
    lags = lags[lags >= 1]
    msd = np.array([np.mean(np.sum((xy[l:] - xy[:-l]) ** 2, axis=1))
                    for l in lags])
    t = lags / fs
    ok = msd > 0
    t, msd = t[ok], msd[ok]
    short = t < short_max_s
    long = t > long_min_s

    def _slope_intercept(mask):
        if mask.sum() < 3:
            return np.nan, np.nan
        a, b = np.polyfit(np.log(t[mask]), np.log(msd[mask]), 1)
        return a, b

    a_s, b_s = _slope_intercept(short)
    a_l, b_l = _slope_intercept(long)
    H_short = a_s / 2.0 if np.isfinite(a_s) else np.nan
    H_long = a_l / 2.0 if np.isfinite(a_l) else np.nan

    # A SHORT-TERM EXPONENT BELOW 0.5 IS USUALLY THE RESAMPLING, NOT THE BODY. The open-loop
    # region is what this analysis exists to find, and on real postural data it is nearly always
    # there: 619 of 626 head-marker recordings in the Oslo Standstill corpus clear 0.5 once the
    # series is anti-alias resampled, and 3 of 60 clear it when a bare polyphase call folds
    # near-Nyquist noise into the band. If this fires, check how the series reached its rate
    # before concluding anything about postural control.
    # 0.45 rather than 0.5, because a series whose true exponent IS 0.5 -- a plain random walk --
    # would otherwise trip this on half of all draws. The artefact case is not marginal: the
    # corpus values were 0.107 against 0.908, so a margin costs nothing and stops the warning
    # crying wolf on legitimate data.
    if np.isfinite(H_short) and H_short < 0.45:
        import warnings as _w
        _w.warn(
            f"short-term Hurst {H_short:.3f} is below 0.5, so this series shows no open-loop "
            "region. On real postural data that is more often an artefact of resampling than a "
            "finding: use micromotion.to_rate rather than a bare scipy resample, and check the "
            "lag-one autocorrelation of the first difference.", RuntimeWarning, stacklevel=2)
    crossover = np.nan
    if np.isfinite(a_s) and np.isfinite(a_l) and abs(a_s - a_l) > 1e-9:
        crossover = float(np.exp((b_l - b_s) / (a_s - a_l)))
    return dict(H_short=float(H_short), H_long=float(H_long),
                crossover_s=crossover)

dfa

dfa(x, *, n_scales=18, min_scale=10, fs=None, min_scale_s=None)

Detrended fluctuation analysis (DFA) scaling exponent, as a float.

White noise gives alpha ~= 0.5, pink noise 1.0, a random walk 1.5.

This is a thin wrapper over :func:micromotion.dynamics.dfa, which is the single implementation and additionally returns the scales and fluctuation curve. The float return is kept because published code calls it that way.

min_scale is a SAMPLE COUNT, so the same call measures different physical scales at different recording rates. Pass fs and min_scale_s to give the floor in seconds instead; on a corpus spanning several rates that is what a comparison needs. See :func:micromotion.dynamics.dfa for how much the floor is worth.

Shared with musicalgestures. The two implementations agree to 1.2 per cent on Brownian motion before being merged; the surviving one sits closer to the analytic answer of 1.5.

Source code in src/micromotion/balance.py
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
def dfa(x, *, n_scales=18, min_scale=10, fs=None, min_scale_s=None):
    """
    Detrended fluctuation analysis (DFA) scaling exponent, as a float.

    White noise gives ``alpha ~= 0.5``, pink noise 1.0, a random walk 1.5.

    This is a thin wrapper over :func:`micromotion.dynamics.dfa`, which is the single
    implementation and additionally returns the scales and fluctuation curve. The float
    return is kept because published code calls it that way.

    ``min_scale`` is a SAMPLE COUNT, so the same call measures different physical scales at
    different recording rates. Pass ``fs`` and ``min_scale_s`` to give the floor in seconds
    instead; on a corpus spanning several rates that is what a comparison needs. See
    :func:`micromotion.dynamics.dfa` for how much the floor is worth.

    Shared with musicalgestures. The two implementations agree to 1.2 per
    cent on Brownian motion before being merged; the surviving one sits closer to the
    analytic answer of 1.5.
    """
    from .dynamics import dfa as _dfa
    if min_scale_s is not None:
        return _dfa(x, nsc=n_scales, fs=fs, min_scale_s=min_scale_s)["alpha"]
    return _dfa(x, smin=min_scale, nsc=n_scales)["alpha"]

sample_entropy

sample_entropy(x, m=2, r=0.2)

Sample entropy: the negative log probability that two segments matching for m samples still match for m + 1. Higher means less regular.

r is a tolerance in units of the signal's standard deviation.

A thin wrapper over :func:micromotion.dynamics.sampen, the single implementation. Shared with musicalgestures; the two agree to 0.2 per cent before being merged.

Source code in src/micromotion/balance.py
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
def sample_entropy(x, m=2, r=0.2):
    """
    Sample entropy: the negative log probability that two segments matching for ``m``
    samples still match for ``m + 1``. Higher means less regular.

    ``r`` is a tolerance in units of the signal's standard deviation.

    A thin wrapper over :func:`micromotion.dynamics.sampen`, the single implementation.
    Shared with musicalgestures; the two agree to 0.2 per cent before
    being merged.
    """
    import numpy as _np

    from .dynamics import sampen as _sampen
    x = _np.asarray(x, float)
    return _sampen(x, m=m, r=r * _np.std(x))

spectral_edges

spectral_edges(x, fs, *, edges=(0.5, 0.95), nperseg=None)

Spectral-edge frequencies of a signal.

Returns the frequencies below which a given cumulative fraction of the Welch power spectrum lies. With the default edges the first value is the median frequency (50% edge) and the second the 95% spectral-edge frequency, two standard descriptors of sway spectral shape.

Source: still standing study (Jensenius), sway-dynamics analysis.

Parameters:

Name Type Description Default
x ndarray

1-D input signal.

required
fs float

Sampling rate in Hz.

required
edges tuple

Cumulative-power fractions in (0, 1). Defaults to (0.5, 0.95).

(0.5, 0.95)
nperseg int

Welch segment length in samples. Defaults to min(2048, len(x)).

None

Returns:

Name Type Description
dict

Mapping "f<pct>" (e.g. "f50", "f95") to the edge frequency in Hz.

Source code in src/micromotion/balance.py
354
355
356
357
358
359
360
361
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
391
def spectral_edges(x, fs, *, edges=(0.5, 0.95), nperseg=None):
    """
    Spectral-edge frequencies of a signal.

    Returns the frequencies below which a given cumulative fraction of the
    Welch power spectrum lies. With the default ``edges`` the first value is
    the median frequency (50% edge) and the second the 95% spectral-edge
    frequency, two standard descriptors of sway spectral shape.

    Source: still standing study (Jensenius), sway-dynamics analysis.

    Args:
        x (np.ndarray): 1-D input signal.
        fs (float): Sampling rate in Hz.
        edges (tuple, optional): Cumulative-power fractions in ``(0, 1)``.
            Defaults to ``(0.5, 0.95)``.
        nperseg (int, optional): Welch segment length in samples. Defaults
            to ``min(2048, len(x))``.

    Returns:
        dict: Mapping ``"f<pct>"`` (e.g. ``"f50"``, ``"f95"``) to the edge
            frequency in Hz.
    """
    from scipy import signal as _sig

    x = np.asarray(x, dtype=float)
    x = x[np.isfinite(x)]
    if len(x) < 8:
        return {f"f{int(round(e * 100))}": np.nan for e in edges}
    if nperseg is None:
        nperseg = min(2048, len(x))
    f, P = _sig.welch(x - x.mean(), fs, nperseg=nperseg)
    cP = np.cumsum(P)
    if cP[-1] <= 0:
        return {f"f{int(round(e * 100))}": np.nan for e in edges}
    cP = cP / cP[-1]
    return {f"f{int(round(e * 100))}": float(np.interp(e, cP, f))
            for e in edges}

sway_texture

sway_texture(speed, fs, *, frozen_threshold=2.0)

Micro-texture of a sway speed signal: frozen fraction and burst rate.

Distinguishes a smooth wander from intermittent ballistic corrections. The frozen fraction is the share of time the speed is below frozen_threshold; the burst rate is the number of upward threshold crossings (onset of a velocity burst) per minute.

Source: still standing study (Jensenius), sway-texture analysis.

Parameters:

Name Type Description Default
speed ndarray

1-D speed signal (e.g. mm/s).

required
fs float

Sampling rate in Hz.

required
frozen_threshold float

Speed below which the signal is considered "frozen", in the units of speed. Defaults to 2.0.

2.0

Returns:

Name Type Description
dict

{"frozen_fraction", "burst_rate"} where burst_rate is in bursts per minute.

Source code in src/micromotion/balance.py
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
def sway_texture(speed, fs, *, frozen_threshold=2.0):
    """
    Micro-texture of a sway speed signal: frozen fraction and burst rate.

    Distinguishes a smooth wander from intermittent ballistic corrections.
    The frozen fraction is the share of time the speed is below
    ``frozen_threshold``; the burst rate is the number of upward threshold
    crossings (onset of a velocity burst) per minute.

    Source: still standing study (Jensenius), sway-texture analysis.

    Args:
        speed (np.ndarray): 1-D speed signal (e.g. mm/s).
        fs (float): Sampling rate in Hz.
        frozen_threshold (float, optional): Speed below which the signal is
            considered "frozen", in the units of ``speed``. Defaults to 2.0.

    Returns:
        dict: ``{"frozen_fraction", "burst_rate"}`` where ``burst_rate`` is
            in bursts per minute.
    """
    speed = np.asarray(speed, dtype=float)
    speed = speed[np.isfinite(speed)]
    if len(speed) < 2:
        return dict(frozen_fraction=np.nan, burst_rate=np.nan)
    frozen = float((speed < frozen_threshold).mean())
    above = (speed >= frozen_threshold).astype(int)
    bursts = int(np.sum(np.diff(above) == 1))
    minutes = len(speed) / fs / 60.0
    burst_rate = bursts / minutes if minutes > 0 else np.nan
    return dict(frozen_fraction=frozen, burst_rate=burst_rate)

sway_orientation

sway_orientation(xy)

Principal sway-axis orientation and anisotropy of a 2-D point cloud.

A PCA of the mean-removed horizontal positions gives the orientation of the major axis as an axial angle in [0, 180) degrees (undirected -- a line, not an arrow) and the anisotropy sqrt(lambda_max / lambda_min) of the sway ellipse. Anisotropy 1.0 is isotropic/circular; values above ~1.3 indicate clearly directional sway.

Source: still standing study (Jensenius), sway-direction analysis.

Parameters:

Name Type Description Default
xy ndarray

Point cloud of shape (T, 2).

required

Returns:

Name Type Description
dict

{"angle_deg", "anisotropy"}. Both are nan if the covariance is degenerate or there are too few finite points.

Source code in src/micromotion/balance.py
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
def sway_orientation(xy):
    """
    Principal sway-axis orientation and anisotropy of a 2-D point cloud.

    A PCA of the mean-removed horizontal positions gives the orientation of
    the major axis as an axial angle in ``[0, 180)`` degrees (undirected --
    a line, not an arrow) and the anisotropy ``sqrt(lambda_max / lambda_min)``
    of the sway ellipse. Anisotropy 1.0 is isotropic/circular; values above
    ~1.3 indicate clearly directional sway.

    Source: still standing study (Jensenius), sway-direction analysis.

    Args:
        xy (np.ndarray): Point cloud of shape ``(T, 2)``.

    Returns:
        dict: ``{"angle_deg", "anisotropy"}``. Both are ``nan`` if the
            covariance is degenerate or there are too few finite points.
    """
    xy = np.asarray(xy, dtype=float)
    xy = xy[np.isfinite(xy).all(axis=1)]
    if len(xy) < 3:
        return dict(angle_deg=np.nan, anisotropy=np.nan)
    x = xy[:, 0] - xy[:, 0].mean()
    y = xy[:, 1] - xy[:, 1].mean()
    cov = np.cov(x, y)
    w, v = np.linalg.eigh(cov)  # ascending eigenvalues
    if w[0] <= 0:
        return dict(angle_deg=np.nan, anisotropy=np.nan)
    angle = float(np.degrees(np.arctan2(v[1, 1], v[0, 1])) % 180.0)
    anisotropy = float(np.sqrt(w[1] / w[0]))
    return dict(angle_deg=angle, anisotropy=anisotropy)

axial_rayleigh

axial_rayleigh(angles_deg)

Axial Rayleigh test for a preferred orientation among axial angles.

Tests whether a sample of axial (undirected, [0, 180) deg) angles -- e.g. per-session principal sway axes -- clusters around a common orientation. Angles are doubled to map the axial circle onto the full circle before computing the mean resultant length R and the Rayleigh p-value (small p with large R means a shared preferred axis).

Source: still standing study (Jensenius), sway-direction analysis.

This takes DEGREES. :func:micromotion.circular.rayleigh_axial computes the same quantity from RADIANS, and the two agree exactly on R and on the mean axis -- a report that they disagree is a units error at the call site, not a difference of method. Feeding radians to this function converts already-small numbers a second time, collapsing every angle towards zero and inflating R towards 1, which is the usual way that mistake shows itself.

Parameters:

Name Type Description Default
angles_deg ndarray

Axial angles in degrees.

required

Returns:

Name Type Description
dict

{"R", "p", "mean_axis_deg", "n"}.

Source code in src/micromotion/balance.py
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
495
496
497
498
499
500
501
502
503
504
505
506
def axial_rayleigh(angles_deg):
    """
    Axial Rayleigh test for a preferred orientation among axial angles.

    Tests whether a sample of axial (undirected, ``[0, 180)`` deg) angles --
    e.g. per-session principal sway axes -- clusters around a common
    orientation. Angles are doubled to map the axial circle onto the full
    circle before computing the mean resultant length ``R`` and the Rayleigh
    p-value (small ``p`` with large ``R`` means a shared preferred axis).

    Source: still standing study (Jensenius), sway-direction analysis.

    This takes DEGREES. :func:`micromotion.circular.rayleigh_axial` computes the same quantity
    from RADIANS, and the two agree exactly on ``R`` and on the mean axis -- a report that they
    disagree is a units error at the call site, not a difference of method. Feeding radians to this
    function converts already-small numbers a second time, collapsing every angle towards zero and
    inflating ``R`` towards 1, which is the usual way that mistake shows itself.

    Args:
        angles_deg (np.ndarray): Axial angles in degrees.

    Returns:
        dict: ``{"R", "p", "mean_axis_deg", "n"}``.
    """
    from .circular import rayleigh

    a = 2 * np.radians(np.asarray(angles_deg, dtype=float))
    a = a[np.isfinite(a)]
    n = len(a)
    if n < 2:
        return dict(R=np.nan, p=np.nan, mean_axis_deg=np.nan, n=n)
    C = np.mean(np.cos(a))
    S = np.mean(np.sin(a))
    R = float(np.hypot(C, S))
    # The p-value comes from `circular.rayleigh` so the package has one Rayleigh approximation
    # rather than two. This used to compute exp(-Z)*(1 + (2Z - Z^2)/(4n)) directly, which is the
    # small-Z series correction and goes NEGATIVE once Z^2 - 2Z exceeds 4n -- exactly the
    # strongly-concentrated case this test exists to detect. It had reached deposited output:
    # `_analysis/reports/circular/stats_sway_direction.txt` printed a negative probability for
    # every one of the six championship editions.
    p = float(rayleigh(a)["p"])
    mean_axis = float(np.degrees(0.5 * np.arctan2(S, C)) % 180.0)
    return dict(R=R, p=p, mean_axis_deg=mean_axis, n=n)

spatial_extent

spatial_extent(pos, fs, *, ellipse_conf=0.95, window_s=20.0, vertical_axis=None)

Spatial extent / occupied volume of a 3-D (or 2-D) position trace.

Complements sway magnitude (QoM) by describing how large a region a marker occupies. Reports the RMS dispersion radius about the session centroid, the Gaussian confidence-ellipsoid volume and its cube-root radius, the mean within-window dispersion (which removes slow drift), and a drift ratio full_dispersion / within_window_dispersion (> 1 when slow drift enlarges the occupied region over the session). When a vertical_axis is given the drift is additionally split into horizontal and vertical components.

Source: still standing study (Jensenius), spatial-range analysis (session_metrics).

Parameters:

Name Type Description Default
pos ndarray

Position trace of shape (T, D) with D 2 or 3, in position units (e.g. mm).

required
fs float

Sampling rate in Hz.

required
ellipse_conf float

Confidence level for the ellipsoid volume. Defaults to 0.95.

0.95
window_s float

Window length in seconds for the within-window dispersion. Defaults to 20.0.

20.0
vertical_axis int

Index of the vertical axis (0, 1 or 2); when given, drift is decomposed into horizontal and vertical parts. Defaults to None.

None

Returns:

Name Type Description
dict

dispersion, ellipsoid_volume, ellipsoid_radius, within_window_dispersion, drift_ratio and (when vertical_axis is set) drift_horizontal and drift_vertical. Returns None if fewer than one window of finite samples is available.

Source code in src/micromotion/balance.py
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
534
535
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
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
def spatial_extent(pos, fs, *, ellipse_conf=0.95, window_s=20.0,
                   vertical_axis=None):
    """
    Spatial extent / occupied volume of a 3-D (or 2-D) position trace.

    Complements sway magnitude (QoM) by describing *how large a region* a
    marker occupies. Reports the RMS dispersion radius about the session
    centroid, the Gaussian confidence-ellipsoid volume and its cube-root
    radius, the mean within-window dispersion (which removes slow drift),
    and a drift ratio ``full_dispersion / within_window_dispersion`` (``> 1``
    when slow drift enlarges the occupied region over the session). When a
    ``vertical_axis`` is given the drift is additionally split into
    horizontal and vertical components.

    Source: still standing study (Jensenius), spatial-range analysis
    (``session_metrics``).

    Args:
        pos (np.ndarray): Position trace of shape ``(T, D)`` with ``D`` 2 or
            3, in position units (e.g. mm).
        fs (float): Sampling rate in Hz.
        ellipse_conf (float, optional): Confidence level for the ellipsoid
            volume. Defaults to 0.95.
        window_s (float, optional): Window length in seconds for the
            within-window dispersion. Defaults to 20.0.
        vertical_axis (int, optional): Index of the vertical axis (``0``,
            ``1`` or ``2``); when given, drift is decomposed into horizontal
            and vertical parts. Defaults to None.

    Returns:
        dict: ``dispersion``, ``ellipsoid_volume``, ``ellipsoid_radius``,
            ``within_window_dispersion``, ``drift_ratio`` and (when
            ``vertical_axis`` is set) ``drift_horizontal`` and
            ``drift_vertical``. Returns ``None`` if fewer than one window of
            finite samples is available.
    """
    from scipy.stats import chi2

    pos = np.asarray(pos, dtype=float)
    if pos.ndim != 2:
        raise ValueError("pos must have shape (T, D)")
    D = pos.shape[1]
    mask = np.isfinite(pos).all(axis=1)
    P = pos[mask]
    w = int(fs * window_s)
    if len(P) < max(w, D + 2):
        return None

    centroid = P.mean(axis=0)
    disp_vec = P - centroid
    d = np.sqrt((disp_vec * disp_vec).sum(axis=1))
    dispersion = float(np.sqrt((d * d).mean()))

    cov = np.cov(P.T)
    det = np.linalg.det(cov)
    chi_d = chi2.ppf(ellipse_conf, D)
    # volume of a D-dim Gaussian confidence ellipsoid
    if D == 2:
        vol = np.pi * chi_d * np.sqrt(max(det, 0.0))
    else:  # D == 3
        vol = (4.0 / 3.0) * np.pi * (chi_d ** 1.5) * np.sqrt(max(det, 0.0))
    ellipsoid_volume = float(vol)
    ellipsoid_radius = float(vol ** (1.0 / D))

    def _within_window(Q):
        cols = Q.shape[1] if Q.ndim == 2 else 1
        Q = Q.reshape(len(Q), cols)
        wr = []
        for s in range(0, len(Q) - w + 1, w):
            seg = Q[s:s + w]
            cc = seg.mean(axis=0)
            e = seg - cc
            wr.append(np.sqrt((e * e).sum(axis=1).mean()))
        return np.mean(wr) if wr else np.nan

    def _full_dispersion(Q):
        cols = Q.shape[1] if Q.ndim == 2 else 1
        Q = Q.reshape(len(Q), cols)
        cc = Q.mean(axis=0)
        e = Q - cc
        return np.sqrt((e * e).sum(axis=1).mean())

    win_disp = float(_within_window(P))
    drift_ratio = (dispersion / win_disp
                   if win_disp and win_disp > 0 else np.nan)

    out = dict(dispersion=dispersion, ellipsoid_volume=ellipsoid_volume,
               ellipsoid_radius=ellipsoid_radius,
               within_window_dispersion=win_disp, drift_ratio=drift_ratio)

    if vertical_axis is not None:
        hax = [k for k in range(D) if k != vertical_axis]

        def _comp_drift(cols):
            Q = P[:, cols]
            wm = _within_window(Q)
            full = _full_dispersion(Q)
            return full / wm if wm and wm > 0 else np.nan

        out["drift_horizontal"] = float(_comp_drift(hax))
        out["drift_vertical"] = float(_comp_drift([vertical_axis]))

    return out

Groups

micromotion.group

Did these people move at the same moments?

Everything else in this package describes one recording, or relates a pair. This module asks the group question: given many people recorded simultaneously, did their movements coincide in time more than chance allows, and if so, when?

That question needs a null hypothesis with some care in it. Comparing each pair and averaging loses the timing. Shuffling samples destroys each person's own rhythm along with the alignment. The scheme used here shifts each person's event train by an independent random offset drawn from a bounded range: every individual keeps their own event rate and local structure, and only the alignment between people is destroyed. That is the null that "they moved together" should be tested against.

Two related things are provided. :func:event_train turns a continuous signal into a point process of events. :func:coincidence_test asks whether those events line up across people, returning both a single score for the whole recording and a p value at each moment, so that the moments where the group actually converged can be located rather than inferred.

Method after Finn Upham's activity-analysis work, used with permission and reimplemented here; see finn42/aa_test_package. The stilling-response statistic below is from Upham, Hoffding and Rosas, "The Stilling Response" (Music & Science, 2024).

event_train

event_train(x, fs: float, frame_s: float = 1.0, threshold: float | None = None, kind: str = 'increase') -> np.ndarray

Turn a continuous signal into a binary point process of events.

An event is a change across a window of frame_s seconds that exceeds threshold. kind selects "increase", "decrease" or "change" for either direction.

threshold defaults to one standard deviation of the framed differences, which makes the definition of "an event" relative to how much this person moves rather than absolute. That matters when the group wears different sensors on different parts of the body: an absolute threshold would count only the noisiest participants as ever doing anything.

Source code in src/micromotion/group.py
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
def event_train(x, fs: float, frame_s: float = 1.0, threshold: float | None = None,
                kind: str = "increase") -> np.ndarray:
    """Turn a continuous signal into a binary point process of events.

    An event is a change across a window of ``frame_s`` seconds that exceeds ``threshold``.
    ``kind`` selects ``"increase"``, ``"decrease"`` or ``"change"`` for either direction.

    ``threshold`` defaults to one standard deviation of the framed differences, which makes the
    definition of "an event" relative to how much this person moves rather than absolute. That
    matters when the group wears different sensors on different parts of the body: an absolute
    threshold would count only the noisiest participants as ever doing anything.
    """
    x = np.asarray(x, float)
    half = max(1, int(round(frame_s * fs / 2)))
    idx = np.arange(len(x))
    lo = np.clip(idx - half, 0, len(x) - 1)
    hi = np.clip(idx + half, 0, len(x) - 1)
    d = x[hi] - x[lo]

    if kind == "decrease":
        d = -d
    elif kind == "change":
        d = np.abs(d)
    elif kind != "increase":
        raise ValueError(f"unknown kind {kind!r}")

    if threshold is None:
        threshold = np.nanstd(d)
    return (d >= threshold).astype(float)

coincidence_test

coincidence_test(trains, fs: float, n_surrogates: int = 1000, shift_range_s: float = 30.0, frame_s: float = 1.0, rng=None) -> dict

Test whether events coincide across people more than chance.

trains is a sequence of equal-length binary event trains, one per person, already on a common timebase.

Returns the observed coincidence count over time, the surrogate mean, a p value at every sample, a surprise transform of it, and two summaries of the whole recording.

Read frac_significant before score. score is the mean of -log10(p) over every sample, kept because it is the published statistic, but it is insensitive to exactly the case these recordings usually present. Measured on twenty simulated people whose event trains were independent apart from a few shared moments: three shared moments scored 0.32 and sixty scored 0.47, while wholly independent trains scored 0.34. The three-moment case scored below the null case, because a mean over three thousand samples is dominated by the 99.9 per cent of them where nothing was happening. Over the same runs frac_significant went 0.012, 0.034, 0.100, 0.200 against 0.004 for independence, which is the separation you want.

The surrogates shift each person independently by up to shift_range_s seconds. Keep that range comfortably longer than the timescale you are testing for and shorter than the recording, or the null starts to resemble the data.

Source code in src/micromotion/group.py
 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
def coincidence_test(trains, fs: float, n_surrogates: int = 1000,
                     shift_range_s: float = 30.0, frame_s: float = 1.0, rng=None) -> dict:
    """Test whether events coincide across people more than chance.

    ``trains`` is a sequence of equal-length binary event trains, one per person, already on a
    common timebase.

    Returns the observed coincidence count over time, the surrogate mean, a ``p`` value at every
    sample, a ``surprise`` transform of it, and two summaries of the whole recording.

    Read ``frac_significant`` before ``score``. ``score`` is the mean of -log10(p) over every
    sample, kept because it is the published statistic, but it is insensitive to exactly the
    case these recordings usually present. Measured on twenty simulated people whose event
    trains were independent apart from a few shared moments: three shared moments scored 0.32
    and sixty scored 0.47, while wholly independent trains scored 0.34. The three-moment case
    scored *below* the null case, because a mean over three thousand samples is dominated by the
    99.9 per cent of them where nothing was happening. Over the same runs
    ``frac_significant`` went 0.012, 0.034, 0.100, 0.200 against 0.004 for independence, which
    is the separation you want.

    The surrogates shift each person independently by up to ``shift_range_s`` seconds. Keep that
    range comfortably longer than the timescale you are testing for and shorter than the
    recording, or the null starts to resemble the data.
    """
    rng = rng or np.random.default_rng()
    T = np.asarray(trains, float)
    if T.ndim != 2:
        raise ValueError("trains must be (n_people, n_samples)")
    n_people, n = T.shape
    win = max(1, int(round(frame_s * fs)))

    def framed(a):
        c = np.cumsum(np.insert(a, 0, 0.0))
        half = win // 2
        lo = np.clip(np.arange(n) - half, 0, n)
        hi = np.clip(np.arange(n) - half + win, 0, n)
        return c[hi] - c[lo]

    observed = framed(T.sum(axis=0))

    max_shift = int(round(shift_range_s * fs))
    null = np.empty((n_surrogates, n))
    for s in range(n_surrogates):
        acc = np.zeros(n)
        for p in range(n_people):
            acc += np.roll(T[p], int(rng.integers(-max_shift, max_shift + 1)))
        null[s] = framed(acc)

    # One-sided: how often does chance reach what was observed?
    p = (np.sum(null >= observed[None, :], axis=0) + 1) / (n_surrogates + 1)
    max_surprise = np.log10(n_surrogates + 1)
    with np.errstate(divide="ignore", invalid="ignore"):
        surprise = np.log10((1 - p) / p)
    score = float(-np.log10(p + 10.0 ** (-max_surprise)).mean())
    frac = float((p < 0.01).mean())

    return {
        "observed": observed,
        "null_mean": null.mean(axis=0),
        "null_sd": null.std(axis=0),
        "p": p,
        "surprise": np.clip(np.nan_to_num(surprise, neginf=-max_surprise,
                                          posinf=max_surprise), -max_surprise, max_surprise),
        "score": score,
        "frac_significant": frac,
        "n_people": n_people,
        "n_surrogates": n_surrogates,
    }

participation_ratio

participation_ratio(series, event_times, fs: float, pre=(-3.0, -2.0), post=(0.0, 1.0)) -> np.ndarray

The fraction of people whose movement decreased after each event.

series is (n_people, n_samples) of quantity of motion on a common timebase; event_times are moments of interest in seconds. For each event, every person's mean in the pre window is compared with their mean in the post window, and the statistic is the proportion who went down.

Counting signs rather than sizes is the point. Participants in these recordings wear different sensors in different places, and their quantity of motion differs by more than an order of magnitude, so any statistic that averages magnitudes is dominated by whoever wore the noisiest device. A proportion is immune to that, and to missing data.

Compare the result against :func:sliding_null, not against 0.5 — see that function for why.

Source code in src/micromotion/group.py
130
131
132
133
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
def participation_ratio(series, event_times, fs: float, pre=(-3.0, -2.0),
                        post=(0.0, 1.0)) -> np.ndarray:
    """The fraction of people whose movement decreased after each event.

    ``series`` is (n_people, n_samples) of quantity of motion on a common timebase;
    ``event_times`` are moments of interest in seconds. For each event, every person's mean in
    the ``pre`` window is compared with their mean in the ``post`` window, and the statistic is
    the proportion who went down.

    Counting signs rather than sizes is the point. Participants in these recordings wear
    different sensors in different places, and their quantity of motion differs by more than an
    order of magnitude, so any statistic that averages magnitudes is dominated by whoever wore
    the noisiest device. A proportion is immune to that, and to missing data.

    Compare the result against :func:`sliding_null`, not against 0.5 — see that function for
    why.
    """
    S = np.asarray(series, float)
    if S.ndim != 2:
        raise ValueError("series must be (n_people, n_samples)")
    out = []
    for t in np.atleast_1d(event_times):
        a = _window_mean(S, fs, t + pre[0], t + pre[1])
        b = _window_mean(S, fs, t + post[0], t + post[1])
        d = b - a
        d = d[np.isfinite(d)]
        n_down, n_up = int((d < 0).sum()), int((d > 0).sum())
        out.append(n_down / (n_down + n_up) if (n_down + n_up) else np.nan)
    return np.asarray(out, float)

sliding_null

sliding_null(series, fs: float, step_s: float = 0.1, **kw) -> np.ndarray

The same statistic computed at every moment of the recording.

This is the null distribution :func:participation_ratio should be judged against, and it is better than a theoretical one for a reason worth stating: people standing still are not a coin flip. Movement decays after any excursion, so at a randomly chosen moment rather more than half of a group is usually already slowing down. Testing observed events against 0.5 would find an effect in any recording; testing them against this finds one only where the events beat the recording's own baseline.

Compare the two with a one-sided Kolmogorov-Smirnov test.

Source code in src/micromotion/group.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def sliding_null(series, fs: float, step_s: float = 0.1, **kw) -> np.ndarray:
    """The same statistic computed at every moment of the recording.

    This is the null distribution :func:`participation_ratio` should be judged against, and it
    is better than a theoretical one for a reason worth stating: people standing still are not
    a coin flip. Movement decays after any excursion, so at a randomly chosen moment rather more
    than half of a group is usually already slowing down. Testing observed events against 0.5
    would find an effect in any recording; testing them against this finds one only where the
    events beat the recording's own baseline.

    Compare the two with a one-sided Kolmogorov-Smirnov test.
    """
    S = np.asarray(series, float)
    n = S.shape[1]
    pre = kw.get("pre", (-3.0, -2.0))
    post = kw.get("post", (0.0, 1.0))
    first = -pre[0]
    last = n / fs - post[1]
    if last <= first:
        return np.asarray([], float)
    times = np.arange(first, last, step_s)
    return participation_ratio(S, times, fs, pre=pre, post=post)

sequential_stability

sequential_stability(x) -> float

How steady a per-cycle measure is, ignoring slow drift.

The median absolute difference between consecutive values. Unlike a standard deviation it is unmoved by a gradual trend, so it answers "was this person's breathing steady" over a window in which their rate is also slowly changing — which is the usual situation.

Source code in src/micromotion/group.py
195
196
197
198
199
200
201
202
203
204
def sequential_stability(x) -> float:
    """How steady a per-cycle measure is, ignoring slow drift.

    The median absolute difference between consecutive values. Unlike a standard deviation it is
    unmoved by a gradual trend, so it answers "was this person's breathing steady" over a window
    in which their rate is also slowly changing — which is the usual situation.
    """
    x = np.asarray(x, float)
    x = x[np.isfinite(x)]
    return float(np.median(np.abs(np.diff(x)))) if len(x) > 1 else float("nan")

Circular statistics

micromotion.circular

Statistics for angles. This module is the family's reference for them.

Where circular statistics live across the four toolboxes. micromotion owns them: the axial tests sway needs, the circular-linear correlation, the V-test. ambiscape keeps a small circstats for the time-series end --- phase_stats, relative_phase --- and the few primitives those need, rather than taking a dependency on this package for six short functions. MGT re-exports this module along with the rest of micromotion.

That division decayed once and would have again. On 2026-08-12 the two Rayleigh implementations were found to disagree on about a fifth of random cases: this one uses Wilkie's approximation, ambiscape used Zar's earlier series expansion, both are published and neither was wrong, and nothing said they were meant to match. ambiscape now matches this module, and ambiscape/tests/test_circstats_agreement.py asserts it on every run rather than trusting it. One further difference is deliberate and remains: circ_corr returns a dict here and a float there, so a caller who swaps the import gets a different shape --- changing either is an API break, and the test pins the arithmetic so at least the numbers cannot drift on top of it.

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
def 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.
    """
    a = np.asarray(angles, float)
    a = a[np.isfinite(a)]
    if not len(a):
        return {"mean": float("nan"), "R": float("nan"), "n": 0}
    w = np.ones_like(a) if weights is None else np.asarray(weights, float)[: len(a)]
    z = np.sum(w * np.exp(1j * a)) / np.sum(w)
    return {"mean": float(np.angle(z)), "R": float(np.abs(z)), "n": len(a)}

circular_sd

circular_sd(R: float) -> float

Circular standard deviation, in radians, from a resultant length.

sqrt(-2 ln R): zero when every angle agrees, growing without bound as the resultant vanishes. Added 2026-08-12 because this module owns circular statistics for the family and musiscape needed it --- a consumer that cannot get a primitive from the owner writes its own, which is how the Rayleigh implementations drifted apart in the first place.

Source code in src/micromotion/circular.py
57
58
59
60
61
62
63
64
65
66
67
68
69
def circular_sd(R: float) -> float:
    """Circular standard deviation, in radians, from a resultant length.

    ``sqrt(-2 ln R)``: zero when every angle agrees, growing without bound as
    the resultant vanishes. Added 2026-08-12 because this module owns circular
    statistics for the family and musiscape needed it --- a consumer that
    cannot get a primitive from the owner writes its own, which is how the
    Rayleigh implementations drifted apart in the first place.
    """
    R = float(R)
    if not 0.0 < R <= 1.0:
        return float("nan") if R <= 0.0 else 0.0
    return float(np.sqrt(-2.0 * np.log(R)))

rayleigh_from_R

rayleigh_from_R(R: float, n: int) -> float

Rayleigh p from a resultant length and a count, without the angles.

:func:rayleigh is the entry point when the angles are to hand. This one is for callers holding a summary --- a resultant length computed earlier, or read from a table --- and uses the same Wilkie approximation, so the two agree by construction rather than by intention.

Source code in src/micromotion/circular.py
72
73
74
75
76
77
78
79
80
81
82
83
def rayleigh_from_R(R: float, n: int) -> float:
    """Rayleigh p from a resultant length and a count, without the angles.

    :func:`rayleigh` is the entry point when the angles are to hand. This one
    is for callers holding a summary --- a resultant length computed earlier,
    or read from a table --- and uses the same Wilkie approximation, so the
    two agree by construction rather than by intention.
    """
    nR = float(n) * float(R)
    n = float(n)
    return float(min(1.0, np.exp(
        np.sqrt(1 + 4 * n + 4 * (n * n - nR * nR)) - (1 + 2 * n))))

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
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def 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.
    """
    a = np.asarray(angles, float)
    a = a[np.isfinite(a)]
    n = len(a)
    if n < 3:
        return {"R": float("nan"), "z": float("nan"), "p": float("nan"), "n": n}
    R = np.abs(np.sum(np.exp(1j * a))) / n
    z = n * R**2
    p = np.exp(np.sqrt(1 + 4 * n + 4 * (n**2 - (n * R) ** 2)) - (1 + 2 * n))
    return {"R": float(R), "z": float(z), "p": float(min(1.0, p)), "n": n}

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
107
108
109
110
111
112
113
114
115
116
117
118
119
def 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.
    """
    a = np.asarray(angles, float)
    a = a[np.isfinite(a)]
    out = rayleigh(2 * a)
    m = circ_mean(2 * a)
    out["mean_axis"] = float(m["mean"] / 2)
    return out

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
122
123
124
125
126
127
128
129
130
131
132
def 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.
    """
    a = np.asarray(angles, float)
    a = a[np.isfinite(a)]
    if len(a) < 2:
        return float("nan")
    R = np.abs(np.sum(np.exp(2j * a))) / len(a)
    return float(np.sqrt(-2 * np.log(max(R, 1e-12))) / 2)

circ_corr

circ_corr(a, b) -> dict

Correlation between two circular variables, after Jammalamadaka and Sengupta.

Source code in src/micromotion/circular.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
def circ_corr(a, b) -> dict:
    """Correlation between two circular variables, after Jammalamadaka and Sengupta."""
    a = np.asarray(a, float)
    b = np.asarray(b, float)
    m = np.isfinite(a) & np.isfinite(b)
    a, b = a[m], b[m]
    n = len(a)
    if n < 4:
        return {"r": float("nan"), "p": float("nan"), "n": n}
    sa = np.sin(a - circ_mean(a)["mean"])
    sb = np.sin(b - circ_mean(b)["mean"])
    r = np.sum(sa * sb) / np.sqrt(np.sum(sa**2) * np.sum(sb**2))
    l20, l02 = np.mean(sa**2), np.mean(sb**2)
    l22 = np.mean(sa**2 * sb**2)
    z = np.sqrt(n * l20 * l02 / l22) * r
    return {"r": float(r), "p": float(2 * (1 - _stats.norm.cdf(abs(z)))), "n": n}

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
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def 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.
    """
    a = np.asarray(angles, float)
    x = np.asarray(x, float)
    m = np.isfinite(a) & np.isfinite(x)
    a, x = a[m], x[m]
    n = len(a)
    if n < 4:
        return {"r": float("nan"), "p": float("nan"), "n": n}
    rxs = np.corrcoef(x, np.sin(a))[0, 1]
    rxc = np.corrcoef(x, np.cos(a))[0, 1]
    rcs = np.corrcoef(np.sin(a), np.cos(a))[0, 1]
    r2 = (rxc**2 + rxs**2 - 2 * rxc * rxs * rcs) / (1 - rcs**2)
    r2 = float(np.clip(r2, 0, 1))
    p = 1 - _stats.chi2.cdf(n * r2, 2)
    return {"r": float(np.sqrt(r2)), "r2": r2, "p": float(p), "n": n}

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
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
def 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.
    """
    a = np.asarray(angles, float)
    a = a[np.isfinite(a)]
    n = len(a)
    if n < 3:
        return {"V": float("nan"), "p": float("nan"), "n": n}
    R = np.abs(np.sum(np.exp(1j * a))) / n
    mean = circ_mean(a)["mean"]
    v = R * np.cos(mean - mu)
    u = v * np.sqrt(2 * n)
    return {"V": float(v), "u": float(u),
            "p": float(1 - _stats.norm.cdf(u)), "n": n}

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.

Each window is a bare maximum inside the band, and it stays one, because the values are what published alignments were computed from. What is new is that the function counts the windows whose band held no peak at all, by :func:~micromotion.spectral.is_band_floor, and warns once if any did. On synthetic 1/f series with nothing in the cardiac band every window returns the band's lowest bin, so the track is a flat line sitting on the edge — and two such flat lines from two instruments will cross-correlate with each other perfectly while carrying nothing. Where a NaN per window is preferable to a number, loop over :func:~micromotion.spectral.spectral_peak instead; where a track already exists, :func:~micromotion.spectral.band_edge_sweep settles whether it is the band edge.

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def 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.

    Each window is a bare maximum inside the band, and it stays one, because the values are what
    published alignments were computed from. What is new is that the function counts the windows
    whose band held no peak at all, by :func:`~micromotion.spectral.is_band_floor`, and warns once
    if any did. On synthetic 1/f series with nothing in the cardiac band every window returns the
    band's lowest bin, so the track is a flat line sitting on the edge — and two such flat lines
    from two instruments will cross-correlate with each other perfectly while carrying nothing.
    Where a NaN per window is preferable to a number, loop over
    :func:`~micromotion.spectral.spectral_peak` instead; where a track already exists,
    :func:`~micromotion.spectral.band_edge_sweep` settles whether it is the band edge.
    """
    x = np.asarray(x, float)
    n, s = int(win_s * fs), int(step_s * fs)
    if n <= 0 or s <= 0 or len(x) < n:
        return np.array([]), np.array([])
    times, out = [], []
    n_floor = 0
    nper = int(min(n, fs * 10))
    for i in range(0, len(x) - n, s):
        seg = _signal.detrend(x[i:i + n])
        f, p = _signal.welch(seg, fs, nperseg=nper)
        m = (f >= band[0]) & (f <= band[1])
        if m.any():
            out.append(f[m][np.argmax(p[m])])
            times.append((i + n / 2) / fs)
            n_floor += is_band_floor(*_floor_spectrum(x[i:i + n], fs, band), band)
    if n_floor:
        _warn_band_floor("instantaneous_rate()", float(np.median(out)), band, n_floor, len(out))
    rate = np.array(out) * (60.0 if per_minute else 1.0)
    return np.array(times), rate

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.

That sentence was false until 1.13.0. The function returned the negative of what it documented, and the test asserted the value the code produced rather than the value the docstring promised, so the two agreed with each other and neither agreed with the convention. A sign is exactly the kind of error a test written after the fact preserves. musicalgestures.xcorr_lag documented and returned the opposite -- the correct -- sign throughout, which is how this was found.

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
 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
def 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``.

    That sentence was false until 1.13.0. The function returned the negative of what it
    documented, and the test asserted the value the code produced rather than the value the
    docstring promised, so the two agreed with each other and neither agreed with the
    convention. A sign is exactly the kind of error a test written after the fact preserves.
    ``musicalgestures.xcorr_lag`` documented and returned the opposite -- the correct --
    sign throughout, which is how this was found.

    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.
    """
    a = np.asarray(a, float)
    b = np.asarray(b, float)
    if difference and len(a) > 1 and len(b) > 1:
        a, b = np.diff(a), np.diff(b)
    a = (a - a.mean()) / (a.std() + 1e-12)
    b = (b - b.mean()) / (b.std() + 1e-12)

    # correlate(b, a), not correlate(a, b): the lag wanted is b's relative to a, so b is the
    # series being slid. Reversing these two arguments is the whole of the sign error above.
    c = _signal.correlate(b, a, mode="full") / min(len(a), len(b))
    lags = _signal.correlation_lags(len(b), len(a), mode="full")
    if max_lag_s is not None:
        keep = np.abs(lags) <= int(max_lag_s * fs)
        c, lags = c[keep], lags[keep]
    if not len(c):
        return {"lag_s": float("nan"), "r": float("nan"), "confident": False}

    # Among near-tied maxima, take the SMALLEST lag. A periodic envelope correlates almost
    # as well at plus or minus one period as at the true offset, and which of those wins is
    # then floating-point noise: the answer jumps a whole period between two runs that differ
    # in the last bit. Adopted from musicalgestures.xcorr_lag, which had it first.
    tied = np.flatnonzero(c >= c.max() - 1e-9)
    k = int(tied[np.argmin(np.abs(lags[tied]))])
    peak = float(c[k])
    return {
        "lag_samples": int(lags[k]),
        "lag_s": float(lags[k]) / fs,
        "r": peak,
        "confident": bool(peak >= min_r),
    }

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.

A positive lag means x_b starts later than x_a, the same convention as :func:xcorr_lag. This function stated no convention at all until 1.13.0 and returned the negative of that one; both were corrected together, since two alignment functions in one module disagreeing about a sign is worse than either being wrong alone.

confident is True only when the best correlation reaches min_r and the overlap reaches min_overlap_s. Recordings that fail the test are better left without an offset than given one nobody can trust: a wrong alignment is harder to detect downstream than a missing one.

Source code in src/micromotion/align.py
133
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
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
def 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.

    A positive lag means ``x_b`` starts later than ``x_a``, the same convention as
    :func:`xcorr_lag`. This function stated no convention at all until 1.13.0 and returned
    the negative of that one; both were corrected together, since two alignment functions in
    one module disagreeing about a sign is worse than either being wrong alone.

    ``confident`` is True only when the best correlation reaches ``min_r`` and the overlap
    reaches ``min_overlap_s``. Recordings that fail the test are better left without an offset
    than given one nobody can trust: a wrong alignment is harder to detect downstream than a
    missing one.
    """
    ga = np.arange(np.min(t_a), np.max(t_a), step_s)
    gb = np.arange(np.min(t_b), np.max(t_b), step_s)
    a = np.interp(ga, t_a, x_a)
    b = np.interp(gb, t_b, x_b)
    a = (a - a.mean()) / (a.std() + 1e-12)
    b = (b - b.mean()) / (b.std() + 1e-12)

    # The two grids start at different absolute times, so an offset in array index is not
    # the offset in seconds. Carrying the origins back in is the whole point: without it the
    # function silently reports every alignment as zero, whatever the true offset.
    origin = float(ga[0] - gb[0])

    n_min = int(min_overlap_s / step_s)
    k_max = int(max_lag_s / step_s)
    best = {"lag_s": float("nan"), "r": -np.inf, "n_overlap": 0, "confident": False}
    for k in range(-k_max, k_max + 1):
        if k >= 0:
            aa, bb = a[k:], b[: len(a) - k]
        else:
            aa, bb = a[: len(b) + k], b[-k:]
        n = min(len(aa), len(bb))
        if n < n_min:
            continue
        r = float(np.corrcoef(aa[:n], bb[:n])[0, 1])
        if np.isfinite(r) and r > best["r"]:
            best = {"lag_s": -(origin + k * step_s), "r": r, "n_overlap": n,
                    "confident": False}
    if not np.isfinite(best["r"]):
        return {"lag_s": float("nan"), "r": float("nan"), "n_overlap": 0,
                "confident": False}
    best["confident"] = bool(best["r"] >= min_r and best["n_overlap"] >= n_min)
    return best

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.

Where sessions open and close with a synchronisation clap, the usual handling is to trim a fixed window 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
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
219
220
221
def 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.

    Where sessions open and close with a synchronisation clap, the usual handling is to trim a
    fixed window 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.
    """
    x = np.asarray(x, float)
    if x.ndim > 1:
        x = np.linalg.norm(x, axis=1)
    env = np.abs(_signal.hilbert(x - np.median(x)))

    if search_s is not None:
        n = int(search_s * fs)
        mask = np.zeros(len(env), bool)
        mask[:n] = True
        mask[-n:] = True
    else:
        mask = np.ones(len(env), bool)

    med = np.median(env)
    mad = np.median(np.abs(env - med)) * 1.4826
    if mad <= 0:
        return np.array([])
    peaks, _ = _signal.find_peaks(
        np.where(mask, env, -np.inf),
        height=med + threshold * mad,
        distance=max(1, int(min_separation_s * fs)),
    )
    return peaks / fs

apply_lag

apply_lag(t, lag_s: float)

Shift a timebase onto another recording's origin.

Source code in src/micromotion/align.py
224
225
226
def apply_lag(t, lag_s: float):
    """Shift a timebase onto another recording's origin."""
    return np.asarray(t, float) + lag_s

Feature vector

micromotion.features

One canonical feature vector per recording.

Every attempt to compare recordings across this corpus -- clustering, identity classification, condition classification, the dimensionality reductions in :mod:micromotion.descriptors -- needs the same thing first: a fixed set of numbers describing one recording. Each attempt had been inventing its own, which makes two results incomparable for reasons that have nothing to do with the question either was asking.

This is that set, and it is the only thing of its kind the package offers. Models belong outside the package, in an analysis repository where a train/test split and its leakage are visible; what belongs here is the input they all start from.

Eleven descriptors, in three groups:

  • amount and smoothness -- qom, jerk
  • frequency and texture -- centroid, f50, frozen, burst
  • sway geometry -- path, extent, area, anis, vert

The geometric five need true position and are nan for accelerometer collections. A chest-worn accelerometer cannot give a sway ellipse, and doubly integrating one to fake it reports drift as posture.

feature_vector

feature_vector(x, fs: float, kind: str | None = None, unit: str | None = None, sensor_fs: float | None = None) -> dict | None

The eleven descriptors for one recording, or None if it is too short to describe.

kind and unit must be passed and are not guessed, because the collections do not record the same quantity: the optical ones store position in mm and the accelerometer ones store acceleration in g or m/s^2. Differentiating an acceleration series as though it were position shifts every descriptor two derivatives up and still returns finite, plausible-looking numbers, which is the failure mode this signature exists to prevent.

sensor_fs is the rate the device actually sampled at, which is not always the rate the file is stored on: a uniform grid can be an upsample of a much slower sensor, and checking the grid would admit a band the data cannot carry. Pass it whenever the two differ.

Everything derives from a band-limited velocity, so the filter order and the units are identical whichever way the recording arrived.

Source code in src/micromotion/features.py
 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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def feature_vector(x, fs: float, kind: str | None = None, unit: str | None = None,
                   sensor_fs: float | None = None) -> dict | None:
    """The eleven descriptors for one recording, or ``None`` if it is too short to describe.

    ``kind`` and ``unit`` must be passed and are not guessed, because the collections do not
    record the same quantity: the optical ones store position in mm and the accelerometer ones
    store acceleration in g or m/s^2. Differentiating an acceleration series as though it were
    position shifts every descriptor two derivatives up and still returns finite,
    plausible-looking numbers, which is the failure mode this signature exists to prevent.

    ``sensor_fs`` is the rate the device actually sampled at, which is not always the rate
    the file is stored on: a uniform grid can be an upsample of a much slower sensor, and
    checking the grid would admit a band the data cannot carry. Pass it whenever the two differ.

    Everything derives from a band-limited velocity, so the filter order and the units are
    identical whichever way the recording arrived.
    """
    # Checked before anything else, because omitting these is a programming error rather than a
    # property of the data. Until 0.13.0 they defaulted to position and mm, which contradicted the
    # paragraph above and reinstated exactly the silent failure it describes: an accelerometer
    # series passed without `kind` was differentiated as though it were position and came back
    # finite and plausible. The docstring was right and the signature was wrong.
    if kind is None or unit is None:
        raise TypeError(
            "feature_vector requires both kind and unit; they are not guessed. Use "
            "kind='position', unit='mm' for optical data, or kind='acceleration' with "
            "unit='g' or 'm/s^2' for accelerometer data.")

    # copy, because the gap fill below writes into it and the caller's array is not ours to edit
    x = np.array(x, dtype=float, copy=True)
    if x.ndim != 2 or x.shape[1] != 3:
        raise ValueError(f"expected an (n, 3) array of x/y/z, got {x.shape}")
    # fill short gaps by linear interpolation along each axis, then require the rest to be finite
    for j in range(x.shape[1]):
        col = x[:, j]
        m = np.isfinite(col)
        if m.any() and not m.all():
            x[:, j] = np.interp(np.arange(len(col)), np.flatnonzero(m), col[m])
    if len(x) < fs * 120 or not np.isfinite(x).all():
        return None

    lo, hi = effective_band(fs)
    if kind == "position":
        vel = velocity_from_position
    elif kind == "acceleration":
        vel = velocity_from_acceleration
    else:
        raise ValueError(f"unknown kind {kind!r}; use 'position' or 'acceleration'")
    V = vel(x, fs, unit=unit, lo=lo, hi=hi)
    v = np.linalg.norm(V, axis=1)                                       # speed, mm/s

    # Jerk is computed at WIDEBAND rather than at the canonical band, because it lives in the
    # octave the canonical band gives up: at a 5 Hz ceiling it falls to between a third and two
    # thirds of its 10 Hz value and the ranking shifts. One definition of jerk across the corpus
    # matters more than one band within this vector. `nan` where the rate cannot deliver it,
    # rather than a narrower measure reported under a wider name.
    wlo, whi = effective_band(fs, *WIDEBAND)
    if (sensor_fs or fs) < 2 * WIDEBAND[1] or whi < WIDEBAND[1] * 0.999:
        j = np.array([np.nan])
    else:
        W = vel(x, fs, unit=unit, lo=wlo, hi=whi)
        j = np.linalg.norm(derivative(derivative(W, fs), fs), axis=1)

    f_, P = signal.welch(v - v.mean(), fs=fs, nperseg=int(min(len(v), fs * 30)))
    k = (f_ >= lo) & (f_ <= hi)
    centroid = float((f_[k] * P[k]).sum() / P[k].sum())
    cum = np.cumsum(P[k]) / P[k].sum()
    f50 = float(np.interp(0.5, cum, f_[k]))

    out = dict(
        qom=float(np.median(v)),                        # amount
        jerk=float(np.median(j)),                       # smoothness, at WIDEBAND
        centroid=centroid,                              # frequency, energy-weighted
        f50=f50,                                        # frequency, median
        frozen=float((v < np.median(v) / 2).mean()),    # fraction nearly stopped
        burst=float(np.percentile(v, 99) / np.median(v)),                    # peakiness
        path=np.nan, extent=np.nan, area=np.nan, anis=np.nan, vert=np.nan,
    )
    if kind != "position":
        return out

    c = bandpass(x if unit == "mm" else x * 1000.0, fs, lo, hi)
    c = c - c.mean(0)
    w = np.linalg.eigvalsh(np.cov(c[:, :2].T))
    d = np.linalg.norm(c, axis=1)
    out.update(
        path=float(np.sum(v) / fs),                     # cumulative distance travelled
        extent=float(np.percentile(d, 95)),             # how far it strays
        area=float(np.pi * np.sqrt(max(w[0], 1e-12) * max(w[1], 1e-12))),    # sway ellipse
        anis=float(np.sqrt(max(w[1], 1e-12) / max(w[0], 1e-12))),            # elongation
        vert=float(np.std(c[:, 2])),                    # vertical excursion
    )
    return out

Equivalence testing

micromotion.equivalence

Testing that an effect is absent, rather than failing to show that it is present.

Most of this corpus's interesting results are nulls: no environment effect, no seasonal rhythm, no coupling between performers, no association between sound level and quantity of motion. A non-significant test does not support any of those claims. It says the data are compatible with no effect, and equally compatible with an effect too small for this sample to resolve. Stated as "no effect was found", that is an overclaim, and reviewers say so.

Equivalence testing states the claim the reports actually want to make. Two one-sided tests (TOST) invert the usual logic: the null is that the effect is at least as large as some bound, and rejecting it supports the statement that the effect is smaller than that bound. The bound is a smallest effect size of interest, and choosing it is a scientific judgement, not a statistical one -- which is the point. "No effect" is not a testable claim; "smaller than half a millimetre per second" is.

Report both together. A result that is neither significant nor equivalent is genuinely inconclusive, and saying so is more honest than either alternative.

>>> tost_paired(before, after, bound=0.5)         # doctest: +SKIP
{'equivalent': True, 'p': 0.004, ...}

tost_paired

tost_paired(a, b, bound: float, alpha: float = 0.05) -> dict

Two one-sided tests on a paired difference, against a bound in the data's own units.

bound is the smallest difference worth caring about. Pass it in the units of a and b -- mm/s for quantity of motion, breaths per minute for a rate -- rather than as a standardised effect size, because a reader can argue about millimetres and cannot argue about Cohen's d.

Source code in src/micromotion/equivalence.py
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
def tost_paired(a, b, bound: float, alpha: float = 0.05) -> dict:
    """Two one-sided tests on a paired difference, against a bound in the data's own units.

    ``bound`` is the smallest difference worth caring about. Pass it in the units of ``a`` and
    ``b`` -- mm/s for quantity of motion, breaths per minute for a rate -- rather than as a
    standardised effect size, because a reader can argue about millimetres and cannot argue
    about Cohen's d.
    """
    a, b = np.asarray(a, float), np.asarray(b, float)
    m = np.isfinite(a) & np.isfinite(b)
    d = a[m] - b[m]
    n = len(d)
    if n < 3:
        raise ValueError(f"need at least 3 paired observations, got {n}")
    if bound <= 0:
        raise ValueError("bound must be positive; it is the smallest effect worth caring about")
    se = d.std(ddof=1) / np.sqrt(n)
    if se == 0:
        se = np.finfo(float).tiny
    df = n - 1
    t_lo = (d.mean() + bound) / se          # H0: difference <= -bound
    t_hi = (d.mean() - bound) / se          # H0: difference >= +bound
    p_tost = max(stats.t.sf(t_lo, df), stats.t.cdf(t_hi, df))
    p_nhst = float(stats.ttest_rel(a[m], b[m]).pvalue)
    half = stats.t.ppf(1 - alpha, df) * se  # the 90% interval TOST is equivalent to at alpha=.05
    return {
        "mean_difference": float(d.mean()),
        "ci_low": float(d.mean() - half),
        "ci_high": float(d.mean() + half),
        "bound": float(bound),
        "p_equivalence": float(p_tost),
        "p_difference": p_nhst,
        "equivalent": bool(p_tost < alpha),
        "verdict": _verdict(p_tost, p_nhst, alpha),
        "n": n,
    }

tost_independent

tost_independent(a, b, bound: float, alpha: float = 0.05) -> dict

The same for two independent samples, using Welch's standard error.

Source code in src/micromotion/equivalence.py
 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
def tost_independent(a, b, bound: float, alpha: float = 0.05) -> dict:
    """The same for two independent samples, using Welch's standard error."""
    a = np.asarray(a, float); a = a[np.isfinite(a)]
    b = np.asarray(b, float); b = b[np.isfinite(b)]
    if len(a) < 3 or len(b) < 3:
        raise ValueError(f"need at least 3 observations per group, got {len(a)} and {len(b)}")
    if bound <= 0:
        raise ValueError("bound must be positive")
    va, vb = a.var(ddof=1) / len(a), b.var(ddof=1) / len(b)
    se = np.sqrt(va + vb)
    if se == 0:
        se = np.finfo(float).tiny
    df = (va + vb) ** 2 / (va ** 2 / (len(a) - 1) + vb ** 2 / (len(b) - 1))
    diff = a.mean() - b.mean()
    p_tost = max(stats.t.sf((diff + bound) / se, df), stats.t.cdf((diff - bound) / se, df))
    p_nhst = float(stats.ttest_ind(a, b, equal_var=False).pvalue)
    half = stats.t.ppf(1 - alpha, df) * se
    return {
        "mean_difference": float(diff),
        "ci_low": float(diff - half),
        "ci_high": float(diff + half),
        "bound": float(bound),
        "p_equivalence": float(p_tost),
        "p_difference": p_nhst,
        "equivalent": bool(p_tost < alpha),
        "verdict": _verdict(p_tost, p_nhst, alpha),
        "n": (len(a), len(b)),
    }

equivalence_correlation

equivalence_correlation(r: float, n: int, bound: float, alpha: float = 0.05) -> dict

Is a correlation smaller in magnitude than bound?

For the corpus's many "no association" results, which are reported as correlations rather than as mean differences. Works on Fisher's z, where the sampling distribution is normal with a standard error that does not depend on the correlation itself.

Takes r and n rather than the raw series, so it can be applied to a correlation that is already published without recomputing it.

Source code in src/micromotion/equivalence.py
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
def equivalence_correlation(r: float, n: int, bound: float, alpha: float = 0.05) -> dict:
    """Is a correlation smaller in magnitude than ``bound``?

    For the corpus's many "no association" results, which are reported as correlations rather
    than as mean differences. Works on Fisher's z, where the sampling distribution is normal
    with a standard error that does not depend on the correlation itself.

    Takes ``r`` and ``n`` rather than the raw series, so it can be applied to a correlation that
    is already published without recomputing it.
    """
    if not -1 < r < 1:
        raise ValueError(f"r must be strictly inside (-1, 1), got {r}")
    if n < 4:
        raise ValueError(f"need n >= 4 for Fisher's z, got {n}")
    if not 0 < bound < 1:
        raise ValueError("bound must be a correlation strictly inside (0, 1)")
    z, zb = np.arctanh(r), np.arctanh(bound)
    se = 1.0 / np.sqrt(n - 3)
    p_tost = max(stats.norm.sf((z + zb) / se), stats.norm.cdf((z - zb) / se))
    p_nhst = float(2 * stats.norm.sf(abs(z) / se))
    crit = stats.norm.ppf(1 - alpha) * se
    return {
        "r": float(r),
        "ci_low": float(np.tanh(z - crit)),
        "ci_high": float(np.tanh(z + crit)),
        "bound": float(bound),
        "p_equivalence": float(p_tost),
        "p_difference": p_nhst,
        "equivalent": bool(p_tost < alpha),
        "verdict": _verdict(p_tost, p_nhst, alpha),
        "n": int(n),
    }

interpret

interpret(result: dict) -> str

One sentence a report can quote, naming the bound rather than hiding it.

Source code in src/micromotion/equivalence.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
def interpret(result: dict) -> str:
    """One sentence a report can quote, naming the bound rather than hiding it."""
    b = result["bound"]
    v = result["verdict"]
    lo, hi = result["ci_low"], result["ci_high"]
    if v == "equivalent":
        return (f"equivalent to zero within ±{b:g}: the 90% interval [{lo:.3g}, {hi:.3g}] "
                f"lies inside the bound (p = {result['p_equivalence']:.3g})")
    if v == "effect":
        return (f"a real effect larger than ±{b:g} cannot be excluded: interval "
                f"[{lo:.3g}, {hi:.3g}] (p = {result['p_difference']:.3g})")
    if v == "trivial":
        return (f"statistically detectable but smaller than ±{b:g}: interval "
                f"[{lo:.3g}, {hi:.3g}]")
    return (f"inconclusive at a bound of ±{b:g}: the interval [{lo:.3g}, {hi:.3g}] is compatible "
            f"both with no effect and with one worth caring about; this sample cannot decide")

Dimensionality and reliability

micromotion.descriptors

How many independent things is a descriptor set measuring, and how much of one is the person?

Two reductions that this corpus kept re-implementing per report, with the arithmetic drifting between copies. Both are here so there is one of each.

They answer questions that are easy to confuse. :func:effective_dimensionality asks how many independent axes a set of measures spans -- whether eleven descriptors are eleven findings or three wearing different names. :func:intraclass_correlation asks, of a single measure, how much of its variance is the person rather than the occasion -- whether it is a trait or a state.

A name collision worth knowing about. group.participation_ratio is a different quantity with a similar name: the fraction of a group whose movement decreased after an event. The participation ratio of an eigenvalue spectrum, which is what effective dimensionality means here, is deliberately not called that.

effective_dimensionality

effective_dimensionality(x, rank: bool = True, by=None) -> dict

How many independent dimensions a set of descriptors spans.

x is (n_observations, n_descriptors). Returns the variance share of each component, the number of components needed for 80 and 90 per cent, and the participation ratio (sum lambda)^2 / sum(lambda^2) -- an effective count that needs no cutoff and is not an integer.

Three choices are baked in because getting them wrong is what made earlier versions of this disagree with each other:

rank=True correlates the ranks rather than the values. Descriptors here are heavy-tailed -- burstiness is a ratio of a 99th percentile to a median -- and on raw values a single descriptor with a long tail dominates the first component and the answer becomes a statement about that descriptor's outliers.

by standardises within groups before pooling, and should be the recording session, edition or collection. Without it, a between-group difference in level appears as shared variance and inflates the first component: descriptors do not become more correlated because two editions were recorded at different rates, but they look it.

Columns that are constant or all-NaN are dropped, and the count is reported, because a degenerate column silently adds an eigenvalue of zero and deflates the participation ratio.

Source code in src/micromotion/descriptors.py
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
def effective_dimensionality(x, rank: bool = True, by=None) -> dict:
    """How many independent dimensions a set of descriptors spans.

    ``x`` is (n_observations, n_descriptors). Returns the variance share of each component, the
    number of components needed for 80 and 90 per cent, and the **participation ratio**
    ``(sum lambda)^2 / sum(lambda^2)`` -- an effective count that needs no cutoff and is not
    an integer.

    Three choices are baked in because getting them wrong is what made earlier versions of this
    disagree with each other:

    ``rank=True`` correlates the *ranks* rather than the values. Descriptors here are heavy-tailed
    -- burstiness is a ratio of a 99th percentile to a median -- and on raw values a single
    descriptor with a long tail dominates the first component and the answer becomes a statement
    about that descriptor's outliers.

    ``by`` standardises within groups before pooling, and should be the recording session, edition
    or collection. Without it, a between-group difference in level appears as shared variance and
    inflates the first component: descriptors do not become more correlated because two editions
    were recorded at different rates, but they look it.

    Columns that are constant or all-NaN are dropped, and the count is reported, because a
    degenerate column silently adds an eigenvalue of zero and deflates the participation ratio.
    """
    import pandas as pd

    d = pd.DataFrame(x).apply(pd.to_numeric, errors="coerce")
    if by is not None:
        g = pd.Series(list(by), index=d.index)
        d = d.groupby(g).rank() if rank else d
        d = d.groupby(g).transform(lambda s: (s - s.mean()) / s.std())
    elif rank:
        d = d.rank()
        d = (d - d.mean()) / d.std()
    else:
        d = (d - d.mean()) / d.std()

    n_before = d.shape[1]
    d = d.dropna(axis=1, how="all").dropna()
    d = d.loc[:, d.std() > 0]
    dropped = n_before - d.shape[1]
    if d.shape[1] < 2:
        raise ValueError("need at least two non-degenerate descriptors")

    ev = np.linalg.eigvalsh(np.corrcoef(d.to_numpy(), rowvar=False))[::-1]
    ev = np.clip(ev, 0.0, None)
    frac = ev / ev.sum()
    cum = np.cumsum(frac)
    return dict(
        variance_fraction=frac,
        n_for_80=int(np.searchsorted(cum, 0.80) + 1),
        n_for_90=int(np.searchsorted(cum, 0.90) + 1),
        participation_ratio=float(ev.sum() ** 2 / (ev ** 2).sum()),
        n_observations=int(len(d)),
        n_descriptors=int(d.shape[1]),
        n_dropped=int(dropped),
    )

intraclass_correlation

intraclass_correlation(values, groups, log: bool | None = None) -> dict

The share of a measure's variance that is between groups rather than within them.

Fitted as a random-intercept mixed model, value ~ 1 with a random intercept per group, which is the estimator the corpus uses for "is this a trait or a state": groups is the person and the residual is the occasion.

log=None log-transforms when every value is strictly positive, because these are scale quantities whose residuals are otherwise skewed; pass False to force the raw scale.

Returns the ICC, both variance components, the counts, and boundary. Check boundary. A random-effect variance can be estimated at exactly zero, which is the optimiser hitting the edge of the parameter space rather than a measurement of no group effect -- the difference matters when the number of groups is small, and reporting 0.000 from such a fit implies a precision that is not there.

Source code in src/micromotion/descriptors.py
 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
def intraclass_correlation(values, groups, log: bool | None = None) -> dict:
    """The share of a measure's variance that is between groups rather than within them.

    Fitted as a random-intercept mixed model, ``value ~ 1`` with a random intercept per group,
    which is the estimator the corpus uses for "is this a trait or a state": ``groups`` is the
    person and the residual is the occasion.

    ``log=None`` log-transforms when every value is strictly positive, because these are scale
    quantities whose residuals are otherwise skewed; pass ``False`` to force the raw scale.

    Returns the ICC, both variance components, the counts, and ``boundary``. **Check
    ``boundary``.** A random-effect variance can be estimated at exactly zero, which is the
    optimiser hitting the edge of the parameter space rather than a measurement of no group
    effect -- the difference matters when the number of groups is small, and reporting ``0.000``
    from such a fit implies a precision that is not there.
    """
    import pandas as pd
    try:
        import statsmodels.formula.api as smf
    except ModuleNotFoundError as exc:  # pragma: no cover - exercised by the extras, not the suite
        raise ModuleNotFoundError(
            "intraclass_correlation needs statsmodels, which is an optional dependency because "
            "it is the only function in this package that uses it. Install it with "
            "`pip install micromotion[mixed]` or `pip install statsmodels`."
        ) from exc

    d = pd.DataFrame({"y": pd.to_numeric(pd.Series(list(values)), errors="coerce"),
                      "g": pd.Series(list(groups)).astype(str)}).dropna()
    if len(d) < 3 or d.g.nunique() < 2:
        raise ValueError("need at least two groups and three observations")
    used_log = bool(d.y.min() > 0) if log is None else bool(log)
    if used_log:
        d["y"] = np.log(d.y)

    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        fit = smf.mixedlm("y ~ 1", d, groups=d.g).fit(reml=True)
    between = float(np.asarray(fit.cov_re)[0, 0])
    within = float(fit.scale)
    total = between + within
    return dict(
        icc=float(between / total) if total > 0 else float("nan"),
        var_between=between,
        var_within=within,
        n=int(len(d)),
        n_groups=int(d.g.nunique()),
        log=used_log,
        boundary=bool(between <= 1e-9),
    )