diff --git a/src/lib/insights/derived/__tests__/sleep-score.test.ts b/src/lib/insights/derived/__tests__/sleep-score.test.ts index 1dc05154a..335ceabae 100644 --- a/src/lib/insights/derived/__tests__/sleep-score.test.ts +++ b/src/lib/insights/derived/__tests__/sleep-score.test.ts @@ -15,6 +15,8 @@ import { scoreComposition, scoreConsistency, scoreTiming, + circularMinuteDistance, + circularMeanMinutes, type SleepSubScoreKey, } from "../sleep-score"; @@ -51,6 +53,15 @@ describe("pure sleep scorers", () => { expect(scoreEfficiency(420, 480)).toBe(88); }); + it("efficiency handles asleep > in-bed overlap explicitly, not via a blind clamp", () => { + // A small overshoot (≤ 5 %, overlapping HK stages) caps at the AASM + // ceiling of 100 rather than reporting > 100 %. + expect(scoreEfficiency(485, 480)).toBe(100); // ~101 % → 100 + // A gross overshoot means the in-bed denominator is not usable — drop the + // sub-score (null) rather than masking the data problem with a fake 100. + expect(scoreEfficiency(600, 480)).toBeNull(); // 125 % → unusable + }); + it("composition is null without a stage breakdown, 100 inside the band", () => { expect(scoreComposition(0, 0, 420, false)).toBeNull(); // REM 90 + Deep 60 = 150 of 420 ≈ 0.357 — inside [0.33, 0.48]. @@ -66,6 +77,39 @@ describe("pure sleep scorers", () => { expect(scoreTiming(180, null, 1)).toBeNull(); expect(scoreTiming(180, 180, 5)).toBe(100); }); + + it("circular distance wraps across midnight (mod 1440)", () => { + // 23:50 (1430) and 00:10 (10) are 20 min apart, not 1420. + expect(circularMinuteDistance(1430, 10)).toBe(20); + expect(circularMinuteDistance(10, 1430)).toBe(20); + // Within-day distance unchanged. + expect(circularMinuteDistance(100, 160)).toBe(60); + }); + + it("circular mean of midnight-straddling midpoints stays near midnight", () => { + // 23:50, 00:10, 00:00 cluster around midnight; a linear mean would + // collapse to ~08:00 (480). The circular mean stays near 0/1440. + const mean = circularMeanMinutes([1430, 10, 0])!; + const distFromMidnight = Math.min(mean, 1440 - mean); + expect(distFromMidnight).toBeLessThan(20); + }); + + it("consistency does NOT penalise a midnight-straddling sleeper", () => { + // Tight cluster around midnight: circular SD is small → high score. A + // linear SD would read a spurious ~24-h spread → 0. + const score = scoreConsistency([1430, 10, 0, 1435, 5])!; + expect(score).toBeGreaterThan(80); + }); + + it("timing uses circular distance across the wrap", () => { + // Night midpoint 00:05 (5) vs habitual 23:55 (1435) is 10 min off (NOT + // 1430), so the score stays high. A linear distance would read ~1430 → + // clamp to 0. + const wrapped = scoreTiming(5, 1435, 5)!; + expect(wrapped).toBeGreaterThan(85); + // 10 min off the habitual midpoint within the day scores identically. + expect(scoreTiming(100, 110, 5)).toBe(wrapped); + }); }); describe("blendSleepSubScores reweighting", () => { diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts index 600734e31..e3e3359ff 100644 --- a/src/lib/insights/derived/sleep-score.ts +++ b/src/lib/insights/derived/sleep-score.ts @@ -121,9 +121,20 @@ export function scoreSufficiency( } /** - * Efficiency: asleep ÷ in-bed, rescaled so the AASM 85 % "good" floor - * maps to ~85 and 100 % maps to 100. Null when in-bed is unknown (legacy - * nights with only asleep stages and no IN_BED row). + * Efficiency: asleep ÷ in-bed (AASM convention; ≥ 85 % is the clinical + * "good" floor). Null when in-bed is unknown (legacy nights with only asleep + * stages and no IN_BED row). + * + * Overlap handling: HealthKit can report ASLEEP-class stages whose summed + * minutes exceed the explicit IN_BED total when stages overlap (the watch + * writes per-sample stage rows and an IN_BED block that don't tile cleanly). + * Treating that as a real ratio yields efficiency > 100 %, which a blind + * `clamp(…, 100)` would silently swallow as a perfect night and hide the + * data problem. Instead, treat asleep > in-bed as the impossible-overlap case + * it is: cap efficiency at 100 % (asleep cannot exceed time in bed) but only + * when the overshoot is within a small tolerance — a gross overshoot signals + * a malformed night with no trustworthy in-bed denominator, so we drop the + * sub-score (null) rather than report a fabricated 100. */ export function scoreEfficiency( asleepMinutes: number, @@ -131,6 +142,14 @@ export function scoreEfficiency( ): number | null { if (inBedMinutes == null || inBedMinutes <= 0) return null; const pct = (asleepMinutes / inBedMinutes) * 100; + if (pct > 100) { + // A small overshoot (≤ 5 %) is rounding / stage-boundary noise — asleep + // cannot truly exceed in-bed, so cap at the AASM ceiling of 100. A larger + // overshoot means the in-bed total is not a usable denominator; the night + // has no honest efficiency, so it drops from the blend. + const EFFICIENCY_OVERSHOOT_TOLERANCE = 105; + return pct <= EFFICIENCY_OVERSHOOT_TOLERANCE ? 100 : null; + } return clamp100(pct); } @@ -158,27 +177,67 @@ export function scoreComposition( return clamp100(100 * (1 - dist / halfWidth)); } +const MINUTES_PER_DAY = 1440; + /** - * Consistency: lower SD of the sleep midpoint (minutes-of-day) across the - * window → higher score. A 90-min SD maps to 0, a 0-min SD to 100. Null - * below 3 nights (no variance signal). + * Shortest distance between two minutes-of-day on the 24-hour clock, treating + * the day as circular (mod 1440). A midpoint at 23:50 (1430) and one at 00:10 + * (10) are 20 minutes apart, NOT 1420 — the linear difference inflates timing + * SD and distance for sleepers whose midpoint straddles midnight (and on a DST + * shift). Pure. + */ +export function circularMinuteDistance(a: number, b: number): number { + const raw = Math.abs(a - b) % MINUTES_PER_DAY; + return Math.min(raw, MINUTES_PER_DAY - raw); +} + +/** + * Circular mean of minutes-of-day via the mean resultant vector (the standard + * directional-statistics circular mean) — averaging clock minutes linearly + * collapses a midnight-straddling cluster to noon. Returns null on an empty + * input or a near-zero resultant (no defined mean direction). Pure. + */ +export function circularMeanMinutes(minutes: number[]): number | null { + if (minutes.length === 0) return null; + let sumSin = 0; + let sumCos = 0; + for (const m of minutes) { + const angle = (m / MINUTES_PER_DAY) * 2 * Math.PI; + sumSin += Math.sin(angle); + sumCos += Math.cos(angle); + } + if (Math.abs(sumSin) < 1e-9 && Math.abs(sumCos) < 1e-9) return null; + let meanAngle = Math.atan2(sumSin, sumCos); + if (meanAngle < 0) meanAngle += 2 * Math.PI; + return (meanAngle / (2 * Math.PI)) * MINUTES_PER_DAY; +} + +/** + * Consistency: lower circular SD of the sleep midpoint (minutes-of-day) + * across the window → higher score. A 90-min SD maps to 0, a 0-min SD to 100. + * Null below 3 nights (no variance signal). The SD is computed against the + * CIRCULAR mean using circular distance so a midnight-straddling sleeper is + * not penalised for a spurious ~24-hour spread. */ export function scoreConsistency(midpointMinutes: number[]): number | null { if (midpointMinutes.length < 3) return null; - const mean = - midpointMinutes.reduce((s, v) => s + v, 0) / midpointMinutes.length; + const mean = circularMeanMinutes(midpointMinutes); + if (mean == null) return null; const variance = - midpointMinutes.reduce((s, v) => s + (v - mean) ** 2, 0) / - midpointMinutes.length; + midpointMinutes.reduce( + (s, v) => s + circularMinuteDistance(v, mean) ** 2, + 0, + ) / midpointMinutes.length; const sd = Math.sqrt(variance); const FULL_SCALE = 90; // minutes — beyond this the rhythm reads erratic. return clamp100(100 * (1 - Math.min(sd, FULL_SCALE) / FULL_SCALE)); } /** - * Timing: this night's midpoint vs the user's habitual midpoint (the - * window mean). On-target → 100; 90 min off → 0. Null when the habitual - * window is not yet established (< 3 nights). + * Timing: this night's midpoint vs the user's habitual midpoint (the window + * circular mean). On-target → 100; 90 min off → 0. Null when the habitual + * window is not yet established (< 3 nights). Uses circular distance so a + * midnight-straddling night is measured by its true clock offset. */ export function scoreTiming( nightMidpoint: number | null, @@ -189,7 +248,7 @@ export function scoreTiming( return null; } const FULL_SCALE = 90; - const dist = Math.abs(nightMidpoint - habitualMidpoint); + const dist = circularMinuteDistance(nightMidpoint, habitualMidpoint); return clamp100(100 * (1 - Math.min(dist, FULL_SCALE) / FULL_SCALE)); } @@ -406,10 +465,9 @@ export async function computeSleepScore( const midpoints = scorableNights .map((n) => n.midpoint) .filter((m): m is number => m != null); - const habitualMidpoint = - midpoints.length > 0 - ? midpoints.reduce((s, v) => s + v, 0) / midpoints.length - : null; + // Circular mean (mod 1440) so a midnight-straddling sleeper's habitual + // midpoint is not dragged to noon by a linear average. + const habitualMidpoint = circularMeanMinutes(midpoints); const needMinutes = sleepNeedMinutes(profile.ageYears);