From c22ff93ba3f5d58b9356d2c711929507e3c41154 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:16:12 +0530 Subject: [PATCH 1/4] nap: say when a bout was already running at the first sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the trailing edge defers a bout it can't see the end of. the leading edge just emitted one as if it started at index 0, so edge has to guess the boundary from a timestamp tolerance — and when the band only starts recording at midnight, both days drop the nap. new NapWindow.startsAtRecordEdge, propagated forward through napChainGapSec the same way unfinished propagates backward. still emitted: this end has a knowable END, only the onset is unknown. no nap appears or disappears and no duration moves, so nothing shipped changes value. --- lib/src/onehz/sleep/nap.dart | 55 +++++++++++++++++++++++++++++++++++- test/onehz/nap_test.dart | 46 ++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/lib/src/onehz/sleep/nap.dart b/lib/src/onehz/sleep/nap.dart index c2e4418..8aa07f3 100644 --- a/lib/src/onehz/sleep/nap.dart +++ b/lib/src/onehz/sleep/nap.dart @@ -69,12 +69,31 @@ class NapWindow { /// wrist-on telemetry corroborated it. NOT sleep efficiency. final double confidence; + /// The bout was ALREADY IN PROGRESS at the first sample of the record, so + /// [startSec] is where the RECORD opens, not where the sleep began. + /// + /// The mirror of the trailing-edge deferral. A bout running past the last + /// sample has no knowable end and is never emitted; a bout running before the + /// first sample has no knowable start, but it DOES have a knowable end, so + /// dropping it would lose a real nap. It is emitted with this flag instead, + /// and the caller decides — a caller slicing days at local midnight can defer + /// on this evidence rather than infer the same thing from a timestamp + /// tolerance and lose the nap whenever the band only started recording there. + /// + /// Propagated FORWARD through [napChainGapSec] exactly as `unfinished` is + /// propagated backward: a five-minute arousal splits one episode into two + /// bouts, and the fragment after it is just as edge-anchored as the first. + /// + /// [tibSec]/[tstSec] are what the record shows, not what the episode was. + final bool startsAtRecordEdge; + const NapWindow({ required this.startSec, required this.endSec, required this.tibSec, required this.tstSec, required this.confidence, + this.startsAtRecordEdge = false, }); /// Fraction of the bout actually spent asleep, in [0, 1]. @@ -87,6 +106,7 @@ class NapWindow { 'tst_sec': tstSec, 'efficiency': round6(efficiency), 'confidence': round6(confidence), + 'starts_at_record_edge': startsAtRecordEdge, }; } @@ -198,6 +218,12 @@ Metric> detectNaps( // the still test outright instead of contributing the constant 0.0° of an // exact (0,0,0) sample — which is what emitted a 50-minute decode gap as a // nap at the 0.85 confidence cap. + // + // At k == 0 there is no predecessor to test, so the discontinuity check is + // skipped and the second is taken at face value. That is not a decision that + // can be made better here — the record simply does not say what happened + // before it — so it is REPORTED instead, via [NapWindow.startsAtRecordEdge] + // below. bool stillAt(int k) => mask.deltaDeg[k] < thr && (k == 0 || absAt(k) - absAt(k - 1) == 1); @@ -253,6 +279,27 @@ Metric> detectNaps( } } + // Which bouts sit against the START of the record, walking FORWARD — the + // mirror of `unfinished`. Only the FIRST bout can touch index 0, and any bout + // chained to it within `napChainGapSec` is a fragment of the same episode, so + // it is edge-anchored too. + // + // Unlike `unfinished` this does NOT suppress the bout: a leading-edge bout + // has a knowable END, so it is a real, measurable episode with an unknown + // start rather than an unknowable one. It is emitted with the flag set and + // the caller decides. It also stays in the awake-HR pool below, for the same + // reason — it is judged, not deferred. + final startsAtEdge = List.filled(bouts.length, false); + for (var b = 0; b < bouts.length; b++) { + if (b == 0) { + startsAtEdge[0] = bouts[0][0] == 0; + } else if (startsAtEdge[b - 1] && + absAt(bouts[b][0]) - (absAt(bouts[b - 1][1] - 1) + 1) < + napChainGapSec) { + startsAtEdge[b] = true; + } + } + // The AWAKE HR baseline pool: every second that is not the main sleep and not // a bout we have already DEFERRED as unfinished. // @@ -433,6 +480,7 @@ Metric> detectNaps( tibSec: tib, tstSec: tst, confidence: conf, + startsAtRecordEdge: startsAtEdge[bi], )); } @@ -462,7 +510,12 @@ Metric> detectNaps( if (offWrist > 0) '$offWrist off-wrist/excluded', if (awakeStill > 0) '$awakeStill still but no HR dip', ]; - final tail = skipped.isEmpty ? '' : '; skipped: ${skipped.join(', ')}'; + final atEdge = naps.where((x) => x.startsAtRecordEdge).length; + final tail = (skipped.isEmpty ? '' : '; skipped: ${skipped.join(', ')}') + + (atEdge == 0 + ? '' + : '; $atEdge already in progress at the first sample ' + '(start_sec is the record edge, not the sleep onset)'); return Metric>( value: naps, diff --git a/test/onehz/nap_test.dart b/test/onehz/nap_test.dart index 68488a7..4defdb3 100644 --- a/test/onehz/nap_test.dart +++ b/test/onehz/nap_test.dart @@ -408,6 +408,52 @@ void main() { 'neither is a nap that happened today'); expect(m.note, contains('deferred')); }); + + test('a bout already running at the record START is FLAGGED, not dropped', + () { + // The mirror of the deferral above. This end has a knowable END, so the + // episode is real and measurable — only its onset is unknown. Dropping it + // loses a genuine nap whenever the band started recording mid-bout; + // emitting it silently claims it began at the record edge. Flag it and + // let the caller decide. + final d = _Day() + ..still(40) // record opens mid-nap + ..active(120); + + final m = detectNaps(d.accel, d.hr); + + expect(m.value, hasLength(1)); + expect(m.value!.single.startsAtRecordEdge, isTrue); + expect(m.note, contains('already in progress at the first sample')); + expect(m.value!.single.toJson()['starts_at_record_edge'], isTrue); + }); + + test('a nap with observed sides is NOT flagged', () { + final d = _Day() + ..active(120) + ..still(30) + ..active(120); + + expect(detectNaps(d.accel, d.hr).value!.single.startsAtRecordEdge, + isFalse); + }); + + test('the flag propagates FORWARD through a chained fragment', () { + // Symmetric with the backward propagation of `unfinished`: an awakening + // longer than the 5-minute bridge splits one edge-anchored episode into + // two bouts, and the second is just as much a fragment of a sleep that + // started before the record as the first. + final d = _Day() + ..still(40) + ..active(6, bpm: 70) // 6 min awakening — over the bridge, under the chain + ..still(40) + ..active(120); + + final naps = detectNaps(d.accel, d.hr).value!; + + expect(naps, hasLength(2)); + expect(naps.every((n) => n.startsAtRecordEdge), isTrue); + }); }); group('detectNaps — the HR baseline is genuinely awake', () { From af9d6f38987c90808c0732d7d62a2c7cd6c5e239 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:19:20 +0530 Subject: [PATCH 2/4] calories: one active gate for the day and the bout, on HRR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the day gated at 0.65*HRmax, the bout at rest + 0.30*HRR, so the same minute of the same stream was billed active by one and resting by the other — 8 to 35 bpm apart depending on the profile, and the sign flipped between them. both now call activeGateHr(hrmax, restingHr) = rest + 0.40*HRR. 0.40 is the same boundary the 0.65 choice was after: ACSM moderate is 40-59% HRR = 64-76% HRmax, so it keeps MOT-02's rigour while scaling with the individual's own rest instead of drifting toward it with age. profile old day old bout now 70y RHR 68 103.4 95.3 104.4 34y RHR 55 119.7 93.8 106.7 25y RHR 45 123.8 88.7 103.2 CHANGES SHIPPED NUMBERS, both paths. day active energy rises for anyone whose rest sits well under 0.65*HRmax (the young and fit, up to 20 bpm of extra billed minutes) and is flat for the older profile. bout kcal falls a little for easy sessions — the gate went up 9-15 bpm — and barely moves for real ones, where nearly every sample clears both. dailyEnergy now requires restingHr, no fallback, same argument as hrmax: an unknown rest is an unknown gate, so the caller abstains rather than bill a day against somebody else's rest. edge has to pass it. --- lib/src/onehz/workout/calories.dart | 103 ++++++++++++++++++---------- test/onehz/steps_test.dart | 93 ++++++++++++++++++------- 2 files changed, 133 insertions(+), 63 deletions(-) diff --git a/lib/src/onehz/workout/calories.dart b/lib/src/onehz/workout/calories.dart index e9bd746..aa890b0 100644 --- a/lib/src/onehz/workout/calories.dart +++ b/lib/src/onehz/workout/calories.dart @@ -8,9 +8,10 @@ // medical advice. // // Faithfulness note: the coefficients, the 86_400 BMR/s divisor, the 251.04 -// workout divisor (60 s/min × 4.184 kJ/kcal), the 0.30 active-HRR bout gate, and -// the elapsed-time-per-sample weighting (capped at mergeGapS = 150 s) are copied -// verbatim from WorkoutDetector.swift. Pure, dart:math only. +// workout divisor (60 s/min × 4.184 kJ/kcal) and the elapsed-time-per-sample +// weighting (capped at mergeGapS = 150 s) are copied verbatim from +// WorkoutDetector.swift. The active gate is NOT — that file's 0.30 HRR is +// superseded, see [Calories.activeHRRFraction]. Pure, dart:math only. import 'dart:math' as math; @@ -97,38 +98,62 @@ class Calories { workoutAlpha: -37.74955, ); - /// Bout active gate: a sample burns the Keytel active rate above - /// resting + this fraction of HRR, else the resting BMR rate. - static const double activeHRRFraction = 0.30; - - /// THE HR-FLEX POINT for [dailyEnergy], as a fraction of HRmax. + /// THE HR-FLEX POINT, as a fraction of heart-rate RESERVE, for BOTH the day + /// ([dailyEnergy]) and the bout ([estimateBoutCalories]). One definition — + /// see [activeGateHr], which is the only place it is turned into a bpm. + /// + /// It decides which minutes get billed the Keytel rate at all. Keytel's + /// regression is fitted on STEADY-STATE SUBMAXIMAL EXERCISE with chest/ECG + /// HR; below the exercise domain there is no HR↔EE slope to extrapolate along + /// (that absence is the entire premise of the HR-FLEX method this file cites + /// — Spurr 1988 / Ceesay 1989 assign resting EE below flex and an + /// INDIVIDUALLY CALIBRATED line above it). Ours is a population line, so the + /// gate has to sit where that line is at least approximately true. + /// + /// WHY HRR AND NOT A FRACTION OF HRmax (issue #43). The two entry points used + /// to disagree: the day gated at 0.65 × HRmax, the bout at RHR + 0.30 × HRR, + /// so the same minute of the same stream was billed active by one and resting + /// by the other, by 8–35 bpm depending on the profile. A fraction of HRmax + /// alone is also the wrong shape: 0.65 × Tanaka is 135.2 − 0.455 · age, which + /// falls with age while resting HR does not, so it drifts toward rest for the + /// old and away from it for the young and fit. + /// + /// profile HRmax old day old bout NOW (0.40 HRR) + /// 70 y, RHR 68 159.0 103.4 95.3 104.4 + /// 34 y, RHR 55 184.2 119.7 93.8 106.7 + /// 25 y, RHR 45 190.5 123.8 88.7 103.2 /// - /// RAISED 0.50 → 0.65 on 2026-08-17 (audit MOT-02). This number decides which - /// waking minutes get billed the Keytel rate at all, and 0.50 put it at - /// 0.50 × 187 = 93.5 bpm for a 30 y/o — a heart rate a person reaches by - /// standing up. Keytel's regression is fitted on STEADY-STATE SUBMAXIMAL - /// EXERCISE with chest/ECG HR; below the exercise domain there is no HR↔EE - /// slope to extrapolate along (that absence is the entire premise of the - /// HR-FLEX method this function cites — Spurr 1988 / Ceesay 1989 assign - /// resting EE below flex and an INDIVIDUALLY CALIBRATED line above it). Ours - /// is a population line, so the gate has to sit where that line is at least - /// approximately true. 0.65 × HRmax is the ACSM moderate-intensity floor - /// (64 % HRmax ≈ 40 % HRR) — the published boundary below which movement is - /// not counted as exercise at all, and the region Keytel's submaximal - /// exercise protocol does not sample. It is a choice, not a number Keytel - /// prints; what Keytel prints is a regression with no resting arm. + /// 0.40 IS THE SAME BOUNDARY THE 0.65 CHOICE WAS REACHING FOR (audit MOT-02, + /// 2026-08-17, which raised the day gate 0.50 → 0.65 × HRmax): ACSM puts + /// moderate intensity at 40–59 % HRR ≡ 64–76 % HRmax, so 0.40 HRR is 0.65 + /// HRmax expressed against the individual's own rest instead of against a + /// population ceiling. Below it, movement is not counted as exercise at all, + /// and it is the region Keytel's protocol does not sample. It is a choice, + /// not a number Keytel prints; what Keytel prints is a regression with no + /// resting arm. The 0.30 that came over from WorkoutDetector.swift sat below + /// that boundary, right where Keytel over-reads worst (a 34 y/o at 92 bpm + /// bills ≈4.6 METs for what is ≈3). /// - /// MEASURED CONSEQUENCE, whoop-4.db, 9 days, 70 kg/170 cm/30 y male stand-in, - /// Tanaka HRmax 187: billed minutes 39.4 % → 4.9 % of the wake window; daily - /// ACTIVE energy min/median/max 769/1,955/4,062 → 9/48/1,917 kcal; daily - /// TOTAL 1,544–5,582 → 793–3,437 kcal. Quiet days lose essentially all their - /// "active" energy, which is the correction: they never earned it. Real - /// sessions keep most of theirs (a 103-min-above-50 %-HRR day: 3,462 → 1,202). + /// MEASURED CONSEQUENCE of MOT-02 (whoop-4.db, 9 days, 70 kg/170 cm/30 y male + /// stand-in, Tanaka HRmax 187): billed minutes 39.4 % → 4.9 % of the wake + /// window; daily ACTIVE energy min/median/max 769/1,955/4,062 → 9/48/1,917 + /// kcal. On that stand-in RHR is unknown, so the HRR restatement cannot be + /// replayed against it; on the profiles above it moves the day gate by + /// +1.0 / −13.0 / −20.6 bpm and the bout gate by +9.1 / +12.9 / +14.5. /// /// This is still a point estimate with no error band, deliberately: Keytel /// publishes a POPULATION MAPE, which is not this user's error distribution, /// and drawing bounds around the number would claim we computed them. - static const double defaultActiveFraction = 0.65; + static const double activeHRRFraction = 0.40; + + /// The HR at or above which a sample is billed the Keytel active rate: + /// `restingHr + [activeHRRFraction] · (hrmax − restingHr)`. + /// + /// THE one definition. Both entry points call it, and so does the edge's live + /// sample-by-sample billing — a second copy of this arithmetic is how the day + /// and the bout came to disagree in the first place. + static double activeGateHr(double hrmax, double restingHr) => + restingHr + activeHRRFraction * (hrmax - restingHr); /// 60 s/min × 4.184 kJ/kcal. static const double workoutDivisor = 251.04; @@ -213,10 +238,9 @@ class Calories { /// filling any minute that has no HR sample. /// /// [hrPerMin] is per-minute mean HR (bpm); 0/absent minutes fall back to BMR. - /// [activeFraction] is the HR-flex point as a fraction of HRmax - /// ([defaultActiveFraction] = 0.65 — read its doc before moving it, it is the - /// single number that decides what "active" means): minutes below it burn BMR - /// only, so a quiet day reads ≈ basal and Keytel's low-HR extrapolation can't + /// The flex point is [activeGateHr] — the SAME gate [estimateBoutCalories] + /// uses, read its constant before moving it: minutes below it burn BMR only, + /// so a quiet day reads ≈ basal and Keytel's low-HR extrapolation can't /// inflate "active". /// [dayMinutes] lets a partial day pro-rate basal (default 1440 = full day). /// @@ -232,11 +256,17 @@ class Calories { /// ceiling has no energy figure: it abstains rather than call this. That is /// why the old `usedDefaultHrmax` flag is gone — the case it flagged can no /// longer reach this function. + /// + /// [restingHr] IS REQUIRED FOR THE SAME REASON and likewise has no fallback: + /// the gate is HRR-relative now (issue #43), so an unknown rest is an unknown + /// gate. A caller without a measured resting HR has no honest day-energy + /// figure and abstains rather than have one computed against somebody else's + /// rest. static ({double total, double active, double basal}) dailyEnergy( List hrPerMin, { required WorkoutUserProfile profile, required double hrmax, - double activeFraction = defaultActiveFraction, + required double restingHr, int dayMinutes = 1440, }) { // (weight/height/age still can't be told apart from their defaults here: @@ -248,7 +278,7 @@ class Calories { final heightCm = profile.heightCm > 0 ? profile.heightCm : 170.0; final age = profile.age > 0 ? profile.age : 30.0; final coeffs = resolveCoeffs(profile.sex); - final flexHr = activeFraction * hrmax; + final flexHr = activeGateHr(hrmax, restingHr); final bmrDay = mifflinBmrKcalDay(weightKg, heightCm, age, profile.sex); final basalPerMin = bmrDay / 1440.0; @@ -295,8 +325,7 @@ class Calories { final usedDefaultAnchors = hrmax == null || restingHr == null; final effHRmax = hrmax ?? 220.0; final effResting = restingHr ?? 60.0; - final activeThreshold = - effResting + activeHRRFraction * (effHRmax - effResting); + final activeThreshold = activeGateHr(effHRmax, effResting); final restingRate = restingKcalPerS(coeffs, weightKg, heightCm, age); diff --git a/test/onehz/steps_test.dart b/test/onehz/steps_test.dart index b87a6a2..af75d25 100644 --- a/test/onehz/steps_test.dart +++ b/test/onehz/steps_test.dart @@ -809,7 +809,8 @@ void main() { weightKg: 80, heightCm: 180, age: 30, sex: 'male'); // 1440 minutes at a low HR (40% HRmax → below active surplus) final hr = List.filled(1440, 70.0); - final e = Calories.dailyEnergy(hr, profile: profile, hrmax: 190); + final e = + Calories.dailyEnergy(hr, profile: profile, hrmax: 190, restingHr: 60); expect(e.basal, closeTo(1780.0, 1.0)); expect(e.active, closeTo(0.0, 60.0)); // tiny if any expect(e.total, greaterThanOrEqualTo(e.basal)); @@ -818,52 +819,91 @@ void main() { test('the flex gate moves with the CALLER-SUPPLIED ceiling, not 220−age', () { // TS-03a: there is no `220 − age` fallback left in here — `hrmax` is - // required, and it is the only thing that sets the flex gate. Two - // ceilings for the same 30 y/o (220−30 = 190 vs Tanaka 208−0.7·30 = 187) - // put the gate at 123.5 vs 121.55 bpm, so a day spent at 122 bpm is - // entirely basal on one and entirely active on the other. That divergence - // is the bug the single dispatched definition exists to remove; this pins - // that the number in front of it is what decides. + // required, and it is what sets the flex gate. Two ceilings for the same + // 30 y/o (220−30 = 190 vs Tanaka 208−0.7·30 = 187) put the gate at 112.0 + // vs 110.8 bpm at RHR 60, so a day spent at 111 bpm is entirely basal on + // one and entirely active on the other. That divergence is the bug the + // single dispatched definition exists to remove; this pins that the + // number in front of it is what decides. final profile = const WorkoutUserProfile( weightKg: 80, heightCm: 180, age: 30, sex: 'male'); - final hr = List.filled(1440, 122.0); - final wide = Calories.dailyEnergy(hr, profile: profile, hrmax: 190); - final tanaka = Calories.dailyEnergy(hr, profile: profile, hrmax: 187); + final hr = List.filled(1440, 111.0); + final wide = + Calories.dailyEnergy(hr, profile: profile, hrmax: 190, restingHr: 60); + final tanaka = + Calories.dailyEnergy(hr, profile: profile, hrmax: 187, restingHr: 60); expect(wide.active, 0.0); expect(tanaka.active, greaterThan(0.0)); }); - test('MOT-02: the flex point is 0.65·HRmax, not 0.50 — a 100 bpm day is ' - 'not "active"', () { - // 0.50 put the gate at 93.5 bpm for a 30 y/o, inside the region where + test('MOT-02: a 100 bpm day is not "active"', () { + // The gate sits at the ACSM moderate floor, well above the region where // Keytel is extrapolating off the end of its own fitted exercise data. // MEASURED on whoop-4.db (9 days, 70 kg/170 cm/30 y male stand-in, Tanaka - // 187): billed wake minutes 39.4 % → 4.9 %, daily ACTIVE energy - // min/median/max 769/1955/4062 → 9/48/1917 kcal, daily TOTAL - // 1544–5582 → 793–3437 kcal. Everyone's active energy drops; the quiet - // days lose nearly all of it, which is the point. - expect(Calories.defaultActiveFraction, 0.65); + // 187) when the gate first moved: billed wake minutes 39.4 % → 4.9 %, + // daily ACTIVE energy min/median/max 769/1955/4062 → 9/48/1917 kcal. + // Everyone's active energy drops; the quiet days lose nearly all of it, + // which is the point. final profile = const WorkoutUserProfile( weightKg: 70, heightCm: 170, age: 30, sex: 'male'); - // 16 h of ordinary waking at 100 bpm — clears the old gate, not the new. + // 16 h of ordinary waking at 100 bpm — under the gate (60 + 0.4·127 = + // 110.8), so none of it is billed as exercise. final quiet = List.filled(960, 100.0); - expect(Calories.dailyEnergy(quiet, profile: profile, hrmax: 187).active, - 0.0); expect( Calories.dailyEnergy(quiet, - profile: profile, hrmax: 187, activeFraction: 0.50) + profile: profile, hrmax: 187, restingHr: 60) .active, - greaterThan(4000.0), - reason: 'the number the old gate published for sitting around'); + 0.0); // A real session still bills: 45 min at 145 bpm. final session = [ ...List.filled(915, 100.0), ...List.filled(45, 145.0), ]; - expect(Calories.dailyEnergy(session, profile: profile, hrmax: 187).active, + expect( + Calories.dailyEnergy(session, + profile: profile, hrmax: 187, restingHr: 60) + .active, closeTo(553.0, 5.0)); }); + 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 hrmax = 184.2, rhr = 55.0; + final gate = Calories.activeGateHr(hrmax, rhr); + expect(gate, closeTo(106.68, 0.01)); + + final restingKcalPerMin = + Calories.restingKcalPerS(Calories.male, 72, 178, 34) * 60; + final ts = List.generate(60, (i) => i); + + for (final (hr, active) in [(gate - 1, false), (gate + 1, true)]) { + final day = Calories.dailyEnergy(List.filled(60, hr), + profile: profile, hrmax: hrmax, restingHr: rhr); + final bout = Calories.estimateBoutCalories( + ts, List.filled(60, hr), + profile: profile, hrmax: hrmax, restingHr: rhr); + expect(day.active > 0, active, reason: 'day at $hr bpm'); + expect(bout.kcal > restingKcalPerMin + 1e-9, active, + reason: 'bout at $hr bpm'); + } + }); + + test('#43: the gate scales with the individual, not just with age', () { + // 0.65·Tanaka is 135.2 − 0.455·age: it falls with age while resting HR + // does not, so it drifted toward rest for the old (gate 11.5 bpm over + // rest for a 70 y/o at RHR 68) and away from it for the young and fit + // (50.2 bpm over rest at RHR 45). Against reserve, all three sit at the + // same effort. + expect(Calories.activeGateHr(159.0, 68.0), closeTo(104.4, 0.05)); + expect(Calories.activeGateHr(184.2, 55.0), closeTo(106.7, 0.05)); + expect(Calories.activeGateHr(190.5, 45.0), closeTo(103.2, 0.05)); + expect(Calories.activeHRRFraction, 0.40, + reason: 'ACSM moderate floor: 40 % HRR ≡ 64 % HRmax'); + }); + test('an exercise block adds active calories on top of basal', () { final profile = const WorkoutUserProfile( weightKg: 80, heightCm: 180, age: 30, sex: 'male'); @@ -871,7 +911,8 @@ void main() { ...List.filled(1380, 65.0), ...List.filled(60, 150.0), // 1 h hard ]; - final e = Calories.dailyEnergy(hr, profile: profile, hrmax: 190); + final e = + Calories.dailyEnergy(hr, profile: profile, hrmax: 190, restingHr: 60); expect(e.active, greaterThan(300.0)); expect(e.total, closeTo(e.basal + e.active, 1e-6)); }); From 0a303151e0d22ceeb3a1cf92f820baea0a73098d Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:24:00 +0530 Subject: [PATCH 3/4] strain: the quiet-waking floor is the user's, not 0.20 (edge#226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quietWakingHrr = 0.20 HRR was a population figure standing in for a personal one, and it sits below where this user's waking minutes actually are (p50 0.274, whoop-4.db). the baseline then under-charges the cost of being awake and the leftover reads as training load — a day with nothing in it lands mid-scale. RHR 55 / HRmax 187 / 960 wake minutes, scored against their own 0.274: day before after nothing at all 11.93 0.00 + 60 min walk @105 12.61 2.78 + 45 min run @145 14.79 8.72 + 90 min hard @165 19.30 16.49 the nothing-day goes to zero and the light day does NOT come down with it: doing nothing and walking an hour were 0.7 points apart on a 0-21 dial, now 2.8, and the rest of the scale stays graded. a user whose quiet really is 0.20 barely moves (anchor profile, inactive: 0.00 before and after) — the level is measured now rather than assumed, so it only bites where the constant was wrong for the person. quietHrr is a required argument on strainScore/baselineTrimp and a required nullable on strainScoreMetric, which abstains with a reason rather than fall back to a constant. dailyQuietWakingHrr measures it — median waking HRR, percentile of self — and returns null for a day whose median clears 0.40 HRR (ACSM moderate), which is the day that would otherwise subtract its own hike away. feed a trailing value where you have one. CHANGES SHIPPED NUMBERS: every strain score for anyone whose quiet waking isn't 0.20 HRR. edge has to pass quietHrr from strain_backfill, onehz_pipeline, manual_session and derivation_engine or strain goes absent. --- lib/src/onehz/clinical/load_trimp.dart | 130 ++++++++++++++++----- test/onehz/clinical_test.dart | 44 ++++++-- test/onehz/strain_calibration_test.dart | 143 +++++++++++++++++++----- 3 files changed, 254 insertions(+), 63 deletions(-) diff --git a/lib/src/onehz/clinical/load_trimp.dart b/lib/src/onehz/clinical/load_trimp.dart index 726e672..a4b9850 100644 --- a/lib/src/onehz/clinical/load_trimp.dart +++ b/lib/src/onehz/clinical/load_trimp.dart @@ -76,16 +76,68 @@ Metric banisterTrimp( ); } -/// Fraction of heart-rate reserve that simply BEING AWAKE costs. +/// Fraction of heart-rate reserve that simply BEING AWAKE costs — THE REFERENCE +/// VALUE THE ANCHOR TABLE IS GENERATED AT, and nothing else. /// /// Whole-day Banister TRIMP counts every waking minute above resting, so ~16 h /// of ordinary living accrues ~180 TRIMP before any exercise happens. That is /// the cost of being alive, not training load, and billing it as load is what -/// put an INACTIVE full-wear day at ~13/21 on the old scale. Quiet waking -/// (sitting, standing, moving about the house) sits ≈20 % of HRR above resting, -/// so that much is treated as the day's overhead rather than as effort. +/// put an INACTIVE full-wear day at ~13/21 on the scale before the baseline +/// subtraction existed. +/// +/// IT IS NOT A DEFAULT ANY MORE (audit MOT-03, edge#226). 0.20 was a +/// population figure standing in for a personal one, and it sat below where +/// this user's quiet waking actually is (p50 0.274 HRR, whoop-4.db) — which +/// left a day with no activity at all scoring 6.93–12.14 on a 0–21 scale, the +/// band the anchor table calls "90 min hard session". Every entry point now +/// takes the level as an argument; [dailyQuietWakingHrr] measures it from the +/// user's own day. Passing this constant reproduces the anchor table exactly. const double quietWakingHrr = 0.20; +/// The most a quiet-waking level may be and still be called quiet waking. +/// +/// ACSM's moderate-intensity floor (40 % HRR). A day whose MEDIAN waking minute +/// sits above it was not ordinary living, so it cannot define what ordinary +/// living costs — [dailyQuietWakingHrr] abstains rather than hand back a level +/// that would subtract the day's own training away. +const double maxQuietHrr = 0.40; + +/// THIS user's quiet-waking level, measured: the MEDIAN per-minute HRR over a +/// day's waking minutes. Percentile of self, never a population number. +/// +/// The median, not the mean, and not a low percentile: a session is a small +/// minority of minutes on any real day, so the median tracks ordinary living +/// and ignores the training — while p10 or p25 would track sitting still +/// specifically, which is lower than living costs and leaves the same +/// over-billing this exists to remove. +/// +/// PREFER A TRAILING VALUE. The day's own median is the right measurement of +/// that day, but the quantity wanted is a TRAIT, and a day spent walking for +/// eight hours would otherwise subtract its own effort away. Feed these into a +/// rolling personal median and score against that; the guard below only refuses +/// the most obvious offenders, it does not make a single day robust. +/// +/// Returns null — never a stand-in — when the anchors are missing or +/// degenerate, when there are fewer than [minMinutes] measured waking minutes, +/// or when the median clears [maxQuietHrr]. +double? dailyQuietWakingHrr( + List hrPerMin, { + required double? restingHr, + required double? maxHr, + int minMinutes = 60, +}) { + if (restingHr == null || maxHr == null || maxHr <= restingHr) return null; + final reserve = maxHr - restingHr; + final hrr = [ + for (final hr in hrPerMin) + if (hr > 0) math.min(1.0, math.max(0.0, (hr - restingHr) / reserve)), + ]; + if (hrr.length < minMinutes) return null; + final q = median(hrr); + if (q == null || q <= 0 || q > maxQuietHrr) return null; + return q; +} + /// Net TRIMP — earned ABOVE the quiet-waking baseline — that defines a maximal /// day and maps to the top of the scale. /// @@ -114,15 +166,17 @@ const double maximalNetTrimp = 400.0; /// baseline subtraction included the scale SATURATES AT ≈3.25 h at 160 bpm /// (195 min) and ≈4.25 h at 150 bpm, so everything above that is one number. /// -/// AND THE ANCHORS ARE NOT WHAT REAL DAYS SCORE. The convention above puts -/// quiet waking at exactly 0.20 HRR; this user's measured wake minutes sit at -/// p50 0.274 / p75 0.332 HRR (whoop-4.db, 9 full-wear days, RHR 55 / HRmax -/// 187). At that real quiet level a day containing only 2–6 minutes above 50 % -/// HRR scores 6.93 / 11.17 / 11.38 / 11.97 / 12.14 — i.e. a NOTHING-DAY lands -/// in the band this table calls "90 min hard session". That gap is -/// [quietWakingHrr] being too low for this user, not the map: it is audit -/// MOT-03, it is not fixed here, and every number in this table moves when it -/// is. Regenerate the table with the constant, never separately. +/// THE TABLE ONLY HOLDS AT THE QUIET LEVEL IT WAS GENERATED AT, which is the +/// point of MOT-03 (edge#226, fixed 2026-08-19). Generated at 0.20 HRR; this +/// user's measured wake minutes sit at p50 0.274 / p75 0.332 HRR (whoop-4.db, +/// 9 full-wear days, RHR 55 / HRmax 187), and scoring that user against a 0.20 +/// baseline put a day containing only 2–6 minutes above 50 % HRR at +/// 6.93 / 11.17 / 11.38 / 11.97 / 12.14 — a NOTHING-DAY in the band this table +/// calls "90 min hard session". The map was never the problem: at their own +/// 0.274 the same day scores 0.00 and the same day plus an hour's walk scores +/// 2.15, which is this table's "60 min walk" row. The quiet level is an +/// argument now, so regenerate the table with whatever you pass, never +/// separately. const double strainCurvature = 15.0; /// The TRIMP that [wakeMinutes] of ordinary waking accrues on its own. @@ -144,25 +198,39 @@ const double strainCurvature = 15.0; /// form within ~1.5 points on gen4 but still breaks MG's honest zeros: 0.00 → /// 4.05/4.83.) The gated form is only defensible once [quietWakingHrr] is this /// user's own quiet level — MOT-03 — so it is blocked on that, not on taste. -double baselineTrimp(double wakeMinutes, {bool female = false}) => - wakeMinutes * - quietWakingHrr * - StrainScorer.banisterY(quietWakingHrr, female: female); +/// +/// [quietHrr] is that level: [dailyQuietWakingHrr] measures it, and it is +/// clamped to (0, [maxQuietHrr]] here so no caller can hand over a baseline +/// that either vanishes or eats a whole day's training. +double baselineTrimp( + double wakeMinutes, { + required double quietHrr, + bool female = false, +}) { + final q = math.min(maxQuietHrr, math.max(0.0, quietHrr)); + return wakeMinutes * q * StrainScorer.banisterY(q, female: female); +} /// Log-map the TRIMP EARNED ABOVE baseline into a 0–21 headline "strain" score. /// -/// net = trimp − baselineTrimp(wakeMinutes) +/// net = trimp − baselineTrimp(wakeMinutes, quietHrr) /// u = min(1, net / maximalNetTrimp) /// strain = 21 · ln(1 + u·(C−1)) / ln(C), C = [strainCurvature] /// /// [wakeMinutes] is the observed waking wear window that produced [trimp] — it -/// sets the baseline, so it is required rather than assumed. +/// sets the baseline, so it is required rather than assumed. [quietHrr] is what +/// a minute of that window costs this user when nothing is happening +/// ([dailyQuietWakingHrr]); it is required for the same reason, and getting it +/// wrong by 0.07 HRR is the difference between a nothing-day scoring 0 and +/// scoring 12. double strainScore( double trimp, { required double wakeMinutes, + required double quietHrr, bool female = false, }) { - final net = trimp - baselineTrimp(wakeMinutes, female: female); + final net = + trimp - baselineTrimp(wakeMinutes, quietHrr: quietHrr, female: female); if (net <= 0) return 0.0; final u = math.min(1.0, net / maximalNetTrimp); final s = @@ -173,15 +241,18 @@ double strainScore( /// Headline 0–21 strain as a Metric, alongside the raw TRIMP (EST tier). /// /// [trimp] the raw Banister TRIMP for the day/session, [wakeMinutes] the wake -/// window it was accumulated over. Absent when either is missing: the baseline -/// subtraction is meaningless without a wake window, and guessing one silently -/// mis-scores every partial-wear day. +/// window it was accumulated over, [quietHrr] this user's quiet-waking level +/// ([dailyQuietWakingHrr]). Absent when any is missing: the baseline +/// subtraction is meaningless without a wake window, guessing one silently +/// mis-scores every partial-wear day, and a stand-in quiet level is what scored +/// a day with no activity in it at 12/21 (MOT-03). Metric strainScoreMetric( double? trimp, { required double? wakeMinutes, + required double? quietHrr, bool female = false, }) { - const inputs = ['trimp', 'wake_minutes']; + const inputs = ['trimp', 'wake_minutes', 'quiet_waking_hrr']; if (trimp == null || trimp < 0 || wakeMinutes == null || wakeMinutes <= 0) { return const Metric.absent( tier: Tier.estimate, @@ -189,8 +260,17 @@ Metric strainScoreMetric( note: 'strain needs a TRIMP and the wake window it was measured over', ); } + if (quietHrr == null || quietHrr <= 0) { + return const Metric.absent( + tier: Tier.estimate, + inputs_used: inputs, + note: 'strain needs this user\'s own quiet-waking HRR to subtract; ' + 'without it the cost of simply being awake reads as training load', + ); + } return Metric( - value: strainScore(trimp, wakeMinutes: wakeMinutes, female: female), + value: strainScore(trimp, + wakeMinutes: wakeMinutes, quietHrr: quietHrr, female: female), confidence: 0.6, tier: Tier.estimate, inputs_used: inputs, diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index d401c06..eb93198 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -788,29 +788,49 @@ void main() { group('strain score (0-21 map of TRIMP above the waking baseline)', () { test('subtracts the quiet-waking baseline for the observed wake window', () { - // 960 waking minutes accrue ~180 TRIMP just by being awake. Charging that - // as effort is what put an inactive day at 12.8/21. - expect(baselineTrimp(960), closeTo(180.4, 0.5)); - expect(strainScore(180.0, wakeMinutes: 960), 0.0); + // 960 waking minutes accrue ~180 TRIMP just by being awake at 0.20 HRR. + // Charging that as effort is what put an inactive day at 12.8/21. + expect(baselineTrimp(960, quietHrr: quietWakingHrr), closeTo(180.4, 0.5)); + expect(strainScore(180.0, wakeMinutes: 960, quietHrr: quietWakingHrr), + 0.0); // Half the wear window, half the allowance. - expect(baselineTrimp(480), closeTo(baselineTrimp(960) / 2, 1e-9)); + expect(baselineTrimp(480, quietHrr: quietWakingHrr), + closeTo(baselineTrimp(960, quietHrr: quietWakingHrr) / 2, 1e-9)); + // A higher personal quiet level costs more allowance, always. + expect(baselineTrimp(960, quietHrr: 0.274), + greaterThan(baselineTrimp(960, quietHrr: 0.20))); + // And no caller can hand over one that eats a day's training whole. + expect(baselineTrimp(960, quietHrr: 0.9), + closeTo(baselineTrimp(960, quietHrr: maxQuietHrr), 1e-9)); }); test('is monotone, floored at 0 and capped at 21', () { - expect(strainScore(0, wakeMinutes: 960), closeTo(0.0, 1e-9)); - expect(strainScore(1e9, wakeMinutes: 960), closeTo(21.0, 1e-9)); + expect(strainScore(0, wakeMinutes: 960, quietHrr: quietWakingHrr), + closeTo(0.0, 1e-9)); + expect(strainScore(1e9, wakeMinutes: 960, quietHrr: quietWakingHrr), + closeTo(21.0, 1e-9)); expect( - strainScore(300, wakeMinutes: 960) < strainScore(400, wakeMinutes: 960), + strainScore(300, wakeMinutes: 960, quietHrr: quietWakingHrr) < + strainScore(400, wakeMinutes: 960, quietHrr: quietWakingHrr), isTrue, ); }); - test('strainScoreMetric is EST tier and absent without either input', () { - final m = strainScoreMetric(392.9, wakeMinutes: 960); + test('strainScoreMetric is EST tier and absent without any input', () { + final m = + strainScoreMetric(392.9, wakeMinutes: 960, quietHrr: quietWakingHrr); expect(m.present, isTrue); expect(m.tier, 'ESTIMATE'); - expect(strainScoreMetric(null, wakeMinutes: 960).present, isFalse); - expect(strainScoreMetric(335, wakeMinutes: null).present, isFalse); + expect( + strainScoreMetric(null, wakeMinutes: 960, quietHrr: quietWakingHrr) + .present, + isFalse); + expect( + strainScoreMetric(335, wakeMinutes: null, quietHrr: quietWakingHrr) + .present, + isFalse); + expect(strainScoreMetric(335, wakeMinutes: 960, quietHrr: null).present, + isFalse); }); }); diff --git a/test/onehz/strain_calibration_test.dart b/test/onehz/strain_calibration_test.dart index 4e6a418..65f4675 100644 --- a/test/onehz/strain_calibration_test.dart +++ b/test/onehz/strain_calibration_test.dart @@ -35,7 +35,12 @@ List dayHr( } /// Full pipeline: per-minute HR → Banister TRIMP → headline 0–21 strain. -double strainOfDay(List hr) { +/// +/// [quietHrr] defaults to the convention the anchor table in load_trimp.dart is +/// generated at, so these anchors reproduce that table exactly. Real callers +/// pass the user's own measured level ([dailyQuietWakingHrr]) — see the MOT-03 +/// group below for what that does. +double strainOfDay(List hr, {double quietHrr = quietWakingHrr}) { final trimp = banisterTrimp( hr, restingHr: kRhr, @@ -43,7 +48,8 @@ double strainOfDay(List hr) { sex: Sex.male, ); expect(trimp.present, isTrue, reason: 'anchors need a real TRIMP'); - return strainScore(trimp.value!, wakeMinutes: hr.length.toDouble()); + return strainScore(trimp.value!, + wakeMinutes: hr.length.toDouble(), quietHrr: quietHrr); } void main() { @@ -90,23 +96,6 @@ void main() { expect(strainOfDay(dayHr(960, 85, [(195, 160)])), closeTo(21.0, 1e-9)); }); - test('MOT-04/MOT-03: at this user MEASURED quiet level a nothing-day still ' - 'scores ~12 — the known defect, pinned', () { - // The anchors above put quiet waking at exactly `quietWakingHrr` = 0.20. - // whoop-4.db says this user's real wake minutes sit at p50 0.274 HRR - // (RHR 55 / HRmax 187), and at that level a full-wear day with no - // exercise at all scores in the band the table calls "90 min hard". - // Measured on the real corpus: 6.93 / 11.17 / 11.38 / 11.97 / 12.14 over - // the five quiet days. THIS TEST IS EXPECTED TO FAIL WHEN MOT-03 LANDS — - // when it does, that is the fix arriving, and the anchor table in - // load_trimp.dart has to be regenerated in the same change. - const rhr = 55.0, hrMax = 187.0; - final quietBpm = rhr + 0.274 * (hrMax - rhr); // 91.2 bpm - final trimp = banisterTrimp(List.filled(960, quietBpm), - restingHr: rhr, maxHr: hrMax, sex: Sex.male); - expect(strainScore(trimp.value!, wakeMinutes: 960), closeTo(11.9, 0.6)); - }); - test('short wear with no activity is not scored as effort', () { // Real bundle 2026-07-10: band worn ~135 waking minutes, 23 steps. // The baseline must scale with wear, or a 2-hour inactive wear window @@ -120,13 +109,99 @@ void main() { final hard = strainOfDay(dayHr(960, 85, [(120, 170)])); expect(easy, lessThan(mid)); expect(mid, lessThan(hard)); - expect(strainScore(1e9, wakeMinutes: 960), closeTo(21.0, 1e-9)); - expect(strainScore(0, wakeMinutes: 960), 0.0); + expect(strainScore(1e9, wakeMinutes: 960, quietHrr: quietWakingHrr), + closeTo(21.0, 1e-9)); + expect(strainScore(0, wakeMinutes: 960, quietHrr: quietWakingHrr), 0.0); }); test('never returns a negative strain when load is under baseline', () { // Asleep-ish all day: TRIMP well below the quiet-waking allowance. - expect(strainScore(1.0, wakeMinutes: 960), 0.0); + expect(strainScore(1.0, wakeMinutes: 960, quietHrr: quietWakingHrr), 0.0); + }); + }); + + // THE FIX for edge#226 / MOT-03. The 0.20 convention above is not this user's + // quiet level, and scoring them against it billed the cost of being awake as + // training load. + group('MOT-03 — the quiet-waking level is the USER\'S, not a constant', () { + // whoop-4.db: this user's wake minutes sit at p50 0.274 HRR, RHR 55. + const rhr = 55.0, hrMax = 187.0; + final quietBpm = rhr + 0.274 * (hrMax - rhr); // 91.2 bpm + + double scored(List hr, {double? quietHrr}) { + final trimp = + banisterTrimp(hr, restingHr: rhr, maxHr: hrMax, sex: Sex.male); + return strainScore(trimp.value!, + wakeMinutes: hr.length.toDouble(), + quietHrr: quietHrr ?? + dailyQuietWakingHrr(hr, restingHr: rhr, maxHr: hrMax)!); + } + + test('a nothing-day scores 0, where it used to score ~12', () { + final nothing = List.filled(960, quietBpm); + expect(scored(nothing, quietHrr: 0.20), closeTo(11.93, 0.05), + reason: 'what the shipped constant published for doing nothing'); + expect(scored(nothing), 0.0); + }); + + test('and a light day is NOT flattened into the same bucket', () { + // The defect flattened the whole bottom of the scale: doing nothing and + // doing an hour's walk were 11.93 vs 12.61, indistinguishable on a 0–21 + // dial. They now sit two and a half points apart, and the rest of the + // scale stays graded rather than collapsing to zero with it. + final walk = [ + ...List.filled(900, quietBpm), + ...List.filled(60, 105.0), + ]; + final run = [ + ...List.filled(915, quietBpm), + ...List.filled(45, 145.0), + ]; + final hard = [ + ...List.filled(870, quietBpm), + ...List.filled(90, 165.0), + ]; + expect(scored(walk, quietHrr: 0.20), closeTo(12.61, 0.05)); + expect(scored(walk), closeTo(2.78, 0.05)); + expect(scored(run), closeTo(8.72, 0.05)); + expect(scored(hard), closeTo(16.49, 0.05)); + }); + + test('a user whose quiet really IS 0.20 barely moves', () { + // The anchor profile: RHR 60, quiet waking at 85 bpm = 0.1969 HRR. The + // fix is not a global re-scaling — it only bites where the constant was + // wrong for the person. + final nothing = List.filled(960, 85.0); + expect(dailyQuietWakingHrr(nothing, restingHr: 60, maxHr: hrMax), + closeTo(0.1969, 0.001)); + expect(strainOfDay(nothing), 0.0); + expect( + strainOfDay(nothing, + quietHrr: + dailyQuietWakingHrr(nothing, restingHr: 60, maxHr: hrMax)!), + 0.0); + }); + + test('an exercise-dominated day cannot define quiet waking', () { + // Otherwise an all-day hike subtracts its own effort away and scores 0. + final hike = List.filled(960, rhr + 0.45 * (hrMax - rhr)); + expect(dailyQuietWakingHrr(hike, restingHr: rhr, maxHr: hrMax), isNull); + // Just under the moderate floor it is still ordinary living. + final busy = List.filled(960, rhr + 0.35 * (hrMax - rhr)); + expect(dailyQuietWakingHrr(busy, restingHr: rhr, maxHr: hrMax), + closeTo(0.35, 1e-9)); + }); + + test('no anchors, or too few minutes, is null — never a stand-in', () { + final day = List.filled(960, quietBpm); + expect(dailyQuietWakingHrr(day, restingHr: null, maxHr: hrMax), isNull); + expect(dailyQuietWakingHrr(day, restingHr: rhr, maxHr: null), isNull); + expect(dailyQuietWakingHrr(day.take(30).toList(), restingHr: rhr, maxHr: hrMax), + isNull); + // Off-skin zeros are not minutes. + expect( + dailyQuietWakingHrr(List.filled(960, 0), restingHr: rhr, maxHr: hrMax), + isNull); }); }); @@ -134,12 +209,28 @@ void main() { test('abstains without wake minutes rather than assuming a full day', () { // Wake minutes set the baseline. Guessing one fabricates the subtraction // and silently mis-scores every partial-wear day. - expect(strainScoreMetric(300, wakeMinutes: null).present, isFalse); - expect(strainScoreMetric(null, wakeMinutes: 960).present, isFalse); + expect( + strainScoreMetric(300, wakeMinutes: null, quietHrr: quietWakingHrr) + .present, + isFalse); + expect( + strainScoreMetric(null, wakeMinutes: 960, quietHrr: quietWakingHrr) + .present, + isFalse); + }); + + test('abstains without a quiet-waking level rather than assuming one', () { + // The whole of MOT-03: a stand-in level is what billed being awake as + // training load. No level, no score. + final m = strainScoreMetric(392.9, wakeMinutes: 960, quietHrr: null); + expect(m.present, isFalse); + expect(m.note, contains('quiet-waking')); + expect(m.inputs_used, contains('quiet_waking_hrr')); }); - test('present and ESTIMATE-tier with both inputs', () { - final m = strainScoreMetric(392.9, wakeMinutes: 960); + test('present and ESTIMATE-tier with every input', () { + final m = + strainScoreMetric(392.9, wakeMinutes: 960, quietHrr: quietWakingHrr); expect(m.present, isTrue); expect(m.tier, Tier.estimate); expect(m.value, greaterThan(14.0)); From 3174a493472a5e6280b11a0ab11fec82483507e1 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:12:02 +0530 Subject: [PATCH 4/4] a nan anchor made a present metric, not an absent one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit activeGateHr returns null now when the anchors can't define a gate (non-finite, or not 0 < rest < hrmax). a NaN gate isn't a loose gate: every `hr < gate` is false against NaN, so every minute of the day billed at the keytel active rate. dailyEnergy abstains on it, the bout takes the 220/60 fallback it already documents and flags it like any other missing anchor. same class in load_trimp — every range check there is false for NaN too, so NaN walked into a present strain. also refuse a quiet-waking level above the moderate floor instead of letting baselineTrimp quietly clamp it, and drop non-finite hr samples (+inf was clamping to a maximal minute). --- lib/src/onehz/clinical/load_trimp.dart | 72 ++++++++++++++++++---- lib/src/onehz/workout/calories.dart | 50 +++++++++++++--- test/onehz/clinical_test.dart | 70 ++++++++++++++++++++++ test/onehz/steps_test.dart | 82 ++++++++++++++++++++++---- 4 files changed, 240 insertions(+), 34 deletions(-) diff --git a/lib/src/onehz/clinical/load_trimp.dart b/lib/src/onehz/clinical/load_trimp.dart index a4b9850..e2daa95 100644 --- a/lib/src/onehz/clinical/load_trimp.dart +++ b/lib/src/onehz/clinical/load_trimp.dart @@ -46,20 +46,26 @@ Metric banisterTrimp( required Sex sex, }) { const inputs = ['hr_per_min', 'resting_hr', 'max_hr']; + // `maxHr <= restingHr` is FALSE when either is NaN, so the finiteness checks + // are load-bearing: a NaN anchor otherwise makes a NaN reserve, and every + // clamp below (`hrr < 0`, `hrr > 1`) is false for NaN too, so the sum comes + // out NaN inside a PRESENT metric. if (restingHr == null || maxHr == null || + !restingHr.isFinite || + !maxHr.isFinite || maxHr <= restingHr || hrPerMin.isEmpty) { return const Metric.absent( tier: Tier.estimate, inputs_used: inputs, - note: 'Banister TRIMP needs measured RHR and HRmax (HRmax>RHR)', + note: 'Banister TRIMP needs finite measured RHR and HRmax (HRmax>RHR)', ); } final reserve = maxHr - restingHr; var trimp = 0.0; for (final hr in hrPerMin) { - if (hr <= 0) continue; // off-skin guard + if (!hr.isFinite || hr <= 0) continue; // off-skin + non-finite guard var hrr = (hr - restingHr) / reserve; if (hrr < 0) hrr = 0; if (hrr > 1) hrr = 1; @@ -117,20 +123,33 @@ const double maxQuietHrr = 0.40; /// rolling personal median and score against that; the guard below only refuses /// the most obvious offenders, it does not make a single day robust. /// -/// Returns null — never a stand-in — when the anchors are missing or -/// degenerate, when there are fewer than [minMinutes] measured waking minutes, -/// or when the median clears [maxQuietHrr]. +/// Returns null — never a stand-in — when the anchors are missing, non-finite +/// or degenerate, when there are fewer than [minMinutes] measured waking +/// minutes, or when the median clears [maxQuietHrr]. double? dailyQuietWakingHrr( List hrPerMin, { required double? restingHr, required double? maxHr, int minMinutes = 60, }) { - if (restingHr == null || maxHr == null || maxHr <= restingHr) return null; + // Finiteness first: `maxHr <= restingHr` is false when either is NaN, and a + // NaN reserve survives BOTH clamps below (min/max propagate NaN) and both + // range checks at the bottom, so the function would hand back NaN as if it + // had measured something. + if (restingHr == null || + maxHr == null || + !restingHr.isFinite || + !maxHr.isFinite || + maxHr <= restingHr) { + return null; + } final reserve = maxHr - restingHr; final hrr = [ + // `hr > 0` already drops NaN, but not +infinity — which would clamp to 1.0 + // and count a garbage sample as a maximal-effort minute. for (final hr in hrPerMin) - if (hr > 0) math.min(1.0, math.max(0.0, (hr - restingHr) / reserve)), + if (hr.isFinite && hr > 0) + math.min(1.0, math.max(0.0, (hr - restingHr) / reserve)), ]; if (hrr.length < minMinutes) return null; final q = median(hrr); @@ -242,7 +261,8 @@ double strainScore( /// /// [trimp] the raw Banister TRIMP for the day/session, [wakeMinutes] the wake /// window it was accumulated over, [quietHrr] this user's quiet-waking level -/// ([dailyQuietWakingHrr]). Absent when any is missing: the baseline +/// ([dailyQuietWakingHrr]). Absent — with the reason in the note — when any is +/// missing, non-finite, or out of range: the baseline /// subtraction is meaningless without a wake window, guessing one silently /// mis-scores every partial-wear day, and a stand-in quiet level is what scored /// a day with no activity in it at 12/21 (MOT-03). @@ -253,19 +273,45 @@ Metric strainScoreMetric( bool female = false, }) { const inputs = ['trimp', 'wake_minutes', 'quiet_waking_hrr']; - if (trimp == null || trimp < 0 || wakeMinutes == null || wakeMinutes <= 0) { + // EVERY RANGE CHECK IN THIS FUNCTION IS FALSE FOR NaN, so each one needs its + // finiteness partner. Without them a NaN input produces a PRESENT metric + // carrying a NaN value, which is worse than an absent one: everything + // downstream treats a present metric as measured. + if (trimp == null || !trimp.isFinite || trimp < 0) { + return const Metric.absent( + tier: Tier.estimate, + inputs_used: inputs, + note: 'strain needs a finite TRIMP >= 0', + ); + } + if (wakeMinutes == null || !wakeMinutes.isFinite || wakeMinutes <= 0) { + return const Metric.absent( + tier: Tier.estimate, + inputs_used: inputs, + note: 'strain needs a finite wake window (minutes > 0) — it is what the ' + 'quiet-waking baseline is subtracted over', + ); + } + if (quietHrr == null || !quietHrr.isFinite || quietHrr <= 0) { return const Metric.absent( tier: Tier.estimate, inputs_used: inputs, - note: 'strain needs a TRIMP and the wake window it was measured over', + note: 'strain needs this user\'s own quiet-waking HRR to subtract, ' + 'finite and > 0; without it the cost of simply being awake reads ' + 'as training load', ); } - if (quietHrr == null || quietHrr <= 0) { + // NOT clamped through to [baselineTrimp]. It clamps to maxQuietHrr as a last + // resort, which would quietly score the day against a level nobody measured; + // a quiet level above the moderate-intensity floor is a broken measurement, + // and [dailyQuietWakingHrr] never emits one. + if (quietHrr > maxQuietHrr) { return const Metric.absent( tier: Tier.estimate, inputs_used: inputs, - note: 'strain needs this user\'s own quiet-waking HRR to subtract; ' - 'without it the cost of simply being awake reads as training load', + note: 'quiet-waking HRR above $maxQuietHrr is not quiet waking — that ' + 'is ACSM\'s moderate-intensity floor, so the level would subtract ' + 'the day\'s own training away', ); } return Metric( diff --git a/lib/src/onehz/workout/calories.dart b/lib/src/onehz/workout/calories.dart index aa890b0..03e6ea3 100644 --- a/lib/src/onehz/workout/calories.dart +++ b/lib/src/onehz/workout/calories.dart @@ -152,8 +152,21 @@ class Calories { /// THE one definition. Both entry points call it, and so does the edge's live /// sample-by-sample billing — a second copy of this arithmetic is how the day /// and the bout came to disagree in the first place. - static double activeGateHr(double hrmax, double restingHr) => - restingHr + activeHRRFraction * (hrmax - restingHr); + /// + /// NULL WHEN THE ANCHORS CANNOT DEFINE A GATE: either one non-finite, or not + /// `0 < restingHr < hrmax`. A NaN gate is not a loose gate, it is NO gate — + /// every `hr < gate` comparison against NaN is false, so EVERY sample bills + /// at the Keytel active rate and a silently enormous kcal figure comes out + /// the far end instead of an obvious failure. Validation lives here rather + /// than in each entry point because this is the one place both of them (and + /// the edge's live billing) route through. + static double? activeGateHr(double hrmax, double restingHr) => + (hrmax.isFinite && + restingHr.isFinite && + restingHr > 0 && + restingHr < hrmax) + ? restingHr + activeHRRFraction * (hrmax - restingHr) + : null; /// 60 s/min × 4.184 kJ/kcal. static const double workoutDivisor = 251.04; @@ -262,7 +275,13 @@ class Calories { /// gate. A caller without a measured resting HR has no honest day-energy /// figure and abstains rather than have one computed against somebody else's /// rest. - static ({double total, double active, double basal}) dailyEnergy( + /// + /// RETURNS NULL when the anchors are present but unusable — non-finite, or + /// not `0 < restingHr < hrmax`, see [activeGateHr]. Same rule as the absent + /// case and for the same reason: no gate, no honest energy figure. It is + /// never the zeros, because a zero here reads downstream as a measured day + /// with nothing in it. + static ({double total, double active, double basal})? dailyEnergy( List hrPerMin, { required WorkoutUserProfile profile, required double hrmax, @@ -279,13 +298,16 @@ class Calories { final age = profile.age > 0 ? profile.age : 30.0; final coeffs = resolveCoeffs(profile.sex); final flexHr = activeGateHr(hrmax, restingHr); + if (flexHr == null) return null; final bmrDay = mifflinBmrKcalDay(weightKg, heightCm, age, profile.sex); final basalPerMin = bmrDay / 1440.0; var active = 0.0; for (final hr in hrPerMin) { - if (hr < flexHr) continue; // below flex point → basal only + // `hr < flexHr` is false for NaN, so an unfiltered non-finite minute + // would bill active and carry its NaN into the day total. + if (!hr.isFinite || hr < flexHr) continue; // below flex → basal only final activePerMin = activeKcalPerS(coeffs, hr, hrmax, weightKg, age) * 60.0; final surplus = activePerMin - basalPerMin; @@ -301,7 +323,8 @@ class Calories { /// seconds. /// /// [hrTsSec]/[hrBpm] are the bout's HR samples (timestamps in SECONDS, same - /// length). [hrmax]/[restingHr] anchors (null → 220 / 60 fallback, flagged + /// length). [hrmax]/[restingHr] anchors (null OR unusable, see [activeGateHr] + /// → 220 / 60 fallback, flagged /// via [usedDefaultAnchors] on the result so a fabricated-anchor calorie /// number can be caveated instead of shown as if it were real). static ({double kcal, double kj, bool usedDefaultAnchors}) @@ -322,10 +345,18 @@ class Calories { // hrmax/restingHr, not weight/height/age (WorkoutUserProfile bakes // 70/170/30 in at construction, so there's nothing left here to tell // "given" from "defaulted" on those three). - final usedDefaultAnchors = hrmax == null || restingHr == null; - final effHRmax = hrmax ?? 220.0; - final effResting = restingHr ?? 60.0; - final activeThreshold = activeGateHr(effHRmax, effResting); + // + // AN UNUSABLE ANCHOR IS AN ABSENT ANCHOR: non-finite, or a rest that isn't + // below the ceiling, cannot define a gate ([activeGateHr] returns null), so + // it takes the SAME documented 220/60 fallback and is flagged like any + // other missing anchor. Anything else here means a NaN gate, and a NaN gate + // bills every sample of the bout at the active rate. + final usedDefaultAnchors = hrmax == null || + restingHr == null || + activeGateHr(hrmax, restingHr) == null; + final effHRmax = usedDefaultAnchors ? 220.0 : hrmax; + final effResting = usedDefaultAnchors ? 60.0 : restingHr; + final activeThreshold = activeGateHr(effHRmax, effResting)!; final restingRate = restingKcalPerS(coeffs, weightKg, heightCm, age); @@ -338,6 +369,7 @@ class Calories { var totalKcal = 0.0; for (var i = 0; i < ts.length; i++) { final b = bpm[i]; + if (!b.isFinite) continue; // `b < threshold` is false for NaN final double dur; if (i < ts.length - 1) { final gap = (ts[i + 1] - ts[i]).toDouble(); diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index eb93198..f69a179 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -832,6 +832,76 @@ void main() { expect(strainScoreMetric(335, wakeMinutes: 960, quietHrr: null).present, isFalse); }); + + test('a non-finite input never produces a PRESENT strain', () { + // EVERY range check in strainScoreMetric is false for NaN (`NaN < 0`, + // `NaN <= 0`, `NaN > 0.40` — all false), so a NaN used to walk straight + // through into a present metric carrying a NaN value. That is worse than + // an absent one: everything downstream treats present as measured. + for (final m in [ + strainScoreMetric(double.nan, + wakeMinutes: 960, quietHrr: quietWakingHrr), + strainScoreMetric(double.infinity, + wakeMinutes: 960, quietHrr: quietWakingHrr), + strainScoreMetric(392.9, + wakeMinutes: double.nan, quietHrr: quietWakingHrr), + strainScoreMetric(392.9, + wakeMinutes: double.infinity, quietHrr: quietWakingHrr), + strainScoreMetric(392.9, wakeMinutes: 960, quietHrr: double.nan), + strainScoreMetric(392.9, wakeMinutes: 960, quietHrr: double.infinity), + ]) { + expect(m.present, isFalse); + expect(m.value, isNull); + expect(m.note, isNotEmpty, reason: 'absent needs a debuggable reason'); + } + + // REFUSED, not clamped. A quiet level above ACSM's moderate floor is a + // broken measurement; baselineTrimp would silently pull it back to + // maxQuietHrr and score the day against a level nobody measured. + expect( + strainScoreMetric(392.9, wakeMinutes: 960, quietHrr: maxQuietHrr) + .present, + isTrue); + expect( + strainScoreMetric(392.9, + wakeMinutes: 960, quietHrr: maxQuietHrr + 0.01) + .present, + isFalse); + }); + + test('non-finite anchors and samples never reach a number', () { + final hr = List.filled(120, 90.0); + // `maxHr <= restingHr` is false when either is NaN, so the finiteness + // check is the only thing standing between a NaN anchor and a NaN result. + for (final (rhr, hrMax) in [ + (double.nan, 187.0), + (55.0, double.nan), + (55.0, double.infinity), + (double.infinity, 187.0), + ]) { + expect(dailyQuietWakingHrr(hr, restingHr: rhr, maxHr: hrMax), isNull, + reason: 'quiet level for rhr=$rhr hrmax=$hrMax'); + expect( + banisterTrimp(hr, restingHr: rhr, maxHr: hrMax, sex: Sex.male) + .present, + isFalse, + reason: 'TRIMP for rhr=$rhr hrmax=$hrMax'); + } + + // +infinity passes `hr > 0` and clamps to 1.0 — a garbage sample read as + // a maximal-effort minute. Dropped now, so it moves neither the median + // nor the TRIMP sum. + final dirty = [...hr, ...List.filled(200, double.infinity)]; + expect(dailyQuietWakingHrr(dirty, restingHr: 55, maxHr: 187), + dailyQuietWakingHrr(hr, restingHr: 55, maxHr: 187)); + expect(banisterTrimp(dirty, restingHr: 55, maxHr: 187, sex: Sex.male).value, + banisterTrimp(hr, restingHr: 55, maxHr: 187, sex: Sex.male).value); + // Nothing measurable left once they are dropped. + expect( + dailyQuietWakingHrr(List.filled(120, double.infinity), + restingHr: 55, maxHr: 187), + isNull); + }); }); group('StrainScorer (Banister TRIMP → 0–100)', () { diff --git a/test/onehz/steps_test.dart b/test/onehz/steps_test.dart index af75d25..e69a9b4 100644 --- a/test/onehz/steps_test.dart +++ b/test/onehz/steps_test.dart @@ -809,8 +809,8 @@ void main() { weightKg: 80, heightCm: 180, age: 30, sex: 'male'); // 1440 minutes at a low HR (40% HRmax → below active surplus) final hr = List.filled(1440, 70.0); - final e = - Calories.dailyEnergy(hr, profile: profile, hrmax: 190, restingHr: 60); + final e = Calories.dailyEnergy(hr, + profile: profile, hrmax: 190, restingHr: 60)!; expect(e.basal, closeTo(1780.0, 1.0)); expect(e.active, closeTo(0.0, 60.0)); // tiny if any expect(e.total, greaterThanOrEqualTo(e.basal)); @@ -828,10 +828,10 @@ void main() { final profile = const WorkoutUserProfile( weightKg: 80, heightCm: 180, age: 30, sex: 'male'); final hr = List.filled(1440, 111.0); - final wide = - Calories.dailyEnergy(hr, profile: profile, hrmax: 190, restingHr: 60); - final tanaka = - Calories.dailyEnergy(hr, profile: profile, hrmax: 187, restingHr: 60); + final wide = Calories.dailyEnergy(hr, + profile: profile, hrmax: 190, restingHr: 60)!; + final tanaka = Calories.dailyEnergy(hr, + profile: profile, hrmax: 187, restingHr: 60)!; expect(wide.active, 0.0); expect(tanaka.active, greaterThan(0.0)); }); @@ -851,7 +851,7 @@ void main() { final quiet = List.filled(960, 100.0); expect( Calories.dailyEnergy(quiet, - profile: profile, hrmax: 187, restingHr: 60) + profile: profile, hrmax: 187, restingHr: 60)! .active, 0.0); // A real session still bills: 45 min at 145 bpm. @@ -861,7 +861,7 @@ void main() { ]; expect( Calories.dailyEnergy(session, - profile: profile, hrmax: 187, restingHr: 60) + profile: profile, hrmax: 187, restingHr: 60)! .active, closeTo(553.0, 5.0)); }); @@ -872,7 +872,7 @@ void main() { const profile = WorkoutUserProfile( weightKg: 72, heightCm: 178, age: 34, sex: 'male'); const hrmax = 184.2, rhr = 55.0; - final gate = Calories.activeGateHr(hrmax, rhr); + final gate = Calories.activeGateHr(hrmax, rhr)!; expect(gate, closeTo(106.68, 0.01)); final restingKcalPerMin = @@ -881,7 +881,7 @@ void main() { for (final (hr, active) in [(gate - 1, false), (gate + 1, true)]) { final day = Calories.dailyEnergy(List.filled(60, hr), - profile: profile, hrmax: hrmax, restingHr: rhr); + profile: profile, hrmax: hrmax, restingHr: rhr)!; final bout = Calories.estimateBoutCalories( ts, List.filled(60, hr), profile: profile, hrmax: hrmax, restingHr: rhr); @@ -911,11 +911,69 @@ void main() { ...List.filled(1380, 65.0), ...List.filled(60, 150.0), // 1 h hard ]; - final e = - Calories.dailyEnergy(hr, profile: profile, hrmax: 190, restingHr: 60); + final e = Calories.dailyEnergy(hr, + profile: profile, hrmax: 190, restingHr: 60)!; expect(e.active, greaterThan(300.0)); expect(e.total, closeTo(e.basal + e.active, 1e-6)); }); + + test('an unusable anchor is NO gate, not a loose one', () { + // `restingHr + 0.40·(hrmax − restingHr)` is NaN if either anchor is, and + // `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'); + final hr = List.filled(1440, 70.0); + final ts = List.generate(60, (i) => i); + + for (final (hrmax, rhr) in [ + (190.0, double.nan), + (double.nan, 60.0), + (double.infinity, 60.0), + (190.0, double.infinity), + (190.0, 0.0), // a rest of zero is not a reserve anchor + (190.0, -5.0), + (190.0, 190.0), // no reserve at all + (60.0, 190.0), // rest above the ceiling + ]) { + expect(Calories.activeGateHr(hrmax, rhr), isNull, + reason: 'gate for hrmax=$hrmax rhr=$rhr'); + expect( + Calories.dailyEnergy(hr, + profile: profile, hrmax: hrmax, restingHr: rhr), + isNull, + reason: 'day for hrmax=$hrmax rhr=$rhr'); + // The bout keeps its documented 220/60 fallback — an unusable anchor is + // an ABSENT anchor — and is flagged, which is what edge reads to decide + // whether the kcal figure may be persisted at all. + final bout = Calories.estimateBoutCalories( + ts, List.filled(60, 130.0), + profile: profile, hrmax: hrmax, restingHr: rhr); + expect(bout.usedDefaultAnchors, isTrue, + reason: 'bout for hrmax=$hrmax rhr=$rhr'); + expect(bout.kcal.isFinite, isTrue, + reason: 'bout for hrmax=$hrmax rhr=$rhr'); + } + }); + + test('a non-finite HR sample is dropped, not billed', () { + 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, + profile: profile, hrmax: 190, restingHr: 60)!; + final b = Calories.dailyEnergy(dirty, + profile: profile, hrmax: 190, restingHr: 60)!; + expect(b.active, closeTo(a.active, 1e-9)); + + final bout = Calories.estimateBoutCalories( + List.generate(dirty.length, (i) => i), dirty, + profile: profile, hrmax: 190, restingHr: 60); + expect(bout.kcal.isFinite, isTrue); + expect(bout.usedDefaultAnchors, isFalse); + }); }); group('Tier B — the floor must be FROZEN, not tracked', () {