Skip to content

Commit ccf26e0

Browse files
authored
Merge pull request #5 from OpenStrap/feat/wake-trigger
feat: sleep/wake ensemble (wake.ts) — pluggable voters
2 parents 17dc1fd + ee9ee22 commit ccf26e0

9 files changed

Lines changed: 1407 additions & 0 deletions

File tree

src/__tests__/analytics.test.ts

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,17 @@ import { calcLoad, calcFitnessTrend } from '../trends';
1515
import { calcAnomaly } from '../readiness';
1616
import { calcBaselines } from '../baselines';
1717
import { calcStress } from '../stress';
18+
import { calcSpo2Index } from '../spo2';
1819
import { calcSleepStress } from '../arousal';
1920
import { calcNocturnalHeart } from '../nocturnal';
2021
import { calcIllness } from '../illness';
2122
import { timeDomainHrv, freqDomainHrv, baevskyStressIndex, cleanRr } from '../hrv';
2223
import { pedometer, calcSteps, STEP_PARAMS } from '../steps';
2324
import { resolveMaxHr } from '../util';
25+
import { calcCircadian, stageSleep } from '../circadian';
26+
import { detectSleepCycles } from '../cycles';
27+
import { detectWakeState, peekRecentState } from '../wake';
28+
import { calcCycle } from '../cycle';
2429

2530
const baseline: Baseline = {
2631
resting_hr: 50,
@@ -532,6 +537,28 @@ console.log('--- §12 calcStress (HRV) ---');
532537
assert(JSON.stringify(calcStress(rr, baseSI)) === JSON.stringify(calcStress(rr, baseSI)), 'stress deterministic');
533538
}
534539

540+
// ── §SpO₂ relative index (red/IR ratio) ───────────────────────────────────────
541+
console.log('--- §calcSpo2Index ---');
542+
{
543+
// Too few minutes → null, conf 0.
544+
assert(calcSpo2Index([0.85, 0.86, 0.85], 0.85).index === null, 'spo2: <30 min → null');
545+
assert(calcSpo2Index([0.85, 0.86], 0.85).confidence === 0, 'spo2: too few → conf 0');
546+
// No baseline yet → seed night_ratio, null index.
547+
const stable = Array.from({ length: 200 }, () => 0.850);
548+
const seed = calcSpo2Index(stable, null);
549+
assert(seed.index === null, 'spo2: no baseline → null index');
550+
approx(seed.night_ratio!, 0.85, 0.001, 'spo2: no baseline → seed night_ratio');
551+
// Stable clean night vs baseline → high confidence; lower ratio than baseline → positive index.
552+
const better = calcSpo2Index(Array.from({ length: 200 }, () => 0.840), 0.850);
553+
assert(better.index !== null && better.index > 0, 'spo2: lower ratio than baseline → positive index');
554+
assert(better.confidence > 0.8, 'spo2: stable + plenty of samples → high confidence');
555+
// Noisy night (high intra-night CV) → low confidence even with a baseline.
556+
const noisy = calcSpo2Index(Array.from({ length: 200 }, (_, i) => 0.85 + (i % 2 ? 0.08 : -0.08)), 0.850);
557+
assert(noisy.confidence < 0.3, 'spo2: high intra-night CV → low confidence');
558+
// Plausibility gate drops garbage ratios.
559+
assert(calcSpo2Index(Array.from({ length: 200 }, () => 3.0), 0.85).index === null, 'spo2: implausible ratios → null');
560+
}
561+
535562
// ── §sleep-stress / nocturnal arousal ─────────────────────────────────────────
536563
console.log('--- §calcSleepStress ---');
537564
{
@@ -650,6 +677,81 @@ console.log('--- regression: sleep stage proportions ---');
650677
assert(st.light_min >= st.rem_min, `light ≥ REM (light ${st.light_min} vs REM ${st.rem_min})`);
651678
}
652679

