diff --git a/docs/biometrics-reference-imports.md b/docs/biometrics-reference-imports.md index 3a36cfc5..9c469a0e 100644 --- a/docs/biometrics-reference-imports.md +++ b/docs/biometrics-reference-imports.md @@ -181,3 +181,22 @@ macro F1, coverage, and stage-duration/transition differences on common support. Absent classes have null scores. Transitions across unobserved gaps are not inferred. Conflicting intervals from separate sources remain unscored. This scorer is a primitive for replay reports; it is not a new live stage classifier. + +### Pod-vs-Watch deviation report + +`biometrics.compareToWatch` (`GET /api/biometrics/watch-deviation`, MCP +`compare_vitals_to_watch`) scores what the pod stored against the legacy Watch +tables (`reference_vitals`, `reference_samples`, `reference_stages`) per sleep +record of at least `minHours` (default 3) in a window of at most 31 days +(default the last 14). Deviation is pod − Watch. Heart rate pairs each Watch +sample with the nearest pod reading within ±60 s, breathing rate within ±150 s, +and HRV compares Watch SDNN with the mean pod HRV within ±5 minutes; Watch +samples are taken inside the record and pod readings up to 5 minutes past its +edges. Each metric reports `n`, `watchSamples`, `coverage`, `mae`, `bias`, +nearest-rank `p90` absolute error and Pearson `r`, null without data. Stages +re-run the side's effective classifier on the stored vitals and movement and +score each 5-minute epoch against the Watch stage at its midpoint (accuracy, +kappa, recall, minutes per stage over the scored epochs). The summary pools +every pair and scored epoch of all nights rather than averaging nightly figures. +HRV is not like-for-like: the pod stores a window IBI variability index, not +SDNN, which the response's `hrvNote` repeats. diff --git a/src/biometrics/references/tests/watch-deviation.test.ts b/src/biometrics/references/tests/watch-deviation.test.ts new file mode 100644 index 00000000..a5b62152 --- /dev/null +++ b/src/biometrics/references/tests/watch-deviation.test.ts @@ -0,0 +1,136 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest' +import type { SleepEpoch, SleepStage } from '@/src/lib/sleep-stages' +import { + compareNightsToWatch, + compareStages, + deviationStats, + meanWithin, + nearestWithin, + pairNight, + type DeviationNightInput, +} from '../watch-deviation' + +const T = Date.UTC(2026, 9, 10, 23) +const S = 1000 +const MIN = 60 * S + +const epoch = (minutes: number, stage: SleepStage): SleepEpoch => + ({ start: T + minutes * MIN, duration: 5 * MIN, stage, heartRate: null, hrv: null, breathingRate: null, movement: null }) + +describe('nearestWithin', () => { + const points = [{ t: 0, v: 60 }, { t: 40 * S, v: null }, { t: 50 * S, v: 70 }, { t: 100 * S, v: 80 }] + + it('takes the nearest non-null reading, the earlier one on a tie, and includes the window edge', () => { + expect(nearestWithin(points, 45 * S, 60 * S)).toBe(70) // 40 s is null, so 50 s (5 s away) wins + expect(nearestWithin(points, 75 * S, 60 * S)).toBe(70) // 50 s and 100 s are both 25 s away + expect(nearestWithin(points, 160 * S, 60 * S)).toBe(80) // exactly 60 s away + expect(nearestWithin(points, 161 * S, 60 * S)).toBeNull() + expect(nearestWithin([], 0, 60 * S)).toBeNull() + }) +}) + +describe('meanWithin', () => { + it('averages the non-null readings inside the window and is null without any', () => { + const points = [{ t: 0, v: 10 }, { t: 60 * S, v: 20 }, { t: 120 * S, v: null }, { t: 400 * S, v: 30 }] + expect(meanWithin(points, 60 * S, 300 * S)).toBe(15) // 400 s is 340 s away + expect(meanWithin(points, 700 * S, 300 * S)).toBe(30) + expect(meanWithin(points, 2000 * S, 300 * S)).toBeNull() + }) +}) + +describe('deviationStats', () => { + it('computes MAE, bias (pod − Watch), nearest-rank p90, Pearson r and coverage', () => { + // pod − Watch: +2, −2, +4, +6 → |err| 2, 2, 4, 6 + const out = deviationStats([[62, 60], [58, 60], [65, 61], [70, 64]], 5) + expect(out).toMatchObject({ n: 4, watchSamples: 5, coverage: 0.8, mae: 3.5, bias: 2.5, p90: 6 }) + // pod mean 63.75, Watch mean 61.25: cov 26.25, Σdp² 76.75, Σdw² 10.75 + expect(out.r).toBeCloseTo(26.25 / Math.sqrt(76.75 * 10.75), 12) + }) + + it('returns nulls, not zeros, without pairs and coverage only when the Watch had samples', () => { + expect(deviationStats([], 3)).toEqual({ n: 0, watchSamples: 3, coverage: 0, mae: null, bias: null, p90: null, r: null }) + expect(deviationStats([], 0)).toEqual({ n: 0, watchSamples: 0, coverage: null, mae: null, bias: null, p90: null, r: null }) + }) + + it('has no correlation for a single pair or a constant series', () => { + expect(deviationStats([[60, 58]], 1)).toMatchObject({ mae: 2, bias: 2, p90: 2, r: null }) + // n = 10: nearest rank ceil(9) is the 9th smallest error, not the maximum. + const ten = Array.from({ length: 10 }, (_, i) => [60 + i + 1, 60] as [number, number]) + expect(deviationStats(ten, 10).p90).toBe(9) + expect(deviationStats([[60, 58], [70, 58]], 2)).toMatchObject({ mae: 7, bias: 7, r: null }) + }) +}) + +describe('pairNight', () => { + it('uses ±60 s for HR, ±150 s for breathing and the ±5 min pod mean for HRV', () => { + const pod = { + heartRate: [{ t: T + 59 * S, v: 62 }, { t: T + 1000 * S + 61 * S, v: 99 }], + breathingRate: [{ t: T + 150 * S, v: 15 }, { t: T + 1000 * S + 151 * S, v: 30 }], + hrv: [{ t: T - 300 * S, v: 40 }, { t: T + 120 * S, v: 60 }, { t: T + 301 * S, v: 500 }], + } + const watch = { + heartRate: [{ t: T, v: 60 }, { t: T + 1000 * S, v: 60 }], + breathingRate: [{ t: T, v: 14 }, { t: T + 1000 * S, v: 14 }], + hrvSdnn: [{ t: T, v: 45 }], + } + expect(pairNight(pod, watch)).toEqual({ heartRate: [[62, 60]], breathingRate: [[15, 14]], hrv: [[50, 45]] }) + }) +}) + +describe('compareStages', () => { + it('scores epochs at their midpoint and counts minutes over the scored epochs only', () => { + const epochs = [epoch(0, 'light'), epoch(5, 'deep'), epoch(10, 'rem'), epoch(15, 'wake')] + const reference = [ + { start: T, end: T + 10 * MIN, stage: 'light' as const }, + { start: T + 10 * MIN, end: T + 15 * MIN, stage: 'rem' as const }, + ] + // 3 scored epochs, 2 match. Chance: light 1/3·2/3 + rem 1/3·1/3 = 1/3, so kappa = (2/3 − 1/3) / (2/3) = 0.5. + const out = compareStages(epochs, reference) + expect(out).toMatchObject({ epochs: 3, recall: { wake: null, light: 0.5, deep: null, rem: 1 } }) + expect(out?.accuracy).toBeCloseTo(2 / 3, 12) + expect(out?.kappa).toBeCloseTo(0.5, 12) + expect(out?.podMinutes).toEqual({ wake: 0, light: 5, deep: 5, rem: 5 }) + expect(out?.watchMinutes).toEqual({ wake: 0, light: 10, deep: 0, rem: 5 }) + }) + + it('is null when no epoch falls inside a Watch segment', () => { + expect(compareStages([epoch(0, 'light')], [{ start: T + 60 * MIN, end: T + 90 * MIN, stage: 'deep' }])).toBeNull() + expect(compareStages([], [])).toBeNull() + }) +}) + +describe('compareNightsToWatch', () => { + const night = (id: number, offsetMs: number, hr: Array<[pod: number, watch: number]>): DeviationNightInput => ({ + sleepRecordId: id, + enteredBedAt: new Date(T + offsetMs), + leftBedAt: new Date(T + offsetMs + 4.25 * 3_600_000), + pod: { heartRate: hr.map(([p], i) => ({ t: T + offsetMs + i * 5 * MIN, v: p })), breathingRate: [], hrv: [] }, + watch: { heartRate: hr.map(([, w], i) => ({ t: T + offsetMs + i * 5 * MIN, v: w })), breathingRate: [{ t: T + offsetMs, v: 14 }], hrvSdnn: [] }, + epochs: [], + reference: [], + }) + + it('reports each night and pools the summary over every pair, not a mean of nightly means', () => { + const out = compareNightsToWatch([night(1, 0, [[70, 60]]), night(2, 86_400_000, [[62, 60], [62, 60], [62, 60]])]) + expect(out.nights.map(n => [n.sleepRecordId, n.hours, n.heartRate.mae])).toEqual([[1, 4.3, 10], [2, 4.3, 2]]) + // Pooled: (10 + 2 + 2 + 2) / 4 = 4; the mean of nightly MAEs would be 6. + expect(out.summary).toMatchObject({ nights: 2, heartRate: { n: 4, watchSamples: 4, coverage: 1, mae: 4, bias: 4 } }) + expect(out.summary.breathingRate).toEqual({ n: 0, watchSamples: 2, coverage: 0, mae: null, bias: null, p90: null, r: null }) + expect(out.summary.hrv).toMatchObject({ watchSamples: 0, coverage: null }) + expect(out.summary.stages).toBeNull() + expect(out.nights[0].stages).toBeNull() + }) + + it('rounds the reported statistics', () => { + const out = compareNightsToWatch([night(1, 0, [[61, 60], [63, 60], [60, 60]])]) + expect(out.nights[0].heartRate).toMatchObject({ mae: 1.33, bias: 1.33, coverage: 1 }) + expect(out.summary.nights).toBe(1) + }) + + it('returns an empty report without nights', () => { + const out = compareNightsToWatch([]) + expect(out.nights).toEqual([]) + expect(out.summary).toMatchObject({ nights: 0, heartRate: { n: 0, watchSamples: 0, coverage: null, mae: null }, stages: null }) + }) +}) diff --git a/src/biometrics/references/watch-deviation.ts b/src/biometrics/references/watch-deviation.ts new file mode 100644 index 00000000..9d9fb9a8 --- /dev/null +++ b/src/biometrics/references/watch-deviation.ts @@ -0,0 +1,268 @@ +/** + * Pod-vs-Apple-Watch deviation per night: each Watch sample is paired with the + * pod reading(s) near it in time, then summarized. Deviation is always + * pod − Watch, so a positive bias means the pod reads high. Ported from the + * offline analysis/watch-deviation scripts so the app, API and MCP report the + * same numbers. + */ +import { z } from 'zod' +import { scoreAgainstReference, type SleepEpoch, type SleepStage, type SleepStageBlock } from '@/src/lib/sleep-stages' + +/** A pod reading (null when the pod had none) or a Watch sample, at unix ms. */ +export interface TimedValue { t: number, v: number | null } +export type DeviationPair = readonly [pod: number, watch: number] + +/** + * How far a pod reading may sit from a Watch sample. HR and breathing take + * the nearest pod reading; HRV averages every pod reading in the window + * because the Watch's SDNN summarizes a few minutes of beats. + */ +export const WATCH_PAIRING_WINDOW_MS = { heartRate: 60_000, breathingRate: 150_000, hrv: 300_000 } as const + +export const HRV_NOTE = 'HRV deviation is not like-for-like yet: the pod stores an IBI-variability index (window_ibi_variability_v1), ' + + 'not SDNN, while the Watch reports SDNN over a few minutes, usually only 1-4 samples a night. Track its trend, not its zero.' + +const STAGES: SleepStage[] = ['wake', 'light', 'deep', 'rem'] + +/** Index of the first point at or after t; points sorted by t. */ +function lowerBound(points: TimedValue[], t: number): number { + let lo = 0 + let hi = points.length + while (lo < hi) { + const mid = (lo + hi) >>> 1 + if (points[mid].t < t) lo = mid + 1 + else hi = mid + } + return lo +} + +/** The non-null value nearest in time to t within toleranceMs (earlier wins a tie); points sorted by t. */ +export function nearestWithin(points: TimedValue[], t: number, toleranceMs: number): number | null { + let best: number | null = null + let bestDt = Infinity + for (let i = lowerBound(points, t - toleranceMs); i < points.length && points[i].t <= t + toleranceMs; i++) { + const dt = Math.abs(points[i].t - t) + if (points[i].v !== null && dt < bestDt) { + best = points[i].v + bestDt = dt + } + } + return best +} + +/** Mean of the non-null values within toleranceMs of t; points sorted by t. */ +export function meanWithin(points: TimedValue[], t: number, toleranceMs: number): number | null { + let sum = 0 + let count = 0 + for (let i = lowerBound(points, t - toleranceMs); i < points.length && points[i].t <= t + toleranceMs; i++) { + const v = points[i].v + if (v !== null) { + sum += v + count++ + } + } + return count ? sum / count : null +} + +/** Pair each Watch sample with the pod value `pick` finds for its time; unmatched samples are dropped. */ +export function pairWatchSamples(watch: TimedValue[], pick: (t: number) => number | null): DeviationPair[] { + const pairs: DeviationPair[] = [] + for (const w of watch) { + if (w.v === null) continue + const pod = pick(w.t) + if (pod !== null) pairs.push([pod, w.v]) + } + return pairs +} + +export const watchDeviationSchema = z.object({ + /** Watch samples paired with a pod reading. */ + n: z.number().int(), + /** Watch samples in the night (or nights), paired or not. */ + watchSamples: z.number().int(), + /** n / watchSamples; null without Watch samples. */ + coverage: z.number().nullable(), + /** Mean absolute error, in the metric's unit. */ + mae: z.number().nullable(), + /** Mean of pod − Watch; positive means the pod reads high. */ + bias: z.number().nullable(), + /** 90th-percentile absolute error (nearest rank). */ + p90: z.number().nullable(), + /** Pearson correlation of pod and Watch values; null below 2 pairs or without variance. */ + r: z.number().nullable(), +}) +export type WatchDeviation = z.infer + +/** MAE, bias, p90 absolute error, Pearson r and coverage. Null statistics, never zeros, without pairs. */ +export function deviationStats(pairs: readonly DeviationPair[], watchSamples: number): WatchDeviation { + const n = pairs.length + const coverage = watchSamples > 0 ? n / watchSamples : null + if (n === 0) return { n, watchSamples, coverage, mae: null, bias: null, p90: null, r: null } + const abs = pairs.map(([p, w]) => Math.abs(p - w)).sort((a, b) => a - b) + const meanPod = pairs.reduce((s, [p]) => s + p, 0) / n + const meanWatch = pairs.reduce((s, [, w]) => s + w, 0) / n + let cov = 0 + let varPod = 0 + let varWatch = 0 + for (const [p, w] of pairs) { + cov += (p - meanPod) * (w - meanWatch) + varPod += (p - meanPod) ** 2 + varWatch += (w - meanWatch) ** 2 + } + return { + n, + watchSamples, + coverage, + mae: abs.reduce((s, x) => s + x, 0) / n, + bias: meanPod - meanWatch, + // Nearest-rank percentile: rank ceil(0.9 n), so n = 10 picks the 9th smallest, not the largest. + p90: abs[Math.ceil(n * 0.9) - 1], + r: varPod > 0 && varWatch > 0 ? cov / Math.sqrt(varPod * varWatch) : null, + } +} + +/** Pod readings per metric, each sorted by time. */ +export interface PodSeries { heartRate: TimedValue[], breathingRate: TimedValue[], hrv: TimedValue[] } +/** Watch samples per metric: heart rate (bpm), breathing rate (breaths/min), HRV SDNN (ms). */ +export interface WatchSeries { heartRate: TimedValue[], breathingRate: TimedValue[], hrvSdnn: TimedValue[] } +export type DeviationMetric = keyof PodSeries + +/** Pair one night's Watch samples with the pod readings near them, per metric. */ +export function pairNight(pod: PodSeries, watch: WatchSeries): Record { + const w = WATCH_PAIRING_WINDOW_MS + return { + heartRate: pairWatchSamples(watch.heartRate, t => nearestWithin(pod.heartRate, t, w.heartRate)), + breathingRate: pairWatchSamples(watch.breathingRate, t => nearestWithin(pod.breathingRate, t, w.breathingRate)), + hrv: pairWatchSamples(watch.hrvSdnn, t => meanWithin(pod.hrv, t, w.hrv)), + } +} + +const stageNumbers = z.object({ wake: z.number(), light: z.number(), deep: z.number(), rem: z.number() }) + +export const stageComparisonSchema = z.object({ + /** Pod epochs whose midpoint falls inside a Watch stage segment. */ + epochs: z.number().int(), + accuracy: z.number(), + kappa: z.number().nullable(), + recall: z.object({ wake: z.number().nullable(), light: z.number().nullable(), deep: z.number().nullable(), rem: z.number().nullable() }), + /** Minutes per stage over the scored epochs only, so pod and Watch cover the same time. */ + podMinutes: stageNumbers, + watchMinutes: stageNumbers, +}) +export type StageComparison = z.infer + +/** + * Agreement of classified epochs with Watch stages, pooled over every epoch + * given (scoreAgainstReference), plus minutes per stage on each side over the + * scored epochs. Null when no epoch falls inside a Watch segment. + */ +export function compareStages(epochs: SleepEpoch[], reference: SleepStageBlock[]): StageComparison | null { + const agreement = scoreAgainstReference(epochs, reference) + if (!agreement) return null + const podMs: Record = { wake: 0, light: 0, deep: 0, rem: 0 } + const watchMs: Record = { wake: 0, light: 0, deep: 0, rem: 0 } + for (const e of epochs) { + const mid = e.start + e.duration / 2 + const ref = reference.find(r => mid >= r.start && mid < r.end) + if (!ref) continue + podMs[e.stage] += e.duration + watchMs[ref.stage] += e.duration + } + const minutes = (ms: Record) => + Object.fromEntries(STAGES.map(s => [s, Math.round(ms[s] / 60_000)])) as Record + return { + epochs: agreement.epochs, + accuracy: agreement.accuracy, + kappa: agreement.kappa, + recall: agreement.recall, + podMinutes: minutes(podMs), + watchMinutes: minutes(watchMs), + } +} + +/** One night's inputs: the pod and Watch series and the classified epochs, all inside the record. */ +export interface DeviationNightInput { + sleepRecordId: number + enteredBedAt: Date + leftBedAt: Date + pod: PodSeries + watch: WatchSeries + epochs: SleepEpoch[] + reference: SleepStageBlock[] +} + +export const deviationNightSchema = z.object({ + sleepRecordId: z.number().int(), + enteredBedAt: z.date(), + leftBedAt: z.date(), + hours: z.number(), + heartRate: watchDeviationSchema, + breathingRate: watchDeviationSchema, + hrv: watchDeviationSchema, + stages: stageComparisonSchema.nullable(), +}) + +export const deviationSummarySchema = z.object({ + nights: z.number().int(), + heartRate: watchDeviationSchema, + breathingRate: watchDeviationSchema, + hrv: watchDeviationSchema, + stages: stageComparisonSchema.nullable(), +}) + +const round = (x: number | null, digits: number) => x === null ? null : Number(x.toFixed(digits)) + +function rounded(d: WatchDeviation): WatchDeviation { + return { ...d, coverage: round(d.coverage, 3), mae: round(d.mae, 2), bias: round(d.bias, 2), p90: round(d.p90, 2), r: round(d.r, 3) } +} + +function roundedStages(s: StageComparison | null): StageComparison | null { + return s && { + ...s, + accuracy: Number(s.accuracy.toFixed(3)), + kappa: round(s.kappa, 3), + recall: { wake: round(s.recall.wake, 3), light: round(s.recall.light, 3), deep: round(s.recall.deep, 3), rem: round(s.recall.rem, 3) }, + } +} + +/** + * Per-night deviation plus a summary pooled over every pair and every scored + * epoch of all nights, not a mean of the nightly figures: a night with more + * Watch samples weighs more, and a night with one HRV sample cannot swing it. + */ +export function compareNightsToWatch(nights: DeviationNightInput[]) { + const pooled: Record = { heartRate: [], breathingRate: [], hrv: [] } + const watchTotals: Record = { heartRate: 0, breathingRate: 0, hrv: 0 } + const report = nights.map((night) => { + const pairs = pairNight(night.pod, night.watch) + const watchCounts: Record = { + heartRate: night.watch.heartRate.length, + breathingRate: night.watch.breathingRate.length, + hrv: night.watch.hrvSdnn.length, + } + for (const metric of ['heartRate', 'breathingRate', 'hrv'] as const) { + pooled[metric].push(...pairs[metric]) + watchTotals[metric] += watchCounts[metric] + } + return { + sleepRecordId: night.sleepRecordId, + enteredBedAt: night.enteredBedAt, + leftBedAt: night.leftBedAt, + hours: Number(((night.leftBedAt.getTime() - night.enteredBedAt.getTime()) / 3_600_000).toFixed(1)), + heartRate: rounded(deviationStats(pairs.heartRate, watchCounts.heartRate)), + breathingRate: rounded(deviationStats(pairs.breathingRate, watchCounts.breathingRate)), + hrv: rounded(deviationStats(pairs.hrv, watchCounts.hrv)), + stages: roundedStages(compareStages(night.epochs, night.reference)), + } + }) + return { + nights: report, + summary: { + nights: nights.length, + heartRate: rounded(deviationStats(pooled.heartRate, watchTotals.heartRate)), + breathingRate: rounded(deviationStats(pooled.breathingRate, watchTotals.breathingRate)), + hrv: rounded(deviationStats(pooled.hrv, watchTotals.hrv)), + stages: roundedStages(compareStages(nights.flatMap(n => n.epochs), nights.flatMap(n => n.reference))), + }, + } +} diff --git a/src/demo/handlers/biometrics.ts b/src/demo/handlers/biometrics.ts index 4872bbe7..97f5927b 100644 --- a/src/demo/handlers/biometrics.ts +++ b/src/demo/handlers/biometrics.ts @@ -6,6 +6,7 @@ import { demoMovement, demoNights, demoNightsOverlapping, + demoStageEpochs, demoVitals, editDemoNight, findDemoNight, @@ -14,9 +15,12 @@ import { type Side, } from '../history' import type { DemoHandlers, RouterOutputs } from '../types' -import { DAY, MINUTE } from '../util' +import { DAY, MINUTE, hashSeed, randomBetween, seededRandom } from '../util' import { classifyDemoWindow } from '../biometrics/stages' import { demoStageView } from './biometricTuning' +import { mergeIntoBlocks, type SleepStageBlock } from '@/src/lib/sleep-stages' +import { compareNightsToWatch, HRV_NOTE, WATCH_PAIRING_WINDOW_MS, type DeviationNightInput } from '@/src/biometrics/references/watch-deviation' +import type { StageClassifier } from '@/src/lib/sleep-stage-rules' type SleepRecord = RouterOutputs['biometrics']['getSleepRecords'][number] @@ -82,6 +86,44 @@ function lastNight(side: Side): DemoNight | undefined { }) ?? recent[0] } +/** + * A finished demo night as compareToWatch sees it. The Watch reads a little + * below the pod with some scatter (HR every 10 min, breathing every 15 min, + * two SDNN samples a night), and the scripted hypnogram stands in for its + * stages, as in the sleepStages demo. + */ +function demoDeviationNight(n: DemoNight & { leftBedAt: Date }, classifier: StageClassifier): DeviationNightInput { + const start = n.enteredBedAt.getTime() + const end = n.leftBedAt.getTime() + const rand = seededRandom(hashSeed(`watch:${n.id}`)) + const pad = WATCH_PAIRING_WINDOW_MS.hrv + const vitals = demoVitals(n.side, start - pad, end + pad) + const inNight = vitals.filter(v => v.t >= start && v.t <= end) + const watch = (every: number, value: (v: typeof vitals[number]) => number) => + inNight.filter((_, i) => i % every === 0).map(v => ({ t: v.t, v: Math.round(value(v) * 10) / 10 })) + const reference: SleepStageBlock[] = mergeIntoBlocks(demoStageEpochs(n, start, end)) + return { + sleepRecordId: n.id, + enteredBedAt: n.enteredBedAt, + leftBedAt: n.leftBedAt, + pod: { + heartRate: vitals.map(v => ({ t: v.t, v: v.heartRate })), + breathingRate: vitals.map(v => ({ t: v.t, v: v.breathingRate })), + hrv: vitals.map(v => ({ t: v.t, v: v.hrv })), + }, + watch: { + heartRate: watch(5, v => v.heartRate - 1.5 + randomBetween(rand, -3, 3)), + breathingRate: watch(8, v => v.breathingRate - 1.4 + randomBetween(rand, -1, 1)), + hrvSdnn: [1 / 3, 2 / 3].flatMap((f) => { + const v = inNight[Math.floor(inNight.length * f)] + return v ? [{ t: v.t, v: Math.round(v.hrv * randomBetween(rand, 0.6, 0.8)) }] : [] + }), + }, + epochs: classifyDemoWindow(n.side, start, end, classifier), + reference, + } +} + export const biometrics: DemoHandlers<'biometrics'> = { getSleepRecords: (input) => { const { start, end } = range(input) @@ -138,6 +180,30 @@ export const biometrics: DemoHandlers<'biometrics'> = { .slice(-(input.limit ?? 5000)) }, + compareToWatch: async (input) => { + const endDate = input.endDate ?? new Date() + const startDate = input.startDate ?? new Date(endDate.getTime() - 14 * DAY) + if (startDate > endDate) throw new TRPCError({ code: 'BAD_REQUEST', message: 'startDate must be before or equal to endDate' }) + if (endDate.getTime() - startDate.getTime() > 31 * DAY) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Select at most 31 days' }) + const minHours = input.minHours ?? 3 + const view = await demoStageView(input.side) + const nights = demoNights(input.side) + .filter((n): n is DemoNight & { leftBedAt: Date } => n.leftBedAt !== null + && n.enteredBedAt >= startDate && n.enteredBedAt <= endDate + && n.leftBedAt.getTime() - n.enteredBedAt.getTime() >= minHours * 3_600_000) + .reverse() + .map(n => demoDeviationNight(n, view.classifier)) + return { + side: input.side, + startDate, + endDate, + minHours, + classifier: { isDefault: view.isDefault, source: view.provenance.source, generation: view.provenance.generation, stageHash: view.provenance.stageHash }, + hrvNote: HRV_NOTE, + ...compareNightsToWatch(nights), + } + }, + getVitalsSummary: (input) => { const end = input.endDate?.getTime() ?? Date.now() const start = input.startDate?.getTime() ?? Date.now() - 7 * DAY diff --git a/src/demo/tests/biometrics.compareToWatch.test.ts b/src/demo/tests/biometrics.compareToWatch.test.ts new file mode 100644 index 00000000..6ada02a1 --- /dev/null +++ b/src/demo/tests/biometrics.compareToWatch.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { biometrics } from '../handlers/biometrics' +import { demoNights } from '../history' +import { DAY } from '../util' + +function required(handler: T | undefined): T { + if (!handler) throw new Error('Missing demo handler') + return handler +} + +const NOW = Date.UTC(2026, 9, 10, 12) + +describe('demo biometrics.compareToWatch', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('reports the finished nights of the last 14 days, oldest first, with a pod that reads a little high', async () => { + const out = await required(biometrics.compareToWatch)({ side: 'left', minHours: 3 }) + const expected = demoNights('left') + .filter(n => n.leftBedAt !== null && n.enteredBedAt.getTime() >= NOW - 14 * DAY && n.sleepDurationSeconds >= 3 * 3600) + .map(n => n.id) + .reverse() + expect(out.nights.map(n => n.sleepRecordId)).toEqual(expected) + expect(out.nights.length).toBeGreaterThan(5) + expect(out.summary.nights).toBe(out.nights.length) + expect(out.summary.heartRate.n).toBe(out.nights.reduce((s, n) => s + n.heartRate.n, 0)) + expect(out.summary.heartRate.bias).toBeGreaterThan(0) + expect(out.summary.breathingRate.bias).toBeGreaterThan(0) + expect(out.summary.hrv.watchSamples).toBe(2 * out.nights.length) + expect(out.summary.stages?.epochs).toBeGreaterThan(0) + expect(out.hrvNote).toContain('SDNN') + // Deterministic across calls, like the rest of the demo history. + expect(await required(biometrics.compareToWatch)({ side: 'left', minHours: 3 })).toEqual(out) + }) + + it('rejects a reversed window and one over 31 days', async () => { + await expect(async () => required(biometrics.compareToWatch)({ side: 'left', startDate: new Date(NOW), endDate: new Date(NOW - 1000), minHours: 3 })) + .rejects.toThrow(/startDate must be before or equal to endDate/) + await expect(async () => required(biometrics.compareToWatch)({ side: 'left', startDate: new Date(NOW - 32 * DAY), endDate: new Date(NOW), minHours: 3 })) + .rejects.toThrow(/at most 31 days/) + }) +}) diff --git a/src/mcp/README.md b/src/mcp/README.md index a0bc4217..1ddf985b 100644 --- a/src/mcp/README.md +++ b/src/mcp/README.md @@ -51,6 +51,7 @@ claude mcp add --transport http sleepypod http://192.168.1.88:3000/api/mcp | "How are my stages classified?" / "Do I have enough watch data to tune?" | `get_sleep_stage_rules` | `sleepStages.getClassifier`, `biometrics.getReferenceStages`, `getSleepRecords` | | "Would the movement-only preset match my watch better?" / "Try deep below 0.90" (dry run) | [`preview_sleep_stage_rules`](#sleep-stage-tuning) | `sleepStages.getClassifier`, `previewClassifier` | | "Apply that ruleset" / "Go back to the default rules" | [`set_sleep_stage_rules`](#sleep-stage-tuning) | `sleepStages.getClassifier`, `setClassifier` | +| "How far is the pod from my watch?" / "Did that change bring HR closer?" | [`compare_vitals_to_watch`](#watch-deviation-report) | `biometrics.compareToWatch` | ### Control tonight @@ -268,6 +269,23 @@ means `reset: true`). Pass the preview's `candidateRuleset` as `ruleset` to save previewed; overrides are re-resolved against the rules saved at save time. A `CONFLICT` means another save landed first: re-read with `get_sleep_stage_rules` and retry. +### Watch deviation report + +`compare_vitals_to_watch` (`biometrics.compareToWatch`, `GET /api/biometrics/watch-deviation`) is +read-only. For one `side` it takes sleep records of at least `minHours` (default 3) entered between +`startDate` and `endDate` (default the last 14 days, at most 31) and reports, per night and pooled +over every pair of all nights, how far the pod sits from imported Apple Watch data. Deviation is +pod − Watch. HR pairs each Watch sample with the nearest pod reading within ±60 s (the same +pairing as `heartRateAgreement`), breathing within ±150 s, and HRV compares Watch SDNN with the +mean pod HRV within ±5 min. Each metric has `n`, `watchSamples`, `coverage`, `mae`, `bias`, `p90` +(absolute error) and Pearson `r`, null without data. `stages` re-runs the side's current classifier +on the stored vitals and reports `epochs`, `accuracy`, `kappa`, `recall` and pod vs Watch minutes +per stage over the scored epochs. `hrvNote` explains that the pod's HRV is an IBI-variability index, +not SDNN, so HRV deviation is not like-for-like yet. + +Run it before and after a profile or stage ruleset change. Stages re-score immediately; stored HR, +breathing and HRV only change for nights recorded after a profile takes effect. + ### Sleep curves Every recurring curve lives in the schedule library (`curve_templates` table, `curveTemplates` router): a diff --git a/src/mcp/tests/server.test.ts b/src/mcp/tests/server.test.ts index f3013ced..f83f4ae6 100644 --- a/src/mcp/tests/server.test.ts +++ b/src/mcp/tests/server.test.ts @@ -25,7 +25,7 @@ const callerMock = vi.hoisted(() => { biometrics: { getLatestSleep: fn(), getSleepRecord: fn(), getSleepRecords: fn(), getSleepStages: fn(), getVitalsSummary: fn(), getMovementSummary: fn(), getVitalsBaseline: fn(), getOccupancy: fn(), importReferenceVitals: fn(), importReferenceSamples: fn(), importReferenceStages: fn(), - getReferenceStages: fn(), getReferenceVitals: fn(), getVitals: fn(), + getReferenceStages: fn(), getReferenceVitals: fn(), getVitals: fn(), compareToWatch: fn(), }, sleepStages: { getClassifier: fn(), previewClassifier: fn(), setClassifier: fn() }, environment: { @@ -117,6 +117,7 @@ describe('sleepypod MCP server', () => { 'apply_biometric_profile', 'apply_sleep_curve', 'backtest_biometrics', + 'compare_vitals_to_watch', 'diagnose_pod', 'export_calibration_dataset', 'get_automations', @@ -1608,6 +1609,41 @@ describe('sleep stage tuning tools', () => { expect(good).toMatchObject({ referenceCoverage: { heartRateAgreement: { correlation: 1, meanAbsErrorBpm: 1 } }, hint: null }) }) + it('compare_vitals_to_watch passes the window through read-only and hints when there is nothing to compare', async () => { + const empty = { n: 0, watchSamples: 0, coverage: null, mae: null, bias: null, p90: null, r: null } + const hr = { n: 80, watchSamples: 85, coverage: 0.941, mae: 2.1, bias: 0.6, p90: 4, r: 0.63 } + const report = { + side: 'left', startDate: new Date('2026-10-01T00:00:00Z'), endDate: new Date('2026-10-15T00:00:00Z'), minHours: 4, + classifier: { isDefault: true, source: 'default', generation: null, stageHash: 'h' }, hrvNote: 'not SDNN', + nights: [{ sleepRecordId: 42, enteredBedAt, leftBedAt, hours: 8, heartRate: hr, breathingRate: empty, hrv: empty, stages: null }], + summary: { nights: 1, heartRate: hr, breathingRate: empty, hrv: empty, stages: null }, + } + callerMock.biometrics.compareToWatch.mockResolvedValue(report) + const result = parse(await call('compare_vitals_to_watch', { side: 'left', startDate: '2026-10-01', endDate: '2026-10-15', minHours: 4 })) + expect(result).toMatchObject({ side: 'left', hrvNote: 'not SDNN', summary: { heartRate: hr }, nights: [{ sleepRecordId: 42 }], hint: null }) + expect(callerMock.biometrics.compareToWatch).toHaveBeenCalledWith({ + side: 'left', minHours: 4, startDate: new Date('2026-10-01'), endDate: new Date('2026-10-15T23:59:59.999Z'), + }) + // A datetime endDate is taken as given; only a date-only one covers its whole day. + await call('compare_vitals_to_watch', { side: 'left', endDate: '2026-10-15T06:00:00Z' }) + expect(callerMock.biometrics.compareToWatch).toHaveBeenLastCalledWith({ side: 'left', minHours: 3, endDate: new Date('2026-10-15T06:00:00Z') }) + + callerMock.biometrics.compareToWatch.mockResolvedValue({ ...report, nights: [{ ...report.nights[0], heartRate: empty }], summary: { ...report.summary, heartRate: empty } }) + expect(parse(await call('compare_vitals_to_watch', { side: 'left' }))).toMatchObject({ hint: expect.stringContaining('import_apple_watch_data') }) + expect(callerMock.biometrics.compareToWatch).toHaveBeenLastCalledWith({ side: 'left', minHours: 3 }) + + callerMock.biometrics.compareToWatch.mockResolvedValue({ ...report, nights: [], summary: { ...report.summary, nights: 0, heartRate: empty } }) + expect(parse(await call('compare_vitals_to_watch', { side: 'right', minHours: 6 }))).toMatchObject({ hint: expect.stringContaining('at least 6 h') }) + + const client = await connect() + try { + const tool = (await client.listTools()).tools.find(t => t.name === 'compare_vitals_to_watch') + expect(tool?.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false }) + expect(tool?.description).toContain('set_sleep_stage_rules') + } + finally { await client.close() } + }) + it('get_sleep_stage_rules clips segment durations to the coverage window', async () => { const straddling = { ...segment, start: new Date(Date.now() - 20 * 24 * 3_600_000), end: new Date(Date.now() - 14 * 24 * 3_600_000 + 10 * 60_000) } callerMock.biometrics.getReferenceStages.mockResolvedValue([straddling]) diff --git a/src/mcp/tools/sleep.ts b/src/mcp/tools/sleep.ts index 623b3c22..afc94850 100644 --- a/src/mcp/tools/sleep.ts +++ b/src/mcp/tools/sleep.ts @@ -9,6 +9,7 @@ import { days, isoDate, optionalSide, side } from '@/src/mcp/schemas' import { STAGE_RULESET_PRESETS, stageRulesetSchema, type StageClassifier, type StageRule, type StageRuleset } from '@/src/lib/sleep-stage-rules' import { scoreAgainstReference, type SleepStage, type StageAgreement } from '@/src/lib/sleep-stages' import { REFERENCE_SAMPLE_RANGE } from '@/src/server/routers/biometrics' +import { nearestWithin, WATCH_PAIRING_WINDOW_MS } from '@/src/biometrics/references/watch-deviation' const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } @@ -18,8 +19,6 @@ const COVERAGE_DAYS = 14 const SEGMENT_LIMIT = 20000 const RECORD_LIMIT = 100 const VITALS_LIMIT = 100000 -/** Pod and watch heart-rate readings this close in time are compared. */ -const HR_MATCH_S = 60 /** Below this pod-vs-watch heart-rate correlation, HR-ratio rules classify noise. */ const HR_WEAK_CORRELATION = 0.5 @@ -47,6 +46,11 @@ const overrides = z.array(z.object({ })).min(1).max(32).optional() .describe('Threshold changes applied to the side\'s saved rules. Everything else in the ruleset stays as saved.') +/** A date-only "YYYY-MM-DD" end bound covers that whole UTC day; datetimes are taken as given. */ +function endOfDateOnly(value: string): Date { + return /^\d{4}-\d{2}-\d{2}$/.test(value) ? new Date(Date.parse(value) + DAY_MS - 1) : new Date(value) +} + export function registerSleepTools(server: McpServer) { server.registerTool('import_apple_watch_data', { title: 'Import Apple Watch heart rate, breathing rate, HRV and sleep stages', @@ -245,6 +249,47 @@ export function registerSleepTools(server: McpServer) { }) })) + server.registerTool('compare_vitals_to_watch', { + title: 'Pod vs Apple Watch deviation report', + description: + 'Read-only: how far the pod\'s heart rate, breathing rate, HRV and sleep stages sit from imported Apple Watch data, per night ' + + 'and as a summary pooled over every pair of all nights. Deviation is pod − Watch: per metric n, watchSamples, coverage ' + + '(paired / Watch samples), mae, bias (positive = pod reads high), p90 absolute error and Pearson r, all null without data. ' + + `Pairing: HR nearest pod reading within ±${WATCH_PAIRING_WINDOW_MS.heartRate / 1000} s (as get_sleep_stage_rules' heartRateAgreement), ` + + `breathing within ±${WATCH_PAIRING_WINDOW_MS.breathingRate / 1000} s, HRV = mean pod HRV within ±${WATCH_PAIRING_WINDOW_MS.hrv / 60_000} min vs Watch SDNN ` + + '(not like-for-like yet; see hrvNote). Stages re-run the side\'s current classifier on stored vitals and report epochs, accuracy, ' + + 'kappa, per-stage recall and pod vs Watch minutes. The goal is near-zero deviation: lower mae and |bias|, higher r, coverage and kappa. ' + + 'Use it before and after any biometric profile change (apply_biometric_profile) or stage ruleset change (set_sleep_stage_rules) ' + + 'to see whether deviation went down. Stages re-score immediately; stored HR, breathing and HRV only change for nights recorded ' + + 'after a profile takes effect, so compare those nights. Nights are picked by bed-entry time; a date-only startDate/endDate is ' + + 'a whole UTC calendar day, so endDate "2026-10-18" includes records entered any time that day. Default window is the last 14 days ' + + '(at most 31); only sleep records of at least minHours count.', + inputSchema: { + side, + startDate: isoDate, + endDate: isoDate, + minHours: z.number().min(0).max(24).default(3).describe('Shortest sleep record that counts as a night, in hours (default 3).'), + }, + annotations: READ_ONLY, + }, async ({ side: s, startDate, endDate, minHours }) => runTool(async () => { + const report = await getCaller().biometrics.compareToWatch({ + side: s, + minHours, + ...(startDate && { startDate: new Date(startDate) }), + ...(endDate && { endDate: endOfDateOnly(endDate) }), + }) + const { summary } = report + const hasWatch = summary.heartRate.watchSamples + summary.breathingRate.watchSamples + summary.hrv.watchSamples > 0 || summary.stages !== null + return jsonResult({ + ...report, + hint: report.nights.length === 0 + ? `No sleep records of at least ${minHours} h in this window. Widen startDate/endDate or lower minHours.` + : hasWatch + ? null + : 'No Apple Watch data overlaps these nights. Run import_apple_watch_data (or POST /api/biometrics/watch-upload) first.', + }) + })) + server.registerTool('set_sleep_stage_rules', { title: 'Save a sleep stage ruleset', description: @@ -392,21 +437,16 @@ async function referenceCoverage(sleeper: 'left' | 'right') { /** * How well the pod's heart-rate estimate tracks the watch: Pearson correlation - * and mean absolute error over readings within HR_MATCH_S of each other. Null + * and mean absolute error, pairing each watch sample with the nearest pod + * reading the way compare_vitals_to_watch does (WATCH_PAIRING_WINDOW_MS). Null * without enough pairs. Stage rules on hrRatio only work when this is high. */ function heartRateAgreement(pod: { timestamp: Date, heartRate: number | null }[], watch: { timestamp: Date, heartRate: number }[]) { - const podSorted = pod.filter((v): v is { timestamp: Date, heartRate: number } => v.heartRate != null).sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()) + const podSeries = pod.map(v => ({ t: v.timestamp.getTime(), v: v.heartRate })).sort((a, b) => a.t - b.t) const pairs: [number, number][] = [] - let i = 0 for (const w of [...watch].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime())) { - const t = w.timestamp.getTime() - while (i < podSorted.length && podSorted[i].timestamp.getTime() < t - HR_MATCH_S * 1000) i++ - let best: { timestamp: Date, heartRate: number } | null = null - for (let j = i; j < podSorted.length && podSorted[j].timestamp.getTime() <= t + HR_MATCH_S * 1000; j++) { - if (!best || Math.abs(podSorted[j].timestamp.getTime() - t) < Math.abs(best.timestamp.getTime() - t)) best = podSorted[j] - } - if (best) pairs.push([w.heartRate, best.heartRate]) + const best = nearestWithin(podSeries, w.timestamp.getTime(), WATCH_PAIRING_WINDOW_MS.heartRate) + if (best !== null) pairs.push([w.heartRate, best]) } if (pairs.length < 10) return null const n = pairs.length diff --git a/src/server/routers/biometrics.ts b/src/server/routers/biometrics.ts index bd837cf4..c8bce236 100644 --- a/src/server/routers/biometrics.ts +++ b/src/server/routers/biometrics.ts @@ -9,7 +9,7 @@ import { deviceSettings } from '@/src/db/schema' import { eq, and, gt, gte, lt, lte, desc, asc, avg, min, max, count, sql, getTableColumns } from 'drizzle-orm' import { sideSchema, idSchema, validateDateRange } from '@/src/server/validation-schemas' import { listRawFiles } from './raw' -import { loadStageView, loadStageInputs } from './sleepStages' +import { loadReference, loadStageView, loadStageInputs } from './sleepStages' import { stageViewSchema, type StageView } from '@/src/biometrics/profiles/stage-view' import { classifySleepStages, @@ -24,6 +24,15 @@ import { pickMinBucketNonStillEpochs, } from '@/src/lib/movement' import { recordLegacyReferenceImport } from '@/src/biometrics/references/legacy' +import { + compareNightsToWatch, + deviationNightSchema, + deviationSummarySchema, + HRV_NOTE, + WATCH_PAIRING_WINDOW_MS, + type DeviationNightInput, +} from '@/src/biometrics/references/watch-deviation' +import type { StageRuleset } from '@/src/lib/sleep-stage-rules' import { getOccupancy } from '@/src/lib/occupancy' /** @@ -80,6 +89,51 @@ function batchOverlaps(segments: StageSegment[]): string[] { /** Plausible Apple Watch values per reference_samples metric, in breaths/min and ms. */ export const REFERENCE_SAMPLE_RANGE = { breathing_rate: [4, 60], hrv_sdnn: [1, 500] } as const +const DAY_MS = 24 * 60 * 60 * 1000 +const WATCH_DEVIATION_DEFAULT_DAYS = 14 +const WATCH_DEVIATION_MAX_DAYS = 31 + +/** + * One sleep record's pod series, Watch samples and re-classified stages for + * compareToWatch. Pod vitals are read past the record's edges by the widest + * pairing window so a Watch sample near bed entry or exit can still pair; the + * classifier and the Watch samples stay inside the record. + */ +async function loadDeviationNight(side: 'left' | 'right', record: typeof sleepRecords.$inferSelect, ruleset: StageRuleset): Promise { + const start = record.enteredBedAt + const end = record.leftBedAt + const pad = WATCH_PAIRING_WINDOW_MS.hrv + const { vitalsData, movementData, calibrationQuality } = await loadStageInputs(side, new Date(start.getTime() - pad), new Date(end.getTime() + pad)) + const reference = await loadReference(side, start, end) + const watchHeartRate = await biometricsDb + .select({ timestamp: referenceVitals.timestamp, value: referenceVitals.heartRate }) + .from(referenceVitals) + .where(and(eq(referenceVitals.side, side), gte(referenceVitals.timestamp, start), lte(referenceVitals.timestamp, end))) + .orderBy(asc(referenceVitals.timestamp)) + const watchSamples = await biometricsDb + .select({ metric: referenceSamples.metric, timestamp: referenceSamples.timestamp, value: referenceSamples.value }) + .from(referenceSamples) + .where(and(eq(referenceSamples.side, side), gte(referenceSamples.timestamp, start), lte(referenceSamples.timestamp, end))) + .orderBy(asc(referenceSamples.timestamp)) + + const inRecord = (rows: T[]) => rows.filter(r => r.timestamp >= start && r.timestamp <= end) + const series = (key: 'heartRate' | 'breathingRate' | 'hrv') => vitalsData.map(v => ({ t: v.timestamp.getTime(), v: v[key] })) + const watch = (rows: { timestamp: Date, value: number }[]) => rows.map(r => ({ t: r.timestamp.getTime(), v: r.value })) + return { + sleepRecordId: record.id, + enteredBedAt: start, + leftBedAt: end, + pod: { heartRate: series('heartRate'), breathingRate: series('breathingRate'), hrv: series('hrv') }, + watch: { + heartRate: watch(watchHeartRate), + breathingRate: watch(watchSamples.filter(s => s.metric === 'breathing_rate')), + hrvSdnn: watch(watchSamples.filter(s => s.metric === 'hrv_sdnn')), + }, + epochs: classifySleepStages(inRecord(vitalsData), inRecord(movementData), calibrationQuality, ruleset), + reference, + } +} + /** * Biometrics router - query sleep and health data collected by Pod sensors. * @@ -1272,6 +1326,97 @@ export const biometricsRouter = router({ } }), + /** + * How far the pod's vitals and sleep stages sit from the Apple Watch, per + * night and pooled over the window: HR (each Watch sample vs the nearest pod + * reading within ±60 s), breathing rate (±150 s) and HRV (Watch SDNN vs the + * mean pod HRV within ±5 min), as n, coverage, MAE, bias (pod − Watch), p90 + * absolute error and Pearson r; stages by re-running the side's effective + * classifier on the stored vitals and movement. Read-only. Nights are sleep + * records of at least `minHours` entered in [startDate, endDate], oldest + * first; the window defaults to the last 14 days and may span at most 31. + */ + compareToWatch: publicProcedure + .meta({ openapi: { method: 'GET', path: '/biometrics/watch-deviation', protect: false, tags: ['Biometrics'] } }) + .input( + z + .object({ + side: sideSchema, + startDate: z.date().optional(), + endDate: z.date().optional(), + minHours: z.number().min(0).max(24).default(3), + }) + .strict() + ) + .output(z.object({ + side: sideSchema, + startDate: z.date(), + endDate: z.date(), + minHours: z.number(), + /** The classifier the stages were re-run with. */ + classifier: z.object({ + isDefault: z.boolean(), + source: stageViewSchema.shape.provenance.shape.source, + generation: z.number().int().nullable(), + stageHash: z.string(), + }), + hrvNote: z.string(), + nights: z.array(deviationNightSchema), + summary: deviationSummarySchema, + })) + .query(async ({ input }) => { + const endDate = input.endDate ?? new Date() + const startDate = input.startDate ?? new Date(endDate.getTime() - WATCH_DEVIATION_DEFAULT_DAYS * DAY_MS) + if (!validateDateRange(startDate, endDate)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'startDate must be before or equal to endDate' }) + } + if (endDate.getTime() - startDate.getTime() > WATCH_DEVIATION_MAX_DAYS * DAY_MS) { + throw new TRPCError({ code: 'BAD_REQUEST', message: `Select at most ${WATCH_DEVIATION_MAX_DAYS} days` }) + } + + try { + const view = loadStageView(input.side) + const records = (await biometricsDb + .select() + .from(sleepRecords) + .where( + and( + eq(sleepRecords.side, input.side), + gte(sleepRecords.enteredBedAt, startDate), + lte(sleepRecords.enteredBedAt, endDate) + ) + ) + .orderBy(asc(sleepRecords.enteredBedAt))) + .filter(r => r.leftBedAt.getTime() - r.enteredBedAt.getTime() >= input.minHours * 3_600_000) + + const nights: DeviationNightInput[] = [] + for (const record of records) { + nights.push(await loadDeviationNight(input.side, record, view.classifier.ruleset)) + } + return { + side: input.side, + startDate, + endDate, + minHours: input.minHours, + classifier: { + isDefault: view.isDefault, + source: view.provenance.source, + generation: view.provenance.generation, + stageHash: view.provenance.stageHash, + }, + hrvNote: HRV_NOTE, + ...compareNightsToWatch(nights), + } + } + catch (error) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to compare with the Watch: ${error instanceof Error ? error.message : 'Unknown error'}`, + cause: error, + }) + } + }), + /** * Returns the count of RAW biometrics files and total size. * RAW files are dual-channel (left + right interleaved), so left = right = total count. diff --git a/src/server/routers/sleepStages.ts b/src/server/routers/sleepStages.ts index 2fcf3732..955598b1 100644 --- a/src/server/routers/sleepStages.ts +++ b/src/server/routers/sleepStages.ts @@ -81,7 +81,7 @@ export async function loadStageInputs(side: Side, windowStart: Date, windowEnd: return { vitalsData, movementData, calibrationQuality: piezoCalibrationQuality(piezoProfile) } } -async function loadReference(side: Side, windowStart: Date, windowEnd: Date): Promise { +export async function loadReference(side: Side, windowStart: Date, windowEnd: Date): Promise { const rows = await biometricsDb .select() .from(referenceStages) diff --git a/src/server/routers/tests/biometrics.compareToWatch.test.ts b/src/server/routers/tests/biometrics.compareToWatch.test.ts new file mode 100644 index 00000000..6f0fb974 --- /dev/null +++ b/src/server/routers/tests/biometrics.compareToWatch.test.ts @@ -0,0 +1,174 @@ +/** + * compareToWatch against real in-memory sleepypod.db and biometrics.db with + * migrations applied: night selection, pairing across the record's edges, + * stage re-classification with the side's classifier, pooling and the + * window bounds. + */ + +import path from 'node:path' +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import { migrate } from 'drizzle-orm/better-sqlite3/migrator' +import { generateOpenApiDocument } from 'trpc-to-openapi' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as mainSchema from '@/src/db/schema' +import * as bioSchema from '@/src/db/biometrics-schema' +import { HRV_NOTE } from '@/src/biometrics/references/watch-deviation' +import { MOVEMENT_ONLY_STAGE_RULESET } from '@/src/lib/sleep-stage-rules' + +const dbHolder = vi.hoisted(() => ({ main: null as unknown, bio: null as unknown })) + +vi.mock('@/src/db', () => ({ + get db() { return dbHolder.main }, + get biometricsDb() { return dbHolder.bio }, +})) +vi.mock('@/src/server/routers/raw', () => ({ listRawFiles: vi.fn(async () => []) })) +vi.mock('@/src/lib/occupancy', () => ({ getOccupancy: vi.fn() })) + +const { biometricsRouter } = await import('@/src/server/routers/biometrics') +const caller = biometricsRouter.createCaller({}) + +const MIN = 60_000 +const HOUR = 60 * MIN +// 2026-10-10 23:00:00 UTC +const BED = new Date(Date.UTC(2026, 9, 10, 23)) +const at = (minutes: number, base = BED) => new Date(base.getTime() + minutes * MIN) +const WINDOW = { startDate: at(-60), endDate: at(3 * 24 * 60) } + +describe('biometrics.compareToWatch', () => { + let mainRaw: Database.Database + let bioRaw: Database.Database + let bio: ReturnType> + + beforeEach(() => { + mainRaw = new Database(':memory:') + const main = drizzle(mainRaw, { schema: mainSchema }) + migrate(main, { migrationsFolder: path.resolve(process.cwd(), 'src/db/migrations') }) + main.insert(mainSchema.sideSettings).values([{ side: 'left', name: 'Left' }, { side: 'right', name: 'Right' }]).run() + dbHolder.main = main + + bioRaw = new Database(':memory:') + bio = drizzle(bioRaw, { schema: bioSchema }) + migrate(bio, { migrationsFolder: path.resolve(process.cwd(), 'src/db/biometrics-migrations') }) + dbHolder.bio = bio + }) + + afterEach(() => { + mainRaw.close() + bioRaw.close() + }) + + /** A sleep record with pod vitals and movement every 5 minutes; HR 60, breathing 15, HRV 50. */ + function seedNight(base: Date, hours: number, side: 'left' | 'right' = 'left') { + const [record] = bio.insert(bioSchema.sleepRecords).values({ + side, enteredBedAt: base, leftBedAt: at(hours * 60, base), sleepDurationSeconds: hours * 3600, + timesExitedBed: 0, presentIntervals: [], notPresentIntervals: [], + }).returning().all() + const slots = Array.from({ length: hours * 12 }, (_, i) => at(i * 5, base)) + bio.insert(bioSchema.vitals).values(slots.map(timestamp => ({ side, timestamp, heartRate: 60, hrv: 50, breathingRate: 15 }))).run() + bio.insert(bioSchema.movement).values(slots.map(timestamp => ({ side, timestamp, totalMovement: 10 }))).run() + return record + } + + function seedWatchHr(side: 'left' | 'right', samples: Array<[Date, number]>) { + bio.insert(bioSchema.referenceVitals).values(samples.map(([timestamp, heartRate]) => ({ side, source: 'apple_watch' as const, timestamp, heartRate }))).run() + } + + function seedWatchSamples(metric: 'breathing_rate' | 'hrv_sdnn', samples: Array<[Date, number]>) { + bio.insert(bioSchema.referenceSamples).values(samples.map(([timestamp, value]) => ({ side: 'left' as const, source: 'apple_watch' as const, metric, timestamp, value }))).run() + } + + it('reports deviation per night with pod − Watch pairing and the default classifier\'s stages', async () => { + const record = seedNight(BED, 4) + // 30 s after a pod reading pairs (HR 60 vs 58); 2 min 30 s from the nearest reading does not. + seedWatchHr('left', [[at(0.5), 58], [at(10.5), 64], [at(17.5), 70]]) + seedWatchSamples('breathing_rate', [[at(2), 13], [at(60), 14]]) + seedWatchSamples('hrv_sdnn', [[at(120), 40]]) + bio.insert(bioSchema.referenceStages).values({ side: 'left', source: 'apple_watch', start: BED, end: at(4 * 60), stage: 'light' }).run() + + const out = await caller.compareToWatch({ side: 'left', ...WINDOW }) + expect(out).toMatchObject({ side: 'left', minHours: 3, hrvNote: HRV_NOTE, classifier: { isDefault: true, source: 'default', generation: null } }) + expect(out.nights).toHaveLength(1) + const [night] = out.nights + expect(night).toMatchObject({ sleepRecordId: record.id, hours: 4, enteredBedAt: BED }) + // HR pairs: (60, 58) and (60, 64); 17.5 min is 2.5 min from the nearest pod reading. + expect(night.heartRate).toEqual({ n: 2, watchSamples: 3, coverage: 0.667, mae: 3, bias: -1, p90: 4, r: null }) + expect(night.breathingRate).toEqual({ n: 2, watchSamples: 2, coverage: 1, mae: 1.5, bias: 1.5, p90: 2, r: null }) + expect(night.hrv).toEqual({ n: 1, watchSamples: 1, coverage: 1, mae: 10, bias: 10, p90: 10, r: null }) + expect(night.stages).toMatchObject({ epochs: 48, recall: { light: expect.any(Number), wake: null, deep: null, rem: null } }) + expect(night.stages?.watchMinutes).toEqual({ wake: 0, light: 240, deep: 0, rem: 0 }) + const pod = night.stages?.podMinutes ?? { wake: 0, light: 0, deep: 0, rem: 0 } + expect(pod.wake + pod.light + pod.deep + pod.rem).toBe(240) + expect(out.summary).toMatchObject({ nights: 1, heartRate: night.heartRate, stages: night.stages }) + }) + + it('pairs a Watch sample at bed entry with a pod reading just before it, but only scores Watch samples inside the record', async () => { + seedNight(BED, 3) + bioRaw.prepare('DELETE FROM vitals WHERE timestamp = ?').run(BED.getTime() / 1000) + bio.insert(bioSchema.vitals).values({ side: 'left', timestamp: at(-20 / 60), heartRate: 70, hrv: 50, breathingRate: 15 }).run() + seedWatchHr('left', [[at(-0.25), 99], [at(0), 69]]) + const out = await caller.compareToWatch({ side: 'left', ...WINDOW }) + // The bed-entry sample pairs with the pod reading 20 s before the record (the next one is 5 min later); + // the sample 15 s before bed entry is outside the record and not scored. + expect(out.nights[0].heartRate).toMatchObject({ n: 1, watchSamples: 1, mae: 1, bias: 1 }) + }) + + it('keeps only this side\'s records of at least minHours entered in the window, oldest first, and pools the summary', async () => { + const first = seedNight(BED, 3) + const second = seedNight(at(24 * 60), 4) + seedNight(at(48 * 60), 1) + seedNight(at(30 * 24 * 60), 5) + seedNight(BED, 5, 'right') + seedWatchHr('left', [[at(5), 70]]) + seedWatchHr('left', [[at(5, second.enteredBedAt), 62], [at(10, second.enteredBedAt), 62], [at(15, second.enteredBedAt), 62]]) + seedWatchHr('right', [[at(5), 99]]) + + const out = await caller.compareToWatch({ side: 'left', ...WINDOW }) + expect(out.nights.map(n => n.sleepRecordId)).toEqual([first.id, second.id]) + expect(out.nights.map(n => n.heartRate.mae)).toEqual([10, 2]) + // Pooled over the 4 pairs: (10 + 2 + 2 + 2) / 4, not the mean of 10 and 2. + expect(out.summary).toMatchObject({ nights: 2, heartRate: { n: 4, watchSamples: 4, mae: 4, bias: -4 } }) + + const short = await caller.compareToWatch({ side: 'left', ...WINDOW, minHours: 1 }) + expect(short.nights).toHaveLength(3) + expect(short.nights[2]).toMatchObject({ hours: 1, heartRate: { watchSamples: 0, mae: null }, stages: null }) + }) + + it('re-runs the side\'s effective classifier and reports which one', async () => { + seedNight(BED, 3) + mainRaw.prepare('UPDATE side_settings SET stage_classifier = ? WHERE side = ?') + .run(JSON.stringify({ engine: 'rules', ruleset: MOVEMENT_ONLY_STAGE_RULESET }), 'left') + bio.insert(bioSchema.referenceStages).values({ side: 'left', source: 'apple_watch', start: BED, end: at(3 * 60), stage: 'light' }).run() + const out = await caller.compareToWatch({ side: 'left', ...WINDOW }) + expect(out.classifier).toMatchObject({ isDefault: false, source: 'legacy' }) + expect(out.nights[0].stages).toMatchObject({ epochs: 36, accuracy: 1, podMinutes: { light: 180 } }) + }) + + it('returns an empty report without nights and defaults to the last 14 days', async () => { + seedNight(at(-20 * 24 * 60, new Date()), 4) + const out = await caller.compareToWatch({ side: 'left' }) + expect(out.nights).toEqual([]) + expect(out.summary).toMatchObject({ nights: 0, heartRate: { n: 0, coverage: null, mae: null }, stages: null }) + expect(out.endDate.getTime() - out.startDate.getTime()).toBe(14 * 24 * HOUR) + }) + + it('rejects a reversed window, a window over 31 days and a negative minHours', async () => { + await expect(caller.compareToWatch({ side: 'left', startDate: at(60), endDate: BED })).rejects.toMatchObject({ code: 'BAD_REQUEST' }) + await expect(caller.compareToWatch({ side: 'left', startDate: BED, endDate: at(32 * 24 * 60) })) + .rejects.toMatchObject({ code: 'BAD_REQUEST', message: 'Select at most 31 days' }) + await expect(caller.compareToWatch({ side: 'left', endDate: BED, startDate: at(-31 * 24 * 60) })).resolves.toMatchObject({ nights: [] }) + await expect(caller.compareToWatch({ side: 'left', minHours: -1 })).rejects.toThrow() + }) + + it('wraps database failures as INTERNAL_SERVER_ERROR', async () => { + bioRaw.exec('DROP TABLE reference_samples') + seedNight(BED, 3) + await expect(caller.compareToWatch({ side: 'left', ...WINDOW })) + .rejects.toMatchObject({ code: 'INTERNAL_SERVER_ERROR', message: expect.stringContaining('Failed to compare with the Watch') }) + }) + + it('publishes a GET REST contract', () => { + const document = generateOpenApiDocument(biometricsRouter, { title: 'Biometrics', version: '1.0.0', baseUrl: 'http://localhost/api' }) + expect(document.paths?.['/biometrics/watch-deviation']?.get).toBeDefined() + }) +})