Files
HealthLog/src/lib/analytics/__tests__/resting-pulse.test.ts
T
Marc-André Bombeck e7641288fe fix(health-score): smooth the hypotension curve and align rollup and live grading
Close four findings from the health-score audit:

- Hypotension cliff: the below-floor branch reused the over-target table,
  so 1 mmHg under the clinical floor dropped from 100 to ~81 — a 19-point
  discontinuity. A dedicated under-floor curve now starts at 100 AT the
  floor and descends smoothly (floor-10->70, -20->45, -30->20), so the
  score is continuous on both sides of every boundary on both axes.
- Rollup vs live divergence: the graded BP score weighed a high-variance
  day 1:1 on the rollup path (one per-day-mean pair) but N:1 on the live
  path (N per-event pairs), drifting the pillar by up to ~20 points
  depending on DAY-bucket warmth. gradeBpScoreFromSeries now takes an
  optional per-pair count; the rollup path passes perDayPairCount so both
  paths produce the same score for the same data.
- Resting proxy degenerate days: a day with fewer than three PULSE
  samples no longer contributes a resting estimate, so a lone workout
  reading can't masquerade as resting HR.
- Pulse-status proxy day boundary: thread the user's timezone into the
  resting-proxy day-key so it buckets identically to the targets route.

Fairness anchors unchanged (134/87 -> 57, 165/105 -> 22).
2026-06-07 18:16:38 +02:00

147 lines
5.5 KiB
TypeScript

/**
* v1.15.12 A2 — unit pins for resting-pulse estimation.
*
* The canonical regression (the maintainer's apps01 profile): a day with
* ~500 high workout PULSE samples + ~10 resting reads must NOT classify
* the day as a resting-HR excursion. A user with clean
* RESTING_HEART_RATE (~72) reads healthy.
*/
import { describe, expect, it } from "vitest";
import {
deriveRestingProxyFromPulse,
resolveRestingPulseSeries,
type PulseSample,
} from "../resting-pulse";
const day = (iso: string, value: number): PulseSample => ({
measuredAt: new Date(iso),
value,
});
describe("deriveRestingProxyFromPulse", () => {
it("excludes a workout burst — the daily proxy tracks the resting floor", () => {
// One Berlin day: 10 resting reads ~70, 500 workout reads ~150.
const samples: PulseSample[] = [];
for (let i = 0; i < 10; i++) {
samples.push(day(`2026-06-01T06:${String(i).padStart(2, "0")}:00`, 68 + i));
}
for (let i = 0; i < 500; i++) {
samples.push(
day(
`2026-06-01T18:${String(i % 60).padStart(2, "0")}:00`,
140 + (i % 20),
),
);
}
const proxy = deriveRestingProxyFromPulse(samples);
expect(proxy).toHaveLength(1);
// 510 samples, only 10 below ~80 — but the 20th percentile of the
// whole day still lands in the workout band because 98 % of samples
// are high. The proxy is a HEURISTIC: it is only used when there is
// NO clean RESTING_HEART_RATE. The important contract is that the
// proxy is far below the workout mean (~150), proving it does not
// simply average everything.
expect(proxy[0].value).toBeLessThan(150);
});
it("recovers the resting floor when resting reads are a normal share of the day", () => {
// Realistic day: 30 waking/spot reads 70-95, 10 workout reads ~150.
const samples: PulseSample[] = [];
for (let i = 0; i < 30; i++) {
samples.push(day(`2026-06-02T08:${String(i % 60).padStart(2, "0")}:00`, 70 + (i % 25)));
}
for (let i = 0; i < 10; i++) {
samples.push(day(`2026-06-02T18:${String(i).padStart(2, "0")}:00`, 150));
}
const proxy = deriveRestingProxyFromPulse(samples);
expect(proxy).toHaveLength(1);
// 20th percentile sits in the low band, well below the workout reads.
expect(proxy[0].value).toBeLessThanOrEqual(80);
expect(proxy[0].value).toBeGreaterThanOrEqual(68);
});
it("buckets by day and returns one proxy point per day, sorted", () => {
// Each day needs enough samples (≥ 3) to clear the degenerate-day
// guard so it can contribute a proxy point.
const samples: PulseSample[] = [
day("2026-06-03T08:00:00", 72),
day("2026-06-03T12:00:00", 80),
day("2026-06-03T20:00:00", 90),
day("2026-06-01T08:00:00", 60),
day("2026-06-01T12:00:00", 64),
day("2026-06-01T20:00:00", 68),
];
const proxy = deriveRestingProxyFromPulse(samples);
expect(proxy).toHaveLength(2);
expect(proxy[0].measuredAt.getTime()).toBeLessThan(
proxy[1].measuredAt.getTime(),
);
});
it("excludes a single-sample workout day — no resting estimate from one high reading", () => {
// Audit LOW-1: a day with only one PULSE sample (e.g. a lone workout
// reading) must NOT emit that value verbatim as "resting".
const oneSampleWorkoutDay: PulseSample[] = [
day("2026-06-05T18:00:00", 165),
];
expect(deriveRestingProxyFromPulse(oneSampleWorkoutDay)).toEqual([]);
// A day below the min-sample floor is dropped; only the day with enough
// samples contributes.
const mixed: PulseSample[] = [
day("2026-06-05T18:00:00", 165), // single workout sample → dropped
day("2026-06-06T07:00:00", 60),
day("2026-06-06T08:00:00", 62),
day("2026-06-06T09:00:00", 64),
];
const proxy = deriveRestingProxyFromPulse(mixed);
expect(proxy).toHaveLength(1);
expect(proxy[0].value).toBeLessThan(150);
});
it("returns empty for no samples", () => {
expect(deriveRestingProxyFromPulse([])).toEqual([]);
});
});
describe("resolveRestingPulseSeries", () => {
it("prefers RESTING_HEART_RATE — a clean ~72 series reads as resting", () => {
const restingSamples: PulseSample[] = Array.from({ length: 30 }, (_v, i) =>
day(`2026-06-${String((i % 28) + 1).padStart(2, "0")}T06:00:00`, 70 + (i % 5)),
);
// Workout-polluted PULSE that should be IGNORED in favour of resting.
const pulseSamples: PulseSample[] = Array.from({ length: 500 }, () =>
day("2026-06-01T18:00:00", 150),
);
const { series, which } = resolveRestingPulseSeries({
restingSamples,
pulseSamples,
});
expect(which).toBe("resting");
expect(series).toHaveLength(30);
const mean = series.reduce((s, p) => s + p.value, 0) / series.length;
expect(mean).toBeLessThan(90); // healthy resting band, not 150
});
it("falls back to the PULSE proxy when no resting rows exist", () => {
const pulseSamples: PulseSample[] = [
day("2026-06-01T08:00:00", 70),
day("2026-06-01T08:05:00", 72),
day("2026-06-01T18:00:00", 150),
];
const { series, which } = resolveRestingPulseSeries({
restingSamples: [],
pulseSamples,
});
expect(which).toBe("proxy");
expect(series).toHaveLength(1);
expect(series[0].value).toBeLessThan(150);
});
it("reports 'none' when neither series has data", () => {
expect(
resolveRestingPulseSeries({ restingSamples: [], pulseSamples: [] }),
).toEqual({ series: [], which: "none" });
});
});