diff --git a/lib/src/onehz/motion/motion.dart b/lib/src/onehz/motion/motion.dart index d773bc0..7cc2882 100644 --- a/lib/src/onehz/motion/motion.dart +++ b/lib/src/onehz/motion/motion.dart @@ -21,7 +21,7 @@ /// physically impossible on the 1 Hz substrate (Nyquist 0.5 Hz < gait /// 1.4–2.5 Hz), so: /// * [livePedometer] counts REAL steps on the ~100 Hz foreground accel -/// (R10 / 0x2B) — adaptive-threshold peak detection (AN-2554 family). +/// (R10 / 0x2B) — stride-periodicity gait detection + measured cadence. /// * [dailyActiveMinutes] reports MINUTES OF SUSTAINED WRIST MOVEMENT from /// the 1 Hz substrate — the only quantity resolvable there — and emits NO /// step count: at the wrist, arm work out-accelerates walking, so movement diff --git a/lib/src/onehz/motion/steps.dart b/lib/src/onehz/motion/steps.dart index cf8c7a2..daa55c8 100644 --- a/lib/src/onehz/motion/steps.dart +++ b/lib/src/onehz/motion/steps.dart @@ -12,13 +12,12 @@ // So we split the problem the only honest way: // // TIER A — [pedometer] / [livePedometer]: a real step counter on the 100 Hz -// foreground stream, ported from the OpenStrap backend -// (openstrap-analytics/src/steps.ts). See [StepParams] for what it actually -// is and is not — it is NOT AN-2554 verbatim, and the ×1.11 "calibration" -// it used to carry was measured to be a fabricated +10.6%. Its CONFIRM=8 -// regularity gate rejects IRREGULAR handling and reads 0 at rest; it does -// NOT reject rhythmic arm work (measured: a steady 1.8 Hz stir over 300 s -// of no locomotion counts 538 steps at 108 spm). Directly testable: walk N +// foreground stream. It finds GAIT by stride periodicity (autocorrelation +// at the stride lag) and counts steps from the measured cadence; see +// [StepParams] for the algorithm and its OxWalk-measured accuracy. It +// replaced an AN-2554-shaped peak counter that under-counted real walkers +// by ~25%. Rhythmic arm work at a gait-like rhythm can still count — a +// wrist |a| cannot separate it from walking. Directly testable: walk N // steps with the app open and compare. // // TIER B — [dailyActiveMinutes]: a 24/7 MOVEMENT-VOLUME index from the 1 Hz @@ -101,218 +100,267 @@ class PedometerResult { }; } -/// WHAT THIS ALGORITHM ACTUALLY IS — the citation, corrected. -/// -/// It is NOT AN-2554, despite what this file said for a long time. It is: -/// -/// * AN-2554's *structure* — centred-window max/min peak search judged -/// against a dynamic threshold that is refreshed from recent (max+min)/2 — -/// but on Euclidean magnitude √(x²+y²+z²), where AN-2554 uses the sum of -/// absolutes, and retuned for 100 Hz where AN-2554 is specified at 50 Hz; -/// * plus Zhao's ADXL345 Analog Dialogue pedometer's confirm/"regulation" -/// mechanic ([confirm] consecutive valid pairs before anything is credited, -/// then free-running until the run breaks) — also a 50 Hz design; -/// * plus [sens] and [confirm], which appear in NEITHER source. They are -/// ours. Do not defend them by citation. -/// -/// What IS taken straight from AN-2554 is the pair of physiological step- -/// interval bounds ([minStepIntervalS] / [maxStepIntervalS]) — max ~5 steps/s -/// running, min ~0.5 steps/s very slow walking. Those were missing from this -/// port until they were added back. -/// -/// Neither ancestor is validated evidence: AN-2554's "~97% on a wrist" is a -/// vendor figure on an unpublished protocol, and Zhao's note has no validation -/// at all. Every independent free-living wrist evaluation puts peak detection -/// between 60% and 230% error. Treat this as a counter that is accurate on the -/// walking it supervises, not as an all-day step source. +/// WHAT THIS ALGORITHM IS — a gait-PERIODICITY counter, not a peak counter. +/// +/// The previous counter (an AN-2554-shaped max/min peak search behind a 0.10 g +/// dead-zone and a CONFIRM=8 run gate) was scored against OxWalk — 39 adults, +/// unscripted free living, dominant-wrist AX3, camera-annotated heel strikes — +/// and measured MAPE 33.0%, only 5/39 within ±10%, and a uniform −25.4% on the +/// participants who actually walked. No setting of its knobs fixed that: every +/// `sens`/`confirm` that recovered the walkers also tripled-to-sextupled the +/// arm-work participants, because a peak detector cannot tell "regular wrist +/// bumps" from "regular wrist bumps while walking". +/// +/// What separates them is STRIDE PERIODICITY. Walking repeats every stride +/// (two steps) with high fidelity; stirring, typing and gesturing mostly do +/// not. So, per 1 s segment, over the [winS]-second window centred on it: +/// +/// 1. band-pass |a| (two 0.1 s moving averages minus a 1 s one) and, above +/// [decimateAboveHz], block-average down to ~50 Hz. Accuracy is identical +/// at 50 and 100 Hz on OxWalk and the cost drops ~4×. +/// 2. normalised autocorrelation over lags [lagLoS]..[lagHiS]. +/// 3. P = the SHORTEST autocorrelation peak in [peakLoS]..[peakHiS] within +/// [harmonic] of the strongest — the fundamental, not a multiple of it. +/// 4. STEP OR STRIDE? At the wrist P is either the step period (|a| bobs +/// once per step) or the stride period (the arm swings once per stride). +/// On OxWalk walking windows the two populations do not overlap: step-P +/// sits at 0.47–0.63 s with the half-lag autocorrelation at −0.96…−0.55 +/// (P/2 is anti-phase), stride-P at 0.94–1.27 s with the half-lag at +/// −0.38…+0.70 (P/2 is itself a step). So P ≥ [strideS], or a half-lag +/// above [halfAc], means stride and step = P/2; otherwise step = P. This +/// single decision was the difference between a cadence that was right +/// and one that read ×2 on ~30% of walking windows. +/// 5. GAIT GATE: band-passed SD ≥ [ampG] and autocorrelation AT THE STRIDE +/// LAG (2 × step) ≥ [regHi], grown through neighbouring seconds that +/// clear [regLo] (hysteresis), and kept only as a run of ≥ [minRun] +/// consecutive seconds. Below 8 s nothing is credited. +/// 6. COUNT = Σ segment seconds / step period over the gait seconds. Steps +/// come from the MEASURED cadence, never from counting individual peaks, +/// so one weak footfall cannot reset a run. +/// +/// OxWalk, production chunking (60 s), raw counts, gain 1.00 — measured +/// 2026-10-23 with `tool/oxwalk_validate.dart`: +/// +/// Wrist 100 Hz MAPE 33.0% → 16.7% within ±10% 5 → 21 of 39 +/// walkers (≥600 steps/h) bias −25.4% → −10.4% +/// daily totals −19.8% → −7.3% +/// Wrist 50 Hz MAPE 33.6% → 16.9% +/// Wrist 25 Hz MAPE 91.9% → 17.3% (was dead: the old constants were in +/// samples; these are in seconds) +/// Hip 100 Hz MAPE 29.5% → 17.6% +/// +/// LIMITS, stated rather than hidden: +/// * Rhythmic arm work at a gait-like rhythm still counts (OxWalk P28: +/// 258 true steps, ~690 counted). Nothing on a wrist |a| separates it +/// from walking; the edge's activity-type gate is the defence. +/// * Cadence band is [stepLoS]..[stepHiS] per step: 60–240 spm. A shuffle +/// below 60 spm is not counted; a sprint above 240 spm locks onto its +/// stride and reads HALF (the edge never lets the strap count sprints). +/// * A wrist signal with ONLY a step fundamental slower than ~67 spm +/// (P ≥ [strideS]) is read as a stride and counted double. On OxWalk that +/// population is empty; on synthetic signals it is not. +/// +/// Every constant is in SECONDS, so the counter is rate-agnostic — the caller +/// must pass the buffer's REAL rate as `sampleRateHz`. class StepParams { - /// Default assembled IMU sample rate (Hz). No longer decorative: it is the - /// default for [pedometer]'s `sampleRateHz`, which the interval bounds below - /// are converted through. Every other constant here is in SAMPLES. + /// Default assembled IMU sample rate (Hz) for [pedometer]'s `sampleRateHz`. static const double fs = 100; - static const int filter = 8; // low-pass moving-average taps - static const int window = 33; // centered peak window (~0.33 s @100 Hz) - static const double sens = 0.10; // g — dead-zone around the dynamic threshold - static const int thrOrder = 4; // dynamic-threshold smoothing buffer - static const int confirm = 8; // consecutive possible steps before counting - static const int maxMinTimeout = 120; // samples to find a min after a max - - /// AN-2554's step-interval window: a human runs at most ~5 steps/s and walks - /// at slowest ~0.5 steps/s, so a peak-to-peak interval outside 0.2–2.0 s is - /// not gait and breaks the run. Distinct from [maxMinTimeout], which bounds - /// the max→min HALF cycle, not the interval between steps. - static const double minStepIntervalS = 0.2; - static const double maxStepIntervalS = 2.0; + + static const double winS = 4.0; // autocorrelation window, centred + static const double hopS = 1.0; // decision segment + static const double decimateAboveHz = 50; // block-average down to ~50 Hz + static const double lagLoS = 0.2; + static const double lagHiS = 2.0; + static const double peakLoS = 0.25; + static const double peakHiS = 1.6; + static const double harmonic = 0.8; // "within 80% of the best peak" + static const double strideS = 0.9; // P at/above this is a stride + static const double halfAc = -0.4; // half-lag above this ⇒ P is a stride + static const double stepLoS = 0.25; // 240 spm + static const double stepHiS = 1.0; // 60 spm + static const double ampG = 0.04; // band-passed SD floor (g) + static const double regHi = 0.6; // stride autocorrelation to START gait + static const double regLo = 0.2; // ...to CONTINUE it (hysteresis) + static const int minRun = 8; // consecutive gait seconds before crediting /// Per-user calibration trim. **Default 1.00, and it must stay 1.00 as a - /// default** — a population fudge factor is not a calibration. - /// - /// This was 1.11 and it was shipping a measured +10.6% over-count. The - /// docstring claimed "raw 90 → ~100 ground truth"; on clean synthetic gait - /// the raw count is exact — raw/truth 1.00 at 60, 80, 100, 120, 140 and - /// 180 spm. The 90→100 figure is only reproducible at ~0.050 g peak - /// amplitude, on the [sens] dead-zone cliff where a 100-step walk counts - /// 0 → 56 → 98 → 100 across 0.048–0.055 g: n=1, at the least reproducible - /// operating point in the parameter space. - /// - /// The real systematic deficit on a still→walk→still bout is a FLAT −0.9%, - /// identical from a 20-step bout to a 10,000-step one. Flat means it is - /// neither a gain error (which would scale) nor an offset (which would - /// shrink): it is per-minute CHUNK-BOUNDARY loss inside [calcSteps], which - /// restarts the state machine every minute and pays [confirm] again. Fixing - /// that means changing how [calcSteps] segments minutes; it is 0.9% and it is - /// deliberately not chased. Do not re-derive a gain from it. - /// - /// A real strap on a real wrist may still need trimming — that is what this - /// knob is for. Measure it against ground truth, per user, and store it with - /// the user; never bake a population constant back in here. + /// default** — a population fudge factor is not a calibration. It was once + /// 1.11 and shipped a measured +10.6% over-count. Measure it against ground + /// truth, per user, and store it with the user. static const double gain = 1.00; } -/// Time-domain step count over ONE contiguous accelerometer-MAGNITUDE signal -/// (g, gravity INCLUDED). Raw count — no calibration gain. See [StepParams] for -/// what this algorithm is (it is not AN-2554 verbatim). -/// -/// low-pass (trailing MA) → centered-window max/min extrema → dynamic -/// threshold ± [StepParams.sens]/2 dead-zone → step-interval bounds -/// ([StepParams.minStepIntervalS]…[StepParams.maxStepIntervalS]) → -/// [StepParams.confirm] consecutive "possible steps" before counting (the -/// regularity gate — it rejects IRREGULAR handling and reads 0 at rest, but -/// it does NOT reject rhythmic arm work). -/// -/// [sampleRateHz] is the rate of THIS buffer. It is used only to convert the -/// step-interval bounds into samples — every other constant is in samples and -/// the peak window is wide enough that 50–200 Hz all count correctly (measured; -/// 25 Hz collapses to 0.29 of truth at 110 spm and no bound saves that). -int pedometer(List sig, {double sampleRateHz = StepParams.fs}) { - final n = sig.length; - if (n < StepParams.window || sampleRateHz <= 0) return 0; - // Step-interval bounds in SAMPLES, from the buffer's real rate. - final minGap = StepParams.minStepIntervalS * sampleRateHz; - final maxGap = StepParams.maxStepIntervalS * sampleRateHz; - const filter = StepParams.filter; - // low-pass: trailing moving average - final lp = List.filled(n, 0); - var acc = 0.0; - for (var i = 0; i < n; i++) { - acc += sig[i]; - if (i >= filter) acc -= sig[i - filter]; - lp[i] = acc / math.min(i + 1, filter); +/// Step count over ONE contiguous accelerometer-MAGNITUDE signal (g, gravity +/// INCLUDED) sampled at [sampleRateHz]. Raw count — no calibration gain. See +/// [StepParams] for the algorithm and its measured accuracy. +int pedometer(List sig, {double sampleRateHz = StepParams.fs}) => + _gaitSteps(sig, sampleRateHz).round(); + +double _gaitSteps(List sig, double sampleRateHz) { + if (sig.isEmpty || !(sampleRateHz > 0)) return 0; + var fs = sampleRateHz; + // 1. band-pass, then decimate. + final lp = _trailingMean(_trailingMean(sig, fs * 0.1), fs * 0.1); + final hp = _trailingMean(lp, fs); + var bp = [for (var i = 0; i < lp.length; i++) lp[i] - hp[i]]; + final k = (fs / StepParams.decimateAboveHz).floor(); + if (k > 1) { + bp = [ + for (var i = 0; i < bp.length; i += k) + () { + final end = math.min(i + k, bp.length); + var s = 0.0; + for (var j = i; j < end; j++) { + s += bp[j]; + } + return s / (end - i); + }(), + ]; + fs = fs / k; } - final half = StepParams.window >> 1; - // centered-window extrema candidates - final candI = []; - final candMax = []; - final candV = []; - for (var i = half; i < n - half; i++) { - var isMax = true, isMin = true; - final v = lp[i]; - for (var j = i - half; j <= i + half; j++) { - if (lp[j] > v) isMax = false; - if (lp[j] < v) isMin = false; - if (!isMax && !isMin) break; - } - if (isMax) { - candI.add(i); - candMax.add(true); - candV.add(v); - } else if (isMin) { - candI.add(i); - candMax.add(false); - candV.add(v); - } + final n = bp.length; + final w = (StepParams.winS * fs).round(); + final h = math.max(1, (StepParams.hopS * fs).round()); + + // 2-4. per-segment cadence + stride regularity. + final segS = []; + final stepS = []; + final ok = []; + final reg = []; + for (var c = 0; c < n; c += h) { + final segLen = math.min(h, n - c); + var lo = math.max(0, c + segLen ~/ 2 - w ~/ 2); + final hi = math.min(n, lo + w); + lo = math.max(0, hi - w); + final d = _decide(bp, lo, hi, fs); + segS.add(segLen / fs); + stepS.add(d.stepS); + ok.add(d.stepS != null && d.sd >= StepParams.ampG); + reg.add(d.strideAc); + } + + // 5. gait gate with hysteresis, then the minimum run. + final m = ok.length; + final gait = [ + for (var i = 0; i < m; i++) ok[i] && reg[i] >= StepParams.regHi + ]; + bool loose(int i) => ok[i] && reg[i] >= StepParams.regLo; + for (var i = 1; i < m; i++) { + if (!gait[i] && gait[i - 1] && loose(i)) gait[i] = true; } - // dynamic threshold + CONFIRM-step regularity - final dyn = []; - var dynVal = 0.0; - for (final v in sig) { - dynVal += v; + for (var i = m - 2; i >= 0; i--) { + if (!gait[i] && gait[i + 1] && loose(i)) gait[i] = true; } - dynVal /= n; - var steps = 0, poss = 0; - var regulation = false; - var stateMax = true; // 'max' → looking for a max; else looking for a min - var curMax = 0.0; - var curMaxIdx = -1; - var lastStepIdx = - -1; // peak index of the last credited pair (interval anchor) - for (var k = 0; k < candI.length; k++) { - final ci = candI[k], cMax = candMax[k], cv = candV[k]; - if (stateMax) { - if (cMax) { - curMax = cv; - curMaxIdx = ci; - stateMax = false; - } - } else { - if (cMax) { - if (cv > curMax) { - curMax = cv; - curMaxIdx = ci; - } - continue; - } - if (ci - curMaxIdx > StepParams.maxMinTimeout) { - stateMax = true; - poss = 0; - regulation = false; - lastStepIdx = -1; - continue; - } - final mx = curMax, mn = cv; - if (mx > dynVal + StepParams.sens / 2 && - mn < dynVal - StepParams.sens / 2) { - // Step-interval bound (AN-2554). The interval between two steps is the - // gap between their peaks; outside 0.2–2.0 s no human produced both, so - // this pair cannot CONTINUE the run — it can only start a new one. - // Without it the counter tracks 250, 300 and 320 spm at raw/truth 1.00 - // and only loses lock near 350 spm, by accident of the peak window's - // width. Checked here rather than above the amplitude test so a noise - // pair can never move the anchor. - final gap = lastStepIdx < 0 ? null : curMaxIdx - lastStepIdx; - lastStepIdx = curMaxIdx; - if (gap != null && (gap < minGap || gap > maxGap)) { - poss = 0; - regulation = false; - stateMax = true; - continue; - } - if (mx - mn > StepParams.sens) { - dyn.add((mx + mn) / 2); - if (dyn.length > StepParams.thrOrder) dyn.removeAt(0); - var s = 0.0; - for (final v in dyn) { - s += v; - } - dynVal = s / dyn.length; - } - poss++; - if (regulation) { - steps++; - } else if (poss >= StepParams.confirm) { - steps += poss; - regulation = true; - } - } else { - poss = 0; - regulation = false; + + // 6. steps = time / measured step period, over runs long enough to be gait. + var steps = 0.0; + var i = 0; + while (i < m) { + if (!gait[i]) { + i++; + continue; + } + var j = i; + while (j + 1 < m && gait[j + 1]) { + j++; + } + if (j - i + 1 >= StepParams.minRun) { + for (var q = i; q <= j; q++) { + steps += segS[q] / stepS[q]!; } - stateMax = true; } + i = j + 1; } return steps; } +/// Trailing moving average over round([w]) samples (shorter at the start). +List _trailingMean(List x, double w) { + final taps = math.max(1, w.round()); + final out = List.filled(x.length, 0); + var acc = 0.0; + for (var i = 0; i < x.length; i++) { + acc += x[i]; + if (i >= taps) acc -= x[i - taps]; + out[i] = acc / math.min(i + 1, taps); + } + return out; +} + +/// Cadence and stride regularity of `bp[lo, hi)`: the step period (s, or null +/// when the window has no gait-band periodicity), the window's SD, and the +/// autocorrelation at the stride lag (−1 when there is none). +({double? stepS, double sd, double strideAc}) _decide( + List bp, int lo, int hi, double fs) { + final n = hi - lo; + if (n <= 0) return (stepS: null, sd: 0.0, strideAc: -1.0); + var mean = 0.0; + for (var i = lo; i < hi; i++) { + mean += bp[i]; + } + mean /= n; + final s = [for (var i = lo; i < hi; i++) bp[i] - mean]; + // Prefix energies, so each lag's normalisation is O(1). + final e = List.filled(n + 1, 0); + for (var i = 0; i < n; i++) { + e[i + 1] = e[i] + s[i] * s[i]; + } + final sd = math.sqrt(e[n] / n); + const none = -1.0; + final lagLo = (StepParams.lagLoS * fs).round(); + final lagHi = + math.min((StepParams.lagHiS * fs).round(), n - (0.5 * fs).round()); + if (lagHi <= lagLo + 2 || sd <= 0) { + return (stepS: null, sd: sd, strideAc: none); + } + final ac = List.filled(lagHi + 1, double.nan); + for (var l = lagLo; l <= lagHi; l++) { + var dot = 0.0; + for (var t = 0; t < n - l; t++) { + dot += s[t] * s[t + l]; + } + final den = math.sqrt(e[n - l] * (e[n] - e[l])); + ac[l] = den > 0 ? dot / den : 0.0; + } + double acAt(int l) => (l >= lagLo && l <= lagHi) ? ac[l] : double.nan; + + final pLo = math.max((StepParams.peakLoS * fs).round(), lagLo + 1); + final pHi = math.min((StepParams.peakHiS * fs).round(), lagHi - 1); + final peaks = [ + for (var l = pLo; l <= pHi; l++) + if (ac[l] >= ac[l - 1] && ac[l] >= ac[l + 1]) l, + ]; + if (peaks.isEmpty) return (stepS: null, sd: sd, strideAc: none); + var best = ac[peaks.first]; + for (final l in peaks) { + if (ac[l] > best) best = ac[l]; + } + if (best <= 0) return (stepS: null, sd: sd, strideAc: none); + final p = peaks.firstWhere((l) => ac[l] >= StepParams.harmonic * best); + // Sub-sample peak: parabolic interpolation (a 20 ms lag grid is a ±2% + // cadence error at 140-180 spm otherwise). + final a0 = ac[p - 1], a1 = ac[p], a2 = ac[p + 1]; + final curv = a0 - 2 * a1 + a2; + final pf = p + (curv < 0 ? 0.5 * (a0 - a2) / curv : 0.0); + final half = (p / 2).round(); + final isStride = p / fs >= StepParams.strideS || + (half / fs >= StepParams.peakLoS && acAt(half) > StepParams.halfAc); + final step = isStride ? pf / 2 : pf; + final stepS = step / fs; + if (stepS < StepParams.stepLoS || stepS > StepParams.stepHiS) { + return (stepS: null, sd: sd, strideAc: none); + } + final r = acAt((2 * step).round()); + return (stepS: stepS, sd: sd, strideAc: r.isNaN ? none : r); +} + /// Daily total: [pedometer] over each per-minute contiguous magnitude signal, /// summed and scaled by [StepParams.gain] (1.00 by default — see there). /// -/// KNOWN, DELIBERATELY UNFIXED: the per-minute chunking costs a flat −0.9%. -/// The state machine restarts at every boundary and has to re-earn -/// [StepParams.confirm], losing ~2 steps per minute of continuous walking. -/// Measured on still→walk→still bouts of 20 / 100 / 1000 / 10000 steps: −0.9% -/// at every size. It is not a gain error and must not be "corrected" with one. -/// Fixing it properly means carrying state across chunk boundaries, i.e. -/// changing how this function segments minutes. +/// Chunking costs little: each chunk's gait runs restart at its edges, so a +/// walk crossing a boundary can lose up to [StepParams.minRun] seconds on each +/// side only when a fragment is shorter than that. Measured on OxWalk wrist +/// 100 Hz, 60 s chunks cost −2.4% of the total vs one contiguous buffer. int calcSteps( List> minuteSignals, { double sampleRateHz = StepParams.fs, @@ -336,7 +384,7 @@ PedometerResult livePedometer( double sampleRateHz = StepParams.fs, }) { final n = math.min(x.length, math.min(y.length, z.length)); - if (n < StepParams.window || sampleRateHz <= 0) return PedometerResult.none; + if (n < 2 || sampleRateHz <= 0) return PedometerResult.none; final mag = [ for (var i = 0; i < n; i++) math.sqrt(x[i] * x[i] + y[i] * y[i] + z[i] * z[i]) @@ -345,8 +393,8 @@ PedometerResult livePedometer( final durationS = n / sampleRateHz; if (steps <= 0) return PedometerResult(0, durationS, 0, 0, 0); // Cadence over the buffer span. For a dedicated walk this is the walking - // cadence; the CONFIRM gate guarantees any nonzero count is confirmed gait, - // so confidence is high. peakToPeak is the magnitude range (motion strength). + // cadence; the gait gate guarantees any nonzero count came from a periodic + // run of at least StepParams.minRun seconds, so confidence is high. peakToPeak is the magnitude range (motion strength). final cadence = durationS > 0 ? steps / (durationS / 60.0) : 0.0; var mn = mag.first, mx = mag.first; for (final v in mag) { diff --git a/test/onehz/steps_test.dart b/test/onehz/steps_test.dart index d479318..3804b21 100644 --- a/test/onehz/steps_test.dart +++ b/test/onehz/steps_test.dart @@ -123,44 +123,60 @@ void main() { }); }); - // These pin the numbers the 2026-08-16 audit measured, so the +10.6% gain - // cannot be re-derived from a comment and the physiological interval bounds - // cannot be quietly dropped again. Every expectation below is a MEASUREMENT - // of the shipping algorithm, not a target. - group('Tier A — the gain is 1.00 and the raw count is already exact', () { + // Every expectation below is a MEASUREMENT of the shipping algorithm, not a + // target. Synthetic gait is the easy case; the real-world numbers are the + // OxWalk block further down. + group('Tier A — synthetic gait is counted from the measured cadence', () { + int stepsAt(double spm, double durationS, + {double amp = 0.25, double fs = 100, double? tellRate}) { + final (x, y, z) = _walk(durationS, spm / 60.0, fs: fs, ampG: amp); + return livePedometer(x, y, z, sampleRateHz: tellRate ?? fs).steps; + } + test('gain is 1.00 — the default is not a population fudge factor', () { - // Was 1.11, claiming to fix "raw 90 -> ~100". Measured, it manufactured - // a +10.6% over-count on gait the raw counter already got right. + // Was 1.11 once and manufactured a +10.6% over-count. expect(StepParams.gain, 1.00); }); - test('raw/truth is 1.00 at 60, 80, 100, 120, 140 and 180 spm', () { - // The whole basis for removing the gain: there is no under-count to - // correct anywhere in the human cadence range. Exact figures measured on - // 300 s of _walk at 0.25 g, 100 Hz. - for (final (spm, want) in const [ - (60, 299), - (80, 399), - (100, 498), - (120, 598), - (140, 698), - (180, 898), - ]) { - final truth = (300 * spm / 60).round(); - final (x, y, z) = _walk(300.0, spm / 60.0); - final got = livePedometer(x, y, z).steps; - expect(got, want, reason: '$spm spm'); - expect(got / truth, closeTo(1.00, 0.005), reason: '$spm spm raw/truth'); + test('raw/truth is 1.00 from 70 to 220 spm', () { + for (final spm in const [70, 80, 100, 120, 140, 180, 220]) { + final truth = 300 * spm / 60; + final got = stepsAt(spm.toDouble(), 300); + expect(got / truth, closeTo(1.00, 0.01), reason: '$spm spm'); } }); - test('the residual deficit is a FLAT -0.9% chunk-boundary loss, not a gain', - () { + test('the documented misreads at both ends of the cadence band', () { + // Slower than ~67 spm with ONLY a step fundamental (P >= strideS) is + // read as a stride: 40 spm counts double. Real wrists swing the arm + // once per stride, and on OxWalk this population was empty — but a + // synthetic signal hits it, so it is pinned rather than hidden. + expect(stepsAt(40, 300), 400); + // Below that, no periodicity inside the peak band: nothing counted. + expect(stepsAt(25, 300), 0); + // A 300 spm sprint's step period (0.2 s) is under StepParams.stepLoS, + // so the stride (0.4 s) is taken as the step: half the true count. A + // documented limit — the edge never lets the strap count sprints. + expect(stepsAt(300, 120), 300); + }); + + test('rate-agnostic: 50 Hz and 25 Hz count like 100 Hz', () { + // The old counter's constants were in SAMPLES and it read ~8% of truth + // at 25 Hz (OxWalk). Every constant is in seconds now. + expect(stepsAt(110, 300, fs: 50) / 550, closeTo(1.0, 0.01)); + expect(stepsAt(110, 300, fs: 25) / 550, closeTo(1.0, 0.02)); + }); + + test('a mislabelled rate barely moves the count inside the band', () { + // Duration and step period both scale with the assumed rate, so their + // ratio — the count — does not. Only the 60-240 spm band edges shift. + expect( + stepsAt(110, 300, fs: 50, tellRate: 100) / 550, closeTo(1.0, 0.02)); + }); + + test('per-minute chunking costs at most a few steps per boundary', () { // still -> N steps at 110 spm -> still, counted in 60 s chunks the way - // calcSteps does. A gain error would scale with N and an offset would - // shrink with it; this does neither, because it is ~2 steps lost per - // minute boundary while the state machine re-earns CONFIRM. It is 0.9%, - // it is known, and it is deliberately not chased. + // calcSteps and the edge's live path do. int chunked(int n) { final walkS = n / (110 / 60.0); final (wx, wy, wz) = _walk(walkS, 110 / 60.0); @@ -177,118 +193,80 @@ void main() { return calcSteps(minutes); } - expect(chunked(100), 100); // one chunk, no boundary, no loss - expect(chunked(1000), 991); // -0.9% - expect(chunked(10000), 9910); // -0.9% — flat, 10x the bout - }); - }); - - group('Tier A — step-interval bounds (0.2-2.0 s, from AN-2554)', () { - int stepsAt(double spm, double durationS, - {double amp = 0.30, double fs = 100, double? tellRate}) { - final (x, y, z) = _walk(durationS, spm / 60.0, fs: fs, ampG: amp); - return livePedometer(x, y, z, sampleRateHz: tellRate ?? fs).steps; - } - - test('the bounds are the ones the note states, and they are named', () { - expect(StepParams.minStepIntervalS, 0.2); // max ~5 steps/s - expect(StepParams.maxStepIntervalS, 2.0); // min ~0.5 steps/s + expect(chunked(1000) / 1000, closeTo(1.0, 0.02)); + expect(chunked(10000) / 10000, closeTo(1.0, 0.02)); }); - test('faster than 5 steps/s is rejected outright', () { - // PRE-FIX these counted at raw/truth 1.00 (320 spm -> 638/640) and 0.95 - // (350 spm -> 666/700): nothing bounded the rhythm, and the only thing - // that eventually broke lock was the width of the centred peak window. - expect(stepsAt(305, 120), 0); - expect(stepsAt(320, 120), 0); - expect(stepsAt(350, 120), 0); + test('a walk shorter than the minimum run is not credited', () { + // StepParams.minRun seconds of periodic signal is what makes it gait. + expect(stepsAt(120, 5), 0); + expect(stepsAt(120, 20), inInclusiveRange(36, 42)); }); - test('the ceiling sits exactly at the stated 5 steps/s, not below it', () { - // A sprint cadence a human can actually produce must still count — the - // guard is physiology, not a convenient way to lose the hard cases. - expect(stepsAt(250, 120), 498); - expect(stepsAt(300, 120), 598); - }); - - test('40 spm still counts — it is inside physiology, so nothing rejects it', - () { - // The audit flagged "1.00 at 40 spm with no floor". 40 spm is 0.67 - // steps/s, above the note's 0.5 steps/s minimum, so the correct floor - // does NOT reject it. Pinning that so the bound does not get tightened - // into a slow-walk deleter. - expect(stepsAt(40, 600, fs: 50), 399); - }); - - test('slower than 0.5 steps/s is rejected — at the buffer\'s REAL rate', () { - // This is the case that proves the bounds are computed from the sample - // rate rather than a hardcoded 100. `maxMinTimeout` is 120 SAMPLES, so - // it already covers the slow end at 100 Hz but stretches to 2.4 s at - // 50 Hz and lets 20-28 spm through. Told the truth, the 2.0 s bound - // rejects them; told "100 Hz" about a 50 Hz buffer, the same signal - // reads a full 250 steps. - expect(stepsAt(25, 600, fs: 50), 0); - expect(stepsAt(28, 600, fs: 50), 0); - expect(stepsAt(25, 600, fs: 50, tellRate: 100), 250); - }); - - test('the bounds cost nothing inside the walking band', () { - // The guards must not pay for themselves with real steps. - expect(stepsAt(110, 600, fs: 50, amp: 0.25), 1098); - expect(stepsAt(120, 300, amp: 0.25), 598); + test('one weak footfall does not reset the run', () { + // The old counter dropped its whole confirm run on one sub-threshold + // pair — the main reason it under-counted real walking. + final (x, y, z) = _walk(60, 2.0); + for (var i = 3000; i < 3050; i++) { + z[i] = 1.0; // ~half a step flattened + } + expect(livePedometer(x, y, z).steps, inInclusiveRange(114, 122)); }); }); - // ── OxWalk: the first ground truth this counter has ever met ────────────── + // ── OxWalk: real free-living wrist ground truth ─────────────────────────── // - // Measured 2026-08-17 with `dart run tool/oxwalk_validate.dart` against + // Measured 2026-10-23 with `dart run tool/oxwalk_validate.dart` against // OxWalk (Small, von Fritsch, Doherty, Khalid, Price; Oxford, Dec 2022, // CC BY) — 39 adults, unscripted free living, dominant wrist + hip, camera - // ground truth. Chunked exactly as production does (`_minuteSamples = 6000`). - // Full write-up: edge/docs/internal/OXWALK_VALIDATION.md. + // ground truth. Chunked exactly as production does (60 s), raw, gain 1.00. + // + // previous peak counter this counter + // Wrist 100 Hz MAPE 33.0% 5/39 ±10% MAPE 16.7% 21/39 ±10% + // walkers bias -25.4% walkers bias -10.4% + // totals -19.8% totals -7.3% + // Wrist 50 Hz MAPE 33.6% MAPE 16.9% (/2 decimated) + // Wrist 25 Hz MAPE 91.9% <- dead MAPE 17.3% + // Hip 100 Hz MAPE 29.5% MAPE 17.6% + // worst over-count: P28 (rhythmic arm work) 258 true -> 696 counted + // chunking cost: -2.4% vs one contiguous buffer // - // Wrist 100 Hz MAPE 33.0% bias -13.6% median -19.5% totals -19.8% - // per-participant ratio 0.33 - 3.00, only 5/39 within +-10% - // Hip 100 Hz MAPE 29.5% bias -29.2% totals -17.6% - // Wrist 25 Hz MAPE 91.9% totals -87.5% <- dead - // Hip 25 Hz MAPE 94.5% totals -93.6% <- dead - // rate cliff (100 Hz wrist decimated): 50 Hz -27.0%, 33 Hz -60.1%, - // 25 Hz -86.5%, 20 Hz -89.9% - // chunk sweep (wrist 100 Hz, MAPE): 25 s 37.4 | 30 s 34.5 | 60 s 33.0 - // | 2 min 34.3 | 5 min 37.7 | 1 h 55.1 - // best single gain 1.18 -> MAPE 27.9% (from 33.0%). Not applied: the error - // is not multiplicative — see the doc. + // The gate thresholds (ampG, regHi, regLo, minRun) were chosen by grid + // search with leave-one-participant-out validation: the held-out MAPE was + // 17.5%, and 38 of 39 folds picked this same configuration. // - // The dataset is 290 MB and is deliberately not committed, so nothing here - // re-measures it. What this pins is the CONFIGURATION those numbers describe: - // change any of it and the recorded numbers are stale, which is exactly when - // you want to be told. - group('Tier A — OxWalk-measured configuration (2026-08-17)', () { + // The dataset is 290 MB and deliberately not committed, so nothing here + // re-measures it. What this pins is the CONFIGURATION those numbers describe. + group('Tier A — OxWalk-measured configuration (2026-10-23)', () { test('the parameters OxWalk was measured against have not moved', () { - const why = 'OxWalk numbers in edge/docs/internal/OXWALK_VALIDATION.md ' - 'were measured against this exact configuration. If you changed it ' - 'deliberately, re-run tool/oxwalk_validate.dart on the 39 ' - 'participants and update BOTH the doc and this test.'; + const why = 'The OxWalk numbers above were measured against this exact ' + 'configuration. If you changed it deliberately, re-run ' + 'tool/oxwalk_validate.dart on the 39 participants and update the ' + 'numbers here and in StepParams.'; expect(StepParams.gain, 1.00, reason: why); - expect(StepParams.sens, 0.10, reason: why); - expect(StepParams.confirm, 8, reason: why); - expect(StepParams.filter, 8, reason: why); - expect(StepParams.window, 33, reason: why); - expect(StepParams.thrOrder, 4, reason: why); - expect(StepParams.maxMinTimeout, 120, reason: why); - expect(StepParams.minStepIntervalS, 0.2, reason: why); - expect(StepParams.maxStepIntervalS, 2.0, reason: why); - }); - - test('gain stays 1.00 — OxWalk found no single multiplier that works', () { - // Tempting and wrong. 1.18 minimises MAPE across the 39 and recovers only - // 5 points of a 33-point error, and the sign of the error FLIPS with - // activity level: participants with >=600 true steps/h under-count by a - // uniform -25.4%, while the 17 with <600 average +1.7% bias around a - // 42.9% MAPE because two of them over-count by 3x (P18: 217 true, 650 - // counted; P28: 258 true, 685 counted). Multiplying by 1.18 improves the - // walkers slightly and pushes those two from +200%/+166% to +254%/+214%. - // A gain cannot fix an error whose sign depends on the input. + expect(StepParams.winS, 4.0, reason: why); + expect(StepParams.hopS, 1.0, reason: why); + expect(StepParams.decimateAboveHz, 50, reason: why); + expect(StepParams.lagLoS, 0.2, reason: why); + expect(StepParams.lagHiS, 2.0, reason: why); + expect(StepParams.peakLoS, 0.25, reason: why); + expect(StepParams.peakHiS, 1.6, reason: why); + expect(StepParams.harmonic, 0.8, reason: why); + expect(StepParams.strideS, 0.9, reason: why); + expect(StepParams.halfAc, -0.4, reason: why); + expect(StepParams.stepLoS, 0.25, reason: why); + expect(StepParams.stepHiS, 1.0, reason: why); + expect(StepParams.ampG, 0.04, reason: why); + expect(StepParams.regHi, 0.6, reason: why); + expect(StepParams.regLo, 0.2, reason: why); + expect(StepParams.minRun, 8, reason: why); + }); + + test('gain stays 1.00 — a population multiplier is not a calibration', () { + // OxWalk's best single gain is 1.03 (MAPE 16.7% -> 15.7%), inside the + // noise of 39 participants, and the residual error is not + // multiplicative: the 45-81 age band is near zero bias while the others + // under-count ~7%. expect(StepParams.gain, 1.00); }); }); @@ -869,8 +847,8 @@ void main() { test('#43: the day and the bout bill the same minute the same way', () { // They used to disagree by 8–35 bpm: the day gated at 0.65·HRmax, the // bout at rest + 0.30·HRR. Same stream, same minute, two answers. - const profile = WorkoutUserProfile( - weightKg: 72, heightCm: 178, age: 34, sex: 'male'); + const profile = + WorkoutUserProfile(weightKg: 72, heightCm: 178, age: 34, sex: 'male'); const hrmax = 184.2, rhr = 55.0; final gate = Calories.activeGateHr(hrmax, rhr)!; expect(gate, closeTo(106.68, 0.01)); @@ -914,8 +892,8 @@ void main() { // CADENCE-Adults publishes the cadence↔MET line for exactly this // region. Minutes the HR gate refuses are billed from cadence instead — // never both, and never from a cadence nobody measured. - const profile = WorkoutUserProfile( - weightKg: 80, heightCm: 180, age: 30, sex: 'male'); + const profile = + WorkoutUserProfile(weightKg: 80, heightCm: 180, age: 30, sex: 'male'); const hrmax = 190.0, rhr = 60.0; final basalPerMin = Calories.mifflinBmrKcalDay(80, 180, 30, 'male') / 1440.0; @@ -1034,8 +1012,8 @@ void main() { // `hr < NaN` is false for EVERY hr — so the day used to bill every single // minute at the Keytel active rate and publish a silently enormous kcal // figure instead of failing visibly. - const profile = WorkoutUserProfile( - weightKg: 80, heightCm: 180, age: 30, sex: 'male'); + const profile = + WorkoutUserProfile(weightKg: 80, heightCm: 180, age: 30, sex: 'male'); final hr = List.filled(1440, 70.0); final ts = List.generate(60, (i) => i); @@ -1070,8 +1048,8 @@ void main() { }); test('a non-finite HR sample is dropped, not billed', () { - const profile = WorkoutUserProfile( - weightKg: 80, heightCm: 180, age: 30, sex: 'male'); + const profile = + WorkoutUserProfile(weightKg: 80, heightCm: 180, age: 30, sex: 'male'); final clean = List.filled(60, 150.0); final dirty = [...clean, double.nan, double.infinity]; final a = Calories.dailyEnergy(clean,