diff --git a/lib/src/onehz/clinical/load_trimp.dart b/lib/src/onehz/clinical/load_trimp.dart index 726e672..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; @@ -76,16 +82,81 @@ 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, 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, +}) { + // 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.isFinite && 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 +185,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 +217,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,24 +260,63 @@ 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 — 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). Metric strainScoreMetric( double? trimp, { required double? wakeMinutes, + required double? quietHrr, bool female = false, }) { - const inputs = ['trimp', 'wake_minutes']; - if (trimp == null || trimp < 0 || wakeMinutes == null || wakeMinutes <= 0) { + const inputs = ['trimp', 'wake_minutes', 'quiet_waking_hrr']; + // 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 this user\'s own quiet-waking HRR to subtract, ' + 'finite and > 0; without it the cost of simply being awake reads ' + 'as training load', + ); + } + // 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 a TRIMP and the wake window it was measured over', + 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( - 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/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/lib/src/onehz/workout/calories.dart b/lib/src/onehz/workout/calories.dart index e9bd746..03e6ea3 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,75 @@ 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. /// - /// 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. + /// 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 /// - /// 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). + /// 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 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. + /// + /// 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; @@ -213,10 +251,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 +269,23 @@ 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. - static ({double total, double active, double basal}) dailyEnergy( + /// + /// [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. + /// + /// 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, - double activeFraction = defaultActiveFraction, + required double restingHr, int dayMinutes = 1440, }) { // (weight/height/age still can't be told apart from their defaults here: @@ -248,14 +297,17 @@ 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); + 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; @@ -271,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}) @@ -292,11 +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 = - effResting + activeHRRFraction * (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); @@ -309,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 d401c06..f69a179 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -788,29 +788,119 @@ 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); + }); + + 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); }); }); 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', () { diff --git a/test/onehz/steps_test.dart b/test/onehz/steps_test.dart index b87a6a2..e69a9b4 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,10 +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); + 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', () { 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));