mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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.
121 lines
4.4 KiB
TypeScript
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" });
|
|
});
|
|
});
|