Files
HealthLog/src/lib/analytics/__tests__/resting-pulse.test.ts
T
Marc-André Bombeck 55c029daae fix(pulse): score resting heart rate, exclude workout samples from target
Every surface that applied a resting-pulse target judged the raw PULSE
series against a resting band. Apple Health maps all heart-rate samples
— including workout HR — onto PULSE, and the resting figure onto a
separate RESTING_HEART_RATE type. A single workout emits hundreds of
expected-high samples, so the resting-target evaluation read a large
share "outside target" and tanked the in-target % / consistency even
when the clean resting series averaged a healthy figure.

Judge the resting band against a resting series. A shared helper prefers
RESTING_HEART_RATE; when the user has none it derives a proxy from the
low percentile of each day's PULSE samples, so a dense workout burst
sitting in the upper tail cannot drag the resting estimate up. Applied
to the resting-pulse tile + its consistency strip, the pulse in-target %
in the assessment snapshot, and the dashboard / sub-page classification.
Raw heart rate, where it is merely charted, no longer carries the
resting-band overlay. The doctor report is unchanged — it reports raw
heart-rate stats factually, with no resting-target judgment to mislead.
2026-06-07 17:36:25 +02:00

121 lines
4.4 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", () => {
const samples: PulseSample[] = [
day("2026-06-03T08:00:00", 72),
day("2026-06-03T20:00:00", 90),
day("2026-06-01T08:00:00", 60),
];
const proxy = deriveRestingProxyFromPulse(samples);
expect(proxy).toHaveLength(2);
expect(proxy[0].measuredAt.getTime()).toBeLessThan(
proxy[1].measuredAt.getTime(),
);
});
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" });
});
});