Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
7f13e35
stop flagging unusually good nights as illness anomalies
abdulsaheel Oct 17, 2026
7089da7
nocturnal rmssd read the stage mask from the first kept beat
abdulsaheel Oct 17, 2026
88cab22
journal numeric insights: stop the shuffle count blanking every findi…
abdulsaheel Oct 17, 2026
27b35f5
active minutes cold-start note reports the frozen-floor gate
abdulsaheel Oct 17, 2026
29a42e4
sleep debt no longer compares from one or two free nights
abdulsaheel Oct 17, 2026
75ddd10
overreaching detector reported a coarse baseline as a cold start
abdulsaheel Oct 17, 2026
004fe6e
sleep need no longer rests on a single free night
abdulsaheel Oct 17, 2026
e9aed13
change-point cusum no longer throws away the baseline on a one-night gap
abdulsaheel Oct 17, 2026
0952633
stress index no longer scores short gap fragments against the 5-min b…
abdulsaheel Oct 17, 2026
4d66a42
cycle length series refuses intervals over 60 days, not 90
abdulsaheel Oct 17, 2026
e0042b5
illness cusum: a short gap no longer wipes the accumulated run
abdulsaheel Oct 17, 2026
4c91729
workout suggestions: treat a window with no motion samples as hr-only
abdulsaheel Oct 17, 2026
56b09b7
strain target named recent load as an input even with no load history
abdulsaheel Oct 17, 2026
9a02284
prsa deceleration capacity was published as a high-tier number and an…
abdulsaheel Oct 17, 2026
f09cc56
temp circadian acrophase came out in utc hours, not local
abdulsaheel Oct 17, 2026
743dc02
one-sided anomaly distance could exceed the two-sided one
abdulsaheel Oct 17, 2026
c25e332
cosinor: no acrophase when there is no rhythm to place
abdulsaheel Oct 17, 2026
4ed8648
alertness forecast claimed a fitted phase while drawing the populatio…
abdulsaheel Oct 17, 2026
ec2c70f
anomaly: an improving feature could push a night over the gate
abdulsaheel Oct 17, 2026
e68a62b
alertness forecast put the post-lunch dip in mid-morning
abdulsaheel Oct 17, 2026
b99039f
lnrmssd readiness band compares the 7-night rolling mean, not one night
abdulsaheel Oct 17, 2026
295a6b3
lnrmssd band: decide tonight against a single-night threshold
abdulsaheel Oct 17, 2026
92c4a25
vo2max: abstain on non-finite heart rate instead of throwing
abdulsaheel Oct 17, 2026
16052f2
cvhr screen published full confidence from a minute of data
abdulsaheel Oct 17, 2026
f1466cb
drop safeLn from util
abdulsaheel Oct 17, 2026
282c870
lnrmssd band: widen for how few nights the baseline comes from
abdulsaheel Oct 17, 2026
7c0c7c5
human: drop personalRecord, nothing calls it
abdulsaheel Oct 18, 2026
af4d1b8
strain: share one honesty envelope between the lump and series scorers
abdulsaheel Oct 18, 2026
4c267fd
cvhr: bound the personal distribution to the last 30 calendar days
abdulsaheel Oct 18, 2026
87479e5
load_trimp: delete the leftover 0-100 strain scale
abdulsaheel Oct 18, 2026
7d68d3e
cvhr: stop calling ordinary night-to-night noise "running higher"
abdulsaheel Oct 18, 2026
b0fd340
cvhr: say nothing comparative until there are enough older nights
abdulsaheel Oct 18, 2026
83a61c2
respiration: withhold rsa rate when the peak is pinned to the 9 br/mi…
abdulsaheel Oct 18, 2026
db0f5ed
respiration: stop the rsa floor guard widening on short windows
abdulsaheel Oct 18, 2026
d44fedd
temp illness flag: carry the elevated run across a short unscored gap
abdulsaheel Oct 28, 2026
2c66e59
workout suggestions: abstain when there is no hrmax instead of assumi…
abdulsaheel Oct 28, 2026
ba9ff61
prsa: abstain on misaligned beat times, treat backwards time as a sea…
abdulsaheel Oct 28, 2026
a13b5f9
temp circadian: drop a cosinor fit with no phase, keep a repeated fal…
abdulsaheel Oct 28, 2026
b98fcf8
journal correlations: fdrAlpha 0 no longer throws on the shuffle count
abdulsaheel Oct 28, 2026
943ae05
sleep debt: a NaN night does not count toward the minimums
abdulsaheel Oct 28, 2026
e62af11
active minutes: no met need_baseline note when the floor is unusable
abdulsaheel Oct 28, 2026
d574209
docs: drop personal records from the readme, describe the widened lnr…
abdulsaheel Oct 28, 2026
9b04ea3
temp circadian: list accel as an input on the degenerate-fit result too
abdulsaheel Oct 29, 2026
d61da3a
tier docs: say what a relative number may carry
abdulsaheel Oct 29, 2026
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
3 changes: 1 addition & 2 deletions ALGORITHMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ Grouped by family (subdirectory under `lib/src/onehz/`). File paths are relative
| `strainScoreFromSeries` | `clinical/load_trimp.dart` | **the headline 0-21 strain** from per-minute wake HR: `netTrimpAboveQuiet` log-mapped onto 0-21 | — |
| `strainCurveFromSeries` | `clinical/load_trimp.dart` | cumulative 0-21 strain per minute, same arithmetic; last point == headline | — |
| `strainScoreMetric` | `clinical/load_trimp.dart` | LUMP form (TRIMP minus wake-minutes × quiet cost) log-squashed onto 0-21; can debit exercise, kept only for rescaling a stored TRIMP with no per-minute series | — |
| `trimpStrain` | `clinical/load_trimp.dart` | TRIMP → 0-100 strain, honesty-wrapped (absent without real HRmax/RHR anchors) | — |
| `ctlAtlTsb` | `clinical/load_trimp.dart` | Fitness-Fatigue-Form: EWMA CTL (42d) / ATL (7d) / TSB = CTL-ATL | Banister impulse-response model |
| `baevskyStressIndex` | `clinical/stress_si.dart` | Baevsky Stress Index, 5-min sliding RR-histogram | Baevsky & Berseneva 2008 |
| `irregularBeatScreen` | `clinical/irregular_rhythm.dart` | Poincaré SD1/SD2 + pNN70 irregular-rhythm screen | — (screen, not a diagnosis; no ECG) |
Expand Down Expand Up @@ -133,7 +132,7 @@ Grouped by family (subdirectory under `lib/src/onehz/`). File paths are relative
| `socialJetlag` / `chronotype` | `human/circadian_lifestyle.dart` | MSFsc-based social jetlag + chronotype | Wittmann & Roenneberg 2006; MCTQ |
| `alcoholNightFlag` | `human/event_detection.dart` | dose-graded autonomic-stress signature — **reports state, never asserts a cause** | Pietilä et al. 2018 |
| `roughNight` | `human/event_detection.dart` | neutral fallback descriptor when the signature is ambiguous | — |
| `percentileOfYou` / `personalRecord` | `human/percentile_of_you.dart` | percentile-vs-your-own-history, miss-tolerant personal-record streaks | — |
| `percentileOfYou` | `human/percentile_of_you.dart` | percentile-vs-your-own-history | — |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| `glassBoxReadiness` | `human/readiness_glassbox.dart` | **deprecated** — kept only for its percentile-of-you breakdown + narrative and edge back-compat; `readinessComposite` is canonical | — |
| `sleepNeed` / `strainTarget` / `recommendedBedtime` / `recommendedWake` / `sleepPerformance` | `human/coaching.dart` | deterministic coaching layer over the metrics above (naps come from `sleep/nap.dart`, not here) | — |
| `journalCorrelations` | `human/coaching.dart` | per-tag mean-difference correlation vs. logged outcomes, on-device, personal | — |
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,10 @@ foundation layers:
baselines, inverse-variance fusion.
- **`clinical/`** (Tier-1) — HRV time/frequency domain (RMSSD/SDNN/pNNx, Lomb-Scargle
LF/HF), PRSA (deceleration/acceleration capacity), nocturnal RHR/dip, an illness-risk
CUSUM state machine, Plews ln-RMSSD readiness, Baevsky stress index, Banister/Edwards
TRIMP + CTL/ATL/TSB training load (the 0-21 day strain, `strainScoreMetric`, is scored
above the user's own quiet waking heart-rate level rather than a fixed population
reference; the 0-100 `trimpStrain` effort score is raw TRIMP with no such subtraction),
CUSUM state machine, Plews ln-RMSSD readiness, Baevsky stress index, Banister
TRIMP + CTL/ATL/TSB training load (the 0-21 day strain is scored above the user's own
quiet waking heart-rate level rather than a fixed population reference, and it is the
only strain scale),
a Poincaré irregular-rhythm screen, cosinor circadian fitting, and real-time cardiac coherence for guided breathing sessions.
- **`sleep/`** — van Hees z-angle segmentation feeding a cardiac/motion stager (the
single source of truth for sleep staging), AASM hypnogram metrics, cardiopulmonary
Expand All @@ -114,8 +114,8 @@ foundation layers:
- **`wellness/`** — the canonical composite readiness score, multivariate (Mahalanobis)
anomaly detection, CUSUM changepoint detection, temperature-based illness flagging.
- **`human/`** — sleep regularity index, social jetlag/chronotype, single-night event
detection (never names a specific cause — more on that below), percentile-of-you/
personal records, and the deterministic coaching layer.
detection (never names a specific cause — more on that below), percentile-of-you,
and the deterministic coaching layer.

