mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(insights): circular sleep-midpoint distance and explicit efficiency overlap
The sleep-score consistency + timing sub-scores measured the midpoint as a linear minutes-of-day difference, so a sleeper whose midpoint straddles midnight (and any night across a DST shift) read a spurious ~24-hour spread that inflated the SD and timing distance. Measure the midpoint on the circular 24-hour clock (mod 1440) — a circular mean for the habitual midpoint and a circular distance for the SD and timing offset. Efficiency clamped asleep > in-bed (overlapping HealthKit stages) silently to 100, masking a data problem. Handle the overlap explicitly: a small overshoot caps at the AASM ceiling of 100 (asleep cannot exceed time in bed), a gross overshoot drops the sub-score because the in-bed denominator is not usable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
cd8e34dac7
commit
fc6924d52f
2 files changed
+120
-18
No files matched your search
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user