From a4fbaf29d81ab7471667fb783d76cf604cd19445 Mon Sep 17 00:00:00 2001 From: Alexander Wagner Date: Mon, 5 Oct 2026 18:15:58 +0200 Subject: [PATCH 1/2] strain: score against a personal quiet level, never let quiet time debit exercise Headline strain subtracts the cost of being awake as a lump, wakeMinutes * Q*y(Q). Two things were wrong with it once Q became an argument (MOT-03): - There was no way to turn dailyQuietWakingHrr into the trailing trait its own doc asks for, so callers kept passing the 0.20 reference. - The lump is not one-sided. Every quiet minute below Q nets a negative term against the whole day, exercise included. A user who sits at 0.10 HRR has a 45-min run at 145 bpm (RHR 50, HRmax 187) cancelled to 0.00/21 against Q = 0.20, and the cumulative curve climbs through the run and falls back to 0 by bedtime. Even with a perfectly personal Q, a day whose quiet stretch sits 0.03 HRR under it halves that run. Changes: - strainFromNetTrimp: the 0-21 map factored out of strainScore. Pure refactor; strainScore's output is unchanged. - netTrimpAboveQuiet / NetTrimp: minutes at or above 40 % HRR (ACSM's moderate floor, the same boundary as maxQuietHrr) are exercise and are never offset. Every other minute nets among itself and floors at 0 as a block. With no exercise minute this equals the lump form, so the honest zeros MOT-05 protected stay zeros. A full per-sample floor is deliberately not used: alternating 0.17/0.23 HRR around 0.20 scores 3.86 that way vs 0.46 here. - strainScoreFromSeries / strainCurveFromSeries: headline and cumulative curve from one shared accumulator, so curve.last equals the headline and banked exercise never falls back out. - personalQuietWakingHrr / QuietLevel: median of the trailing 28 prior daily levels. Abstains with need_baseline below 3 days and never falls back to 0.20. 3-6 days scores at lower confidence with a "calibrating" note. The curve is deliberately not monotone. Positive living banked before a run (a busy morning above Q) can be netted back out by a quiet evening below Q: 120 min at 0.30 HRR, 45 min at 145 bpm, then 735 min at 0.10 HRR reads 10.74 at the end of the run and 9.38 at the end of the day. What is guaranteed, documented and tested: the exercise contribution never decreases after the last exercise minute, the curve never drops below what the exercise alone scores, and curve.last is the headline. A running-max ratchet would hide the dip but bias every quiet day upward (the running max of a random walk around Q drifts up), which is the MOT-05 inflation the exercise/living split avoids. Before/after, low-quiet user (0.10 HRR) + 45 min @145, Q = 0.20: 0.00 -> 9.38; at their own Q = 0.10: 9.77. Anchor profile, guarded at Q = 0.20: inactive 0.00, walk 2.70, run 8.55 -> 8.88, hard 16.54 -> 16.65, 195 min @160 still 21.00. strainScore / strainScoreMetric (lump) stay for rescaling a stored TRIMP that has no per-minute series left. --- ALGORITHMS.md | 7 +- lib/src/onehz/clinical/load_trimp.dart | 340 ++++++++++++++++++++++-- test/onehz/strain_calibration_test.dart | 243 +++++++++++++++++ 3 files changed, 566 insertions(+), 24 deletions(-) diff --git a/ALGORITHMS.md b/ALGORITHMS.md index f43d820..2742ea9 100644 --- a/ALGORITHMS.md +++ b/ALGORITHMS.md @@ -64,7 +64,12 @@ Grouped by family (subdirectory under `lib/src/onehz/`). File paths are relative | `readinessLnRmssd` | `clinical/readiness_lnrmssd.dart` | ln(RMSSD) z-scored against a rolling prior-nights baseline | Plews et al. 2013 | | `cosinor` | `clinical/cosinor.dart` | Cosinor rhythmometry (MESOR/amplitude/acrophase) | Halberg & Nelson 1979 | | `banisterTrimp` | `clinical/load_trimp.dart` | Training impulse from HR-reserve | Banister 1991 | -| `strainScoreMetric` | `clinical/load_trimp.dart` | log-squash of TRIMP onto a 0-21 scale | — | +| `dailyQuietWakingHrr` | `clinical/load_trimp.dart` | one day's quiet-waking level: median per-minute %HRR over waking minutes; null above 40 % HRR | Karvonen 1957; ACSM moderate-intensity floor | +| `personalQuietWakingHrr` | `clinical/load_trimp.dart` | THIS user's quiet-waking level: median of the trailing 28 prior days' `dailyQuietWakingHrr`; abstains (need_baseline) below 3 days, "calibrating" below 7 | — | +| `netTrimpAboveQuiet` | `clinical/load_trimp.dart` | Banister TRIMP above the quiet level, split at 40 % HRR so quiet minutes never offset exercise minutes (living block floored at 0) | Banister 1991; Morton 1990; ACSM 40 % HRR | +| `strainScoreFromSeries` | `clinical/load_trimp.dart` | **the headline 0-21 strain** from per-minute wake HR: `netTrimpAboveQuiet` log-mapped onto 0-21 | — | +| `strainCurveFromSeries` | `clinical/load_trimp.dart` | cumulative 0-21 strain per minute, same arithmetic; last point == headline | — | +| `strainScoreMetric` | `clinical/load_trimp.dart` | LUMP form (TRIMP minus wake-minutes × quiet cost) log-squashed onto 0-21; can debit exercise, kept only for rescaling a stored TRIMP with no per-minute series | — | | `trimpStrain` | `clinical/load_trimp.dart` | TRIMP → 0-100 strain, honesty-wrapped (absent without real HRmax/RHR anchors) | — | | `ctlAtlTsb` | `clinical/load_trimp.dart` | Fitness-Fatigue-Form: EWMA CTL (42d) / ATL (7d) / TSB = CTL-ATL | Banister impulse-response model | | `baevskyStressIndex` | `clinical/stress_si.dart` | Baevsky Stress Index, 5-min sliding RR-histogram | Baevsky & Berseneva 2008 | diff --git a/lib/src/onehz/clinical/load_trimp.dart b/lib/src/onehz/clinical/load_trimp.dart index 8db5f8f..16557a7 100644 --- a/lib/src/onehz/clinical/load_trimp.dart +++ b/lib/src/onehz/clinical/load_trimp.dart @@ -97,7 +97,10 @@ Metric banisterTrimp( /// 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. +/// user's own day, and [personalQuietWakingHrr] turns those into the trailing +/// trait every scorer takes. Passing this constant reproduces the anchor +/// table's Q = 0.20 columns exactly; no caller should pass it for a real user, +/// and cold start abstains rather than falling back to it. const double quietWakingHrr = 0.20; /// The most a quiet-waking level may be and still be called quiet waking. @@ -171,19 +174,27 @@ const double maximalNetTrimp = 400.0; /// mean anything with the profile and the convention printed next to them: /// /// PROFILE RHR 60, HRmax 187 (Tanaka @30), male constants, 960 waking -/// minutes, every NON-SESSION minute sitting at exactly -/// [quietWakingHrr] (= 85 bpm on this profile). +/// minutes, every NON-SESSION minute sitting at 85 bpm (0.1969 +/// HRR on this profile, its own measured [dailyQuietWakingHrr]). /// -/// inactive 16 h TRIMP 176.5 → 0.00 -/// + 60 min walk @105 bpm TRIMP 192.3 → 2.70 -/// + 45 min moderate run @145 bpm TRIMP 237.9 → 8.55 -/// + 90 min hard session @165 bpm TRIMP 392.9 → 16.54 -/// + 5 h @160 bpm TRIMP 806.9 → 21.00 (SATURATED) -/// 135 min wear, no activity TRIMP 24.8 → 0.00 +/// TRIMP lump guarded guarded +/// Q=0.20 Q=0.20 Q=0.1969 +/// inactive 16 h 176.5 0.00 0.00 0.00 +/// + 60 min walk @105 bpm 192.3 2.70 2.70 3.41 +/// + 45 min moderate run @145 bpm 237.9 8.55 8.88 8.90 +/// + 90 min hard session @165 bpm 392.9 16.54 16.65 16.66 +/// + 5 h @160 bpm 806.9 21.00 21.00 21.00 +/// 135 min wear, no activity 24.8 0.00 0.00 0.00 +/// +/// "lump" is [strainScore]; "guarded" is [strainScoreFromSeries], which is +/// what ships for anything with a per-minute series. They differ only where +/// quiet minutes below Q would otherwise have been netted against session +/// minutes — here the few 85-bpm minutes sitting 0.003 HRR under 0.20. /// /// The old table's "5 h at 160 bpm → 21" was loose, not wrong: with the /// 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. +/// (195 min → 21.00 in every column, 190 min stays below) and ≈4.25 h at +/// 150 bpm, so everything above that is one number. /// /// 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 @@ -194,8 +205,8 @@ const double maximalNetTrimp = 400.0; /// 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. +/// argument now — this user's trailing median, [personalQuietWakingHrr] — 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. @@ -215,8 +226,18 @@ const double strainCurvature = 15.0; /// 10.72/12.72/0.06. Crediting a quiet minute in FULL the moment it clears the /// gate is what does it. (Σ(x−Q)·y(x) over the same minutes tracks the current /// 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. +/// 4.05/4.83.) +/// +/// WHAT SHIPS NOW is neither that gate nor this lump alone. With MOT-03 done +/// the level is this user's own trailing median ([personalQuietWakingHrr]), +/// and [netTrimpAboveQuiet] gates on EXERCISE rather than on Q: minutes at or +/// above [exerciseFloorHrr] (ACSM's 40 % HRR) are never offset, everything +/// else nets out among itself exactly as this lump does and floors at 0 as a +/// block. On a day with no minute at 40 % HRR that is this function to the +/// last digit, so the honest zeros measured above stay zeros; it only departs +/// from the lump where quiet time would otherwise have debited a workout. This +/// subtraction survives as [strainScore] for rescaling a stored TRIMP with no +/// per-minute series left. /// /// [quietHrr] is that level: [dailyQuietWakingHrr] measures it, and it is /// clamped to (0, [maxQuietHrr]] here so no caller can hand over a baseline @@ -230,11 +251,8 @@ double baselineTrimp( 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, quietHrr) -/// u = min(1, net / maximalNetTrimp) -/// strain = 21 · ln(1 + u·(C−1)) / ln(C), C = [strainCurvature] +/// Log-map the TRIMP EARNED ABOVE baseline into a 0–21 headline "strain" score: +/// `strainFromNetTrimp(trimp − baselineTrimp(wakeMinutes, quietHrr))`. /// /// [wakeMinutes] is the observed waking wear window that produced [trimp] — it /// sets the baseline, so it is required rather than assumed. [quietHrr] is what @@ -242,15 +260,32 @@ double baselineTrimp( /// ([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. +/// +/// LUMP FORM — CAN DEBIT EXERCISE. Every quiet minute below [quietHrr] nets a +/// negative term against the whole day, exercise minutes included, so a user +/// whose quiet sits under the level can see a real run cancelled to 0. Only +/// for rescaling a stored TRIMP that has no per-minute series left (edge's +/// strain backfill). Everything with a per-minute series uses +/// [strainScoreFromSeries]. double strainScore( double trimp, { required double wakeMinutes, required double quietHrr, bool female = false, -}) { - final net = - trimp - baselineTrimp(wakeMinutes, quietHrr: quietHrr, female: female); - if (net <= 0) return 0.0; +}) => + strainFromNetTrimp( + trimp - baselineTrimp(wakeMinutes, quietHrr: quietHrr, female: female)); + +/// Map net TRIMP earned above the quiet-waking baseline onto 0–21: +/// +/// u = min(1, net / maximalNetTrimp) +/// strain = 21 · ln(1 + u·(C−1)) / ln(C), C = [strainCurvature] +/// +/// The one 0–21 map in this package; [strainScore] (lump) and +/// [strainScoreFromSeries] (guarded) differ only in how they get to `net`. +/// A non-finite or non-positive net is 0. +double strainFromNetTrimp(double net) { + if (!net.isFinite || net <= 0) return 0.0; final u = math.min(1.0, net / maximalNetTrimp); final s = 21.0 * math.log(1 + u * (strainCurvature - 1)) / @@ -260,6 +295,9 @@ double strainScore( /// Headline 0–21 strain as a Metric, alongside the raw TRIMP (EST tier). /// +/// LUMP FORM — see [strainScore]. Kept for rescaling a stored TRIMP that has no +/// per-minute series; anything holding the series uses [strainScoreFromSeries]. +/// /// [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 — with the reason in the note — when any is @@ -326,6 +364,262 @@ Metric strainScoreMetric( ); } +/// %HRR at or above which a minute is EXERCISE for the strain floor: ACSM's +/// moderate-intensity floor (40 % HRR). Deliberately the same number as +/// [maxQuietHrr] and as `Calories.activeHRRFraction`: one boundary between +/// "living" and "exercise" in the whole package. +const double exerciseFloorHrr = maxQuietHrr; + +/// TRIMP earned above the quiet-waking level, split so quiet time can never +/// debit exercise. +/// +/// The lump form ([strainScore]) is `Σ_i (f(x_i) − f(Q))` with `f(x) = x·y(x)` +/// over every wake minute, so each minute below Q is a negative term that +/// offsets exercise minutes. For a user who sits at 0.10 HRR against Q = 0.20, +/// 855 quiet minutes cancel a 45-min run at 145 bpm to 0.00. Even with Q this +/// user's own level, a day whose quiet stretch sits 0.03 HRR under it halves +/// that run. +/// +/// Why not a full per-sample floor `Σ max(0, f(x) − f(Q))`: it is biased +/// upward on any day with spread around Q, because positive deviations count +/// and negative ones are dropped — quiet minutes alternating 0.17/0.23 HRR +/// around Q = 0.20 score 3.86 that way against 0.46 here. That is the MOT-05 +/// failure noted on [baselineTrimp] (honest zeros going to 4.05/4.83). +/// +/// So living minutes net among themselves and are floored at 0 AS A BLOCK, and +/// exercise is added in full. On a day with no minute at or above +/// [exerciseFloorHrr] this equals the lump form exactly, which keeps the +/// honest zeros MOT-05 protected; algebraically `net == max(lumpNet, +/// exercise)`, "never less than the exercise minutes alone". +class NetTrimp { + /// Σ over minutes with x ≥ [exerciseFloorHrr] of (x·y(x) − Q·y(Q)). ≥ 0, + /// because x ≥ 0.40 ≥ Q and x·y(x) is increasing. + final double exercise; + + /// Σ over every other valid minute of (x·y(x) − Q·y(Q)). Either sign. + final double living; + + /// Valid (finite, > 0 bpm) minutes the sums ran over. + final int minutes; + + const NetTrimp(this.exercise, this.living, this.minutes); + + /// Living minutes cancel among themselves (noise around Q nets to ~0, so an + /// honest nothing-day stays 0), then floor at 0 as a block; exercise is + /// added in full and is never offset by a quiet afternoon. + double get net => exercise + math.max(0.0, living); +} + +/// Whether the anchors and the quiet level can price a minute at all — the +/// shared gate of [netTrimpAboveQuiet] and [strainCurveFromSeries]. +bool _netAnchorsValid(double? restingHr, double? maxHr, double? quietHrr) => + restingHr != null && + maxHr != null && + quietHrr != null && + restingHr.isFinite && + maxHr.isFinite && + quietHrr.isFinite && + maxHr > restingHr && + quietHrr > 0 && + quietHrr <= maxQuietHrr; + +/// [NetTrimp] over per-minute wake HR, against this user's quiet level +/// [quietHrr] (see [personalQuietWakingHrr]). +/// +/// Null — never a stand-in — when the anchors are missing, non-finite or +/// degenerate, when [quietHrr] is outside (0, [maxQuietHrr]], or when no +/// minute is valid. Same off-skin guard and the same Banister weighting +/// ([StrainScorer.banisterY]) as [banisterTrimp]. +NetTrimp? netTrimpAboveQuiet( + List hrPerMin, { + required double? restingHr, + required double? maxHr, + required double? quietHrr, + required Sex sex, +}) { + if (!_netAnchorsValid(restingHr, maxHr, quietHrr)) return null; + final acc = _NetAccumulator(restingHr!, maxHr!, quietHrr!, sex); + for (final hr in hrPerMin) { + acc.add(hr); + } + return acc.minutes == 0 ? null : acc.result; +} + +/// One pass of [NetTrimp] arithmetic, shared so the curve and the headline +/// cannot disagree. +class _NetAccumulator { + final double restingHr; + final double reserve; + final bool female; + final double quietRate; + var exercise = 0.0; + var living = 0.0; + var minutes = 0; + + _NetAccumulator(this.restingHr, double maxHr, double quietHrr, Sex sex) + : reserve = maxHr - restingHr, + female = sex == Sex.female, + quietRate = quietHrr * + StrainScorer.banisterY(quietHrr, female: sex == Sex.female); + + /// Adds one minute; false (and no change) for an off-skin/non-finite one. + bool add(double hr) { + if (!hr.isFinite || hr <= 0) return false; // same guard as banisterTrimp + final x = math.min(1.0, math.max(0.0, (hr - restingHr) / reserve)); + final e = x * StrainScorer.banisterY(x, female: female) - quietRate; + if (x >= exerciseFloorHrr) { + exercise += e; + } else { + living += e; + } + minutes++; + return true; + } + + NetTrimp get result => NetTrimp(exercise, living, minutes); +} + +/// Headline 0–21 strain from per-minute wake HR: Banister TRIMP above this +/// user's quiet-waking level, exercise minutes protected (see [NetTrimp]). +/// +/// [quietHrr] is THIS user's level ([personalQuietWakingHrr]); there is no +/// default and no population fallback (MOT-03). [quietSettled] false (fewer +/// than [quietHrrSettledDays] days behind the level) lowers confidence and +/// says "calibrating" — it never changes the value. Required, so no caller +/// can report a calibrating level at settled confidence by leaving it out +/// ([QuietLevel.settled]). +Metric strainScoreFromSeries( + List hrPerMin, { + required double? restingHr, + required double? maxHr, + required double? quietHrr, + required Sex sex, + required bool quietSettled, +}) { + const inputs = ['hr_per_min', 'resting_hr', 'max_hr', 'quiet_waking_hrr']; + final n = netTrimpAboveQuiet(hrPerMin, + restingHr: restingHr, maxHr: maxHr, quietHrr: quietHrr, sex: sex); + if (n == null) { + return const Metric.absent( + tier: Tier.estimate, + inputs_used: inputs, + note: 'strain needs finite RHR < HRmax, this user\'s own quiet-waking ' + 'HRR in (0, $maxQuietHrr], and at least one worn minute', + ); + } + return Metric( + value: strainFromNetTrimp(n.net), + confidence: quietSettled ? 0.6 : 0.45, + tier: Tier.estimate, + inputs_used: inputs, + note: quietSettled + ? 'Banister TRIMP above your quiet-waking level, log-mapped to 0–21' + : 'calibrating: quiet-waking level from fewer than ' + '$quietHrrSettledDays days', + ); +} + +/// Cumulative 0–21 strain after each minute of [hrPerMin] (same length; an +/// invalid minute repeats the previous value, 0 before the first valid one). +/// +/// Same arithmetic as [strainScoreFromSeries] in one pass, so `curve.last` is +/// the headline. Exercise already banked never falls back out: `exercise` +/// only grows, so the curve never drops below what the exercise minutes alone +/// score. +/// +/// NOT MONOTONE, deliberately. Living minutes net among themselves, so +/// positive living banked earlier (a busy morning above Q) can be netted back +/// out by a quiet evening below it, and the curve declines by exactly that — +/// e.g. 120 min at 0.30 HRR, 45 min at 145 bpm, then 735 min at 0.10 HRR +/// (RHR 50, HRmax 187, Q 0.20) reads 10.74 at the end of the run and 9.38 at +/// the end of the day. A running-max ratchet would hide that, but it would +/// also bias every quiet day upward (the running max of a random walk around +/// Q drifts up), which is the MOT-05 inflation [NetTrimp] exists to avoid. +/// +/// Null when [netTrimpAboveQuiet] would be null. +List? strainCurveFromSeries( + List hrPerMin, { + required double? restingHr, + required double? maxHr, + required double? quietHrr, + required Sex sex, +}) { + if (!_netAnchorsValid(restingHr, maxHr, quietHrr)) return null; + final acc = _NetAccumulator(restingHr!, maxHr!, quietHrr!, sex); + final out = List.filled(hrPerMin.length, 0.0); + var last = 0.0; + for (var i = 0; i < hrPerMin.length; i++) { + if (acc.add(hrPerMin[i])) last = strainFromNetTrimp(acc.result.net); + out[i] = last; + } + return acc.minutes == 0 ? null : out; +} + +/// Prior days needed before a personal level is used at all. With 3 the +/// median tolerates one unusual day. +const int quietHrrMinDays = 3; + +/// Prior days at which the level counts as settled: a full weekly cycle, so a +/// run of workdays (or weekend days) cannot set it alone. +const int quietHrrSettledDays = 7; + +/// Trailing window, the same 28 days every other personal baseline uses. +const int quietHrrWindowDays = 28; + +/// Wake minutes a day needs before its own median may enter the trait series. +/// A 1-hour window measures that hour, not ordinary living. +const int quietHrrTraitMinMinutes = 360; + +/// A personal quiet-waking level and how many prior days back it. +class QuietLevel { + final double hrr; + final int days; + bool get settled => days >= quietHrrSettledDays; + const QuietLevel(this.hrr, this.days); + Map toJson() => + {'hrr': round6(hrr), 'days': days, 'settled': settled}; +} + +/// THIS user's quiet-waking level: the median of the trailing +/// [quietHrrWindowDays] prior days' [dailyQuietWakingHrr] — the rolling +/// personal median that function's doc asks for. +/// +/// [priorDailyLevels] must be strictly BEFORE the day being scored, oldest → +/// newest: scoring a day against its own median would subtract its own living +/// (and, on a walking day, its own effort) from itself. +/// +/// COLD START ABSTAINS. Below [quietHrrMinDays] valid days this is absent with +/// the `need_baseline` grammar, never [quietWakingHrr]: that constant is a +/// population figure for an input that is personal by definition, and passing +/// it is what billed being awake as training load (MOT-03). 3–6 days is a real +/// measurement with a wider error, disclosed by a lower confidence and +/// [QuietLevel.settled] false. +Metric personalQuietWakingHrr(List priorDailyLevels) { + const inputs = ['quiet_waking_hrr_history']; + final valid = [ + for (final q in priorDailyLevels) + if (q.isFinite && q > 0 && q <= maxQuietHrr) q, + ]; + final w = valid.length > quietHrrWindowDays + ? valid.sublist(valid.length - quietHrrWindowDays) + : valid; + if (w.length < quietHrrMinDays) { + return Metric.absent( + tier: Tier.estimate, + inputs_used: inputs, + note: needBaselineNote(have: w.length, need: quietHrrMinDays), + ); + } + final lvl = QuietLevel(median(w)!, w.length); + return Metric( + value: lvl, + confidence: lvl.settled ? 0.7 : 0.4, + tier: Tier.estimate, + inputs_used: inputs, + note: 'median of ${w.length} prior days of quiet waking', + ); +} + // Two other TRIMPs used to sit here: a top-level `edwardsTrimp` binning minutes // on %HRmax, and `StrainScorer`'s zone sum binning the SAME cut-offs on %HRR. // Both are deleted (audit MOT-06) — see the file header. Banister is the one diff --git a/test/onehz/strain_calibration_test.dart b/test/onehz/strain_calibration_test.dart index 65f4675..bcd5b6e 100644 --- a/test/onehz/strain_calibration_test.dart +++ b/test/onehz/strain_calibration_test.dart @@ -236,4 +236,247 @@ void main() { expect(m.value, greaterThan(14.0)); }); }); + + // MOT-03 shipped the level as an argument; these pin what scoring against it + // from a per-minute series does. Quiet time is netted among itself, never + // against exercise minutes (>= 40 % HRR), so a quiet afternoon cannot erase + // a morning run. + group('guarded net: quiet time never debits exercise', () { + const rhr = 50.0, hrMax = 187.0; + double q(double x) => rhr + x * (hrMax - rhr); + + Metric scored(List hr, double quietHrr, + {double r = rhr, double m = hrMax}) => + strainScoreFromSeries(hr, + restingHr: r, + maxHr: m, + quietHrr: quietHrr, + sex: Sex.male, + quietSettled: true); + + double lump(List hr, double quietHrr, + {double r = rhr, double m = hrMax}) { + final t = banisterTrimp(hr, restingHr: r, maxHr: m, sex: Sex.male); + return strainScore(t.value!, + wakeMinutes: hr.length.toDouble(), quietHrr: quietHrr); + } + + final lowQuietRun = [ + ...List.filled(45, 145.0), + ...List.filled(855, q(0.10)), + ]; + final jitter = [ + for (var i = 0; i < 900; i++) i.isEven ? q(0.17) : q(0.23), + ]; + + test('a low-quiet user\'s 45-min run is not erased', () { + expect(lump(lowQuietRun, 0.20), 0.0, + reason: 'the lump form lets 855 quiet minutes cancel the run'); + expect(scored(lowQuietRun, 0.20).value, closeTo(9.38, 0.02)); + expect(scored(lowQuietRun, 0.10).value, closeTo(9.77, 0.02)); + }); + + test('a nothing-day with spread around Q keeps lump\'s honest value', () { + expect(lump(jitter, 0.20), closeTo(0.46, 0.02)); + expect(scored(jitter, 0.20).value, closeTo(lump(jitter, 0.20), 1e-9)); + }); + + test('anchor table, guarded at Q = 0.20', () { + double at(List hr) => scored(hr, 0.20, r: kRhr, m: kHrMax).value!; + expect(at(dayHr(960, 85)), 0.0); + expect(at(dayHr(960, 85, [(60, 105)])), closeTo(2.70, 0.01)); + expect(at(dayHr(960, 85, [(45, 145)])), closeTo(8.88, 0.01)); + expect(at(dayHr(960, 85, [(90, 165)])), closeTo(16.65, 0.01)); + expect(at(dayHr(960, 85, [(190, 160)])), lessThan(21.0)); + expect(at(dayHr(960, 85, [(195, 160)])), closeTo(21.0, 1e-9)); + }); + + test('never below the exercise minutes alone', () { + for (final hr in [ + lowQuietRun, + jitter, + dayHr(960, 85, [(45, 145)]) + ]) { + final n = netTrimpAboveQuiet(hr, + restingHr: rhr, maxHr: hrMax, quietHrr: 0.20, sex: Sex.male)!; + expect(scored(hr, 0.20).value, + greaterThanOrEqualTo(strainFromNetTrimp(n.exercise))); + } + }); + + test('strainFromNetTrimp is strainScore\'s map', () { + final t = banisterTrimp(dayHr(960, 85, [(45, 145)]), + restingHr: kRhr, maxHr: kHrMax, sex: Sex.male) + .value!; + expect( + strainFromNetTrimp(t - baselineTrimp(960, quietHrr: quietWakingHrr)), + strainScore(t, wakeMinutes: 960, quietHrr: quietWakingHrr)); + expect(strainFromNetTrimp(-5), 0.0); + expect(strainFromNetTrimp(double.nan), 0.0); + }); + + test('calibrating lowers confidence, never the value', () { + final settled = scored(lowQuietRun, 0.20); + final calibrating = strainScoreFromSeries(lowQuietRun, + restingHr: rhr, + maxHr: hrMax, + quietHrr: 0.20, + sex: Sex.male, + quietSettled: false); + expect(calibrating.value, settled.value); + expect(settled.confidence, 0.6); + expect(calibrating.confidence, 0.45); + expect(calibrating.note, startsWith('calibrating')); + }); + }); + + group('cumulative curve', () { + const rhr = 50.0, hrMax = 187.0; + double q(double x) => rhr + x * (hrMax - rhr); + final hr = [ + ...List.filled(120, q(0.10)), + ...List.filled(45, 145.0), + ...List.filled(735, q(0.10)), + ]; + + test('banked exercise never falls back out, and last == headline', () { + final curve = strainCurveFromSeries(hr, + restingHr: rhr, maxHr: hrMax, quietHrr: 0.20, sex: Sex.male)!; + expect(curve.length, 900); + expect(curve[164], closeTo(9.38, 0.02)); + for (var i = 164; i < curve.length; i++) { + expect(curve[i], greaterThanOrEqualTo(curve[164] - 1e-9)); + } + expect( + curve.last, + strainScoreFromSeries(hr, + restingHr: rhr, + maxHr: hrMax, + quietHrr: 0.20, + sex: Sex.male, + quietSettled: true) + .value); + }); + + test('after the last exercise minute only ordinary living moves it', () { + // Positive living (0.30 HRR before the run) is banked in the living + // block, and a quiet evening below Q nets it back out — so the curve CAN + // decline after exercise. What it can never do is debit the exercise: + // the exercise contribution is fixed from the last exercise minute on, + // and the curve never drops below what that alone scores. + final hr = [ + ...List.filled(120, q(0.30)), + ...List.filled(45, 145.0), + ...List.filled(735, q(0.10)), + ]; + final curve = strainCurveFromSeries(hr, + restingHr: rhr, maxHr: hrMax, quietHrr: 0.20, sex: Sex.male)!; + expect(curve[164], closeTo(10.74, 0.02)); + expect(curve.last, closeTo(9.38, 0.02)); + expect( + curve.last, + strainScoreFromSeries(hr, + restingHr: rhr, + maxHr: hrMax, + quietHrr: 0.20, + sex: Sex.male, + quietSettled: true) + .value); + double exerciseUpTo(int i) => netTrimpAboveQuiet(hr.sublist(0, i + 1), + restingHr: rhr, maxHr: hrMax, quietHrr: 0.20, sex: Sex.male)! + .exercise; + final banked = exerciseUpTo(164); + for (var i = 165; i < hr.length; i += 15) { + expect(exerciseUpTo(i), banked, reason: 'minute $i'); + expect( + curve[i], greaterThanOrEqualTo(strainFromNetTrimp(banked) - 1e-9), + reason: 'minute $i'); + } + }); + + test('an invalid minute repeats the previous value', () { + final curve = strainCurveFromSeries([145, double.nan, 0, 145], + restingHr: rhr, maxHr: hrMax, quietHrr: 0.20, sex: Sex.male)!; + expect(curve.length, 4); + expect(curve[1], curve[0]); + expect(curve[2], curve[0]); + expect(curve[3], greaterThan(curve[0])); + }); + + test('null when the level or anchors are missing', () { + expect( + strainCurveFromSeries(hr, + restingHr: rhr, maxHr: hrMax, quietHrr: null, sex: Sex.male), + isNull); + expect( + strainCurveFromSeries(hr, + restingHr: null, maxHr: hrMax, quietHrr: 0.2, sex: Sex.male), + isNull); + }); + }); + + group('personalQuietWakingHrr', () { + test('fewer than 3 prior days abstains with the baseline grammar', () { + final none = personalQuietWakingHrr(const []); + expect(none.present, isFalse); + expect(none.note, 'need_baseline:have=0,need=3'); + expect(personalQuietWakingHrr(const [0.2, 0.3]).note, + 'need_baseline:have=2,need=3'); + }); + + test('3 days is a calibrating median', () { + final m = personalQuietWakingHrr(const [0.20, 0.30, 0.25]); + expect(m.value!.hrr, closeTo(0.25, 1e-12)); + expect(m.value!.days, 3); + expect(m.value!.settled, isFalse); + expect(m.confidence, 0.4); + expect(m.toJson((v) => v.toJson())['value'], + {'hrr': 0.25, 'days': 3, 'settled': false}); + }); + + test('7 days is settled', () { + final m = personalQuietWakingHrr(List.filled(7, 0.2)); + expect(m.value!.settled, isTrue); + expect(m.confidence, 0.7); + }); + + test('only the trailing 28 days count', () { + final m = personalQuietWakingHrr([ + ...List.filled(12, 0.39), + ...List.filled(28, 0.20), + ]); + expect(m.value!.hrr, 0.20); + expect(m.value!.days, 28); + }); + + test('invalid levels are filtered, not counted', () { + final m = + personalQuietWakingHrr(const [double.nan, 0, 0.5, 0.2, 0.2, 0.2]); + expect(m.value!.hrr, 0.20); + expect(m.value!.days, 3); + }); + }); + + group('strainScoreFromSeries absent envelope', () { + final hr = List.filled(100, 120); + Metric s( + {double? restingHr = 50, + double? quietHrr = 0.2, + List? series}) => + strainScoreFromSeries(series ?? hr, + restingHr: restingHr, + maxHr: 187, + quietHrr: quietHrr, + sex: Sex.male, + quietSettled: true); + + test('abstains on every missing or out-of-range input', () { + expect(s(quietHrr: null).present, isFalse); + expect(s(quietHrr: 0.45).present, isFalse); + expect(s(quietHrr: 0).present, isFalse); + expect(s(restingHr: null).present, isFalse); + expect(s(series: List.filled(100, 0)).present, isFalse); + expect(s(quietHrr: null).inputs_used, contains('quiet_waking_hrr')); + }); + }); } From 4e67d3cd913b5c2a35de8658927fa05dbe0cca2a Mon Sep 17 00:00:00 2001 From: Alexander Wagner Date: Thu, 8 Oct 2026 13:15:13 +0200 Subject: [PATCH 2/2] strain: the stored-TRIMP rescale discloses a calibrating quiet level too strainScoreMetric rescales a stored TRIMP against the same personal quiet level the live scorer uses, but always reported confidence 0.6 and a generic note, so a level backed by 3-6 days looked settled. It now takes the same required quietSettled flag as strainScoreFromSeries: false lowers confidence to 0.45 and says "calibrating", and the value is unchanged. personalQuietWakingHrr's doc now states that each prior level must already be an eligible day (minMinutes: quietHrrTraitMinMinutes), which the caller that persists the daily levels enforces. --- lib/src/onehz/clinical/load_trimp.dart | 19 ++++++++++++++----- test/onehz/clinical_test.dart | 24 ++++++++++++------------ test/onehz/strain_calibration_test.dart | 21 +++++++++++++++++---- 3 files changed, 43 insertions(+), 21 deletions(-) diff --git a/lib/src/onehz/clinical/load_trimp.dart b/lib/src/onehz/clinical/load_trimp.dart index 16557a7..e8c7b3d 100644 --- a/lib/src/onehz/clinical/load_trimp.dart +++ b/lib/src/onehz/clinical/load_trimp.dart @@ -304,11 +304,14 @@ double strainFromNetTrimp(double net) { /// 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). +/// a day with no activity in it at 12/21 (MOT-03). [quietSettled] as in +/// [strainScoreFromSeries]: false lowers confidence and says "calibrating", +/// never the value — required for the same reason. Metric strainScoreMetric( double? trimp, { required double? wakeMinutes, required double? quietHrr, + required bool quietSettled, bool female = false, }) { const inputs = ['trimp', 'wake_minutes', 'quiet_waking_hrr']; @@ -356,11 +359,14 @@ Metric strainScoreMetric( return Metric( value: strainScore(trimp, wakeMinutes: wakeMinutes, quietHrr: quietHrr, female: female), - confidence: 0.6, + confidence: quietSettled ? 0.6 : 0.45, tier: Tier.estimate, inputs_used: inputs, - note: 'headline 0–21 strain = log map of TRIMP earned above the ' - 'quiet-waking baseline; wrist-HR estimate', + note: quietSettled + ? 'headline 0–21 strain = log map of TRIMP earned above the ' + 'quiet-waking baseline; wrist-HR estimate' + : 'calibrating: quiet-waking level from fewer than ' + '$quietHrrSettledDays days', ); } @@ -586,7 +592,10 @@ class QuietLevel { /// /// [priorDailyLevels] must be strictly BEFORE the day being scored, oldest → /// newest: scoring a day against its own median would subtract its own living -/// (and, on a walking day, its own effort) from itself. +/// (and, on a walking day, its own effort) from itself. Each must already be +/// an eligible day — [dailyQuietWakingHrr] with `minMinutes:` +/// [quietHrrTraitMinMinutes] — since a bare level carries no coverage to +/// check here; the caller that persists the daily levels enforces it. /// /// COLD START ABSTAINS. Below [quietHrrMinDays] valid days this is absent with /// the `need_baseline` grammar, never [quietWakingHrr]: that constant is a diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index 6d2a5a7..5bcb3e7 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -1379,18 +1379,18 @@ void main() { test('strainScoreMetric is EST tier and absent without any input', () { final m = - strainScoreMetric(392.9, wakeMinutes: 960, quietHrr: quietWakingHrr); + strainScoreMetric(392.9, wakeMinutes: 960, quietHrr: quietWakingHrr, quietSettled: true); expect(m.present, isTrue); expect(m.tier, 'ESTIMATE'); expect( - strainScoreMetric(null, wakeMinutes: 960, quietHrr: quietWakingHrr) + strainScoreMetric(null, wakeMinutes: 960, quietHrr: quietWakingHrr, quietSettled: true) .present, isFalse); expect( - strainScoreMetric(335, wakeMinutes: null, quietHrr: quietWakingHrr) + strainScoreMetric(335, wakeMinutes: null, quietHrr: quietWakingHrr, quietSettled: true) .present, isFalse); - expect(strainScoreMetric(335, wakeMinutes: 960, quietHrr: null).present, + expect(strainScoreMetric(335, wakeMinutes: 960, quietHrr: null, quietSettled: true).present, isFalse); }); @@ -1401,15 +1401,15 @@ void main() { // an absent one: everything downstream treats present as measured. for (final m in [ strainScoreMetric(double.nan, - wakeMinutes: 960, quietHrr: quietWakingHrr), + wakeMinutes: 960, quietHrr: quietWakingHrr, quietSettled: true), strainScoreMetric(double.infinity, - wakeMinutes: 960, quietHrr: quietWakingHrr), + wakeMinutes: 960, quietHrr: quietWakingHrr, quietSettled: true), strainScoreMetric(392.9, - wakeMinutes: double.nan, quietHrr: quietWakingHrr), + wakeMinutes: double.nan, quietHrr: quietWakingHrr, quietSettled: true), strainScoreMetric(392.9, - wakeMinutes: double.infinity, quietHrr: quietWakingHrr), - strainScoreMetric(392.9, wakeMinutes: 960, quietHrr: double.nan), - strainScoreMetric(392.9, wakeMinutes: 960, quietHrr: double.infinity), + wakeMinutes: double.infinity, quietHrr: quietWakingHrr, quietSettled: true), + strainScoreMetric(392.9, wakeMinutes: 960, quietHrr: double.nan, quietSettled: true), + strainScoreMetric(392.9, wakeMinutes: 960, quietHrr: double.infinity, quietSettled: true), ]) { expect(m.present, isFalse); expect(m.value, isNull); @@ -1420,12 +1420,12 @@ void main() { // 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) + strainScoreMetric(392.9, wakeMinutes: 960, quietHrr: maxQuietHrr, quietSettled: true) .present, isTrue); expect( strainScoreMetric(392.9, - wakeMinutes: 960, quietHrr: maxQuietHrr + 0.01) + wakeMinutes: 960, quietHrr: maxQuietHrr + 0.01, quietSettled: true) .present, isFalse); }); diff --git a/test/onehz/strain_calibration_test.dart b/test/onehz/strain_calibration_test.dart index bcd5b6e..ed83a3e 100644 --- a/test/onehz/strain_calibration_test.dart +++ b/test/onehz/strain_calibration_test.dart @@ -210,11 +210,11 @@ void main() { // Wake minutes set the baseline. Guessing one fabricates the subtraction // and silently mis-scores every partial-wear day. expect( - strainScoreMetric(300, wakeMinutes: null, quietHrr: quietWakingHrr) + strainScoreMetric(300, wakeMinutes: null, quietHrr: quietWakingHrr, quietSettled: true) .present, isFalse); expect( - strainScoreMetric(null, wakeMinutes: 960, quietHrr: quietWakingHrr) + strainScoreMetric(null, wakeMinutes: 960, quietHrr: quietWakingHrr, quietSettled: true) .present, isFalse); }); @@ -222,15 +222,28 @@ void main() { 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); + final m = strainScoreMetric(392.9, wakeMinutes: 960, quietHrr: null, quietSettled: true); expect(m.present, isFalse); expect(m.note, contains('quiet-waking')); expect(m.inputs_used, contains('quiet_waking_hrr')); }); + test('a calibrating quiet level lowers confidence and says so', () { + // Same disclosure as the live scorer: fewer than a week of days behind + // the level is a real but wider measurement. The value is unchanged. + final settled = strainScoreMetric(392.9, + wakeMinutes: 960, quietHrr: quietWakingHrr, quietSettled: true); + final cal = strainScoreMetric(392.9, + wakeMinutes: 960, quietHrr: quietWakingHrr, quietSettled: false); + expect(cal.value, settled.value); + expect(cal.confidence, lessThan(settled.confidence)); + expect(cal.note, contains('calibrating')); + expect(settled.note, isNot(contains('calibrating'))); + }); + test('present and ESTIMATE-tier with every input', () { final m = - strainScoreMetric(392.9, wakeMinutes: 960, quietHrr: quietWakingHrr); + strainScoreMetric(392.9, wakeMinutes: 960, quietHrr: quietWakingHrr, quietSettled: true); expect(m.present, isTrue); expect(m.tier, Tier.estimate); expect(m.value, greaterThan(14.0));