680+
// ── regression: stageSleep detects REM from RR variability on a flat-HR night ──
681+
console.log('--- regression: RR-driven REM staging ---');
682+
{
683+
// A calm night where HR is nearly flat (≈60 bpm) so HR LEVEL alone CANNOT separate
684+
// REM from light (the real bug: REM read 0%). REM is encoded the physiological way —
685+
// parasympathetic withdrawal → REDUCED beat-to-beat variability (low RMSSD), with HR
686+
// only mildly above light. Deep = high RMSSD + lowest HR. stageSleep must use the RR
687+
// autonomic axis to recover a physiological REM share (15–25%), not 0.
688+
const ONSET = 0, WAKE = 280 * 60;
689+
// Build a minute's RR stream with a target RMSSD: alternate ±d around the mean RR so
690+
// successive |Δ| ≈ 2d ⇒ RMSSD ≈ 2d (kept < 200 ms so cleanRr doesn't drop beats).
691+
const rrFor = (hr: number, rmssdTarget: number): number[] => {
692+
const meanRr = Math.round(60000 / hr);
693+
const d = Math.min(95, Math.round(rmssdTarget / 2));
694+
const out: number[] = [];
695+
for (let j = 0; j < 48; j++) out.push(meanRr + (j % 2 === 0 ? d : -d));
696+
return out;
697+
};
698+
type SM = { ts: number; hr_avg: number; rr?: number[] };
699+
const night: SM[] = [];
700+
const push = (a: number, b: number, hr: number, rmssd: number) => {
701+
for (let i = a; i < b; i++) night.push({ ts: i * 60, hr_avg: hr, rr: rrFor(hr, rmssd) });
702+
};
703+
push(0, 30, 60, 50); // light (medium variability)
704+
push(30, 95, 56, 90); // deep (high RMSSD, lowest HR)
705+
push(95, 150, 60, 50); // light
706+
push(150, 215, 64, 16); // REM (low RMSSD, mildly elevated HR)
707+
push(215, 280, 60, 50); // light
708+
const ss = stageSleep(night, ONSET, WAKE, /*mesor*/ 90);
709+
const tot = ss.light_min + ss.deep_min + ss.rem_min;
710+
assert(tot > 0, 'RR-staged night produced stages');
711+
const remPct = (100 * ss.rem_min) / tot, deepPct = (100 * ss.deep_min) / tot;
712+
assert(remPct >= 12 && remPct <= 35, `REM recovered from RR, physiological share (got ${remPct.toFixed(0)}%)`);
713+
assert(deepPct >= 8, `deep detected from high-RMSSD block (got ${deepPct.toFixed(0)}%)`);
714+
assert(ss.light_min >= ss.rem_min, 'light remains dominant');
715+
// 0 short(<20 min) awake flaps in the hypnogram.
716+
let flaps = 0;
717+
for (let i = 0; i < ss.hypnogram.length;) {
718+
let j = i; while (j < ss.hypnogram.length && ss.hypnogram[j].stage === ss.hypnogram[i].stage) j++;
719+
if (ss.hypnogram[i].stage === 'awake' && (j - i) < 20) flaps++;
720+
i = j;
721+
}
722+
assert(flaps === 0, `no short awake flaps (got ${flaps})`);
723+
// Without RR, the SAME flat-HR night cannot resolve REM → graceful HR-only fallback
724+
// (must not throw, must not fabricate a REM-dominated night).
725+
const noRr = night.map((m) => ({ ts: m.ts, hr_avg: m.hr_avg }));
726+
const fb = stageSleep(noRr, ONSET, WAKE, 90);
727+
assert((fb.light_min + fb.deep_min + fb.rem_min) > 0, 'HR-only fallback still stages');
728+
}
729+
730+
// ── §Sleep cycles (fractal-cycle method on HRV) ───────────────────────────────
731+
console.log('--- §detectSleepCycles ---');
732+
{
733+
// RMSSD oscillating with a ~80-min ultradian period over a 320-min night → the
734+
// findpeaks(20min, 0.9z) detector should recover ~4 peaks ⇒ ~3 cycles near 80 min.
735+
// RR is built so each minute's RMSSD ≈ target: alternate ±d ⇒ RMSSD ≈ 2d.
736+
const rrFor = (rmssd: number): number[] => {
737+
const d = Math.max(2, Math.round(rmssd / 2));
738+
return Array.from({ length: 40 }, (_, j) => 900 + (j % 2 ? d : -d));
739+
};
740+
const mins: { ts: number; rr: number[] }[] = [];
741+
for (let i = 0; i < 320; i++) {
742+
const rmssd = 50 + 30 * Math.sin((2 * Math.PI * i) / 80); // ~80-min cycle
743+
mins.push({ ts: i * 60, rr: rrFor(rmssd) });
744+
}
745+
const c = detectSleepCycles(mins, 0, 319 * 60);
746+
assert(c.n >= 2 && c.n <= 6, `cycles: ~3-4 ultradian cycles found (got ${c.n})`);
747+
assert(c.mean_duration_min != null && c.mean_duration_min >= 55 && c.mean_duration_min <= 110,
748+
`cycles: mean duration near the ~80-min period (got ${c.mean_duration_min})`);
749+
assert(c.series.length > 0, 'cycles: emits a z-series for plotting');
750+
// No RR → abstain cleanly (no fabricated cycles).
751+
const noRr = detectSleepCycles(Array.from({ length: 200 }, (_, i) => ({ ts: i * 60 })), 0, 199 * 60);
752+
assert(noRr.n === 0 && noRr.cycles.length === 0, 'cycles: no RR → abstains');
753+
}
754+
653755
// ── regression: resolveMaxHr doesn't promote a quiet within-day peak ──────────
654756
console.log('--- regression: resolveMaxHr source ---');
655757
{
@@ -693,4 +795,124 @@ console.log('--- §Steps pedometer ---');
693795
assert(STEP_PARAMS.GAIN === 1.11, 'locked calibration gain = 1.11');
694796
}
695797

