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..e8c7b3d 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,17 +295,23 @@ 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 /// 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']; @@ -318,11 +359,273 @@ 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: 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', + ); +} + +/// %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. 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 +/// 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: 'headline 0–21 strain = log map of TRIMP earned above the ' - 'quiet-waking baseline; wrist-HR estimate', + note: 'median of ${w.length} prior days of quiet waking', ); } 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 65f4675..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,18 +222,274 @@ 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)); }); }); + + // 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')); + }); + }); }