## The rule that matters most: never name a cause

Expand Down
58 changes: 50 additions & 8 deletions lib/src/onehz/clinical/cosinor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,15 @@ import '../util.dart';
class CosinorFit {
final double mesor; // rhythm-adjusted mean
final double amplitude; // half the peak-to-trough range
final double acrophaseRad; // phase of the peak (rad), in [-π, π]
final double acrophaseHours; // acrophase expressed as clock-hours of [period]
/// Phase of the peak (rad), in [-π, π]. Null when the zero-amplitude F-test
/// does not reject (Nelson 1979): with no resolvable rhythm the angle of
/// (β, γ) is set by noise or rounding, not by the data, and a caller would
/// otherwise present it as the user's own peak time.
final double? acrophaseRad;
final double? acrophaseHours; // acrophase expressed as clock-hours of [period]

/// p-value of the zero-amplitude F-test, F(2, n−3).
final double amplitudeP;
final double periodHours;
final double r2; // raw goodness of fit (0..1)

Expand All @@ -34,18 +41,34 @@ class CosinorFit {
required this.periodHours,
required this.r2,
required this.r2Adj,
required this.amplitudeP,
});
Map<String, dynamic> toJson() => {
'mesor': round6(mesor),
'amplitude': round6(amplitude),
'acrophase_rad': round6(acrophaseRad),
'acrophase_hours': round6(acrophaseHours),
'acrophase_rad': acrophaseRad == null ? null : round6(acrophaseRad!),
'acrophase_hours':
acrophaseHours == null ? null : round6(acrophaseHours!),
'period_hours': round6(periodHours),
'r2': round6(r2),
'r2_adj': round6(r2Adj),
'amplitude_p': round6(amplitudeP),
};
}

