mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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).
147 lines
5.5 KiB
TypeScript
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" });
|
|
});
|
|
});
|