@@ -15,12 +15,17 @@ import { calcLoad, calcFitnessTrend } from '../trends';
1515import { calcAnomaly } from '../readiness' ;
1616import { calcBaselines } from '../baselines' ;
1717import { calcStress } from '../stress' ;
18+ import { calcSpo2Index } from '../spo2' ;
1819import { calcSleepStress } from '../arousal' ;
1920import { calcNocturnalHeart } from '../nocturnal' ;
2021import { calcIllness } from '../illness' ;
2122import { timeDomainHrv , freqDomainHrv , baevskyStressIndex , cleanRr } from '../hrv' ;
2223import { pedometer , calcSteps , STEP_PARAMS } from '../steps' ;
2324import { 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
2530const 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 ─────────────────────────────────────────
536563console . 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 ──────────
654756console . 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+
696918summary ( 'analytics' ) ;
0 commit comments