/// Significance level of the zero-amplitude test below which the acrophase is
/// reported.
const double cosinorAcrophaseAlpha = 0.05;

/// Upper tail of F(2, d): P(F > f) = (1 + 2f/d)^(−d/2), exact for 2 numerator
/// degrees of freedom.
double _f2Survival(double f, int d) {
if (f.isNaN) return 1.0;
if (f.isInfinite) return 0.0;
if (f <= 0) return 1.0;
return math.pow(1 + 2 * f / d, -d / 2).toDouble();
}

/// Minimum samples for a cosinor fit. The model has 3 free parameters
/// (M, β, γ); with n = 4 there is a single residual degree of freedom, so R²
/// is essentially an interpolation score (4 RANDOM points routinely fit at
Expand Down Expand Up @@ -131,22 +154,41 @@ Metric<CosinorFit> cosinor(
// score confidence 0.95 at tier HIGH.
final double r2Adj = (1 - (1 - r2) * (n - 1) / (n - 3)).clamp(0, 1);

// Zero-amplitude F-test (Nelson 1979): the acrophase is only defined once
// the test rejects. A flat or rhythm-free series still yields some
// atan2(−γ, β) from residual noise or float rounding.
final dof = n - 3;
final double ampP;
if (ssTot <= 0) {
ampP = 1.0;
} else if (ssRes <= 0) {
ampP = 0.0;
} else {
final f = ((ssTot - ssRes) / 2) / (ssRes / dof);
ampP = _f2Survival(f, dof);
}
final phaseDefined = ampP < cosinorAcrophaseAlpha;

final conf = r2Adj.clamp(0.1, 0.95);
return Metric<CosinorFit>(
value: CosinorFit(
mesor: mesor,
amplitude: amplitude,
acrophaseRad: acro,
acrophaseHours: phaseHours,
acrophaseRad: phaseDefined ? acro : null,
acrophaseHours: phaseDefined ? phaseHours : null,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
periodHours: periodHours,
r2: r2,
r2Adj: r2Adj,
amplitudeP: ampP,
),
confidence: conf,
tier: Tier.high,
inputs_used: inputs,
note: 'single-component cosinor; anchor circadian phase on the nocturnal '
'trough, not the daytime peak',
note: phaseDefined
? 'single-component cosinor; anchor circadian phase on the nocturnal '
'trough, not the daytime peak'
: 'single-component cosinor; no resolvable rhythm '
'(zero-amplitude test not rejected), acrophase undefined',
);
}

