Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 68 additions & 21 deletions lib/src/onehz/clinical/load_trimp.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,42 +65,89 @@ Metric<double> 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,
Comment on lines +112 to +115

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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 --stat

Repository: 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 --stat

Repository: 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)}")
PY

Repository: 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)}")
PY

Repository: 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)}")
PY

Repository: 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)}")
PY

Repository: 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.

}) {
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));
Comment on lines +99 to +122

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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-positive wakeMinutes, and reject non-finite or negative trimp before calculating a score.
  • lib/src/onehz/clinical/load_trimp.dart#L137-L143: require finite trimp and finite wakeMinutes before constructing a present Metric.
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.

Suggested change
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));
Suggested change
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.

}

/// 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<double> 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<double> 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<double>.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<double>(
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',
);
}

Expand Down
33 changes: 23 additions & 10 deletions lib/src/onehz/human/coaching.dart
Original file line number Diff line number Diff line change
Expand Up @@ -129,31 +129,44 @@ Metric<StrainTarget> 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);
Expand Down
36 changes: 25 additions & 11 deletions test/onehz/clinical_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
72 changes: 58 additions & 14 deletions test/onehz/coaching_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down
Loading
Loading