@@ -26,6 +26,11 @@ import { calcCircadian, stageSleep } from '../circadian';
2626import { detectSleepCycles } from '../cycles' ;
2727import { detectWakeState , peekRecentState } from '../wake' ;
2828import { calcCycle } from '../cycle' ;
29+ import { extractHarFeatures , classifyActivityWindow , segmentWorkout , DB10_LO , dwtDetailEnergies } from '../har' ;
30+ import type { ClassVote } from '../har' ;
31+ import { calcRestlessness } from '../restlessness' ;
32+ import { calcDaytimeHrv } from '../hrv' ;
33+ import { calcDesaturation } from '../spo2' ;
2934
3035const baseline : Baseline = {
3136 resting_hr : 50 ,
@@ -306,12 +311,28 @@ console.log('--- §7 detectSessions ---');
306311 assert ( ses . type === 'run/cardio' , 'high act + high HR → run/cardio' ) ;
307312 assert ( ses . strain > 0 && ses . kcal > 0 , 'session carries strain + calories' ) ;
308313
309- // a <5min bout is discarded
314+ // a 2-min sustained bout now qualifies (threshold lowered 3/5 → 2 min).
315+ const short : Minute [ ] = [ ] ;
316+ for ( let i = 0 ; i < 10 ; i ++ ) short . push ( min ( i * 60 , 55 , 1 ) ) ;
317+ for ( let i = 10 ; i < 12 ; i ++ ) short . push ( min ( i * 60 , 150 , 100 ) ) ; // 2 min
318+ for ( let i = 12 ; i < 20 ; i ++ ) short . push ( min ( i * 60 , 55 , 1 ) ) ;
319+ assert ( detectSessions ( short , baseline ) . length === 1 , '2-min bout now detected' ) ;
320+
321+ // a 1-min blip is still discarded.
310322 const tiny : Minute [ ] = [ ] ;
311323 for ( let i = 0 ; i < 10 ; i ++ ) tiny . push ( min ( i * 60 , 55 , 1 ) ) ;
312- for ( let i = 10 ; i < 13 ; i ++ ) tiny . push ( min ( i * 60 , 150 , 100 ) ) ; // only 3 min
313- for ( let i = 13 ; i < 20 ; i ++ ) tiny . push ( min ( i * 60 , 55 , 1 ) ) ;
314- assert ( detectSessions ( tiny , baseline ) . length === 0 , 'bout <5 min discarded' ) ;
324+ tiny . push ( min ( 10 * 60 , 150 , 100 ) ) ; // 1 min only
325+ for ( let i = 11 ; i < 20 ; i ++ ) tiny . push ( min ( i * 60 , 55 , 1 ) ) ;
326+ assert ( detectSessions ( tiny , baseline ) . length === 0 , '1-min blip discarded' ) ;
327+
328+ // Per-minute HAR class → motion-based workout type + confidence (not the HR heuristic).
329+ const cyc : Minute [ ] = [ ] ;
330+ for ( let i = 0 ; i < 10 ; i ++ ) cyc . push ( min ( i * 60 , 55 , 1 ) ) ;
331+ for ( let i = 10 ; i < 20 ; i ++ ) cyc . push ( min ( i * 60 , 140 , 100 , { hr_max : 150 , act_class : 'cycle' } ) ) ;
332+ for ( let i = 20 ; i < 30 ; i ++ ) cyc . push ( min ( i * 60 , 55 , 1 ) ) ;
333+ const cs = detectSessions ( cyc , baseline ) [ 0 ] ;
334+ assert ( cs . type === 'cycle' && cs . type_confidence > 0.4 , `motion class → cycle (got ${ cs . type } /${ cs . type_confidence } )` ) ;
335+ assert ( cs . detected_type === 'cycle' , 'detected_type recorded for calibration ledger' ) ;
315336}
316337
317338// ── §8 calcHrRecovery ────────────────────────────────────────────────────────
@@ -389,6 +410,10 @@ console.log('--- §10 calcRecovery / calcAnomaly / calcIllness ---');
389410 const an = calcAnomaly ( { recent_rhr : [ 50 , 51 , 55 , 56 ] } , baseline ) ;
390411 assert ( an . signal === true && an . triggers . includes ( 'rhr_elevated_2d' ) , 'two elevated RHR days → signal' ) ;
391412 assert ( an . note === 'signal, not a diagnosis' , 'anomaly non-diagnostic note' ) ;
413+ // §4 cycle gate: luteal phase suppresses the pure-RHR-elevation rule (expected rise).
414+ const anLuteal = calcAnomaly ( { recent_rhr : [ 50 , 51 , 55 , 56 ] } , baseline , { cyclePhase : 'luteal' } ) ;
415+ assert ( anLuteal . signal === false && / c y c l e / i. test ( anLuteal . note ) ,
416+ 'luteal phase suppresses pure-RHR anomaly with a cycle note' ) ;
392417
393418 // illness (Mahalanobis): RHR↑ + RMSSD↓ + temp↑ vs baseline → signal.
394419 const hist = {
@@ -401,6 +426,26 @@ console.log('--- §10 calcRecovery / calcAnomaly / calcIllness ---');
401426 assert ( sick . note === 'a signal, not a diagnosis' , 'illness non-diagnostic note' ) ;
402427 const well = calcIllness ( { resting_hr : 56 , rmssd : 76 , skin_temp : 34.05 } , hist ) ;
403428 assert ( well . signal === false , 'normal vector → no illness signal' ) ;
429+
430+ // §5 respiratory rate as a 4th Mahalanobis feature: RMSSD↓ + resp↑ fires + lists 'resp'.
431+ const histR = { ...hist , resp_rate : Array . from ( { length : 20 } , ( _ , i ) => 14 + ( i % 2 ) ) } ;
432+ const sickResp = calcIllness ( { resting_hr : 56 , rmssd : 45 , skin_temp : 34.05 , resp_rate : 19 } , histR ) ;
433+ assert ( sickResp . signal === true && sickResp . triggers . includes ( 'resp' ) ,
434+ 'elevated respiratory rate drives the illness signal' ) ;
435+ assert ( sickResp . inputs_used . includes ( 'resp_rate' ) , 'resp_rate listed in inputs_used' ) ;
436+
437+ // §4 cycle gating: temp+RHR rise ALONE is phase-expected → suppressed in luteal,
438+ // but still fires when no cycle context is supplied.
439+ const cycIn = { resting_hr : 64 , rmssd : 76 , skin_temp : 35.0 } ;
440+ const noCyc = calcIllness ( cycIn , hist ) ;
441+ assert ( noCyc . signal === true && noCyc . triggers . includes ( 'rhr' ) && noCyc . triggers . includes ( 'temp' ) ,
442+ 'temp+RHR rise → illness signal with no cycle context' ) ;
443+ const luteal = calcIllness ( cycIn , hist , { cyclePhase : 'luteal' } ) ;
444+ assert ( luteal . signal === false , 'luteal phase suppresses temp/RHR-only illness signal' ) ;
445+ assert ( / c y c l e / i. test ( luteal . note ) , 'suppressed signal explains the cycle phase' ) ;
446+ // …but HRV/resp deviations are NOT explained by the cycle → still fires.
447+ const lutealReal = calcIllness ( { resting_hr : 64 , rmssd : 45 , skin_temp : 35.0 , resp_rate : 19 } , histR , { cyclePhase : 'luteal' } ) ;
448+ assert ( lutealReal . signal === true , 'HRV/resp shift still fires even in luteal phase' ) ;
404449}
405450
406451// ── §11 calcBaselines ────────────────────────────────────────────────────────
@@ -915,4 +960,104 @@ console.log('--- §Circadian calcCircadian ---');
915960 `cycle: single log uses 28d default (got ${ one . predicted_next } /${ one . confidence } )` ) ;
916961}
917962
963+ // ── §HAR — activity recognition (Mannini features + classifier + segmentation) ──
964+ console . log ( '--- §HAR activity recognition ---' ) ;
965+ {
966+ // db10 orthonormality invariants — catch any coefficient transcription error.
967+ const sumLo = DB10_LO . reduce ( ( s , v ) => s + v , 0 ) ;
968+ const sumSq = DB10_LO . reduce ( ( s , v ) => s + v * v , 0 ) ;
969+ assert ( DB10_LO . length === 20 , 'db10 has 20 taps' ) ;
970+ approx ( sumLo , Math . SQRT2 , 1e-6 , 'db10 Σh = √2' ) ;
971+ approx ( sumSq , 1 , 1e-6 , 'db10 Σh² = 1' ) ;
972+
973+ // Synthetic tri-axial window: gravity on Z + a sinusoidal swing at f0 on X.
974+ const fs = 100 , secs = 4 , n = fs * secs ;
975+ // Oscillate the magnitude (gravity axis) at f0 so SMV ≈ 1 + amp·sin(2π f0 t) — this
976+ // matches how the accel-vector magnitude actually varies with gait (avoids the sin²
977+ // frequency-doubling artifact you get from a single off-axis sinusoid).
978+ const mk = ( f0 : number , amp : number , noise = 0.004 ) => {
979+ const x : number [ ] = [ ] , y : number [ ] = [ ] , z : number [ ] = [ ] ;
980+ for ( let i = 0 ; i < n ; i ++ ) {
981+ const t = i / fs ;
982+ z . push ( 1 + amp * Math . sin ( 2 * Math . PI * f0 * t ) + ( ( ( i * 7919 ) % 991 ) / 991 - 0.5 ) * noise ) ;
983+ x . push ( ( ( ( i * 1103515245 + 12345 ) % 1000 ) / 1000 - 0.5 ) * noise ) ;
984+ y . push ( ( ( ( i * 1103 ) % 997 ) / 997 - 0.5 ) * noise ) ;
985+ }
986+ return { x, y, z } ;
987+ } ;
988+
989+ // Frequency detection: a 2.0 Hz swing → dom1_freq ≈ 2.0 (within bin resolution).
990+ const w2 = mk ( 2.0 , 0.5 ) ;
991+ const f2 = extractHarFeatures ( w2 . x , w2 . y , w2 . z , fs ) ;
992+ approx ( f2 . dom1_freq , 2.0 , 0.3 , `HAR dom1_freq ≈ 2.0 (got ${ f2 . dom1_freq . toFixed ( 2 ) } )` ) ;
993+ assert ( f2 . dom1_ratio > 0.25 , 'HAR strong sine → periodic (high dom1_ratio)' ) ;
994+
995+ // Classification: flat (gravity only, tiny noise) → sedentary.
996+ const flat = mk ( 1.0 , 0.0 ) ;
997+ assert ( classifyActivityWindow ( extractHarFeatures ( flat . x , flat . y , flat . z , fs ) ) . cls === 'sedentary' ,
998+ 'HAR flat signal → sedentary' ) ;
999+
1000+ // 2 Hz strong swing → a locomotion class (walk), not sedentary/other.
1001+ const cw = classifyActivityWindow ( f2 ) ;
1002+ assert ( cw . cls === 'walk' , `HAR 2 Hz → walk (got ${ cw . cls } )` ) ;
1003+
1004+ // 2.8 Hz strong swing → run.
1005+ const w3 = mk ( 2.8 , 0.6 ) ;
1006+ assert ( classifyActivityWindow ( extractHarFeatures ( w3 . x , w3 . y , w3 . z , fs ) ) . cls === 'run' ,
1007+ 'HAR 2.8 Hz → run' ) ;
1008+
1009+ // wavelet detail energies present (6 levels), non-negative.
1010+ const we = dwtDetailEnergies ( w2 . x , 6 ) ;
1011+ assert ( we . length === 6 && we . every ( ( e ) => e >= 0 ) , 'db10 detail energies: 6 levels, ≥0' ) ;
1012+
1013+ // Segmentation: 5 min walk → 5 min run (one continuous bout) → two phases, primary = either.
1014+ const votes : ClassVote [ ] = [ ] ;
1015+ for ( let t = 0 ; t < 300 ; t += 4 ) votes . push ( { ts : 1000 + t , cls : 'walk' , conf : 0.7 } ) ;
1016+ for ( let t = 300 ; t < 600 ; t += 4 ) votes . push ( { ts : 1000 + t , cls : 'run' , conf : 0.7 } ) ;
1017+ const seg = segmentWorkout ( votes ) ;
1018+ assert ( seg . segments . length === 2 , `HAR segment: walk→run → 2 phases (got ${ seg . segments . length } )` ) ;
1019+ assert ( seg . segments [ 0 ] . type === 'walk' && seg . segments [ 1 ] . type === 'run' , 'HAR phases ordered walk then run' ) ;
1020+
1021+ // A single-window blip inside a long run is smoothed away (no spurious phase).
1022+ const blip : ClassVote [ ] = [ ] ;
1023+ for ( let t = 0 ; t < 600 ; t += 4 ) blip . push ( { ts : 2000 + t , cls : t === 300 ? 'cycle' : 'run' , conf : 0.7 } ) ;
1024+ assert ( segmentWorkout ( blip ) . segments . length === 1 , 'HAR single-window blip smoothed → one phase' ) ;
1025+ }
1026+
1027+ // ── §Restlessness / §Daytime HRV / §Desaturation ────────────────────────────
1028+ console . log ( '--- §restlessness / daytime HRV / desaturation ---' ) ;
1029+ {
1030+ // Restlessness: a still night with a movement spike every 30 min → bouts detected.
1031+ const sleepMin : Minute [ ] = [ ] ;
1032+ for ( let i = 0 ; i < 240 ; i ++ ) {
1033+ const moving = i % 30 === 0 ;
1034+ sleepMin . push ( { ts : 1000 + i * 60 , hr_avg : 55 , hr_min : 54 , hr_max : 56 , hr_n : 60 , activity : moving ? 0.5 : 0.01 , steps : 0 , wrist_on : true } ) ;
1035+ }
1036+ const rest = calcRestlessness ( sleepMin ) ;
1037+ assert ( rest . score !== null && rest . movement_bouts >= 5 , `restlessness: detects bouts (got ${ rest . movement_bouts } )` ) ;
1038+ assert ( rest . longest_still_min > 0 && rest . mobility_pct !== null , 'restlessness: still stretch + mobility' ) ;
1039+ assert ( calcRestlessness ( sleepMin . slice ( 0 , 5 ) ) . score === null , 'restlessness: <20 min → null' ) ;
1040+
1041+ // Daytime HRV: 60 min of RR bucketed into 5-min windows → per-window RMSSD series.
1042+ const byMin : { ts : number ; rr : number [ ] } [ ] = [ ] ;
1043+ for ( let i = 0 ; i < 60 ; i ++ ) {
1044+ const rr : number [ ] = [ ] ;
1045+ for ( let k = 0 ; k < 12 ; k ++ ) rr . push ( 850 + ( ( i + k ) % 5 ) * 15 ) ;
1046+ byMin . push ( { ts : 1000 + i * 60 , rr } ) ;
1047+ }
1048+ const dh = calcDaytimeHrv ( byMin , 300 ) ;
1049+ assert ( dh . rmssd_median !== null && dh . n_windows >= 10 , `daytime HRV: windows (got ${ dh . n_windows } )` ) ;
1050+ assert ( dh . series . length === dh . n_windows && dh . lowest_ts !== null , 'daytime HRV: series + lowest window' ) ;
1051+ assert ( calcDaytimeHrv ( [ ] , 300 ) . rmssd_median === null , 'daytime HRV: no RR → null' ) ;
1052+
1053+ // Desaturation: a 2-min dip (R↑ above baseline) every 20 min → events counted.
1054+ const ratios : number [ ] = [ ] ;
1055+ for ( let i = 0 ; i < 120 ; i ++ ) ratios . push ( i % 20 < 2 ? 0.86 : 0.79 ) ;
1056+ const des = calcDesaturation ( ratios , 0.80 ) ;
1057+ assert ( des . events >= 4 && des . odi !== null , `desaturation: counts dips (got ${ des . events } )` ) ;
1058+ assert ( des . deepest_pct !== null && des . deepest_pct > 0 , 'desaturation: reports deepest dip' ) ;
1059+ const desNoBase = calcDesaturation ( ratios , null ) ;
1060+ assert ( desNoBase . events === 0 && desNoBase . confidence === 0 , 'desaturation: no baseline → abstain' ) ;
1061+ }
1062+
9181063summary ( 'analytics' ) ;
0 commit comments