mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
The score is the worse of the systolic and diastolic sub-scores, and until now nothing said which one it was or how far over its ceiling the reading sat. The grader now returns that basis together with the score, as one value, and it is carried as one value all the way to the pillar so an explanation can never belong to a different grading run than the number it explains.
319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
/**
|
||
* v1.15.12 A1 — graded blood-pressure pillar score.
|
||
*
|
||
* The Personal Health Score's BP pillar previously equalled
|
||
* `bpInTargetRate`: the share of paired readings where BOTH systolic and
|
||
* diastolic sat at-or-below the age-band ceiling, scored binary per
|
||
* reading over the all-time window. That metric is honest as a "% of
|
||
* readings in range" stat, but it is the wrong thing to use AS the
|
||
* pillar score: a user who averages 134/87 (borderline stage-1, every
|
||
* reading slightly over the diastolic ceiling) collapses to ~10-16/100
|
||
* because nearly every reading fails the binary check — even though
|
||
* clinically they are "a bit high", not catastrophic. Improvement also
|
||
* never shows, because the rate is all-time and a binary cliff.
|
||
*
|
||
* This module replaces the binary rate AS THE SCORE with a smooth
|
||
* clinical-proximity grade. A representative BP (recency-weighted toward
|
||
* the last weeks) is mapped to a 0-100 score per axis, and the pillar
|
||
* takes the WORSE of the two sub-scores — so an elevated diastolic
|
||
* counts against the score (it should) without binary-zeroing it.
|
||
*
|
||
* The curve is anchored on the SAME age-band ceiling the rest of the app
|
||
* uses (`BpTargets.sysHigh` / `diaHigh` from `bp-targets.ts`). No new
|
||
* thresholds are invented; the score bands are derived as offsets from
|
||
* the configured ceiling:
|
||
*
|
||
* offset from ceiling → axis score
|
||
* ≤ −12 (well below) 100 (optimal — comfortably in band)
|
||
* 0 (at the ceiling) 85 (at goal — "well controlled")
|
||
* +5 65
|
||
* +10 52
|
||
* +20 40
|
||
* +40 18
|
||
* ≥ +60 5 (far above — uncontrolled)
|
||
*
|
||
* Piecewise-linear between anchors — no hard cliffs, so a one-mmHg
|
||
* change moves the score by a small, continuous amount. The diastolic
|
||
* and systolic axes share the same offset→score table, so each axis is
|
||
* judged against its own ceiling.
|
||
*
|
||
* Hypotension is penalised on a SEPARATE under-floor curve that is
|
||
* CONTINUOUS with the optimal plateau at the boundary: at exactly the
|
||
* clinical floor (`sys = 90`, `dia = 50`, mirroring `isBpReadingInTarget`)
|
||
* the score is 100, and it descends smoothly as the value drops further
|
||
* below the floor. The earlier implementation fed `floor − value` through
|
||
* the over-target table, which made 1 mmHg under the floor jump to ~81 —
|
||
* a 19-point cliff against the 100 the plateau holds AT the floor. The
|
||
* dedicated anchors below (floor→100, floor−10→70, floor−20→45,
|
||
* floor−30→20) mirror the over-target steepness without the cliff, so the
|
||
* curve is continuous on BOTH sides of every boundary.
|
||
*
|
||
* Pure & deterministic — fully unit-tested in `__tests__/bp-grade.test.ts`.
|
||
*/
|
||
import type { BpTargets } from "./bp-targets";
|
||
import { SYS_HYPOTENSION_FLOOR, DIA_HYPOTENSION_FLOOR } from "./bp-in-target";
|
||
|
||
/**
|
||
* Offset-from-ceiling → axis-score anchors. Offsets are in mmHg; the
|
||
* first anchor is the "optimal" plateau (comfortably below the ceiling),
|
||
* the last is the "uncontrolled" floor. Interpolated piecewise-linearly.
|
||
*/
|
||
const AXIS_ANCHORS: ReadonlyArray<readonly [offset: number, score: number]> = [
|
||
[-12, 100],
|
||
[0, 85],
|
||
[5, 65],
|
||
[10, 52],
|
||
[20, 40],
|
||
[40, 18],
|
||
[60, 5],
|
||
] as const;
|
||
|
||
/**
|
||
* Below-floor hypotension anchors. `below` is mmHg the value sits UNDER
|
||
* the clinical floor; the score starts at 100 AT the floor (`below = 0`)
|
||
* so the curve is continuous with the optimal plateau the over-target
|
||
* branch holds at the floor, then descends to mirror the over-target
|
||
* steepness. Interpolated piecewise-linearly; clamps below the last
|
||
* anchor.
|
||
*/
|
||
const HYPO_ANCHORS: ReadonlyArray<readonly [below: number, score: number]> = [
|
||
[0, 100],
|
||
[10, 70],
|
||
[20, 45],
|
||
[30, 20],
|
||
[40, 5],
|
||
] as const;
|
||
|
||
/**
|
||
* Which axis set the score, measured against which boundary, and by how
|
||
* far. Published with the score (never beside it) so the surface can
|
||
* answer "why this number" without recomputing anything client-side.
|
||
*
|
||
* `relation` is a trichotomy on the binding axis' own value:
|
||
*
|
||
* value < floor → `below_floor` (boundary = floor)
|
||
* value <= ceiling → `in_band` (boundary = ceiling)
|
||
* otherwise → `above_ceiling` (boundary = ceiling)
|
||
*
|
||
* so the three relations are exhaustive and mutually exclusive by
|
||
* construction, whatever the anchors say. `offsetMmHg` is the distance
|
||
* from that boundary as a NON-NEGATIVE integer (rounded); the relation
|
||
* carries the direction, so the number never needs a sign. The relation
|
||
* is decided on the unrounded value, so it always agrees with the score
|
||
* even when the rounded offset reads 0.
|
||
*/
|
||
export type BpScoreAxis = "systolic" | "diastolic";
|
||
export type BpScoreRelation = "above_ceiling" | "below_floor" | "in_band";
|
||
|
||
export interface BpScoreBasis {
|
||
/** The axis whose sub-score the pillar took — the worse of the two. */
|
||
axis: BpScoreAxis;
|
||
relation: BpScoreRelation;
|
||
/** Rounded, non-negative distance from `boundaryMmHg`. */
|
||
offsetMmHg: number;
|
||
/** The ceiling the axis was graded against, or its hypotension floor. */
|
||
boundaryMmHg: number;
|
||
}
|
||
|
||
/**
|
||
* A graded BP reading: the score AND the basis that produced it, as one
|
||
* value. They are returned together, and carried together through every
|
||
* envelope down to the pillar, precisely so no caller can ever pair a
|
||
* score from one run with a basis from another (different representative
|
||
* pair, or — the live trap — different targets).
|
||
*/
|
||
export interface BpGrade {
|
||
score: number;
|
||
basis: BpScoreBasis;
|
||
}
|
||
|
||
/**
|
||
* Generic piecewise-linear interpolation over a monotonic-x anchor table.
|
||
* Clamps to the first / last anchor outside the table range.
|
||
*/
|
||
function interpolateAnchors(
|
||
anchors: ReadonlyArray<readonly [number, number]>,
|
||
x: number,
|
||
): number {
|
||
const first = anchors[0];
|
||
const last = anchors[anchors.length - 1];
|
||
if (x <= first[0]) return first[1];
|
||
if (x >= last[0]) return last[1];
|
||
for (let i = 0; i < anchors.length - 1; i++) {
|
||
const [x1, y1] = anchors[i];
|
||
const [x2, y2] = anchors[i + 1];
|
||
if (x >= x1 && x <= x2) {
|
||
const fraction = (x - x1) / (x2 - x1);
|
||
return y1 + fraction * (y2 - y1);
|
||
}
|
||
}
|
||
return last[1];
|
||
}
|
||
|
||
/**
|
||
* Piecewise-linear interpolation of an over-/under-target `offset`
|
||
* (mmHg relative to the axis ceiling) onto the 0-100 axis score.
|
||
* Clamps to the first / last anchor outside the table range.
|
||
*/
|
||
function interpolateAxisScore(offset: number): number {
|
||
return interpolateAnchors(AXIS_ANCHORS, offset);
|
||
}
|
||
|
||
/**
|
||
* Score one BP axis against its ceiling.
|
||
*
|
||
* - Above the floor: offset = `value - ceiling`. Negative offsets sit in
|
||
* the optimal plateau, positive ones climb the over-target curve.
|
||
* - Below the hypotension floor: the distance below the floor is fed
|
||
* through the dedicated `HYPO_ANCHORS` curve, which starts at 100 AT
|
||
* the floor (`below = 0`) so the score is continuous with the plateau
|
||
* the over-target branch holds at the floor, then descends smoothly —
|
||
* no boundary cliff.
|
||
*/
|
||
function gradeAxis(
|
||
value: number,
|
||
ceiling: number,
|
||
floor: number,
|
||
): { score: number; relation: BpScoreRelation; boundary: number } {
|
||
if (value < floor) {
|
||
return {
|
||
score: interpolateAnchors(HYPO_ANCHORS, floor - value),
|
||
relation: "below_floor",
|
||
boundary: floor,
|
||
};
|
||
}
|
||
const offset = value - ceiling;
|
||
return {
|
||
score: interpolateAxisScore(offset),
|
||
relation: offset > 0 ? "above_ceiling" : "in_band",
|
||
boundary: ceiling,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Map a representative blood-pressure reading to a smooth 0-100 score,
|
||
* together with the basis that produced it.
|
||
*
|
||
* Takes the WORSE of the systolic and diastolic axis sub-scores so a
|
||
* high diastolic counts against the score (it should) without
|
||
* binary-zeroing the result the way the all-time in-target rate did.
|
||
*
|
||
* **Tie rule**: when both axes grade identically, the basis names the
|
||
* SYSTOLIC axis. The two are interchangeable for the number, so the
|
||
* choice is arbitrary in arithmetic but must not be arbitrary in the
|
||
* text; systolic is the axis clinicians lead with and the one the app
|
||
* shows first everywhere else.
|
||
*
|
||
* @returns integer score 0-100 plus the binding axis, its relation to
|
||
* its boundary, and the distance to it.
|
||
*/
|
||
export function gradeBpScore(input: {
|
||
sys: number;
|
||
dia: number;
|
||
target: BpTargets;
|
||
}): BpGrade {
|
||
const sys = gradeAxis(input.sys, input.target.sysHigh, SYS_HYPOTENSION_FLOOR);
|
||
const dia = gradeAxis(input.dia, input.target.diaHigh, DIA_HYPOTENSION_FLOOR);
|
||
// `<=` puts a tie on systolic — see the tie rule above.
|
||
const bindingIsSystolic = sys.score <= dia.score;
|
||
const binding = bindingIsSystolic ? sys : dia;
|
||
const bindingValue = bindingIsSystolic ? input.sys : input.dia;
|
||
return {
|
||
score: Math.round(Math.max(0, Math.min(100, binding.score))),
|
||
basis: {
|
||
axis: bindingIsSystolic ? "systolic" : "diastolic",
|
||
relation: binding.relation,
|
||
offsetMmHg: Math.round(Math.abs(bindingValue - binding.boundary)),
|
||
boundaryMmHg: binding.boundary,
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* One timestamped BP pair for the recency-weighted representative.
|
||
*/
|
||
export interface BpPairPoint {
|
||
/** Wall-clock of the reading (ms epoch ordering only — no tz math). */
|
||
at: Date;
|
||
sys: number;
|
||
dia: number;
|
||
/**
|
||
* v1.15.12 — how many underlying readings this point stands for.
|
||
* Defaults to 1 (one per-event pair). The rollup path collapses a day
|
||
* into a single per-day-MEAN pair and passes `perDayPairCount` here so
|
||
* a 4-reading day's mean counts 4× in the recency-weighted grade,
|
||
* matching the live per-event behaviour. Without it a high-variance
|
||
* day weighed 1:1 on rollup but N:1 on live, diverging the BP pillar by
|
||
* up to ~20 points for the same data. The recency weight MULTIPLIES
|
||
* this count.
|
||
*/
|
||
count?: number;
|
||
}
|
||
|
||
/**
|
||
* Collapse a series of paired BP readings into a single recency-weighted
|
||
* representative `{ sys, dia }`, then grade it.
|
||
*
|
||
* Recent readings should dominate so that genuine improvement surfaces in
|
||
* the score rather than being diluted by years of older readings. Each
|
||
* pair is weighted by an exponential decay on its age relative to `now`,
|
||
* with a half-life of `HALF_LIFE_DAYS` (45 d): a reading 45 days old
|
||
* counts half as much as today's, 90 days old a quarter, etc. The
|
||
* weighted mean of sys and dia is the representative reading.
|
||
*
|
||
* Returns `null` when the series is empty (caller leaves the pillar
|
||
* absent — the redistribution helper then drops the BP weight cleanly).
|
||
*/
|
||
const HALF_LIFE_DAYS = 45;
|
||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||
|
||
export function representativeBpFromSeries(input: {
|
||
pairs: ReadonlyArray<BpPairPoint>;
|
||
now: Date;
|
||
}): { sys: number; dia: number } | null {
|
||
const { pairs, now } = input;
|
||
if (pairs.length === 0) return null;
|
||
|
||
const nowMs = now.getTime();
|
||
let weightSum = 0;
|
||
let sysWeighted = 0;
|
||
let diaWeighted = 0;
|
||
for (const p of pairs) {
|
||
const ageDays = Math.max(0, (nowMs - p.at.getTime()) / DAY_MS);
|
||
// The recency decay multiplies the per-point reading count so a
|
||
// per-day-mean pair (rollup path) carries the same weight as the N
|
||
// per-event pairs it stands for (live path).
|
||
const count = p.count ?? 1;
|
||
const weight = Math.pow(0.5, ageDays / HALF_LIFE_DAYS) * count;
|
||
weightSum += weight;
|
||
sysWeighted += weight * p.sys;
|
||
diaWeighted += weight * p.dia;
|
||
}
|
||
if (weightSum === 0) return null;
|
||
return {
|
||
sys: sysWeighted / weightSum,
|
||
dia: diaWeighted / weightSum,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Grade the recency-weighted representative of a pair series.
|
||
*
|
||
* Returns the score and its basis as ONE value. Callers must carry that
|
||
* value on, never split it: a score stored beside a basis that came from
|
||
* a second evaluation (or from a second set of targets — the analytics
|
||
* route deliberately re-runs this with the CLINICAL targets when the user
|
||
* has a personal one) would let the surface explain a number that is not
|
||
* the number on screen.
|
||
*/
|
||
export function gradeBpScoreFromSeries(input: {
|
||
pairs: ReadonlyArray<BpPairPoint>;
|
||
target: BpTargets;
|
||
now: Date;
|
||
}): BpGrade | null {
|
||
const representative = representativeBpFromSeries(input);
|
||
return representative
|
||
? gradeBpScore({ ...representative, target: input.target })
|
||
: null;
|
||
}
|