diff --git a/src/lib/insights/derived/__tests__/baseline.test.ts b/src/lib/insights/derived/__tests__/baseline.test.ts index dd30f888f..0789542ee 100644 --- a/src/lib/insights/derived/__tests__/baseline.test.ts +++ b/src/lib/insights/derived/__tests__/baseline.test.ts @@ -70,6 +70,50 @@ describe("pure statistics", () => { expect(band!.k).toBe(3); }); + // A run of identical days has no dispersion at all: the bare MAD band + // collapsed to one value and every surface read "your usual range is + // 61–61 bpm", while a reading of 62 counted as outside it. + it("never collapses to a single value when the days are identical", () => { + const band = buildBaselineBand(Array(20).fill(61), "RESTING_HEART_RATE"); + expect(band).not.toBeNull(); + expect(band!.center).toBe(61); + expect(band!.low).toBeLessThan(61); + expect(band!.high).toBeGreaterThan(61); + // Distinct at the precision any surface prints a heart rate. + expect(Math.round(band!.low)).not.toBe(Math.round(band!.high)); + // An ordinary next-day reading sits inside the band, not above it. + expect(62).toBeLessThanOrEqual(band!.high); + // The dispersion itself is reported as it is. + expect(band!.spread).toBe(0); + }); + + it.each([ + ["RESTING_HEART_RATE", 61], + ["PULSE", 72], + ["HEART_RATE_VARIABILITY", 45], + ["RESPIRATORY_RATE", 14], + ["OXYGEN_SATURATION", 97], + ["BODY_TEMPERATURE", 36.6], + ["SKIN_TEMPERATURE", 34.1], + ["BLOOD_PRESSURE_SYS", 118], + ["BLOOD_PRESSURE_DIA", 76], + ["BLOOD_GLUCOSE", 95], + ["WEIGHT", 80], + ["VO2_MAX", 42], + ["ACTIVITY_STEPS", 8000], + ] as const)( + "gives %s a band that reads as a range at one decimal", + (type, value) => { + const band = buildBaselineBand(Array(10).fill(value), type); + expect(band!.low.toFixed(1)).not.toBe(band!.high.toFixed(1)); + }, + ); + + it("leaves a band wider than the floor exactly as the spread makes it", () => { + const band = buildBaselineBand([50, 52, 54, 56, 58], "PULSE", 3); + expect(band!.high - band!.center).toBeCloseTo(band!.spread, 10); + }); + it("parity: a composed-DAY-bucket series and the raw-DAY series yield the same band", () => { // Same per-day means whether they arrive as raw rows or DAY rollups. const perDayMeans = [60, 61, 62, 59, 63, 58, 64]; diff --git a/src/lib/insights/derived/baseline.ts b/src/lib/insights/derived/baseline.ts index a53f5675b..4aa3ea559 100644 --- a/src/lib/insights/derived/baseline.ts +++ b/src/lib/insights/derived/baseline.ts @@ -34,6 +34,7 @@ import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; import { toProfileSex } from "@/lib/profile/sex"; import { clampDerivedLowerBound, + minimumBandHalfWidth, isPlausibleMetricValue, plausibleMetricRange, } from "@/lib/measurements/value-domain"; @@ -70,12 +71,13 @@ export interface VitalsBaselineValue { /** Robust center (median of the per-day means). */ center: number; /** - * Band lower edge (center − k·MAD·scale), floored at 0 for a metric whose + * Band lower edge (center − k·MAD·scale, at least the metric's + * `minimumBandHalfWidth` below center), floored at 0 for a metric whose * plausibility domain forbids negative values. `spread` below stays the * unclamped dispersion, so a deviation-in-σ calculation is unaffected. */ low: number; - /** Band upper edge (center + k·MAD·scale). */ + /** Band upper edge (center + k·MAD·scale, with the same minimum width). */ high: number; /** The MAD-derived σ-equivalent spread (k applied; same units as the metric). */ spread: number; @@ -204,10 +206,15 @@ export function buildBaselineBand( const mad = medianAbsoluteDeviation(dayMeans); // Scale MAD to a σ-equivalent so the band reads like a robust ±kσ. const spread = k * mad * MAD_SIGMA_SCALE; + // The edges never sit closer than the measurement's own day-to-day wobble: + // a run of identical days has no dispersion, and a zero-width band read + // "61–61 bpm" and put the next ordinary reading outside it. `spread` stays + // the measured dispersion for anything that reasons in σ. + const halfWidth = Math.max(spread, minimumBandHalfWidth(type)); return { center, - low: clampDerivedLowerBound(type, center - spread), - high: center + spread, + low: clampDerivedLowerBound(type, center - halfWidth), + high: center + halfWidth, spread, sampleDays: dayMeans.length, k, diff --git a/src/lib/measurements/value-domain.ts b/src/lib/measurements/value-domain.ts index d6e485827..0f071e6f9 100644 --- a/src/lib/measurements/value-domain.ts +++ b/src/lib/measurements/value-domain.ts @@ -153,3 +153,35 @@ export function metricFractionDigits(type: string | null | undefined): number { if (type && INTEGER_VALUED_MEASUREMENT_TYPES.has(type)) return 0; return DEFAULT_METRIC_FRACTION_DIGITS; } + +/** + * The smallest half-width a personal band may have, in the metric's canonical + * unit: roughly the day-to-day wobble of the measurement itself. + * + * A median ± k·MAD band has no width at all when most days carry the same + * value (a device that reports a whole-number resting rate does this often), + * and then reads "your usual range is 61–61 bpm" while a reading of 62 counts + * as outside it. Below this width a band says nothing a single reading could + * not, so the band is widened to it. Metrics not listed take one step of + * their own display precision, which is the least that keeps the two edges + * apart on screen. + */ +const MIN_BAND_HALF_WIDTH: Readonly> = { + RESTING_HEART_RATE: 2, // bpm + PULSE: 3, // bpm + HEART_RATE_VARIABILITY: 3, // ms + RESPIRATORY_RATE: 0.5, // breaths/min + OXYGEN_SATURATION: 1, // % + BODY_TEMPERATURE: 0.2, // °C + SKIN_TEMPERATURE: 0.2, // °C + WRIST_TEMPERATURE: 0.2, // °C + BLOOD_PRESSURE_SYS: 4, // mmHg + BLOOD_PRESSURE_DIA: 3, // mmHg + BLOOD_GLUCOSE: 5, // mg/dL + WEIGHT: 0.3, // kg +}; + +export function minimumBandHalfWidth(type: string | null | undefined): number { + if (!type) return 0; + return MIN_BAND_HALF_WIDTH[type] ?? 10 ** -metricFractionDigits(type); +}