798+
// ── §Circadian — CircaCP cosinor + bounded change-point ──────────────────────
799+
console.log('--- §Circadian calcCircadian ---');
800+
{
801+
// 2 days of 1-min HR: asleep (hr≈55) hours [1,8), awake (hr≈80) otherwise.
802+
// Onset ≈ 01:00, wake ≈ 08:00 each day; sharp transitions.
803+
const mins: Minute[] = [];
804+
for (let i = 0; i < 2 * 1440; i++) {
805+
const ts = i * 60;
806+
const hod = Math.floor(ts / 3600) % 24;
807+
const asleep = hod >= 1 && hod < 8;
808+
const hr = (asleep ? 55 : 80) + (i % 5) - 2; // tiny deterministic jitter
809+
mins.push(min(ts, hr));
810+
}
811+
const c = calcCircadian(mins);
812+
assert(c.amplitude !== null && c.amplitude > 5, `circadian amplitude detected (got ${c.amplitude})`);
813+
// day-2 onset ≈ 90000s (25:00 → 01:00 day 2), wake ≈ 115200s (32:00 → 08:00 day 2)
814+
assert(c.onset_ts !== null && Math.abs(c.onset_ts - 90000) <= 1800, `onset ≈ 01:00 day2 (got ${c.onset_ts})`);
815+
assert(c.wake_ts !== null && Math.abs(c.wake_ts - 115200) <= 1800, `wake ≈ 08:00 day2 (got ${c.wake_ts})`);
816+
assert(c.settled === true, 'completed night marked settled');
817+
assert(c.confidence > 0.5, `confidence high on clean rhythm (got ${c.confidence})`);
818+
819+
// flat HR (no rhythm) → abstain
820+
const flat: Minute[] = [];
821+
for (let i = 0; i < 2 * 1440; i++) flat.push(min(i * 60, 70));
822+
const cf = calcCircadian(flat);
823+
assert(cf.onset_ts === null && cf.confidence < 0.3, 'flat HR → abstains (no fabricated boundary)');
824+
}
825+
826+
// ── detectWakeState (sleep/wake ensemble) ─────────────────────────────────────
827+
{
828+
const bl: Baseline = { resting_hr: 50, max_hr: 190, sleep_need_min: 480 };
829+
// 8h sleep (low HR, still) then N min awake (elevated HR, moving + steps).
830+
const build = (awakeMin: number): Minute[] => {
831+
const out: Minute[] = [];
832+
let t = 0;
833+
for (let i = 0; i < 480; i++, t += 60) out.push(min(t, 50, 0.01, { wrist_on: true }));
834+
for (let i = 0; i < awakeMin; i++, t += 60) out.push(min(t, 72, 0.4, { steps: 20, wrist_on: true }));
835+
return out;
836+
};
837+
838+
const woke = detectWakeState({ minutes: build(15), baseline: bl });
839+
assert(woke.state === 'awake', `ensemble: state awake after waking (got ${woke.state})`);
840+
assert(woke.wake_ts != null && Math.abs(woke.wake_ts - 480 * 60) <= 180, `ensemble: wake_ts ≈ sleep→wake boundary ±3min (got ${woke.wake_ts})`);
841+
assert(woke.awake_min >= 12 && woke.awake_min <= 19, `ensemble: sustained awake ~15 min ±detector fuzz (got ${woke.awake_min})`);
842+
assert(woke.asleep_min >= 90, `ensemble: main sleep ≥90 min (got ${woke.asleep_min})`);
843+
844+
const tooSoon = detectWakeState({ minutes: build(5), baseline: bl });
845+
assert(tooSoon.wake_ts === null, 'ensemble: <10 min awake → no premature wake fire');
846+
847+
const stillAsleep = detectWakeState({ minutes: build(0), baseline: bl });
848+
assert(stillAsleep.state === 'asleep' && stillAsleep.wake_ts === null, 'ensemble: mid-sleep → asleep, no wake_ts');
849+
850+
const movingTail = [min(0, 72, 0.4, { steps: 20, wrist_on: true }), min(60, 73, 0.5, { steps: 25, wrist_on: true }), min(120, 71, 0.3, { steps: 10, wrist_on: true })];
851+
assert(peekRecentState(movingTail, bl) === 'awake', 'peek: moving + HR up → awake');
852+
}
853+
854+
// ── regression: QUIET sedentary wake (HR up, NO motion, RR present) must fire ──
855+
// The real-world bug: a user awake but still (on the phone in bed) has elevated HR
856+
// and RR but ~zero motion. The OLD flat 2-of-3 majority let the two motion voters
857+
// (blind to quiet wake) outvote cardiac → "asleep" → close never fired → no recovery.
858+
// The ≥2 consensus must let the autonomic pair (cardiac + hrvArousal) carry the wake.
859+
{
860+
const bl: Baseline = { resting_hr: 55, max_hr: 190, sleep_need_min: 480 };
861+
const minutes: Minute[] = [];
862+
const rrByMin = new Map<number, number[]>();
863+
let t = 0;
864+
const rr = (meanMs: number, sd: number, n = 40) => Array.from({ length: n }, (_, j) => meanMs + (j % 2 ? sd : -sd));
865+
for (let i = 0; i < 480; i++, t += 60) { minutes.push(min(t, 52, 0.01, { wrist_on: true })); rrByMin.set(t, rr(1150, 12)); } // sleep: low HR, still, low RR-SD
866+
for (let i = 0; i < 30; i++, t += 60) { minutes.push(min(t, 74, 0.01, { wrist_on: true })); rrByMin.set(t, rr(810, 60)); } // QUIET wake: HR up, NO motion, high RR-SD
867+
868+
const ws = detectWakeState({ minutes, baseline: bl, rrByMin });
869+
assert(ws.state === 'awake', `quiet sedentary wake detected without motion (got ${ws.state})`);
870+
assert(ws.wake_ts != null && Math.abs(ws.wake_ts - 480 * 60) <= 180, `quiet wake_ts at the boundary (got ${ws.wake_ts})`);
871+
assert(ws.asleep_min >= 90, `main sleep preserved (got ${ws.asleep_min})`);
872+
assert(ws.votes.cardiac === 'awake' && ws.votes.hrvArousal === 'awake', 'autonomic pair both vote awake at wake');
873+
874+
// Honest degradation: same still-but-awake tail with NO RR → only 1 signal (cardiac)
875+
// → below the ≥2 bar → stays asleep rather than guess.
876+
const noRr = detectWakeState({ minutes, baseline: bl });
877+
assert(noRr.wake_ts === null, `no-RR + no-motion quiet wake cannot be confirmed (honest) (got ${noRr.wake_ts})`);
878+
}
879+
880+
// ── menstrual cycle (log-anchored calendar method) ───────────────────────────
881+
{
882+
// No logs → empty/abstain.
883+
const none = calcCycle([], '2026-06-20');
884+
assert(none.confidence === 0 && none.phase === 'unknown' && none.predicted_next === null,
885+
'cycle: no logs → abstain');
886+
887+
// Three regular 28-day starts → median 28, prediction = last + 28.
888+
const starts = ['2026-04-04', '2026-05-02', '2026-05-30'];
889+
const c = calcCycle(starts, '2026-06-06'); // day 8 of the cycle that began 05-30
890+
assert(c.mean_length === 28, `cycle: median length 28 (got ${c.mean_length})`);
891+
assert(c.length_history.length === 2, `cycle: 2 observed lengths (got ${c.length_history.length})`);
892+
assert(c.cycle_day === 8, `cycle: cycle day 8 (got ${c.cycle_day})`);
893+
assert(c.predicted_next === '2026-06-27', `cycle: next period 06-27 (got ${c.predicted_next})`);
894+
assert(c.ovulation_est === '2026-06-13', `cycle: ovulation = next−14 (got ${c.ovulation_est})`);
895+
assert(c.fertile_start === '2026-06-08' && c.fertile_end === '2026-06-14',
896+
`cycle: fertile window ov−5..ov+1 (got ${c.fertile_start}..${c.fertile_end})`);
897+
assert(c.phase === 'follicular', `cycle: day 8 pre-ovulation → follicular (got ${c.phase})`);
898+
assert(c.confidence > 0.5, `cycle: confidence grows with cycles (got ${c.confidence})`);
899+
900+
// Menstruation window (day ≤ 5).
901+
const m = calcCycle(starts, '2026-05-31'); // day 2
902+
assert(m.phase === 'menstruation', `cycle: day 2 → menstruation (got ${m.phase})`);
903+
904+
// Luteal: after the fertile window, before next period.
905+
const l = calcCycle(starts, '2026-06-20'); // day 22
906+
assert(l.phase === 'luteal', `cycle: day 22 → luteal (got ${l.phase})`);
907+
908+
// Very overdue → prediction unreliable, abstain on phase + low confidence.
909+
const od = calcCycle(starts, '2026-07-25'); // ~56 days since last start
910+
assert(od.phase === 'unknown' && od.confidence <= 0.2, `cycle: very overdue → unknown/low conf (got ${od.phase}/${od.confidence})`);
911+
912+
// Single log → 28-day default, low-but-nonzero confidence.
913+
const one = calcCycle(['2026-06-10'], '2026-06-15');
914+
assert(one.mean_length === null && one.predicted_next === '2026-07-08' && one.confidence > 0,
915+
`cycle: single log uses 28d default (got ${one.predicted_next}/${one.confidence})`);
916+
}
917+
696918
summary('analytics');

0 commit comments

Comments
 (0)