Files
intervalsicu-mcp/src/intervals_mcp_server/utils/readiness.py
T
Chris Farhood 91dace2e7c fix(readiness): calendar-anchored windows, disjoint RHR baseline, SWC floor
Address three confirmed review findings:
- Windows were sample-count based, so "7-day" and "last night" claims could be
  built from weeks-old data. All signals now use calendar windows anchored on a
  reference date (the tool passes today); stale metrics report "no recent data"
  and the verdict is withheld instead of presenting old samples as current.
- rhr_signal's `or vals[:-1]` fallback compared the recent week against itself at
  the sample minimum, reading a uniformly-ill week as "ok". The baseline is now
  disjoint by construction and insufficient baselines return nodata.
- The HRV SWC band had no floor, so a near-constant baseline flagged trivial
  fluctuations (50->49) as red "parasympathetic suppression". SWC now floors at
  0.05 ln units (~5% rMSSD, on the order of normal day-to-day variation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
2026-07-20 16:27:36 -04:00

331 lines
12 KiB
Python

"""
Training-readiness computation for Intervals.icu wellness data.
Pure functions (no I/O) so they can be unit-tested on fixtures. The HRV method
follows Plews & Laursen: a rolling mean of ``ln(rMSSD)`` over the last 7 calendar
days compared to a baseline from the preceding ~30 days, with a "normal" band of
baseline mean +/- the smallest worthwhile change (SWC = 0.5 x baseline SD, with a
floor so a near-constant baseline can't produce a zero-width band). Resting HR,
sleep and subjective inputs are each compared to their own recent baseline.
All windows are **calendar-based**, anchored on ``reference_date`` (callers should
pass today): a metric whose samples are older than the window reports "no recent
data" instead of silently treating stale samples as current. Nothing is
fabricated: a metric with too little data in its window reports "no data" rather
than defaulting, and the overall verdict is withheld (not guessed) when the
objective signals are too sparse to be meaningful. Subjective fields use the
conventional Intervals.icu direction (soreness/fatigue/stress/injury: higher is
worse; mood/motivation: higher is better) and only ever contribute a soft
warning, never a hard alert.
"""
from __future__ import annotations
import math
import statistics
from datetime import date, timedelta
from typing import Any
_RECENT_DAYS = 7
_BASELINE_DAYS = 30
_MIN_HRV_RECENT = 4 # samples needed inside the 7-day window
_MIN_HRV_BASELINE = 7
_MIN_RHR_RECENT = 4
_MIN_RHR_BASELINE = 5
_MIN_SLEEP_BASELINE = 5
_MIN_SUBJ_BASELINE = 5
_SUBJ_LATEST_MAX_AGE = 3 # days; older subjective entries aren't "current" feelings
# Floor for the HRV smallest-worthwhile-change band, in ln(rMSSD) units. A
# near-constant baseline (coarsely-rounded device output, very steady athlete)
# would otherwise give SWC ~= 0 and flag trivial fluctuations as alerts. 0.05 ln
# units is ~5% in rMSSD — on the order of normal day-to-day variation.
_SWC_FLOOR = 0.05
_SUBJ_WORSE_HIGH = ("soreness", "fatigue", "stress", "injury")
_SUBJ_WORSE_LOW = ("motivation", "mood")
def _parse_date(value: Any) -> date | None:
try:
return date.fromisoformat(str(value)[:10])
except (ValueError, TypeError):
return None
def _dated_series(
records: list[dict[str, Any]], key: str, positive: bool = False
) -> list[tuple[date, float]]:
"""Date-sorted ``(date, value)`` pairs for ``key``; undated/non-numeric skipped."""
out: list[tuple[date, float]] = []
for r in records:
if not isinstance(r, dict):
continue
d = _parse_date(r.get("id") or r.get("date"))
v = r.get(key)
if d is None or not isinstance(v, (int, float)) or isinstance(v, bool):
continue
if positive and v <= 0:
continue
out.append((d, float(v)))
out.sort(key=lambda p: p[0])
return out
def _windows(
pairs: list[tuple[date, float]], ref: date
) -> tuple[list[float], list[float]]:
"""Split values into recent (last 7 calendar days) and baseline (30 before that)."""
recent_start = ref - timedelta(days=_RECENT_DAYS)
baseline_start = recent_start - timedelta(days=_BASELINE_DAYS)
recent = [v for d, v in pairs if recent_start < d <= ref]
baseline = [v for d, v in pairs if baseline_start < d <= recent_start]
return recent, baseline
def _newest_date(records: list[dict[str, Any]]) -> date | None:
dates = [
d
for d in (_parse_date(r.get("id") or r.get("date")) for r in records if isinstance(r, dict))
if d is not None
]
return max(dates) if dates else None
def _resolve_ref(records: list[dict[str, Any]], reference_date: str | None) -> date | None:
return _parse_date(reference_date) if reference_date else _newest_date(records)
def hrv_signal(records: list[dict[str, Any]], reference_date: str | None = None) -> dict[str, Any]:
"""HRV readiness via 7-day rolling lnRMSSD vs baseline band (mean +/- SWC)."""
pairs = _dated_series(records, "hrv", positive=True)
ref = _resolve_ref(records, reference_date)
if ref is None or not pairs:
return {"name": "HRV", "level": "nodata", "detail": "no HRV data"}
recent_vals, baseline_vals = _windows(pairs, ref)
if len(recent_vals) < _MIN_HRV_RECENT:
return {
"name": "HRV",
"level": "nodata",
"detail": f"only {len(recent_vals)} HRV sample(s) in the last {_RECENT_DAYS} days",
}
if len(baseline_vals) < _MIN_HRV_BASELINE:
return {
"name": "HRV",
"level": "nodata",
"detail": f"only {len(baseline_vals)} baseline day(s) — need >= {_MIN_HRV_BASELINE}",
}
recent_mean = statistics.mean(math.log(v) for v in recent_vals)
ln_base = [math.log(v) for v in baseline_vals]
base_mean = statistics.mean(ln_base)
swc = max(0.5 * statistics.pstdev(ln_base), _SWC_FLOOR)
if recent_mean < base_mean - swc:
return {
"name": "HRV",
"level": "alert",
"detail": "7-day lnHRV below baseline band — parasympathetic suppression",
}
if recent_mean > base_mean + swc:
return {
"name": "HRV",
"level": "warn",
"detail": "7-day lnHRV above baseline band — super-compensation, "
"or saturation if resting HR is also elevated",
}
return {"name": "HRV", "level": "ok", "detail": "7-day lnHRV within normal band"}
def rhr_signal(records: list[dict[str, Any]], reference_date: str | None = None) -> dict[str, Any]:
"""Resting-HR readiness: 7-day mean vs a disjoint 30-day baseline, flag if >5% above."""
pairs = _dated_series(records, "restingHR", positive=True)
ref = _resolve_ref(records, reference_date)
if ref is None or not pairs:
return {"name": "Resting HR", "level": "nodata", "detail": "no resting-HR data"}
recent_vals, baseline_vals = _windows(pairs, ref)
if len(recent_vals) < _MIN_RHR_RECENT:
return {
"name": "Resting HR",
"level": "nodata",
"detail": f"only {len(recent_vals)} RHR sample(s) in the last {_RECENT_DAYS} days",
}
if len(baseline_vals) < _MIN_RHR_BASELINE:
return {
"name": "Resting HR",
"level": "nodata",
"detail": f"only {len(baseline_vals)} baseline day(s) of RHR — need >= {_MIN_RHR_BASELINE}",
}
recent = statistics.mean(recent_vals)
base = statistics.mean(baseline_vals)
if base > 0 and (recent - base) / base > 0.05:
return {
"name": "Resting HR",
"level": "warn",
"detail": f"7-day RHR {recent:.0f} is >5% above baseline {base:.0f}",
}
return {
"name": "Resting HR",
"level": "ok",
"detail": f"7-day RHR {recent:.0f} near baseline {base:.0f}",
}
def sleep_signal(records: list[dict[str, Any]], reference_date: str | None = None) -> dict[str, Any]:
"""Sleep readiness: last night (dated within a day of reference) vs baseline mean."""
pairs = _dated_series(records, "sleepSecs", positive=True)
ref = _resolve_ref(records, reference_date)
if ref is None or not pairs:
return {"name": "Sleep", "level": "nodata", "detail": "no sleep data"}
last_date, last_secs = pairs[-1]
if (ref - last_date).days > 1:
return {
"name": "Sleep",
"level": "nodata",
"detail": f"no sleep logged since {last_date.isoformat()}",
}
baseline = [
v / 3600
for d, v in pairs
if d != last_date and ref - timedelta(days=_BASELINE_DAYS) < d <= ref
]
if len(baseline) < _MIN_SLEEP_BASELINE:
return {"name": "Sleep", "level": "nodata", "detail": "not enough sleep data"}
last = last_secs / 3600
mean = statistics.mean(baseline)
if mean > 0 and last < 0.85 * mean:
return {
"name": "Sleep",
"level": "warn",
"detail": f"last night {last:.1f}h below baseline {mean:.1f}h",
}
return {
"name": "Sleep",
"level": "ok",
"detail": f"last night {last:.1f}h near baseline {mean:.1f}h",
}
def subjective_signals(
records: list[dict[str, Any]], reference_date: str | None = None
) -> list[dict[str, Any]]:
"""Soft warnings when a *current* subjective field has moved off baseline for the worse."""
signals: list[dict[str, Any]] = []
ref = _resolve_ref(records, reference_date)
if ref is None:
return signals
fields = [(f, True) for f in _SUBJ_WORSE_HIGH] + [(f, False) for f in _SUBJ_WORSE_LOW]
for field, worse_high in fields:
pairs = _dated_series(records, field)
if not pairs:
continue
latest_date, latest = pairs[-1]
if (ref - latest_date).days > _SUBJ_LATEST_MAX_AGE:
continue # stale entries aren't current feelings
baseline = [
v
for d, v in pairs
if d != latest_date and ref - timedelta(days=_BASELINE_DAYS) < d <= ref
]
if len(baseline) < _MIN_SUBJ_BASELINE:
continue
mean = statistics.mean(baseline)
sd = statistics.pstdev(baseline) if len(baseline) > 1 else 0.0
threshold = max(sd, 0.5) # require a meaningful move, not noise
worse = (latest - mean > threshold) if worse_high else (mean - latest > threshold)
if worse:
direction = "elevated" if worse_high else "low"
signals.append(
{
"name": field.capitalize(),
"level": "warn",
"detail": f"{field} {direction} vs baseline ({latest:g} vs {mean:.1f})",
}
)
return signals
def form_context(records: list[dict[str, Any]]) -> dict[str, Any] | None:
"""Latest Form (TSB = CTL - ATL) if both components are present."""
dated = sorted(
[r for r in records if isinstance(r, dict)],
key=lambda r: str(r.get("id") or r.get("date") or ""),
)
if not dated:
return None
latest = dated[-1]
ctl, atl = latest.get("ctl"), latest.get("atl")
if isinstance(ctl, (int, float)) and isinstance(atl, (int, float)):
return {"form": round(ctl - atl, 1), "ctl": ctl, "atl": atl}
return None
def assess_readiness(
records: list[dict[str, Any]], reference_date: str | None = None
) -> dict[str, Any]:
"""Produce a structured readiness assessment from wellness records.
``reference_date`` (YYYY-MM-DD) anchors the calendar windows — pass today so
stale data reads as "no recent data" instead of masquerading as current. If
omitted, the newest record's date is used (fixture-friendly, but blind to
how old that record is).
"""
core = [
hrv_signal(records, reference_date),
rhr_signal(records, reference_date),
sleep_signal(records, reference_date),
]
signals = core + subjective_signals(records, reference_date)
alerts = [s for s in signals if s["level"] == "alert"]
warns = [s for s in signals if s["level"] == "warn"]
core_with_data = [s for s in core if s["level"] != "nodata"]
if core[0]["level"] == "nodata" and len(core_with_data) < 2:
verdict = "insufficient"
elif alerts or len(warns) >= 3:
verdict = "red"
elif warns:
verdict = "amber"
else:
verdict = "green"
return {
"verdict": verdict,
"signals": signals,
"form": form_context(records),
"days": len(records),
}
_VERDICT_LABEL = {
"green": "🟢 Ready — signals within normal range",
"amber": "🟡 Caution — one or more signals off baseline",
"red": "🔴 Compromised — strong or multiple negative signals",
"insufficient": "⚪ Verdict withheld — not enough data to judge",
}
_LEVEL_ICON = {"ok": "✓", "warn": "!", "alert": "‼", "nodata": "·"}
def render_readiness(assessment: dict[str, Any]) -> str:
"""Render a readiness assessment into a plain-language report."""
lines = [
"Training Readiness:",
"",
_VERDICT_LABEL.get(assessment["verdict"], assessment["verdict"]),
f"(based on {assessment['days']} day(s) of wellness data)",
"",
"Signals:",
]
for s in assessment["signals"]:
lines.append(f" {_LEVEL_ICON.get(s['level'], '-')} {s['name']}: {s['detail']}")
form = assessment["form"]
if form:
lines += ["", f"Form (TSB): {form['form']} (CTL {form['ctl']} / ATL {form['atl']})"]
if assessment["verdict"] == "insufficient":
lines += [
"",
"Log daily HRV (and resting HR) for ~2+ weeks to enable a readiness verdict.",
]
return "\n".join(lines)