From 00a2efef57ffdf37c08b6f9255f136e559c2b7ae Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 3 Aug 2026 20:49:01 +0530 Subject: [PATCH 1/2] Delete the 1 Hz step estimate; movement minutes on measured evidence A user reported 2,645 steps on a day they took under 400. Diagnosed on their real DB: the number was 23 "active minutes" x an assumed 115 spm cadence. Both halves of that conversion are invalid at 1 Hz and neither is fixable by retuning, so the conversion is removed rather than recalibrated. * Cadence is NOT IDENTIFIABLE. Gait is 1.4-2.3 Hz (Straczkiewicz 2023, doi:10.1038/s41746-022-00745-z); at 1 Hz every fundamental is sub-Nyquist and 80/100/140/160 spm alias to the same 0.333 Hz. No published step detector exists below 10 Hz. * The minutes were never specifically ambulation. At the wrist, arm work out-accelerates walking (stirring ~104 mg, chopping ~139 mg vs walking ~66 mg ENMO), so a movement threshold cannot isolate gait even at full rate: wrist devices emit 22-27 false steps/min during dishes, reaching and driving (O'Connell 2017, doi:10.1371/journal.pone.0169616) while detecting slow walking at sensitivity 0.05. The errors have OPPOSITE sign, so no gain constant corrects both. Confirmed against that DB's own ground truth: in the single window where the 100 Hz pedometer and 1 Hz data overlap, HR ran 95->108 with dynAmp 0.31-0.40 g and the REAL count was 11 steps in 3.1 min (3.5 spm) where the estimator would have assigned ~115 spm. `dailyStepEstimate` -> `dailyActiveMinutes`, returning `DailyMovementEstimate`: active minutes only, no steps, no cadence, no range. A contract test asserts the serialised surface can never regain a step or cadence key. Everything below was PROVEN against 4 days of real 1 Hz substrate first: * HR GATE DELETED. `restingHr + 8 bpm` changed the answer by exactly ZERO minutes on every day tested. At RHR ~62 it sits at ~6% of heart-rate reserve (below every ACSM band) and 73-100% of covered minutes already cleared it. It failed in the wrong direction too: PPG HR is least reliable during the motion being gated, so a dropout deleted minutes the accelerometer measured fine. The API no longer accepts HR at all. * x3 CEILING DELETED. Rejected ZERO minutes on all 4 days with 0.42-0.55 g of headroom, and cannot fire on artifacts (a 3 s knock averages ~0.23 g, below the FLOOR). The only thing it could ever exclude was real exertion. * FLOOR MUST BE FROZEN. It is derived from the signal it thresholds, so a continuously recomputed floor cancels the trend it exists to report: scaling a real day's dynAmp gave 37 active minutes at 1x, 1.5x, 2x AND 3x when recomputed, versus 23 -> 254 frozen. Adds `enrollmentDaysForFrozenFloor` and `shouldRefreezeFloor`. REFUTED and deliberately not built: a sleep-anchored floor (CV 138.6% across days vs 9.3% for the current estimator, and on one night it landed above the entire day's range, which would report zero); accel autocalibration (offset and uniform gain cancel exactly through the high-pass and the floor normalisation -- +5% gain moves the gate decision by 0.0000; only anisotropic gain survives at ~1-3%); gravity/forearm orientation (it solved the ambulation problem this commit deletes). SEMANTICS CORRECTED. The WHOOP 1 Hz accel field is a fused GRAVITY vector, not acceleration: across 269,486 real samples ||a|| is p50 1.027 g with 0.030% above 1.3 g, and during the single most vigorous minute of a day it measured 1.033 g +- 0.006 with 0 of 420 samples above 1.2 g. So `dynAmp` measures how fast the wrist RE-ORIENTS, and ENMO/MAD over this substrate reduce to ~(1.03 - gRef): a pure calibration artifact with zero signal. That is the true root cause of the 42,155-steps-at-gRef-0.97 / 0-at-1.02 collapse, and it is now documented on the type instead of a comment claiming gravity removal leaves "motion". 388 tests pass. --- lib/src/onehz/motion/enmo.dart | 53 ++++- lib/src/onehz/motion/motion.dart | 15 +- lib/src/onehz/motion/steps.dart | 324 +++++++++++++++++-------------- test/onehz/steps_test.dart | 267 +++++++++++++------------ 4 files changed, 376 insertions(+), 283 deletions(-) diff --git a/lib/src/onehz/motion/enmo.dart b/lib/src/onehz/motion/enmo.dart index 23c0836..6b45931 100644 --- a/lib/src/onehz/motion/enmo.dart +++ b/lib/src/onehz/motion/enmo.dart @@ -56,16 +56,53 @@ import '../util.dart'; class MotionMinute { final double tsMinStartMs; // wall-clock start of the minute (ms) final int nSamples; // valid samples that fed this minute - final double enmo; // mean ENMO over the minute (g), ≥0 - final double mad; // mean amplitude deviation over the minute (g), ≥0 + + /// ⚠️ MEANINGLESS ON THE WHOOP 1 Hz SUBSTRATE — diagnostics only. + /// + /// ENMO is `mean(max(0, ‖a‖ − gRef))`, which assumes ‖a‖ carries dynamic + /// acceleration. The band's 1 Hz historical record does NOT: it ships a fused + /// gravity/orientation vector (see [dynAmp]). Measured over 269,486 real + /// samples, ‖a‖ sits at p50 = 1.027 g with only 0.030% above 1.3 g — during + /// the single most vigorous minute of a day it was 1.033 g ± 0.006. + /// + /// So on this substrate ENMO reduces to roughly `1.03 − gRef`: a pure + /// calibration artifact carrying ZERO signal. That is precisely why an early + /// step estimator built on it reported 42,155 steps at gRef 0.97 and 0 at + /// 1.02. Do NOT threshold this, and do NOT feed it to anything expecting + /// accelerometry (Brage fusion, MET/cut-point models). It stays only because + /// a HIGH-RATE source (the 100 Hz live stream) does carry real accel. + final double enmo; + + /// ⚠️ Same caveat as [enmo] on the 1 Hz substrate — see above. + final double mad; final double meanMag; // mean ‖a‖ over the minute (g) — for diagnostics - /// Mean magnitude of the GRAVITY-REMOVED (per-axis high-passed) accel vector - /// over the minute (g), ≥0. CALIBRATION-INVARIANT: any constant per-axis - /// offset — sensor bias, or the projection of gravity in the current posture - /// — cancels exactly, and a per-axis gain only rescales it. This is the - /// feature to threshold when the cut-point must be stable across days; see - /// the file header for the derivation. + /// Mean magnitude of the per-axis HIGH-PASSED vector over the minute (g), ≥0. + /// + /// WHAT IT ACTUALLY MEASURES: on the WHOOP 1 Hz record this is the rate at + /// which the wrist is RE-ORIENTING, not how hard it is accelerating. That + /// record's accel field is a firmware-fused gravity vector — its magnitude is + /// pinned near 1 g even during the most vigorous minute of a day (measured: + /// 1.033 g ± 0.006, 0 of 420 samples above 1.2 g). High-passing an + /// (approximately) unit vector yields how fast its DIRECTION is changing. + /// + /// That is still a usable activity-volume index — rotating the wrist a lot is + /// real movement — but read it honestly: + /// • walking with hands in pockets, holding a phone, or pushing a cart + /// keeps the forearm still and is nearly INVISIBLE here; + /// • stirring, chopping, tool use and gesturing are MAXIMAL here. + /// It is not a locomotion measure and must never be converted to steps. + /// + /// CALIBRATION-INVARIANT, and this is load-bearing: a constant per-axis offset + /// (sensor bias, or the gravity projection of a held posture) appears in both + /// the sample and its trailing mean and cancels EXACTLY; a uniform gain error + /// rescales signal and threshold alike, so a floor derived from this same + /// signal's own distribution cancels it too (verified numerically: +5% gain + /// moves the gate decision by 0.0000). Only per-axis ANISOTROPIC gain + /// survives, at ~1-3%. This is why autocalibration is unnecessary here — but + /// the invariance is a property of the TRAILING-MEAN REFERENCE, not of the + /// sensor. Reintroduce any fixed-1 g reference and calibration becomes + /// mandatory again, and this type's property tests will NOT catch it. final double dynAmp; const MotionMinute( diff --git a/lib/src/onehz/motion/motion.dart b/lib/src/onehz/motion/motion.dart index d0f55bc..d773bc0 100644 --- a/lib/src/onehz/motion/motion.dart +++ b/lib/src/onehz/motion/motion.dart @@ -22,13 +22,14 @@ /// 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). -/// * [dailyStepEstimate] reports ACTIVE MINUTES from the 1 Hz substrate — -/// the quantity that IS resolvable there — and derives a step RANGE from -/// the free-living cadence band. Its threshold is a multi-day personal -/// reference ([personalDynFloor]); with too little history it ABSTAINS -/// rather than substituting a constant. -/// * [calibrateCadence] lets the live path narrow that range to the user's -/// own 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 +/// minutes are activity volume, not locomotion. Its threshold is a +/// multi-day personal reference ([personalDynFloor]); with too little +/// history it ABSTAINS rather than substituting a constant. +/// * [calibrateCadence] measures the user's real walking cadence from the +/// 100 Hz path. Reportable on its own; never used to synthesise steps. /// Still genuinely impossible / not faked: dynamic-orientation limb tracking, /// frequency-domain activity TYPE classification (walk vs run vs cycle). /// At 1 Hz only an AMPLITUDE index + STATIC orientation are recoverable, and diff --git a/lib/src/onehz/motion/steps.dart b/lib/src/onehz/motion/steps.dart index 3792df5..9461cba 100644 --- a/lib/src/onehz/motion/steps.dart +++ b/lib/src/onehz/motion/steps.dart @@ -17,11 +17,14 @@ // waving/typing/handling and reads 0 at rest. Directly testable: walk N // steps with the app open and compare. // -// TIER B — [dailyStepEstimate]: a 24/7 estimate from the 1 Hz substrate. We -// cannot count steps, so the PRIMARY quantity we report is the one that IS -// resolvable at 1 Hz: ACTIVE (ambulatory) MINUTES. Steps are then reported -// as a RANGE, minutes × the free-living cadence band (Tudor-Locke 2011, -// ~100–130 steps/min), never as a single fabricated-precision number. +// TIER B — [dailyActiveMinutes]: a 24/7 MOVEMENT-VOLUME index from the 1 Hz +// substrate. It reports MINUTES OF SUSTAINED WRIST MOVEMENT and NOTHING +// ELSE. It emits no step count and no cadence, because at 1 Hz gait is +// unidentifiable (aliasing) AND wrist amplitude ranks arm work above +// walking, so a movement threshold cannot isolate ambulation in principle. +// A previous version multiplied these minutes by a cadence band; it was +// measured over-reporting ~6× on a real day and has been removed. Steps +// come only from a gait-capable source (Tier A, or the phone pedometer). // // Three things make the minute detector stable, and all three matter: // 1. The feature is [MotionMinute.dynAmp] — per-axis high-passed dynamic @@ -256,9 +259,9 @@ PedometerResult livePedometer( /// A personal cadence model learned from live (100 Hz) walking. /// -/// [cadenceSpm] is the user's measured walking cadence — the one quantity Tier A -/// genuinely measures and the only one Tier B consumes (to narrow its reported -/// step band; see [dailyStepEstimate]). [refEnmo] is the concurrent 1 Hz ENMO +/// [cadenceSpm] is the user's measured walking cadence — a genuine Tier A +/// measurement, reportable on its own. It is deliberately NOT consumed by the +/// 1 Hz path ([dailyActiveMinutes]). [refEnmo] is the concurrent 1 Hz ENMO /// level (g), retained as a diagnostic of what the norm-based index read during /// known walking; it is NOT part of any threshold. [n] counts the live windows /// folded in (more = more trusted). @@ -348,24 +351,15 @@ StepCalibration? calibrateCadence( // // And when there is not enough history to estimate that floor, we ABSTAIN. -/// Free-living walking cadence band (steps/min), Tudor-Locke 2011 (and the -/// cadence-band literature that follows it): purposeful adult ambulation in -/// free living sits around 100 steps/min, with normal walking spanning roughly -/// 100–130. We report the BAND, not a point, because 1 Hz accel cannot resolve -/// cadence at all — see [dailyStepEstimate]. -const double freeLivingCadenceLowSpm = 100.0; -const double freeLivingCadenceHighSpm = 130.0; - -/// Physiological clamp for any personally-measured cadence used to narrow the -/// band. Outside this, the "measurement" is not walking. +/// Physiological clamp for a personally-measured (Tier A, 100 Hz) cadence. +/// Outside this, the "measurement" is not walking. +/// +/// NOTE: cadence is used ONLY to describe a real 100 Hz walking bout. It is +/// never applied to 1 Hz minutes to synthesise a step count — see +/// [dailyActiveMinutes] for why that conversion was removed. const double cadenceClampLowSpm = 60.0; const double cadenceClampHighSpm = 180.0; -/// Half-width (fraction) of the band placed around a personally MEASURED -/// cadence. Tier A measures cadence over a bout; ±10% covers the ordinary -/// within-person spread between strolling and purposeful walking. -const double personalCadenceBandFrac = 0.10; - /// Quantile of the POOLED trailing dynAmp minutes used as the ambulatory floor. /// /// p90 means "the top decile of your minutes is where ambulation lives", which @@ -436,6 +430,32 @@ double? personalDynFloor( /// unusable in the first place. Pooling the raw minutes would let one very long /// day dominate; the median weights every day equally. /// +/// ⚠️ FREEZE THE RESULT. Call this ONCE, persist it, and keep using the stored +/// value — do NOT recompute it every day. See [enrollmentDaysForFrozenFloor]. +/// +/// This threshold is derived from the same signal it thresholds, so if it is +/// recomputed continuously it TRACKS the user and cancels the trend it exists +/// to report. Measured on real substrate by scaling one day's `dynAmp` and +/// recomputing both ways: +/// +/// ``` +/// activity x FROZEN floor recomputed floor +/// 1.00 23 37 +/// 1.50 66 37 +/// 2.00 128 37 +/// 3.00 254 37 +/// ``` +/// +/// A recomputed floor reports the SAME number whether the user tripled their +/// activity or did nothing. A frozen one tracks it. The continuously-updating +/// form is a metric that cannot see change. +/// +/// (A sleep-quiet anchor was tested as the alternative and REFUTED on the same +/// data: coefficient of variation 138.6% across days versus 9.3% for this +/// estimator, and on one night it landed above the entire day's range, which +/// would report zero movement. The median-of-daily-p90s is the right ESTIMATOR; +/// the bug was only ever that it was never frozen.) +/// /// Returns `null` below [minDays] of history, or when the result is degenerate. double? personalDynFloorFromDailySummaries( List dailyHighQuantiles, { @@ -451,6 +471,35 @@ double? personalDynFloorFromDailySummaries( return m; } +/// Days of history to accumulate before FREEZING the movement floor. +/// +/// [personalDynFloorMinDays] (5) is the minimum at which the median is +/// meaningful at all; this is the point at which it is stable enough to commit +/// to. Measured CV of the daily p90 across real days is ~9%, so a median over +/// this many days is well inside the estimator's own noise. +const int enrollmentDaysForFrozenFloor = 14; + +/// Should the persisted floor be re-estimated? +/// +/// Deliberately restrictive. The whole value of freezing is lost if it thaws on +/// its own, so this returns true only for events that genuinely change the +/// signal's scale — never merely because time passed or activity changed. +/// +/// [daysSinceFrozen] guards against indefinite staleness; [wearGapDays] catches +/// a device that was off-wrist long enough that the body/device relationship may +/// have changed; [deviceChanged] and [wristChanged] are explicit user events. +bool shouldRefreezeFloor({ + required int daysSinceFrozen, + int wearGapDays = 0, + bool deviceChanged = false, + bool wristChanged = false, + int maxAgeDays = 365, +}) => + deviceChanged || + wristChanged || + wearGapDays >= 30 || + daysSinceFrozen >= maxAgeDays; + /// The per-day value a caller should persist to feed /// [personalDynFloorFromDailySummaries] — this day's own high quantile of /// `dynAmp` over its covered minutes. Returns `null` when the day is too thin @@ -472,72 +521,77 @@ double? dailyDynSummary( return q; } -/// Daily ACTIVITY estimate from the 1 Hz substrate. +/// Daily MOVEMENT estimate from the 1 Hz substrate. /// -/// [activeMinutes] is the PRIMARY, honest quantity: minutes spent ambulatory. -/// It is what a 1 Hz accel stream can actually support, and it is the unit -/// public activity guidance is written in (minutes of moderate activity). +/// [activeMinutes] — minutes containing sustained wrist movement — is the ONLY +/// quantity this returns, because it is the only one a 1 Hz wrist accel stream +/// supports. There is deliberately no step count and no cadence here; see +/// [dailyActiveMinutes] for why converting these minutes to steps was removed. /// -/// Steps are reported as the RANGE [stepsLow]–[stepsHigh] = activeMinutes × -/// the cadence band. [steps] is the midpoint, provided only so callers that -/// must render one scalar can; it carries no more information than the range -/// and should be shown with the range wherever there is room. -class DailyStepEstimate { - final int activeMinutes; // primary quantity - final int stepsLow; // activeMinutes × cadenceLowSpm - final int stepsHigh; // activeMinutes × cadenceHighSpm - final int steps; // midpoint of the range (back-compat scalar) - final double cadenceLowSpm; - final double cadenceHighSpm; +/// Read it as "minutes the wrist was moving", NOT as "minutes spent walking": +/// at the wrist, ordinary arm work (cooking, dishes, driving, tool use) produces +/// MORE acceleration than walking does, so a high value does not imply +/// ambulation. It is an activity-volume index, not a locomotion measure. +class DailyMovementEstimate { + final int activeMinutes; // minutes with sustained wrist movement final double dynFloorG; // personal floor actually applied (g) final double coverage; // fraction of the day with valid motion data - final bool calibrated; // a personally MEASURED cadence narrowed the band + final int boutCount; // number of qualifying bouts - const DailyStepEstimate({ + const DailyMovementEstimate({ required this.activeMinutes, - required this.stepsLow, - required this.stepsHigh, - required this.steps, - required this.cadenceLowSpm, - required this.cadenceHighSpm, required this.dynFloorG, required this.coverage, - required this.calibrated, + required this.boutCount, }); Map toJson() => { 'active_min': activeMinutes, - 'steps_low': stepsLow, - 'steps_high': stepsHigh, - 'steps': steps, - 'cadence_low_spm': round6(cadenceLowSpm), - 'cadence_high_spm': round6(cadenceHighSpm), - 'cadence_source': calibrated ? 'personal_measured' : 'population_band', + 'bout_count': boutCount, 'dyn_floor_g': round6(dynFloorG), 'coverage': round6(coverage), - 'calibrated': calibrated, }; } -/// 1 Hz ACTIVE-MINUTES estimate, with steps as a derived RANGE. +/// 1 Hz MOVEMENT-MINUTES estimate. Returns NO step count, by design. +/// +/// WHY THERE IS NO STEP COUNT HERE (this replaced a step estimator that was +/// measured to over-report by ~6× on a real day, and the reason is structural, +/// not a calibration error): +/// +/// 1. NYQUIST. Gait is 1.4–2.3 Hz (Straczkiewicz 2023, npj Digit Med, +/// doi:10.1038/s41746-022-00745-z). At 1 Hz sampling every gait +/// fundamental is sub-Nyquist and the alias map is many-to-one: 80, 100, +/// 140 and 160 spm all alias to 0.333 Hz. Cadence is not merely noisy, it +/// is NOT IDENTIFIABLE. No published step detector exists below 10 Hz. +/// +/// 2. AMPLITUDE IS INVERTED AT THE WRIST. Walking produces LESS wrist +/// acceleration than ordinary arm work — walking ≈ 66 mg ENMO vs stirring +/// ≈ 104 mg and chopping ≈ 139 mg. So a movement threshold cannot isolate +/// walking: walking sits mid-pack in a normal day's distribution. Wrist +/// devices are documented to emit 22–27 false steps/min during dishes, +/// reaching and cycling (O'Connell 2017, PLoS ONE, +/// doi:10.1371/journal.pone.0169616) while detecting slow walking with +/// sensitivity 0.05 — the two errors have OPPOSITE sign, so no gain +/// constant can fix both. /// -/// NYQUIST, stated plainly: gait is 1.4–2.5 Hz and 2.0 Hz — 120 steps/min, the -/// most common adult cadence — aliases exactly to DC on a 1 Hz stream. Steps -/// are therefore NOT resolvable here and neither is cadence. What IS resolvable -/// is whether a minute contained sustained whole-body movement. So this -/// function detects AMBULATORY MINUTES and converts them to a step RANGE using -/// a cadence band (Tudor-Locke 2011), never a per-minute cadence estimate. +/// A real step count must come from a source that can see gait: the Tier A +/// 100 Hz pedometer ([pedometer]/[livePedometer]) or the phone's own pedometer. +/// Absent those, the honest output is no number at all. /// -/// A covered minute is ambulatory when ALL of these hold: -/// • its [MotionMinute.dynAmp] is above [personalDynFloorG] and at or below -/// `personalDynFloorG × [vigorousCeilingRatio]` (above the ceiling is -/// vigorous/non-ambulatory arm motion, counted as activity elsewhere); -/// • when HR is supplied, its HR is at least `restingHr + [hrMarginBpm]` -/// ([restingHr] if given, else the day's 10th-percentile HR); -/// • it belongs to a run of at least [minBoutMin] CONSECUTIVE ambulatory +/// What IS resolvable at 1 Hz is whether a minute contained sustained movement, +/// which is what this returns. A covered minute counts when BOTH of these hold: +/// • its [MotionMinute.dynAmp] is above [personalDynFloorG]; +/// • it belongs to a run of at least [minBoutMin] CONSECUTIVE qualifying /// minutes, where consecutive means adjacent in ORIGINAL minute index — a /// coverage gap breaks the run and cannot stitch two short stretches into -/// one qualifying bout. +/// one qualifying bout. This is a DENOISER, not a health threshold: the +/// 2018 US Physical Activity Guidelines removed the 10-minute minimum-bout +/// rule, so do not defend this number physiologically. +/// +/// There is deliberately no upper ceiling and no HR gate. Both were measured +/// against real substrate and found to be dead or harmful — see the comments +/// at their former sites before reintroducing either. /// /// [personalDynFloorG] is REQUIRED and MAY BE NULL. Null means "not enough /// history to know this user's movement scale", and the honest answer to that @@ -546,28 +600,22 @@ class DailyStepEstimate { /// note can report progress. There is deliberately NO constant fallback: a /// constant absolute floor is precisely the failure this design removes. /// -/// [calib] (a personally MEASURED Tier A cadence) narrows the reported band to -/// ±[personalCadenceBandFrac] around that cadence; otherwise the population -/// band is used. Tier is always ESTIMATE. +/// Tier is always ESTIMATE. /// -/// IMPORTANT (no double-count): the caller must pass ONLY minutes NOT covered by -/// the live 100 Hz pedometer — 100 Hz steps are real and always preferred for the -/// time they cover. This function never sees those minutes. -Metric dailyStepEstimate( +/// This function is intentionally independent of the 100 Hz pedometer: it +/// measures movement volume, not steps, so there is nothing to double-count. +/// Callers may still exclude 100 Hz-covered minutes if they want the two +/// quantities to describe disjoint spans. +Metric dailyActiveMinutes( List motion, { required double? personalDynFloorG, - List? hrPerMin, - double? restingHr, - StepCalibration? calib, - double hrMarginBpm = 8.0, double minSamplesPerMinute = 30, int minBoutMin = 3, - double vigorousCeilingRatio = defaultVigorousCeilingRatio, int pooledMinutesAvailable = 0, }) { - const inputs = ['dyn_amp_per_min', 'hr_per_min', 'personal_dyn_floor']; + const inputs = ['dyn_amp_per_min', 'personal_dyn_floor']; if (motion.isEmpty) { - return const Metric.absent( + return const Metric.absent( tier: Tier.estimate, inputs_used: inputs, note: 'no motion minutes', @@ -578,7 +626,7 @@ Metric dailyStepEstimate( // constant floor; a constant floor is the bug this rewrite exists to fix. final floor = personalDynFloorG; if (floor == null || !floor.isFinite || floor <= 0) { - return Metric.absent( + return Metric.absent( tier: Tier.estimate, inputs_used: inputs, note: needBaselineNote( @@ -587,7 +635,14 @@ Metric dailyStepEstimate( ), ); } - final ceiling = floor * math.max(vigorousCeilingRatio, 1.0); + // NO CEILING. A `floor x 3` upper bound used to reject "vigorous non-gait" + // motion. It was MEASURED against 4 days of real substrate and rejected + // ZERO minutes on every one of them, with 0.42-0.55 g of headroom to the + // day's maximum. It cannot fire on artifacts either: a 3-second knock + // averages to ~0.23 g over its minute, which does not even reach the FLOOR. + // So the only thing it could ever exclude is a genuinely hard session — a + // volume metric that discards its highest-volume minutes is broken by + // definition. Removed rather than retuned. // Covered minutes only — sparse minutes can't be judged. final idx = []; @@ -601,7 +656,7 @@ Metric dailyStepEstimate( final covered = idx.length; final coverage = covered / motion.length; if (covered < dailyStepMinCoveredMinutes) { - return Metric.absent( + return Metric.absent( tier: Tier.estimate, inputs_used: inputs, note: 'too few covered minutes to estimate activity ' @@ -609,45 +664,30 @@ Metric dailyStepEstimate( ); } - // Cadence band. A personally MEASURED cadence (Tier A, 100 Hz, real counts) - // narrows the band; otherwise we use the free-living population band. We do - // NOT model per-minute cadence — 1 Hz cannot resolve it. - final measured = calib != null && - calib.n >= 3 && - calib.cadenceSpm >= cadenceClampLowSpm && - calib.cadenceSpm <= cadenceClampHighSpm - ? calib.cadenceSpm - : null; - final calibrated = measured != null; - final cadLow = calibrated - ? clamp(measured * (1 - personalCadenceBandFrac), cadenceClampLowSpm, - cadenceClampHighSpm) - : freeLivingCadenceLowSpm; - final cadHigh = calibrated - ? clamp(measured * (1 + personalCadenceBandFrac), cadenceClampLowSpm, - cadenceClampHighSpm) - : freeLivingCadenceHighSpm; - - // HR corroboration: HR must be lifted off rest for a minute to count. - final useHr = hrPerMin != null && hrPerMin.length == motion.length; - double restHr = restingHr ?? 0; - if (useHr && restingHr == null) { - final hrs = [for (final h in hrPerMin) if (h > 0) h]; - if (hrs.length >= 10) restHr = percentile(hrs, 10)!; - } - final hrGate = restHr + hrMarginBpm; + // NO CADENCE BAND. Converting these minutes to steps via any cadence — even a + // personally measured one — is the fabrication this function was rewritten to + // remove. See the doc comment: cadence is not identifiable at 1 Hz, and the + // minutes being counted are not specifically ambulation. + + // NO HR GATE. A `restingHr + 8 bpm` corroboration gate used to sit here. It + // was MEASURED across 4 days of real substrate and changed the answer by + // exactly ZERO minutes on every day: at a resting HR of ~62 the gate lands + // at ~6% of heart-rate reserve, below every ACSM band -- physiologically + // "not lying down" -- and 73-100% of covered minutes already cleared it. + // + // It was worse than merely useless. It implied a physiological corroboration + // it never performed, it is satisfied all day in a hot climate (passive heat + // raises HR 10-25 bpm at zero metabolic cost), and it failed in the WRONG + // DIRECTION: PPG-derived HR is least reliable during exactly the motion + // being gated, so a dropout deleted minutes the accelerometer measured + // perfectly well. An honestly accelerometer-only metric is more defensible + // than one carrying a decorative HR gate. // pass 1 — per-minute gate: movement inside the ambulatory band, and (when // HR is available) HR lifted off rest. final gateOk = List.filled(idx.length, false); for (var k = 0; k < idx.length; k++) { - final d = dyns[k]; - if (d <= floor || d > ceiling) continue; // sedentary, or vigorous non-gait - if (useHr && restHr > 0) { - final hr = hrPerMin[idx[k]]; - if (hr > 0 && hr < hrGate) continue; // HR says still at rest - } - gateOk[k] = true; + gateOk[k] = dyns[k] > floor; } // pass 2 — bout gate: only credit minutes inside a run of >= minBoutMin @@ -657,6 +697,7 @@ Metric dailyStepEstimate( // covered-minutes array, so a coverage gap can't stitch two separate // stretches into one fake long bout. var activeMin = 0; + var boutCount = 0; var k = 0; while (k < idx.length) { if (!gateOk[k]) { @@ -669,45 +710,32 @@ Metric dailyStepEstimate( idx[end + 1] == idx[end] + 1) { end++; } - if (end - k + 1 >= minBoutMin) activeMin += end - k + 1; + if (end - k + 1 >= minBoutMin) { + activeMin += end - k + 1; + boutCount++; + } k = end + 1; } - final stepsLow = (activeMin * cadLow).round(); - final stepsHigh = (activeMin * cadHigh).round(); - final stepsMid = ((stepsLow + stepsHigh) / 2).round(); - - // Confidence reflects (a) how much of the day we could actually judge and - // (b) whether the cadence band is this user's or the population's. It never - // reflects the step number itself — that number is a band by construction. - final conf = clamp( - (calibrated ? 0.45 : 0.30) * clamp(coverage / 0.6, 0.3, 1.0), - 0.1, - 0.7, - ); + // Confidence reflects how much of the day we could actually judge. It is + // capped low because this is a movement-volume index from a wrist sensor: it + // cannot distinguish walking from arm work, which is a limitation of the + // measurement site, not of the estimator. + final conf = clamp(0.30 * clamp(coverage / 0.6, 0.3, 1.0), 0.1, 0.45); - return Metric( - value: DailyStepEstimate( + return Metric( + value: DailyMovementEstimate( activeMinutes: activeMin, - stepsLow: stepsLow, - stepsHigh: stepsHigh, - steps: stepsMid, - cadenceLowSpm: cadLow, - cadenceHighSpm: cadHigh, dynFloorG: floor, coverage: coverage, - calibrated: calibrated, + boutCount: boutCount, ), confidence: conf, tier: Tier.estimate, inputs_used: inputs, - note: calibrated - ? 'ESTIMATE: active minutes from gravity-removed 1 Hz amplitude vs your ' - 'personal movement floor; steps = minutes × your measured cadence ' - '(${cadLow.round()}–${cadHigh.round()} spm) — 1 Hz cannot count steps' - : 'ESTIMATE: active minutes from gravity-removed 1 Hz amplitude vs your ' - 'personal movement floor; steps = minutes × the free-living cadence ' - 'band (${cadLow.round()}–${cadHigh.round()} spm). Walk with the app ' - 'open to measure your own cadence and narrow the range', + note: 'ESTIMATE: minutes of sustained wrist movement, measured against your ' + 'personal movement floor. This is activity volume, NOT walking — at the ' + 'wrist, arm work (cooking, dishes, driving) registers as strongly as ' + 'walking does. It is deliberately not converted to a step count', ); } diff --git a/test/onehz/steps_test.dart b/test/onehz/steps_test.dart index c48eea3..7ea3282 100644 --- a/test/onehz/steps_test.dart +++ b/test/onehz/steps_test.dart @@ -1,13 +1,17 @@ -// STEPS — hybrid pedometry tests. +// MOTION — pedometry (Tier A only) + 1 Hz movement minutes. // // (1) TIER A live pedometer: synthetic walking at a KNOWN cadence on a 100 Hz // accel must recover the right step count (±1) and cadence; still/noise // must yield ~0; a faster cadence must read more steps than a slower one. +// This is the ONLY path in this file that produces a step count. // (2) CALIBRATION: a credible live bout updates the cadence model; fidgeting -// (low confidence / out-of-band cadence) does not. -// (3) TIER B 1 Hz estimate: sedentary day → ~0; an ambulatory block → a -// plausible count that scales with the block length; HR gate suppresses -// stationary arm motion; calibration shifts the cadence used. +// (low confidence / out-of-band cadence) does not. The measured cadence is +// reportable on its own and is deliberately NOT consumed by the 1 Hz path. +// (3) TIER B 1 Hz movement minutes: sedentary day → ~0; a movement block → a +// count that scales with the block length. It emits NO steps and NO +// cadence, and it has no HR gate and no upper ceiling — both were measured +// against real substrate and found dead or harmful. Several tests here +// exist specifically to keep them from coming back. // // Imports the motion barrel by package path (onehz.dart re-exports it too). @@ -281,7 +285,7 @@ void main() { const cal = StepCalibration(cadenceSpm: 110, refEnmo: 0.06, n: 10); test('COLD START: no personal floor → ABSTAIN with a need_baseline note', () { - final m = dailyStepEstimate(day(120, 30), + final m = dailyActiveMinutes(day(120, 30), personalDynFloorG: null, pooledMinutesAvailable: 640); expect(m.present, isFalse, reason: 'no constant fallback is permitted'); expect(m.confidence, 0); @@ -291,7 +295,7 @@ void main() { test('a non-positive floor is treated as absent, not as "pass everything"', () { - final m = dailyStepEstimate(day(120, 30), personalDynFloorG: 0.0); + final m = dailyActiveMinutes(day(120, 30), personalDynFloorG: 0.0); expect(m.present, isFalse); expect(m.note, startsWith('need_baseline:')); }); @@ -304,10 +308,9 @@ void main() { // a multi-day reference, so a whole quiet day sitting at 0.055 g simply // sits far below it — there is nothing for a drift to push it over. final drifted = rows(List.filled(1400, 0.055)); - final m = dailyStepEstimate(drifted, personalDynFloorG: floorG); + final m = dailyActiveMinutes(drifted, personalDynFloorG: floorG); expect(m.value!.activeMinutes, 0); - expect(m.value!.steps, 0); - expect(m.value!.stepsHigh, 0); + expect(m.value!.boutCount, 0); }); test('REGRESSION: a quiet day cannot collapse its own threshold', () { @@ -315,98 +318,83 @@ void main() { // 4·MAD): on a quiet day the baseline collapses and everything passes. // The floor here comes from history, so a quiet day stays quiet. final quiet = rows(List.filled(1400, sedDyn)); - final m = dailyStepEstimate(quiet, personalDynFloorG: floorG); + final m = dailyActiveMinutes(quiet, personalDynFloorG: floorG); expect(m.value!.activeMinutes, 0); }); - test('an ambulatory block over a sedentary day → active minutes × the ' - 'cadence band', () { - final m = dailyStepEstimate(day(120, 30), personalDynFloorG: floorG); + test('a movement block over a sedentary day → that many active minutes', + () { + final m = dailyActiveMinutes(day(120, 30), personalDynFloorG: floorG); expect(m.present, isTrue); expect(m.value!.activeMinutes, 30); - expect(m.value!.calibrated, isFalse); - // population band 100–130 spm (Tudor-Locke 2011) - expect(m.value!.stepsLow, 3000); - expect(m.value!.stepsHigh, 3900); - expect(m.value!.steps, 3450); // midpoint, the back-compat scalar - expect(m.value!.steps, - inInclusiveRange(m.value!.stepsLow, m.value!.stepsHigh)); + expect(m.value!.boutCount, 1); expect(m.value!.dynFloorG, closeTo(floorG, 1e-12)); expect(m.tier, Tier.estimate); }); - test('more ambulatory minutes → more steps, and the range scales with them', - () { - final few = dailyStepEstimate(day(120, 10), personalDynFloorG: floorG); - final many = dailyStepEstimate(day(120, 40), personalDynFloorG: floorG); + test('CONTRACT: no step count or cadence is ever emitted', () { + // The whole point of the rewrite. 1 Hz cannot identify gait (aliasing) + // and wrist amplitude ranks arm work ABOVE walking, so any minutes -> + // steps conversion is fabrication. Guard the serialised surface too, so + // a future change cannot quietly reintroduce a step field. + final m = dailyActiveMinutes(day(120, 30), personalDynFloorG: floorG); + final json = m.value!.toJson(); + for (final banned in const [ + 'steps', + 'steps_low', + 'steps_high', + 'cadence_low_spm', + 'cadence_high_spm', + 'cadence_source', + ]) { + expect(json.containsKey(banned), isFalse, + reason: '$banned must not be emitted by the 1 Hz path'); + } + expect(m.note, contains('NOT walking')); + }); + + test('more movement → more active minutes', () { + final few = dailyActiveMinutes(day(120, 10), personalDynFloorG: floorG); + final many = dailyActiveMinutes(day(120, 40), personalDynFloorG: floorG); expect(many.value!.activeMinutes, greaterThan(few.value!.activeMinutes)); - expect(many.value!.steps, greaterThan(few.value!.steps)); - expect(many.value!.stepsHigh - many.value!.stepsLow, - greaterThan(few.value!.stepsHigh - few.value!.stepsLow)); }); - test('VIGOROUS CEILING: motion far above the floor is not walking', () { - // 30 minutes of violent arm motion (shaking / a lifting set): well over - // floor × vigorousCeilingRatio, so it is activity but not ambulation. - final m = dailyStepEstimate( + test('NO CEILING: the highest-amplitude minutes still COUNT', () { + // A `floor x 3` ceiling used to reject these as "vigorous non-gait". + // It was measured against 4 days of real substrate and rejected ZERO + // minutes, with 0.42-0.55 g of headroom to each day's maximum — so the + // only thing it could ever exclude was a genuinely hard session. A + // volume metric that discards its highest-volume minutes is broken. + final m = dailyActiveMinutes( rows([ ...List.filled(120, sedDyn), ...List.filled(30, floorG * 5), ]), personalDynFloorG: floorG); - expect(m.value!.activeMinutes, 0); - // …and raising the ceiling lets the same minutes through, proving the - // ceiling (not some other gate) was what rejected them. - final loose = dailyStepEstimate( - rows([ - ...List.filled(120, sedDyn), - ...List.filled(30, floorG * 5), - ]), - personalDynFloorG: floorG, - vigorousCeilingRatio: 10.0); - expect(loose.value!.activeMinutes, 30); - }); - - test('HR GATE: ambulatory-amplitude minutes at resting HR do not count', () { - final m = dailyStepEstimate(day(120, 30), - personalDynFloorG: floorG, - hrPerMin: [ - ...List.filled(120, 58.0), - ...List.filled(30, 58.0) - ], - restingHr: 58); - expect(m.value!.activeMinutes, 0, reason: 'HR at rest → not walking'); - }); - - test('HR GATE: HR elevated over the block → it counts', () { - final m = dailyStepEstimate(day(120, 30), - personalDynFloorG: floorG, - hrPerMin: [ - ...List.filled(120, 58.0), - ...List.filled(30, 95.0) - ], - restingHr: 58); + expect(m.value!.activeMinutes, 30, + reason: 'hard exertion is the MOST movement, not none'); + }); + + test('NO HR GATE: movement at resting HR still counts', () { + // A `restingHr + 8 bpm` gate used to sit here. Measured across 4 real + // days it changed the answer by exactly ZERO minutes, while sitting at + // ~6% of heart-rate reserve (below every ACSM band) and failing in the + // wrong direction — a PPG dropout deleted minutes the accelerometer had + // measured fine. The API no longer accepts HR at all, which is the + // structural guarantee that it cannot come back by accident. + final m = dailyActiveMinutes(day(120, 30), personalDynFloorG: floorG); expect(m.value!.activeMinutes, 30); - }); - - test('HR GATE: resting HR falls back to the day p10 when none is supplied', - () { - final m = dailyStepEstimate(day(120, 30), - personalDynFloorG: floorG, - hrPerMin: [ - ...List.filled(120, 55.0), - ...List.filled(30, 57.0), // < p10 + 8 bpm - ]); - expect(m.value!.activeMinutes, 0); + expect(m.inputs_used, isNot(contains('hr_per_min')), + reason: 'the metric must not claim an HR input it never reads'); }); test('BOUT GATE: an isolated elevated minute does not count on its own', () { final d = List.filled(60, sedDyn); d[30] = walkDyn; - final m = dailyStepEstimate(rows(d), personalDynFloorG: floorG); + final m = dailyActiveMinutes(rows(d), personalDynFloorG: floorG); expect(m.value!.activeMinutes, 0); - expect(m.value!.steps, 0); + expect(m.value!.boutCount, 0); }); test('BOUT GATE: exactly at the boundary — 3 in a row counts, 2 does not', @@ -414,7 +402,7 @@ void main() { final d2 = List.filled(60, sedDyn); d2[30] = walkDyn; d2[31] = walkDyn; - expect(dailyStepEstimate(rows(d2), personalDynFloorG: floorG) + expect(dailyActiveMinutes(rows(d2), personalDynFloorG: floorG) .value! .activeMinutes, 0); @@ -423,7 +411,7 @@ void main() { d3[30] = walkDyn; d3[31] = walkDyn; d3[32] = walkDyn; - expect(dailyStepEstimate(rows(d3), personalDynFloorG: floorG) + expect(dailyActiveMinutes(rows(d3), personalDynFloorG: floorG) .value! .activeMinutes, 3); @@ -437,44 +425,25 @@ void main() { d[11] = walkDyn; d[20] = walkDyn; d[21] = walkDyn; - final m = dailyStepEstimate(rows(d), personalDynFloorG: floorG); + final m = dailyActiveMinutes(rows(d), personalDynFloorG: floorG); expect(m.value!.activeMinutes, 0); }); - test('a MEASURED personal cadence narrows the reported range', () { - final pop = dailyStepEstimate(day(120, 30), personalDynFloorG: floorG); - final personal = dailyStepEstimate(day(120, 30), - personalDynFloorG: floorG, calib: cal); - expect(personal.value!.calibrated, isTrue); - expect(personal.value!.activeMinutes, pop.value!.activeMinutes); - expect(personal.value!.stepsHigh - personal.value!.stepsLow, - lessThan(pop.value!.stepsHigh - pop.value!.stepsLow), - reason: 'a measured cadence is a narrower band than the population'); - // ±10% around a measured 110 spm - expect(personal.value!.cadenceLowSpm, closeTo(99.0, 1e-9)); - expect(personal.value!.cadenceHighSpm, closeTo(121.0, 1e-9)); - expect(personal.confidence, greaterThan(pop.confidence)); - }); - - test('a faster measured cadence lifts the count', () { - final slow = - dailyStepEstimate(day(120, 30), personalDynFloorG: floorG, calib: cal); - final fast = dailyStepEstimate(day(120, 30), - personalDynFloorG: floorG, - calib: const StepCalibration(cadenceSpm: 135, refEnmo: 0.06, n: 10)); - expect(fast.value!.steps, greaterThan(slow.value!.steps)); - }); - - test('a thin calibration (n < 3) does not claim a personal cadence', () { - final m = dailyStepEstimate(day(120, 30), - personalDynFloorG: floorG, - calib: const StepCalibration(cadenceSpm: 110, refEnmo: 0.06, n: 1)); - expect(m.value!.calibrated, isFalse); - expect(m.value!.cadenceLowSpm, freeLivingCadenceLowSpm); + test('a measured Tier A cadence is NOT consumed by the 1 Hz path', () { + // `calibrateCadence` still measures a real walking cadence from the + // 100 Hz path and that number is reportable on its own. What it must + // never do again is multiply 1 Hz movement minutes into a step count: + // the minutes are not specifically ambulation, so scaling them by a + // walking cadence produces a number about nothing. The API enforces + // this structurally — there is no `calib` parameter to pass. + expect(cal.cadenceSpm, greaterThan(0), + reason: 'the calibration type itself is retained for Tier A'); + final m = dailyActiveMinutes(day(120, 30), personalDynFloorG: floorG); + expect(m.value!.toJson().keys, isNot(contains('cadence_source'))); }); test('empty motion → absent ESTIMATE', () { - final m = dailyStepEstimate(const [], personalDynFloorG: floorG); + final m = dailyActiveMinutes(const [], personalDynFloorG: floorG); expect(m.present, isFalse); expect(m.tier, Tier.estimate); expect(m.note, 'no motion minutes'); @@ -485,26 +454,27 @@ void main() { for (var i = 0; i < 3; i++) MotionMinute(i * 60000.0, 60, 0.055, 0.02, 1.055, walkDyn), ]; - final m = dailyStepEstimate(sparse, personalDynFloorG: floorG); + final m = dailyActiveMinutes(sparse, personalDynFloorG: floorG); expect(m.present, isFalse); // uncovered minutes are excluded before the count, too final uncovered = [ for (var i = 0; i < 100; i++) MotionMinute(i * 60000.0, 5, 0.055, 0.02, 1.055, walkDyn), ]; - expect(dailyStepEstimate(uncovered, personalDynFloorG: floorG).present, + expect(dailyActiveMinutes(uncovered, personalDynFloorG: floorG).present, isFalse); }); - test('toJson leads with active minutes and carries the range', () { - final j = dailyStepEstimate(day(120, 30), personalDynFloorG: floorG) + test('toJson carries active minutes and the floor, and nothing about steps', + () { + final j = dailyActiveMinutes(day(120, 30), personalDynFloorG: floorG) .value! .toJson(); expect(j['active_min'], 30); - expect(j['steps_low'], 3000); - expect(j['steps_high'], 3900); - expect(j['steps'], 3450); - expect(j['cadence_source'], 'population_band'); + expect(j['bout_count'], 1); + expect(j['dyn_floor_g'], closeTo(floorG, 1e-9)); + expect(j.keys.where((k) => k.contains('step')), isEmpty); + expect(j.keys.where((k) => k.contains('cadence')), isEmpty); }); }); @@ -572,7 +542,7 @@ void main() { personalDynFloor([for (final m in pool.minutes) m.dynAmp]); expect(floor, isNotNull, reason: '2400 pooled minutes is enough history'); final est = - dailyStepEstimate(today.minutes, personalDynFloorG: floor); + dailyActiveMinutes(today.minutes, personalDynFloorG: floor); expect(est.present, isTrue); return (active: est.value!.activeMinutes, floor: floor!); } @@ -646,4 +616,61 @@ void main() { }); }); + group('Tier B — the floor must be FROZEN, not tracked', () { + // PROVEN on 4 days of real substrate. A threshold derived from the signal + // it thresholds cancels the trend it exists to report, unless frozen. + List rowsOf(List dyn) => [ + for (var i = 0; i < dyn.length; i++) + MotionMinute(i * 60000.0, 60, 0.055, 0.01, 1.0, dyn[i]) + ]; + + List day(double sed, double act, int nSed, int nAct) => [ + ...List.filled(nSed, sed), + ...List.filled(nAct, act), + ]; + + test('a FROZEN floor rises with activity; a recomputed one does not', () { + const frozen = 0.30; + var lastFrozen = -1; + final tracked = []; + for (final k in const [1.0, 1.5, 2.0, 3.0]) { + final d = day(0.05 * k, 0.4 * k, 200, 60); + final f = dailyActiveMinutes(rowsOf(d), personalDynFloorG: frozen) + .value! + .activeMinutes; + // What a self-referential floor converges to: this day's own p90. + final sorted = [...d]..sort(); + final p90 = sorted[(sorted.length * 0.9).floor()]; + final t = dailyActiveMinutes(rowsOf(d), personalDynFloorG: p90) + .value! + .activeMinutes; + expect(f, greaterThanOrEqualTo(lastFrozen), + reason: 'a frozen floor must never REPORT LESS as activity rises'); + lastFrozen = f; + tracked.add(t); + } + // The tracked floor is flat across a 3x activity change — the bug. + expect(tracked.toSet().length, 1, + reason: 'a recomputed floor reports the same number at 1x and 3x'); + }); + + test('shouldRefreezeFloor only thaws on real scale changes', () { + // Must NOT thaw for ordinary passage of time or behaviour change. + expect(shouldRefreezeFloor(daysSinceFrozen: 200), isFalse); + expect(shouldRefreezeFloor(daysSinceFrozen: 30, wearGapDays: 3), isFalse); + // Must thaw for events that change the signal's scale. + expect(shouldRefreezeFloor(daysSinceFrozen: 10, deviceChanged: true), + isTrue); + expect( + shouldRefreezeFloor(daysSinceFrozen: 10, wristChanged: true), isTrue); + expect(shouldRefreezeFloor(daysSinceFrozen: 10, wearGapDays: 45), isTrue); + expect(shouldRefreezeFloor(daysSinceFrozen: 400), isTrue); + }); + + test('enrollment window is longer than the bare minimum for the median', + () { + expect(enrollmentDaysForFrozenFloor, + greaterThan(personalDynFloorMinDays)); + }); + }); } From 38a8636ae676888bc062cd9b0163b2de90ef02de Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 3 Aug 2026 21:38:32 +0530 Subject: [PATCH 2/2] Address review: a vacuous freeze test, a stale gate comment, a dead clamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit findings, all three verified against the code before fixing. * The frozen-floor test PASSED VACUOUSLY. `day(0.05*k, 0.4*k, 200, 60)` held the active block at a fixed 60 minutes for every k, so the frozen count was 60 on every iteration and `expect(f, greaterThanOrEqualTo(lastFrozen))` compared a constant to itself. It proved nothing while claiming to prove the central result of the PR. Rebuilt on a GRADED distribution, which is what real activity looks like: activity does not scale a fixed-size block, it lifts more of a continuous distribution above the threshold (the real-data proof moved 23 -> 254). Frozen counts now genuinely vary (125 -> 250) and the assertion is STRICT monotonic increase plus a 1.5x margin, so a regression to constant output fails loudly. * Stale pass-1 gate comment still described "movement inside the ambulatory band" and "(when HR is available) HR lifted off rest" above a loop that does neither. Same class of known-false comment this PR exists to remove. * `clamp(0.30 * clamp(...), 0.1, 0.45)` — the inner term caps at 0.30, so the 0.45 outer bound was unreachable and implied a confidence this metric never claims. Aligned to the reachable value. Also corrected the FILE HEADER, which review did not flag but was describing the removed design: it still promised an HR corroboration gate, a "reported step band", and a cadence that "NARROWS" that band. Now describes the frozen floor, the deleted gates, and why cadence is measured but deliberately not consumed by the 1 Hz path. 388 tests pass. --- lib/src/onehz/motion/steps.dart | 65 +++++++++++++++++++-------------- test/onehz/steps_test.dart | 52 ++++++++++++++++---------- 2 files changed, 71 insertions(+), 46 deletions(-) diff --git a/lib/src/onehz/motion/steps.dart b/lib/src/onehz/motion/steps.dart index 9461cba..275c652 100644 --- a/lib/src/onehz/motion/steps.dart +++ b/lib/src/onehz/motion/steps.dart @@ -1,9 +1,11 @@ -// STEPS — hybrid pedometry for a wrist that gives us TWO different streams. +// MOTION — a real pedometer on one stream, movement volume on the other. // // The honest constraint (see motion.dart §"what 1 Hz accel CANNOT do"): // * The always-on 24/7 substrate is 1 Hz accel. Human gait is 1.4–2.5 Hz, far // above the 0.5 Hz Nyquist limit of a 1 Hz stream — so you CANNOT count -// individual steps from the stored substrate. Full stop. +// individual steps from the stored substrate. Full stop. And even at full +// rate a WRIST ranks arm work above walking, so amplitude alone cannot +// isolate gait from cooking or driving. // * Real per-step detection is only possible on the ~100 Hz foreground accel // (R10 / 0x2B), which exists only while the app is connected and streaming. // @@ -26,34 +28,39 @@ // measured over-reporting ~6× on a real day and has been removed. Steps // come only from a gait-capable source (Tier A, or the phone pedometer). // -// Three things make the minute detector stable, and all three matter: +// Two things make the minute detector stable, and both matter: // 1. The feature is [MotionMinute.dynAmp] — per-axis high-passed dynamic -// amplitude — NOT ENMO. ENMO depends on a scalar gravity reference -// whose per-day estimate moves by about the same amount as the signal -// being measured, so an absolute cut-point on ENMO is not stable -// across days. dynAmp removes gravity as a vector and is invariant to -// per-axis offset. (Vähä-Ypyä 2015 argues for calibration-robust -// amplitude measures over ENMO for exactly this reason.) -// 2. The cut-point is a MULTI-DAY PERSONAL REFERENCE ([personalDynFloor], -// a quantile of the user's POOLED trailing dynAmp minutes) — neither -// an absolute g constant (calibration-fragile) nor a same-day relative -// baseline (which collapses on a quiet day and then passes -// everything). One floor, computed over enough history to be stable, -// applied to every day. -// 3. Corroboration + duration: HR must be lifted off rest when HR is -// available, and a minute only counts inside a run of consecutive -// ambulatory minutes. +// amplitude — NOT ENMO. On this substrate ENMO is worse than unstable, +// it is EMPTY: the band ships a fused gravity vector whose magnitude +// sits at ~1.03 g even during the most vigorous minute of a day, so +// ENMO reduces to ~(1.03 − gRef), a pure calibration artifact. dynAmp +// removes gravity as a VECTOR and measures how fast the wrist +// re-orients. (Vähä-Ypyä 2015 argues for calibration-robust amplitude +// measures over ENMO for exactly this reason.) +// 2. The cut-point is a MULTI-DAY PERSONAL REFERENCE +// ([personalDynFloorFromDailySummaries]) that is FROZEN after an +// enrollment window — neither an absolute g constant +// (calibration-fragile) nor anything recomputed daily. A threshold +// recomputed from the signal it thresholds cancels the trend it +// exists to report: measured, 37 active minutes at 1×, 1.5×, 2× AND +// 3× activity, versus 23 → 254 with a frozen floor. +// +// There is deliberately NO upper ceiling and NO HR gate. Both existed and +// both were measured against real substrate: the ceiling rejected zero +// minutes across every day tested, and the HR gate changed the answer by +// zero minutes while sitting at ~6% of heart-rate reserve. See the +// comments at their former sites in [dailyActiveMinutes] before +// reintroducing either. // // With no personal reference the estimator ABSTAINS (absent Metric with a // `need_baseline:` note). It never substitutes a constant — substituting a // constant IS the failure mode this design exists to prevent. // -// CALIBRATION — [StepCalibration] / [calibrateCadence]: Tier A is also Tier -// B's teacher. When live walking data exists we measure THIS user's real -// cadence, and that measured cadence NARROWS the reported step band. It is -// used only where it is real (cadence, from a 100 Hz count); it is never -// extrapolated into a per-minute cadence regression, because 1 Hz cannot -// resolve cadence at all (gait 1.4–2.5 Hz; 2.0 Hz aliases exactly to DC). +// CALIBRATION — [StepCalibration] / [calibrateCadence]: measures THIS user's +// real walking cadence from the 100 Hz stream. That number is reportable on +// its own. It is deliberately NOT consumed by Tier B: multiplying movement +// minutes by a walking cadence produces a number about nothing, because the +// minutes are not specifically ambulation. // // Pure: dart:math only. No I/O, no clock, no randomness. @@ -683,8 +690,9 @@ Metric dailyActiveMinutes( // perfectly well. An honestly accelerometer-only metric is more defensible // than one carrying a decorative HR gate. - // pass 1 — per-minute gate: movement inside the ambulatory band, and (when - // HR is available) HR lifted off rest. + // pass 1 — per-minute gate: dynAmp above the personal movement floor. That + // is the WHOLE gate. There is no upper band and no HR condition; both were + // measured dead and removed (see the two comments above). final gateOk = List.filled(idx.length, false); for (var k = 0; k < idx.length; k++) { gateOk[k] = dyns[k] > floor; @@ -721,7 +729,10 @@ Metric dailyActiveMinutes( // capped low because this is a movement-volume index from a wrist sensor: it // cannot distinguish walking from arm work, which is a limitation of the // measurement site, not of the estimator. - final conf = clamp(0.30 * clamp(coverage / 0.6, 0.3, 1.0), 0.1, 0.45); + // Upper bound is 0.30, which is what the expression can actually reach — + // the inner clamp caps at 1.0, so `0.30 * 1.0` is the ceiling. Writing a + // larger outer bound would imply a confidence this metric never claims. + final conf = clamp(0.30 * clamp(coverage / 0.6, 0.3, 1.0), 0.1, 0.30); return Metric( value: DailyMovementEstimate( diff --git a/test/onehz/steps_test.dart b/test/onehz/steps_test.dart index 7ea3282..791d065 100644 --- a/test/onehz/steps_test.dart +++ b/test/onehz/steps_test.dart @@ -624,34 +624,48 @@ void main() { MotionMinute(i * 60000.0, 60, 0.055, 0.01, 1.0, dyn[i]) ]; - List day(double sed, double act, int nSed, int nAct) => [ - ...List.filled(nSed, sed), - ...List.filled(nAct, act), - ]; - test('a FROZEN floor rises with activity; a recomputed one does not', () { + // The day must be GRADED, not bimodal. Real activity does not scale a + // fixed-size block: it lifts more of a continuous distribution above the + // threshold, which is why the real-data proof moved 23 -> 254. A day made + // of one constant-amplitude block would keep the frozen count pinned at + // the block length for every k and this test would pass on a constant, + // proving nothing. + List gradedDay(double k) => [ + for (var i = 0; i < 300; i++) (0.02 + i * 0.0016) * k, + ]; + const frozen = 0.30; - var lastFrozen = -1; - final tracked = []; + final frozenCounts = []; + final trackedCounts = []; for (final k in const [1.0, 1.5, 2.0, 3.0]) { - final d = day(0.05 * k, 0.4 * k, 200, 60); - final f = dailyActiveMinutes(rowsOf(d), personalDynFloorG: frozen) + final d = gradedDay(k); + frozenCounts.add(dailyActiveMinutes(rowsOf(d), personalDynFloorG: frozen) .value! - .activeMinutes; + .activeMinutes); // What a self-referential floor converges to: this day's own p90. final sorted = [...d]..sort(); final p90 = sorted[(sorted.length * 0.9).floor()]; - final t = dailyActiveMinutes(rowsOf(d), personalDynFloorG: p90) + trackedCounts.add(dailyActiveMinutes(rowsOf(d), personalDynFloorG: p90) .value! - .activeMinutes; - expect(f, greaterThanOrEqualTo(lastFrozen), - reason: 'a frozen floor must never REPORT LESS as activity rises'); - lastFrozen = f; - tracked.add(t); + .activeMinutes); + } + + // A frozen floor must STRICTLY rise — not merely "not fall". + for (var i = 1; i < frozenCounts.length; i++) { + expect(frozenCounts[i], greaterThan(frozenCounts[i - 1]), + reason: 'frozen floor must report MORE as activity rises: ' + '$frozenCounts'); } - // The tracked floor is flat across a 3x activity change — the bug. - expect(tracked.toSet().length, 1, - reason: 'a recomputed floor reports the same number at 1x and 3x'); + expect(frozenCounts.last, greaterThan((frozenCounts.first * 1.5).round()), + reason: 'a 3x activity increase must move the number substantially, ' + 'not by a rounding margin: $frozenCounts'); + + // ...while a recomputed floor is FLAT across the same 3x change. That is + // the bug freezing exists to fix. + expect(trackedCounts.toSet().length, 1, + reason: 'a recomputed floor reports the same number at 1x and 3x: ' + '$trackedCounts'); }); test('shouldRefreezeFloor only thaws on real scale changes', () {