Expand Down
11 changes: 8 additions & 3 deletions lib/src/onehz/clinical/hrv_time.dart
Original file line number Diff line number Diff line change
Expand Up @@ -785,8 +785,9 @@ double? _breathingRmssd(List<double> nn, List<double> times) {
/// window into 5-min bins; required (returns absent without it). [windowMs] bin
/// width (default 300 000 = 5 min). [minBeatsPerWindow] min NN diffs a window
/// needs to contribute (default 5). [stageMaskPerSec] OPTIONAL per-second mask
/// (true = keep, e.g. NREM & immobile); a window is kept only when the mask is
/// true at the window's MIDPOINT second.
/// (true = keep, e.g. NREM & immobile) on the [nnTimesMs] clock — index s
/// covers nnTime s·1000 ms, NOT seconds since the first kept beat; a window is
/// kept only when the mask is true at the window's MIDPOINT second.
///
/// Returns a Metric whose value is the median-of-windows RMSSD (ms). Keeps the
/// PRV-not-ECG honesty note. Absent when there are too few usable windows, when
Expand Down Expand Up @@ -840,7 +841,11 @@ Metric<double> nocturnalRmssd(
final indices = buckets.keys.toList()..sort();
for (final idx in indices) {
if (stageMaskPerSec != null) {
final midSec = ((idx + 0.5) * windowMs / 1000.0).floor();
// Windows are bucketed from the first KEPT beat, but `correctRr` keeps
// the elapsed time of a dropped leading artifact run, so t0 can sit
// minutes past the mask's origin. Read the mask on the beat clock or
// every window takes the stage label from t0 seconds earlier.
final midSec = ((t0 + (idx + 0.5) * windowMs) / 1000.0).floor();
final keep = midSec >= 0 &&
midSec < stageMaskPerSec.length &&
stageMaskPerSec[midSec];
Expand Down
14 changes: 13 additions & 1 deletion lib/src/onehz/clinical/illness_cusum.dart
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ class IllnessDay {
/// so this is roughly 4× more specific than the method it used to cite. Say the
/// number, not "conservative": red drives a medical-class notification.
/// [persistDays] yellow nights required before escalating to red.
/// [maxGapNights] unscored nights (no RHR, or a baseline that abstained) that
/// may sit between two scored nights without breaking the run. A longer gap
/// zeroes the accumulator and every run counter.
///
/// Returns a per-night list. Nights before [minBaseline] valid history are
/// green with null cusum (we never flag without a baseline — no fabrication).
Expand All @@ -101,6 +104,7 @@ List<IllnessDay> illnessCusum(
int minBaseline = illnessCusumMinBaseline,
double returnZ = 0.5,
int recoverDays = 2,
int maxGapNights = 2,
}) {
final n = rhr.length;
final out = <IllnessDay>[];
Expand Down Expand Up @@ -174,7 +178,15 @@ List<IllnessDay> illnessCusum(
// guard had just zeroed normalRun so recovery could not clear it either.
// A CUSUM is evidence accumulated over consecutive observations; there are
// no observations across a gap, so there is no evidence to carry.
if (day[i] - lastScoredDay > 1) {
//
// But a gap of one or two nights must not count as a break. Sleep goes
// undetected and the band comes off precisely while someone is sick, so
// resetting on any single unscored night wiped an episode's evidence at
// the moment it mattered: two +2.5 z nights (cusum 4.0), one night with no
// main sleep, and a third +2.5 night restarted from 0 and read green; on
// alternate-night gaps the alarm could never trip at all. A short gap
// carries the run untouched — it neither advances nor resets it.
if (day[i] - lastScoredDay > 1 + maxGapNights) {
yellowRun = 0;
normalRun = 0;
cusum = 0;
Expand Down
Loading
Loading