From 914410e8776547b0e8431dfab5b5dd67186b63f9 Mon Sep 17 00:00:00 2001 From: SATHVIK SVS Date: Wed, 12 Aug 2026 20:52:22 +0530 Subject: [PATCH] price strain above the cost of being awake, and rebase the coach on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0-21 headline was `min(21, ln(TRIMP+1)/ln(1.5))` over whole-waking-day Banister TRIMP. Two things were wrong with that, and they compounded. Whole-day TRIMP counts every waking minute above resting, so ~16 h of ordinary living accrues ~180 TRIMP before any exercise happens. Log base 1.5 is steepest near zero, so that overhead alone bought ~13 of the 21 points. On a real bundle (2026-07-09, 2641 steps) the day scored 12.79; a day with 23 steps and 24 kcal scored 7.88. An INACTIVE full-wear day reads 12.8 — the number moved with how long the band was worn, not with training. The other half: each further point cost 1.5x the load, so 21 sat at TRIMP ~4987, roughly 35 h at 80 % HRR. The top third of the scale was unreachable by any human day — a marathon reads ~15.8. The usable range was about 8 to 16, and neither end meant anything. Strain is now the load earned ABOVE a quiet-waking baseline. The baseline is 20 % of HRR priced through the same banisterY the TRIMP was scored with, scaled by the wake window actually observed, so a partial-wear day is not charged a full day's overhead — 2026-07-10 was worn 135 waking minutes, and borrowing a 16 h allowance for it would have read as effort. The net is mapped by 21*ln(1+u*14)/ln(15) with u = net/400. Anchored on real days rather than on the formula restated: inactive ~0, rest plus a walk 2-4, a 45-min moderate run 8-11, a 90-min hard session 14-17, 5 h at 160 bpm 21. strain_calibration_test.dart builds each day from per-minute HR and runs it through the real banisterTrimp, so the anchors constrain the pipeline and not just the map. The old test pinned strainScore(335) ~ 14.347 — the formula as its own expectation, which let the miscalibration pass forever. wakeMinutes is REQUIRED rather than assumed. The baseline subtraction is meaningless without a wake window, and guessing one silently mis-scores every partial-wear day, so strainScoreMetric abstains instead. SAME COMMIT, the coach. strainTarget's bands were sized for a distribution the package never produced: "recover 4-8" sat below what an inactive worn day scored, so a low-recovery day asked for a number the user had already passed before getting out of bed, while "push 14-18" needed more than a marathon. They are rebased onto the distribution above. Its load adjustment was also dimensionally wrong. ctl/atl/tsb arrive as raw daily TRIMP (hundreds), but the thresholds were `atl - ctl > 10` and `tsb > 5`, magnitudes sized for the 0-21 scale. On TRIMP-scale inputs those fire on ordinary week-to-week noise — a 320-vs-300 acute:chronic pair is a 6.7 % lift, not fatigue. Both are now ratios against CTL, guarded on ctl > 0. The sleep-need strain bonus needs no change and self-corrects: it is linear in strain, so an inactive day used to earn ~28 min of extra "training recovery" sleep need and now earns 0. --- lib/src/onehz/clinical/load_trimp.dart | 89 +++++++++++++---- lib/src/onehz/human/coaching.dart | 33 +++++-- test/onehz/clinical_test.dart | 36 ++++--- test/onehz/coaching_test.dart | 72 +++++++++++--- test/onehz/strain_calibration_test.dart | 122 ++++++++++++++++++++++++ 5 files changed, 296 insertions(+), 56 deletions(-) create mode 100644 test/onehz/strain_calibration_test.dart diff --git a/lib/src/onehz/clinical/load_trimp.dart b/lib/src/onehz/clinical/load_trimp.dart index 96413e6..0509e9e 100644 --- a/lib/src/onehz/clinical/load_trimp.dart +++ b/lib/src/onehz/clinical/load_trimp.dart @@ -65,42 +65,89 @@ Metric banisterTrimp( ); } -/// Log-squash a raw TRIMP into a 0–21 headline "strain" score. +/// Fraction of heart-rate reserve that simply BEING AWAKE costs. /// -/// Raw Banister TRIMP grows roughly linearly with duration·intensity and lands -/// in the hundreds for a normal active day (~335), which is meaningless as a -/// headline number. A logarithmic squash compresses it into a bounded WHOOP-like -/// 0–21 scale where each extra point is progressively harder to earn: +/// 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. +const double quietWakingHrr = 0.20; + +/// Net TRIMP — earned ABOVE the quiet-waking baseline — that defines a maximal +/// day and maps to the top of the scale. /// -/// strain(trimp) = min(21, ln(trimp + 1) / ln(1.5)) +/// The old map put 21 at a raw TRIMP of ~4987, i.e. ≈35 h at 80 % HRR: the top +/// third of the scale was unreachable by any human day, so the headline number +/// never used the range it advertised. +const double maximalNetTrimp = 400.0; + +/// Curvature of the 0–21 map; higher gives more resolution at the low end. +/// +/// Calibrated together with [maximalNetTrimp] so that, for a representative +/// profile (RHR 60, HRmax 187, 16 h awake), a day scores: +/// inactive → ~0 · rest + a walk → 2–4 · 45 min moderate run → 8–11 · +/// 90 min hard session → 14–17 · 5 h at 160 bpm → 21. +const double strainCurvature = 15.0; + +/// The TRIMP that [wakeMinutes] of ordinary waking accrues on its own. +/// +/// Scales with the wake window ACTUALLY observed, so a partial-wear day is not +/// charged a full day's overhead (a 2 h inactive wear window would otherwise +/// come out negative and clamp, while a 16 h one read as real effort). +double baselineTrimp(double wakeMinutes, {bool female = false}) => + wakeMinutes * + quietWakingHrr * + StrainScorer.banisterY(quietWakingHrr, female: female); + +/// Log-map the TRIMP EARNED ABOVE baseline into a 0–21 headline "strain" score. /// -/// Check-points: 0 → 0; 335 → ln(336)/ln(1.5) ≈ 14.34 (cap not hit). -double strainScore(double trimp) { - if (trimp <= 0) return 0.0; - final s = math.log(trimp + 1) / math.log(1.5); - return math.min(21.0, s); +/// net = trimp − baselineTrimp(wakeMinutes) +/// 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. +double strainScore( + double trimp, { + required double wakeMinutes, + bool female = false, +}) { + final net = trimp - baselineTrimp(wakeMinutes, female: female); + if (net <= 0) return 0.0; + final u = math.min(1.0, net / maximalNetTrimp); + final s = + 21.0 * math.log(1 + u * (strainCurvature - 1)) / math.log(strainCurvature); + return math.min(21.0, math.max(0.0, s)); } -/// Headline 0–21 strain as a Metric, alongside the raw TRIMP (HIGH/EST tier). +/// Headline 0–21 strain as a Metric, alongside the raw TRIMP (EST tier). /// -/// [trimp] the raw Banister TRIMP for the day/session. Returns absent when no -/// TRIMP is available (never fabricate a strain from nothing). -Metric strainScoreMetric(double? trimp) { - const inputs = ['trimp']; - if (trimp == null || trimp < 0) { +/// [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. +Metric strainScoreMetric( + double? trimp, { + required double? wakeMinutes, + bool female = false, +}) { + const inputs = ['trimp', 'wake_minutes']; + if (trimp == null || trimp < 0 || wakeMinutes == null || wakeMinutes <= 0) { return const Metric.absent( tier: Tier.estimate, inputs_used: inputs, - note: 'no TRIMP available for a strain score', + note: 'strain needs a TRIMP and the wake window it was measured over', ); } return Metric( - value: strainScore(trimp), + value: strainScore(trimp, wakeMinutes: wakeMinutes, female: female), confidence: 0.6, tier: Tier.estimate, inputs_used: inputs, - note: 'headline 0–21 strain = log-squash of raw TRIMP ' - '(min(21, ln(trimp+1)/ln(1.5))); wrist-HR estimate', + note: 'headline 0–21 strain = log map of TRIMP earned above the ' + 'quiet-waking baseline; wrist-HR estimate', ); } diff --git a/lib/src/onehz/human/coaching.dart b/lib/src/onehz/human/coaching.dart index f7c906d..cfe4ac2 100644 --- a/lib/src/onehz/human/coaching.dart +++ b/lib/src/onehz/human/coaching.dart @@ -129,31 +129,44 @@ Metric strainTarget({ ); } final rec = recovery0to100.clamp(0.0, 100.0); + // Bands sit on the SAME distribution `strainScore` now produces: an inactive + // day ≈0, a rest day with a walk 2–4, a typical active day 8–11, a hard + // session 14–17, a maximal day 19–21. They used to be sized for a scale the + // app never produced — "recover 4–8" was below what an inactive worn day + // scored, and "push 14–18" needed more than a marathon to reach. double lo; double hi; String band; if (rec < 40) { - lo = 4; - hi = 8; + lo = 0; + hi = 5; band = 'recover'; } else if (rec < 60) { - lo = 7; - hi = 11; + lo = 5; + hi = 10; band = 'ease'; } else if (rec < 80) { - lo = 10; - hi = 15; + lo = 9; + hi = 14; band = 'maintain'; } else { - lo = 14; + lo = 13; hi = 18; band = 'push'; } - final fatigue = (atl != null && ctl != null) ? (atl - ctl) : null; - if (fatigue != null && fatigue > 10) { + // ctl/atl/tsb arrive as raw daily TRIMP (hundreds), NOT strain points, so + // these comparisons have to be scale-free. The thresholds used to be absolute + // (`atl − ctl > 10`, `tsb > 5`) — magnitudes sized for the 0–21 scale — which + // on TRIMP-scale inputs fired on ordinary week-to-week noise: a 320-vs-300 + // acute:chronic pair is a 6.7 % lift, not fatigue, yet it shrank the window. + // Expressed against CTL, the same thresholds mean what they were meant to. + final hasLoad = ctl != null && ctl > 0; + final fatigueRatio = (hasLoad && atl != null) ? atl / ctl : null; + final freshnessRatio = (hasLoad && tsb != null) ? tsb / ctl : null; + if (fatigueRatio != null && fatigueRatio > 1.10) { lo -= 1; hi -= 2; - } else if (tsb != null && tsb > 5) { + } else if (freshnessRatio != null && freshnessRatio > 0.10) { hi += 1; } lo = lo.clamp(0.0, 21.0); diff --git a/test/onehz/clinical_test.dart b/test/onehz/clinical_test.dart index 606feb1..291e17d 100644 --- a/test/onehz/clinical_test.dart +++ b/test/onehz/clinical_test.dart @@ -580,21 +580,35 @@ void main() { }); }); - group('strain score (0-21 log-squash of TRIMP)', () { - test('pins TRIMP -> strain check-points', () { - expect(strainScore(0), closeTo(0.0, 1e-9)); - expect(strainScore(335), closeTo(14.347, 1e-2)); - // monotone + capped at 21. - expect(strainScore(1e9), closeTo(21.0, 1e-9)); - expect(strainScore(100) < strainScore(335), isTrue); + // The CALIBRATION of this scale (what a rest / active / hard / maximal day + // scores) lives in strain_calibration_test.dart, asserted on real days rather + // than on the formula restated. This group keeps only the mechanical + // properties of the map itself. + 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); + // Half the wear window, half the allowance. + expect(baselineTrimp(480), closeTo(baselineTrimp(960) / 2, 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(300, wakeMinutes: 960) < strainScore(400, wakeMinutes: 960), + isTrue, + ); }); - test('strainScoreMetric is HIGH/EST and absent on null', () { - final m = strainScoreMetric(335); + test('strainScoreMetric is EST tier and absent without either input', () { + final m = strainScoreMetric(392.9, wakeMinutes: 960); expect(m.present, isTrue); - expect(m.value, closeTo(14.347, 1e-2)); expect(m.tier, 'ESTIMATE'); - expect(strainScoreMetric(null).present, isFalse); + expect(strainScoreMetric(null, wakeMinutes: 960).present, isFalse); + expect(strainScoreMetric(335, wakeMinutes: null).present, isFalse); }); }); diff --git a/test/onehz/coaching_test.dart b/test/onehz/coaching_test.dart index a0375f7..03dda2c 100644 --- a/test/onehz/coaching_test.dart +++ b/test/onehz/coaching_test.dart @@ -188,27 +188,71 @@ void main() { 'push'); }); - test('maintain band base window is [10,15]', () { + test('maintain band base window is [9,14]', () { final m = strainTarget(recovery0to100: 70, ctl: null, atl: null, tsb: null); - expect(m.value!.targetMin, closeTo(10, 1e-9)); - expect(m.value!.targetMax, closeTo(15, 1e-9)); + expect(m.value!.targetMin, closeTo(9, 1e-9)); + expect(m.value!.targetMax, closeTo(14, 1e-9)); expect(m.tier, Tier.estimate); expect(m.confidence, closeTo(0.6, 1e-9)); }); - test('high fatigue (atl−ctl>10) lowers the window', () { - // maintain base [10,15]; fatigue = 30−10 = 20 (>10) → lo−1, hi−2 → [9,13]. - final m = strainTarget(recovery0to100: 70, ctl: 10, atl: 30, tsb: null); + test('REGRESSION: a recover target is reachable, not below the floor', () { + // The bands were sized for a scale the app never produced: "recover 4–8" + // sat BELOW what an inactive worn day scored (~13 on the old map), so a + // low-recovery day asked for a number the user had already passed before + // getting out of bed. A recover ceiling must sit above a rest day (2–4) + // and below a typical active day (8–11). + final m = strainTarget(recovery0to100: 20, ctl: null, atl: null, tsb: null); + expect(m.value!.band, 'recover'); + expect(m.value!.targetMin, closeTo(0, 1e-9)); + expect(m.value!.targetMax, greaterThan(4.0)); + expect(m.value!.targetMax, lessThan(8.0)); + }); + + test('a push target stays inside what a real day can reach', () { + // 21 is a maximal day. A push ceiling above ~19 is not a target, it is a + // dare — the old band topped out at 18 on a scale whose real ceiling was + // ~16 for a marathon. + final m = strainTarget(recovery0to100: 90, ctl: null, atl: null, tsb: null); + expect(m.value!.band, 'push'); + expect(m.value!.targetMin, closeTo(13, 1e-9)); + expect(m.value!.targetMax, lessThanOrEqualTo(19.0)); + }); + + test('fatigue is judged on the ATL:CTL RATIO, not a raw TRIMP difference', () { + // ctl/atl arrive as raw daily TRIMP (hundreds), but the thresholds were + // sized as if they were 0–21 strain points: `atl − ctl > 10` fired on + // ordinary week-to-week noise. 320 vs 300 is a 6.7 % lift — not fatigue — + // yet the old absolute test (diff 20 > 10) shrank the window for it. + final noise = strainTarget(recovery0to100: 70, ctl: 300, atl: 320, tsb: null); + expect(noise.value!.targetMin, closeTo(9, 1e-9)); + expect(noise.value!.targetMax, closeTo(14, 1e-9)); + + // A genuine 30 % acute lift over chronic still lowers the window. + final real = strainTarget(recovery0to100: 70, ctl: 100, atl: 130, tsb: null); + expect(real.value!.targetMin, closeTo(8, 1e-9)); + expect(real.value!.targetMax, closeTo(12, 1e-9)); + }); + + test('freshness is judged on TSB relative to CTL, not a raw TRIMP value', () { + // tsb 6 against a chronic load of 300 is 2 % — noise, not freshness. + final noise = strainTarget(recovery0to100: 70, ctl: 300, atl: 294, tsb: 6); + expect(noise.value!.targetMax, closeTo(14, 1e-9)); + + // tsb 20 against a chronic load of 100 is a real 20 % taper. + final real = strainTarget(recovery0to100: 70, ctl: 100, atl: 80, tsb: 20); + expect(real.value!.targetMax, closeTo(15, 1e-9)); + }); + + test('no load history leaves the recovery window untouched', () { + final m = strainTarget(recovery0to100: 70, ctl: null, atl: null, tsb: null); expect(m.value!.targetMin, closeTo(9, 1e-9)); - expect(m.value!.targetMax, closeTo(13, 1e-9)); - }); - - test('positive freshness (tsb>5) raises the ceiling', () { - // maintain base [10,15]; low fatigue so tsb branch applies → hi+1 → [10,16]. - final m = strainTarget(recovery0to100: 70, ctl: 20, atl: 20, tsb: 8); - expect(m.value!.targetMin, closeTo(10, 1e-9)); - expect(m.value!.targetMax, closeTo(16, 1e-9)); + expect(m.value!.targetMax, closeTo(14, 1e-9)); + // A zero chronic load must not divide by zero into an adjustment. + final zero = strainTarget(recovery0to100: 70, ctl: 0, atl: 0, tsb: 0); + expect(zero.value!.targetMin, closeTo(9, 1e-9)); + expect(zero.value!.targetMax, closeTo(14, 1e-9)); }); test('targets stay within [0,21] and hi > lo', () { diff --git a/test/onehz/strain_calibration_test.dart b/test/onehz/strain_calibration_test.dart new file mode 100644 index 0000000..ef8d817 --- /dev/null +++ b/test/onehz/strain_calibration_test.dart @@ -0,0 +1,122 @@ +// Behavioural anchors for the 0–21 headline strain scale. +// +// These tests deliberately assert on WHAT A DAY SHOULD SCORE, not on the +// formula's own arithmetic. The previous test only pinned `strainScore(335) ≈ +// 14.347` — the formula restated as an expectation — which let a badly +// calibrated scale pass forever: whole-waking-day Banister TRIMP through a +// log base 1.5 put an INACTIVE full-wear day at ~13/21 and left strain 21 +// needing a TRIMP of ~4987 (≈35 h at 80 % HRR, i.e. unreachable). +// +// Every day here is built from real per-minute HR and run through the real +// `banisterTrimp`, so the anchors constrain the whole pipeline, not just the map. + +import 'package:test/test.dart'; +import 'package:openstrap_analytics/onehz.dart'; + +// One representative profile, matching the `max_hr_used` seen in real bundles. +const double kRhr = 60.0; +const double kHrMax = 187.0; + +/// A day's per-minute WAKING HR: [totalMin] minutes at [quietHr], with the +/// leading minutes replaced by each (minutes, hr) bout. +List dayHr( + int totalMin, + double quietHr, [ + List<(int, double)> bouts = const [], +]) { + final hr = List.filled(totalMin, quietHr); + var i = 0; + for (final (mins, bpm) in bouts) { + for (var k = 0; k < mins && i < totalMin; k++, i++) { + hr[i] = bpm; + } + } + return hr; +} + +/// Full pipeline: per-minute HR → Banister TRIMP → headline 0–21 strain. +double strainOfDay(List hr) { + final trimp = banisterTrimp( + hr, + restingHr: kRhr, + maxHr: kHrMax, + sex: Sex.male, + ); + expect(trimp.present, isTrue, reason: 'anchors need a real TRIMP'); + return strainScore(trimp.value!, wakeMinutes: hr.length.toDouble()); +} + +void main() { + group('strain scale — behavioural anchors', () { + test('an inactive full-wear day scores near zero, not 13', () { + // THE REPORTED BUG. 16 h awake at a quiet 85 bpm — no exercise at all. + // Old scale: TRIMP 176.5 → ln(177.5)/ln(1.5) = 12.8. Merely being awake + // and having a pulse consumed 61 % of the scale. + expect(strainOfDay(dayHr(960, 85)), lessThan(2.0)); + }); + + test('a rest day with a light walk scores 2–4', () { + final s = strainOfDay(dayHr(960, 85, [(60, 105)])); + expect(s, greaterThanOrEqualTo(2.0)); + expect(s, lessThanOrEqualTo(4.0)); + }); + + test('a typical active day with a 45-min moderate run scores 8–11', () { + final s = strainOfDay(dayHr(960, 85, [(45, 145)])); + expect(s, greaterThanOrEqualTo(8.0)); + expect(s, lessThanOrEqualTo(11.0)); + }); + + test('a hard 90-min session day scores 14–17', () { + final s = strainOfDay(dayHr(960, 85, [(90, 165)])); + expect(s, greaterThanOrEqualTo(14.0)); + expect(s, lessThanOrEqualTo(17.0)); + }); + + test('a maximal day scores 19–21 and is reachable', () { + // 5 h at 160 bpm. Under the old scale 21 needed TRIMP ~4987 — no human + // day reached it, so the top of the scale was decorative. + final s = strainOfDay(dayHr(960, 85, [(300, 160)])); + expect(s, greaterThanOrEqualTo(19.0)); + expect(s, lessThanOrEqualTo(21.0)); + }); + + 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 + // borrows a full day's allowance and reads as rest-day effort. + expect(strainOfDay(dayHr(135, 85)), lessThan(1.0)); + }); + + test('is monotone in load and clamped to the 0–21 range', () { + final easy = strainOfDay(dayHr(960, 85, [(30, 130)])); + final mid = strainOfDay(dayHr(960, 85, [(60, 150)])); + 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); + }); + + 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); + }); + }); + + group('strainScoreMetric honesty envelope', () { + 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); + }); + + test('present and ESTIMATE-tier with both inputs', () { + final m = strainScoreMetric(392.9, wakeMinutes: 960); + expect(m.present, isTrue); + expect(m.tier, Tier.estimate); + expect(m.value, greaterThan(14.0)); + }); + }); +}