Skip to content

Commit 408ac3c

Browse files
committed
feat(analytics): wrist activity recognition + workout typing, plus restlessness, daytime HRV, SpO2 desaturation, cycle-gated illness/anomaly
- har.ts: Mannini 2013 wrist HAR — 15Hz Butterworth + SMV, FFT (dominant/cadence/peakiness), db10 wavelet energies; threshold classifier (ESTIMATE); segmentWorkout (smoothing + RLE phases, graceful activity switches) - detectSessions: 2-min sustained; motion-based type via per-minute act_class + segmentWorkout; segments + detected_type; HR-heuristic fallback for flash bouts - restlessness.ts: nocturnal movement fragmentation (bouts/mobility/continuity) from per-minute activity - hrv.ts: calcDaytimeHrv (waking ultradian RMSSD timeline) - spo2.ts: calcDesaturation (ODI-style overnight relative-desat screen) - illness.ts: respiratory rate as 4th Mahalanobis feature; cycle-phase gating - readiness.ts: calcAnomaly cycle-phase gating - 232 tests pass; db10 coefficients validated via orthonormality
1 parent ccf26e0 commit 408ac3c

11 files changed

Lines changed: 819 additions & 63 deletions

File tree

src/__tests__/_harness.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export function min(
3030
ts: number,
3131
hr: number,
3232
activity = 0,
33-
opts: Partial<{ steps: number; wrist_on: boolean; hr_max: number }> = {}
33+
opts: Partial<{ steps: number; wrist_on: boolean; hr_max: number; act_class: import('../types').ActivityClass }> = {}
3434
) {
3535
return {
3636
ts,
@@ -41,5 +41,6 @@ export function min(
4141
activity,
4242
steps: opts.steps ?? 0,
4343
wrist_on: opts.wrist_on ?? hr > 0,
44+
...(opts.act_class ? { act_class: opts.act_class } : {}),
4445
};
4546
}

src/__tests__/analytics.test.ts

Lines changed: 149 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ import { calcCircadian, stageSleep } from '../circadian';
2626
import { detectSleepCycles } from '../cycles';
2727
import { detectWakeState, peekRecentState } from '../wake';
2828
import { 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

3035
const 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 && /cycle/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(/cycle/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+
9181063
summary('analytics');

0 commit comments

Comments
 (0)