price strain above the cost of being awake, and rebase the coach on it - #45
price strain above the cost of being awake, and rebase the coach on it#45svssathvik7 wants to merge 1 commit into
Conversation
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.
📝 WalkthroughWalkthroughThe PR replaces raw TRIMP strain mapping with a wake-adjusted 0–21 scale. It requires valid wake duration, retunes coaching target adjustments using ATL, TSB, and CTL ratios, and adds calibration and regression tests. ChangesStrain scoring and coaching
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant HeartRateData
participant BanisterTRIMP
participant strainScoreMetric
HeartRateData->>BanisterTRIMP: calculate daily TRIMP
BanisterTRIMP->>strainScoreMetric: provide TRIMP and wake_minutes
strainScoreMetric->>strainScoreMetric: subtract baseline and clamp to 0–21
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lib/src/onehz/clinical/load_trimp.dart`:
- Around line 99-122: Update strainScore in
lib/src/onehz/clinical/load_trimp.dart:99-122 to reject non-finite or
non-positive wakeMinutes and non-finite or negative trimp before calculating the
score. Also update the Metric construction at
lib/src/onehz/clinical/load_trimp.dart:137-143 to require finite trimp and
wakeMinutes before creating a present Metric.
- Around line 112-115: Update the package version in pubspec.yaml from 1.0.0 to
2.0.0 to publish the required breaking API change for strainScore and
strainScoreMetric, and add migration documentation describing the required
wakeMinutes argument before release.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 2922f803-06f7-4e06-9144-923ce53719fd
📒 Files selected for processing (5)
lib/src/onehz/clinical/load_trimp.dartlib/src/onehz/human/coaching.darttest/onehz/clinical_test.darttest/onehz/coaching_test.darttest/onehz/strain_calibration_test.dart
| 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)); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Reject non-finite and invalid strain inputs.
double.nan passes the relational checks and produces a present metric with a NaN value. double.infinity can silently map to strain 21, and infinite wake time can map to strain 0. The direct APIs also accept zero or negative wake windows, despite the required positive wake-window contract.
lib/src/onehz/clinical/load_trimp.dart#L99-L122: reject non-finite or non-positivewakeMinutes, and reject non-finite or negativetrimpbefore calculating a score.lib/src/onehz/clinical/load_trimp.dart#L137-L143: require finitetrimpand finitewakeMinutesbefore constructing a presentMetric.
Proposed fix
-double baselineTrimp(double wakeMinutes, {bool female = false}) =>
- wakeMinutes *
- quietWakingHrr *
- StrainScorer.banisterY(quietWakingHrr, female: female);
+double baselineTrimp(double wakeMinutes, {bool female = false}) {
+ if (!wakeMinutes.isFinite || wakeMinutes <= 0) {
+ throw ArgumentError.value(
+ wakeMinutes,
+ 'wakeMinutes',
+ 'must be finite and greater than zero',
+ );
+ }
+ return wakeMinutes *
+ quietWakingHrr *
+ StrainScorer.banisterY(quietWakingHrr, female: female);
+}
}) {
+ if (!trimp.isFinite || trimp < 0) {
+ throw ArgumentError.value(
+ trimp,
+ 'trimp',
+ 'must be finite and non-negative',
+ );
+ }
final net = trimp - baselineTrimp(wakeMinutes, female: female);
- if (trimp == null || trimp < 0 || wakeMinutes == null || wakeMinutes <= 0) {
+ if (trimp == null ||
+ !trimp.isFinite ||
+ trimp < 0 ||
+ wakeMinutes == null ||
+ !wakeMinutes.isFinite ||
+ wakeMinutes <= 0) {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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)); | |
| double baselineTrimp(double wakeMinutes, {bool female = false}) { | |
| if (!wakeMinutes.isFinite || wakeMinutes <= 0) { | |
| throw ArgumentError.value( | |
| wakeMinutes, | |
| 'wakeMinutes', | |
| 'must be finite and greater than zero', | |
| ); | |
| } | |
| return wakeMinutes * | |
| quietWakingHrr * | |
| StrainScorer.banisterY(quietWakingHrr, female: female); | |
| } | |
| /// Log-map the TRIMP EARNED ABOVE baseline into a 0–21 headline "strain" score. | |
| /// | |
| /// 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, | |
| }) { | |
| if (!trimp.isFinite || trimp < 0) { | |
| throw ArgumentError.value( | |
| trimp, | |
| 'trimp', | |
| 'must be finite and non-negative', | |
| ); | |
| } | |
| 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)); |
| 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)); | |
| if (trimp == null || | |
| !trimp.isFinite || | |
| trimp < 0 || | |
| wakeMinutes == null || | |
| !wakeMinutes.isFinite || | |
| wakeMinutes <= 0) { | |
| return const Metric<double>.absent( | |
| tier: Tier.estimate, | |
| inputs_used: inputs, | |
| note: 'strain needs a TRIMP and the wake window it was measured over', | |
| ); | |
| } |
📍 Affects 1 file
lib/src/onehz/clinical/load_trimp.dart#L99-L122(this comment)lib/src/onehz/clinical/load_trimp.dart#L137-L143
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/src/onehz/clinical/load_trimp.dart` around lines 99 - 122, Update
strainScore in lib/src/onehz/clinical/load_trimp.dart:99-122 to reject
non-finite or non-positive wakeMinutes and non-finite or negative trimp before
calculating the score. Also update the Metric construction at
lib/src/onehz/clinical/load_trimp.dart:137-143 to require finite trimp and
wakeMinutes before creating a present Metric.
| double strainScore( | ||
| double trimp, { | ||
| required double wakeMinutes, | ||
| bool female = false, |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find repository call sites and inspect each argument list.
rg -n -C 3 '\bstrainScore(?:Metric)?\s*\(' lib test
# Inspect the package version before publishing this required-parameter change.
fd -a '^pubspec\.yaml$' . -x sh -c 'printf "\n--- %s ---\n" "$1"; rg -n "^version:" "$1"' sh {}Repository: OpenStrap/analytics
Length of output: 7887
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- all tracked references ---'
rg -n -C 2 '\bstrainScore(?:Metric)?\s*\(' . --glob '!build/**' --glob '!dist/**' --glob '!node_modules/**' || true
printf '%s\n' '--- exports and declarations ---'
rg -n -C 4 '\b(strainScore|strainScoreMetric)\b|load_trimp' lib/onehz.dart lib/src/onehz/clinical/load_trimp.dart
printf '%s\n' '--- release metadata ---'
for f in $(git ls-files | rg '(^|/)(pubspec\.yaml|CHANGELOG(\.md)?|README(\.md)?|.*ชangelog.*)$' || true); do
printf '\n--- %s ---\n' "$f"
rg -n -C 2 '^(version:|## |# |-\s)|breaking|BREAKING|strainScore|strainScoreMetric' "$f" || true
done
printf '%s\n' '--- package status summary ---'
git diff --statRepository: OpenStrap/analytics
Length of output: 14304
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- all tracked references ---'
rg -n -C 2 '\bstrainScore(?:Metric)?\s*\(' . --glob '!build/**' --glob '!dist/**' --glob '!node_modules/**' || true
printf '%s\n' '--- exports and declarations ---'
rg -n -C 4 '\b(strainScore|strainScoreMetric)\b|load_trimp' lib/onehz.dart lib/src/onehz/clinical/load_trimp.dart
printf '%s\n' '--- release metadata ---'
for f in $(git ls-files | rg '(^|/)(pubspec\.yaml|CHANGELOG(\.md)?|README(\.md)?|.*changelog.*)$' || true); do
printf '\n--- %s ---\n' "$f"
rg -n -C 2 '^(version:|## |# |-\s)|breaking|BREAKING|strainScore|strainScoreMetric' "$f" || true
done
printf '%s\n' '--- package status summary ---'
git diff --statRepository: OpenStrap/analytics
Length of output: 14304
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- signature change and package metadata diff ---'
git diff --unified=8 -- lib/src/onehz/clinical/load_trimp.dart pubspec.yaml
printf '%s\n' '--- call-site verifier ---'
python3 - <<'PY'
import subprocess
from pathlib import Path
tracked = subprocess.check_output(["git", "ls-files", "*.dart"], text=True).splitlines()
needle_names = ("strainScore", "strainScoreMetric")
def matching_close(text, opening):
depth = 0
quote = None
escaped = False
for i in range(opening, len(text)):
ch = text[i]
if quote:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == quote:
quote = None
continue
if ch in ("'", '"'):
quote = ch
elif ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
return i
return None
found = []
for filename in tracked:
text = Path(filename).read_text()
i = 0
while i < len(text):
hit = next((text.find(name, i) for name in needle_names
if text.find(name, i) != -1), -1)
if hit < 0:
break
name = next(name for name in needle_names if text.startswith(name, hit))
j = hit + len(name)
while j < len(text) and text[j].isspace():
j += 1
if j < len(text) and text[j] == "(":
end = matching_close(text, j)
if end is not None:
args = text[j + 1:end]
line = text.count("\n", 0, hit) + 1
found.append((filename, line, name, "wakeMinutes:" in args))
i = end + 1
continue
i = j
for filename, line, name, has_wake in found:
print(f"{filename}:{line}: {name}: wakeMinutes={'yes' if has_wake else 'no'}")
print(f"calls={len(found)}")
print(f"missing_wakeMinutes={sum(not item[3] for item in found)}")
PYRepository: OpenStrap/analytics
Length of output: 1158
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- signature change and package metadata diff ---'
git diff --unified=8 -- lib/src/onehz/clinical/load_trimp.dart pubspec.yaml
printf '%s\n' '--- call-site verifier ---'
python3 - <<'PY'
import subprocess
from pathlib import Path
tracked = subprocess.check_output(["git", "ls-files", "*.dart"], text=True).splitlines()
needle_names = ("strainScore", "strainScoreMetric")
def matching_close(text, opening):
depth = 0
quote = None
escaped = False
for i in range(opening, len(text)):
ch = text[i]
if quote:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == quote:
quote = None
continue
if ch in ("'", '"'):
quote = ch
elif ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
return i
return None
found = []
for filename in tracked:
text = Path(filename).read_text()
i = 0
while i < len(text):
candidates = [(text.find(name, i), name) for name in needle_names]
candidates = [(pos, name) for pos, name in candidates if pos >= 0]
if not candidates:
break
hit, name = min(candidates)
j = hit + len(name)
while j < len(text) and text[j].isspace():
j += 1
if j < len(text) and text[j] == "(":
end = matching_close(text, j)
if end is not None:
args = text[j + 1:end]
line = text.count("\n", 0, hit) + 1
found.append((filename, line, name, "wakeMinutes:" in args))
i = end + 1
continue
i = j
for filename, line, name, has_wake in found:
print(f"{filename}:{line}: {name}: wakeMinutes={'yes' if has_wake else 'no'}")
print(f"calls={len(found)}")
print(f"missing_wakeMinutes={sum(not item[3] for item in found)}")
PYRepository: OpenStrap/analytics
Length of output: 1158
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
import re
import subprocess
from pathlib import Path
files = subprocess.check_output(["git", "ls-files", "*.dart"], text=True).splitlines()
def mask_comments(text):
out = list(text)
i = 0
state = "code"
quote = ""
while i < len(text):
ch = text[i]
nxt = text[i + 1] if i + 1 < len(text) else ""
if state == "code":
if ch == "/" and nxt == "/":
out[i] = out[i + 1] = " "
i += 2
state = "line_comment"
continue
if ch == "/" and nxt == "*":
out[i] = out[i + 1] = " "
i += 2
state = "block_comment"
continue
if ch in ("'", '"'):
quote = ch
state = "string"
i += 1
elif state == "line_comment":
if ch == "\n":
state = "code"
else:
out[i] = " "
i += 1
elif state == "block_comment":
if ch == "*" and nxt == "/":
out[i] = out[i + 1] = " "
i += 2
state = "code"
else:
if ch != "\n":
out[i] = " "
i += 1
else:
# Mask string contents so documentation text cannot look like a call.
if ch == "\\":
if i + 1 < len(text):
out[i] = out[i + 1] = " "
i += 2
else:
out[i] = " "
i += 1
elif ch == quote:
state = "code"
i += 1
else:
if ch != "\n":
out[i] = " "
i += 1
return "".join(out)
def matching_close(text, opening):
depth = 0
for i in range(opening, len(text)):
if text[i] == "(":
depth += 1
elif text[i] == ")":
depth -= 1
if depth == 0:
return i
return None
patterns = [
re.compile(r"\bstrainScoreMetric\s*\("),
re.compile(r"\bstrainScore\s*\("),
]
results = []
for filename in files:
original = Path(filename).read_text()
masked = mask_comments(original)
for pattern in patterns:
for match in pattern.finditer(masked):
opening = masked.find("(", match.start(), match.end())
closing = matching_close(masked, opening)
if closing is None:
continue
args = masked[opening + 1:closing]
line = original.count("\n", 0, match.start()) + 1
declaration = bool(re.search(r"\b(?:double|Metric<double>)\s+$",
masked[max(0, match.start() - 24):match.start()]))
results.append((filename, line, match.group(0).split("(")[0],
"declaration" if declaration else "call",
"yes" if re.search(r"\bwakeMinutes\s*:", args) else "no"))
for item in sorted(results):
print(f"{item[0]}:{item[1]}: {item[2]}: {item[3]}: wakeMinutes={item[4]}")
calls = [item for item in results if item[3] == "call"]
print(f"calls={len(calls)}")
print(f"calls_missing_wakeMinutes={sum(item[4] == 'no' for item in calls)}")
PYRepository: OpenStrap/analytics
Length of output: 1602
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
import re
import subprocess
from pathlib import Path
files = subprocess.check_output(["git", "ls-files", "*.dart"], text=True).splitlines()
def mask_comments(text):
out = list(text)
i = 0
state = "code"
quote = ""
while i < len(text):
ch = text[i]
nxt = text[i + 1] if i + 1 < len(text) else ""
if state == "code":
if ch == "/" and nxt == "/":
out[i] = out[i + 1] = " "
i += 2
state = "line_comment"
continue
if ch == "/" and nxt == "*":
out[i] = out[i + 1] = " "
i += 2
state = "block_comment"
continue
if ch in ("'", '"'):
quote = ch
state = "string"
i += 1
elif state == "line_comment":
if ch == "\n":
state = "code"
else:
out[i] = " "
i += 1
elif state == "block_comment":
if ch == "*" and nxt == "/":
out[i] = out[i + 1] = " "
i += 2
state = "code"
else:
if ch != "\n":
out[i] = " "
i += 1
else:
if ch == "\\":
if i + 1 < len(text):
out[i] = out[i + 1] = " "
i += 2
else:
out[i] = " "
i += 1
elif ch == quote:
state = "code"
i += 1
else:
if ch != "\n":
out[i] = " "
i += 1
return "".join(out)
def matching_close(text, opening):
depth = 0
for i in range(opening, len(text)):
if text[i] == "(":
depth += 1
elif text[i] == ")":
depth -= 1
if depth == 0:
return i
return None
patterns = [
re.compile(r"\bstrainScoreMetric\s*\("),
re.compile(r"\bstrainScore\s*\("),
]
results = []
for filename in files:
original = Path(filename).read_text()
masked = mask_comments(original)
for pattern in patterns:
for match in pattern.finditer(masked):
opening = masked.find("(", match.start(), match.end())
closing = matching_close(masked, opening)
if closing is None:
continue
args = masked[opening + 1:closing]
line = original.count("\n", 0, match.start()) + 1
declaration = bool(re.search(
r"\b(?:double|Metric<double>)\s+$",
masked[max(0, match.start() - 24):match.start()],
))
results.append((
filename,
line,
match.group(0).split("(")[0],
"declaration" if declaration else "call",
"yes" if re.search(r"\bwakeMinutes\s*:", args) else "no",
))
for item in sorted(results):
print(f"{item[0]}:{item[1]}: {item[2]}: {item[3]}: wakeMinutes={item[4]}")
calls = [item for item in results if item[3] == "call"]
print(f"calls={len(calls)}")
print(f"calls_missing_wakeMinutes={sum(item[4] == 'no' for item in calls)}")
PYRepository: OpenStrap/analytics
Length of output: 1602
Publish this breaking API change under a major version.
strainScore and strainScoreMetric are publicly exported. All repository callers pass wakeMinutes, but external callers will break. Change pubspec.yaml from 1.0.0 to 2.0.0 and document the migration before publishing.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/src/onehz/clinical/load_trimp.dart` around lines 112 - 115, Update the
package version in pubspec.yaml from 1.0.0 to 2.0.0 to publish the required
breaking API change for strainScore and strainScoreMetric, and add migration
documentation describing the required wakeMinutes argument before release.
The problem
The 0–21 headline was
min(21, ln(TRIMP+1)/ln(1.5))over whole-waking-day Banister TRIMP. Two defects, and they compounded.A huge floor. Whole-day TRIMP counts every waking minute above resting, so ~16 h of ordinary living accrues ~180 TRIMP before any exercise. Log base 1.5 is steepest near zero, so that overhead alone bought ~13 of the 21 points. Verified against real bundles (formula reproduces the stored scalars bit-for-bit):
The number moved with how long the band was worn, not with training.
No reachable ceiling. Each further point cost 1.5× the load, so 21 sat at TRIMP ~4987 — roughly 35 h at 80 % HRR. A marathon reads ~15.8. The usable range was about 8 to 16, and neither end meant anything.
The fix
Strain is now the load earned above a quiet-waking baseline — 20 % of HRR, priced through the same
banisterYthe TRIMP was scored with, scaled by the wake window actually observed. The net maps by21·ln(1+u·14)/ln(15),u = net/400.Scaling the baseline by the observed wake window is what stops a partial-wear day being charged a full day's overhead: 2026-07-10 was worn 135 waking minutes, and lending it a 16 h allowance would read as effort.
Anchors, on days built from per-minute HR and run through the real
banisterTrimp:wakeMinutesis required, not assumed — the subtraction is meaningless without it, and guessing silently mis-scores every partial-wear day, sostrainScoreMetricabstains instead.The coach
strainTarget's bands were sized for a distribution this 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; "push 14–18" needed more than a marathon. Rebased onto the distribution above.Its load adjustment was also dimensionally wrong:
ctl/atl/tsbarrive as raw daily TRIMP (hundreds), but the thresholds wereatl − ctl > 10andtsb > 5— magnitudes sized for the 0–21 scale. On TRIMP-scale inputs those fire on ordinary noise (a 320-vs-300 acute:chronic pair is a 6.7 % lift, not fatigue). Both are now ratios against CTL, guarded onctl > 0.Tests
The old test pinned
strainScore(335) ≈ 14.347— the formula as its own expectation, which let the miscalibration pass forever. Replaced with behavioural anchors instrain_calibration_test.dartthat constrain the whole pipeline, not just the map.435 tests pass,
dart analyze lib testclean.Notes for review
OpenStrap/edgehas a companion PR that cannot build against upstream until this merges and its pinned SHA is bumped — see the sequencing note there.Summary by CodeRabbit
New Features
Improvements