fix(insights): give a personal band a minimum width

When most days in the window carry the same value, the median ± k·MAD band
has no width: the coach strip read "Your usual range is 61–61 bpm" and the
next ordinary reading of 62 counted as outside it. The shared band builder
now keeps the edges at least the measurement's own day-to-day wobble apart
(per metric, in its canonical unit, falling back to one display step), so
every surface built on it reads a real range and classifies against it.
The reported spread stays the measured dispersion.
This commit is contained in:
Marc-André Bombeck committed 2026-10-05 12:40:55 +02:00
1 parent f5b63d33ab
commit 3381026acb
3 files changed
+87 -4

No files matched your search

@@ -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];
+11 -4
View File
@@ -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,
+32
View File
@@ -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<Record<string, number>> = {
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);
}