fix(insights): interpolate age/sex norm bands across bracket boundaries

The age/sex reference tables are coarse decade / paediatric brackets, so a
fractional age read a hard step at a bracket edge (39.9 -> 30s band, 40.0 ->
40s band). Anchor each band at its bracket centre and linearly interpolate a
fractional age between the two adjacent centres; below the youngest centre /
above the oldest centre hold the nearest cited band flat (clamp, never
extrapolate) so the band stays within the span of the two cited brackets and
the standard's provenance stays accurate.
This commit is contained in:
Marc-André Bombeck committed 2026-06-03 19:48:48 +02:00
1 parent 99e089ff4c
commit 3ca444d23f
3 files changed
+109 -20

No files matched your search

@@ -61,11 +61,16 @@ describe("computeFitnessAge", () => {
expect(r.status).toBe("ok");
if (r.status === "ok") {
expect(r.value.vo2Max).toBe(46);
expect(r.value.band).toBe("green");
expect(r.value.referenceBand).toEqual({ low: 35, high: 45 });
// Age 40 sits between the 30s (centre 34.5 → {39,49}) and 40s (centre
// 44.5 → {35,45}) VO2max bands; the fractional-age lookup interpolates
// to {36.8, 46.8} rather than reading the flat 40s bracket.
expect(r.value.referenceBand).toEqual({ low: 36.8, high: 46.8 });
// 46 < the interpolated upper edge (46.8) → not "excellent" green.
expect(r.value.band).toBe("yellow");
// ≥3 readings → trend = 46 - 44 = 2
expect(r.value.trendDelta).toBe(2);
expect(r.value.fitnessAgeDeltaYears).toBe(-6); // midpoint 40, 46 → -6
// midpoint 41.8, 46 → -round(4.2) = -4
expect(r.value.fitnessAgeDeltaYears).toBe(-4);
}
});
@@ -50,6 +50,37 @@ describe("lookupNormalRange — the age/sex reference-range enabler", () => {
expect(old).not.toBeNull();
});
it("interpolates a fractional age between adjacent brackets (no hard step)", () => {
// Male VO2max: 30s centre 34.5 → {39,49}; 40s centre 44.5 → {35,45}.
// At the lower centre the band equals the 30s band exactly.
const at345 = lookupNormalRange("VO2_MAX", 34.5, "MALE");
expect(at345).toEqual({ low: 39, high: 49 });
// Midway between the two centres → halfway between the two bands.
const at395 = lookupNormalRange("VO2_MAX", 39.5, "MALE");
expect(at395).toEqual({ low: 37, high: 47 });
// Just into the 40s the band must move only slightly, not jump.
const at40 = lookupNormalRange("VO2_MAX", 40, "MALE")!;
expect(at40.low).toBeGreaterThan(35);
expect(at40.low).toBeLessThan(37);
expect(at40.high).toBeGreaterThan(45);
expect(at40.high).toBeLessThan(47);
});
it("the band changes smoothly across a bracket boundary, not in a step", () => {
// Either side of the 30s/40s boundary (age 40) the bands are close — a
// hard bracket lookup would have returned two different fixed bands.
const justBelow = lookupNormalRange("VO2_MAX", 39.9, "MALE")!;
const justAbove = lookupNormalRange("VO2_MAX", 40.1, "MALE")!;
expect(Math.abs(justAbove.low - justBelow.low)).toBeLessThan(0.5);
expect(Math.abs(justAbove.high - justBelow.high)).toBeLessThan(0.5);
});
it("holds the youngest band flat below the first bracket centre", () => {
// Age below the youngest centre clamps to the youngest cited band.
const young = lookupNormalRange("VO2_MAX", 21, "MALE");
expect(young).toEqual({ low: 42, high: 53 });
});
it("rejects negative / non-finite ages", () => {
expect(lookupNormalRange("RESTING_HEART_RATE", -5, null)).toBeNull();
expect(lookupNormalRange("RESTING_HEART_RATE", Number.NaN, null)).toBeNull();
+70 -17
View File
@@ -112,15 +112,49 @@ const NORM_TABLES: Partial<Record<MetricStatusMetricId, NormRow[]>> = {
RESPIRATORY_RATE: RESPIRATORY_RATE_NORMS,
};
/** The age a bracket row is anchored at for interpolation — its centre. */
function bracketCentre(row: NormRow): number {
return (row.minAge + row.maxAge) / 2;
}
/**
* Resolve the candidate rows for a profile sex: the sex-specific rows when
* an exact match exists, else the sex-agnostic rows, else none. Returns the
* rows ordered by bracket centre so the caller can interpolate across them.
* `null` (rather than an empty array) signals "no honest band for this sex"
* — a sex-specific-only table with no profile sex.
*/
function resolveSexRows(rows: NormRow[], sex: NormSex): NormRow[] | null {
const exact = sex ? rows.filter((row) => row.sex === sex) : [];
if (exact.length > 0) {
return [...exact].sort((a, b) => bracketCentre(a) - bracketCentre(b));
}
const agnostic = rows.filter((row) => row.sex === null);
if (agnostic.length > 0) {
return [...agnostic].sort((a, b) => bracketCentre(a) - bracketCentre(b));
}
return null;
}
/**
* Resolve the age/sex-adjusted reference band for a metric, or `null`
* when no sharper band applies (unsupported metric, or demographics
* absent). On `null` the caller keeps the existing flat `normalRange`
* anchor — the enabler is strictly additive.
*
* Sex matching: a sex-specific table prefers the row matching the
* profile sex; when the profile sex is absent it falls back to the
* row's `null`-sex variant if present, else the first matching-age row.
* Sex matching: a sex-specific table prefers the rows matching the profile
* sex; when the profile sex is absent it falls back to the sex-agnostic
* rows; a sex-specific-only table with no profile sex yields no honest band.
*
* Age handling: the bracket tables are coarse (decade / paediatric bands),
* so a fractional age that lands near a bracket edge would otherwise read a
* hard step at the boundary (e.g. 39.9 → 30s band, 40.0 → 40s band). Instead
* each band is anchored at its bracket CENTRE and a fractional age is linearly
* interpolated between the two adjacent centres — the band moves smoothly with
* age. Below the youngest centre / above the oldest centre the nearest band is
* held flat (clamp, never extrapolate), so the interpolated band always lies
* within the span of the two cited bracket bands and the standard's provenance
* stays accurate.
*/
export function lookupNormalRange(
metricId: MetricStatusMetricId,
@@ -133,23 +167,42 @@ export function lookupNormalRange(
const table = NORM_TABLES[metricId];
if (!table) return null;
const ageRows = table.filter(
(row) => ageYears >= row.minAge && ageYears <= row.maxAge,
);
if (ageRows.length === 0) return null;
const rows = resolveSexRows(table, sex);
if (!rows || rows.length === 0) return null;
// Prefer an exact sex match; then a sex-agnostic row; then any row in
// the age bracket (so a sex-specific-only table still yields a band for
// a profile with no sex by averaging is avoided — we pick a defined
// band rather than fabricate).
const exact = sex ? ageRows.find((row) => row.sex === sex) : undefined;
if (exact) return { ...exact.range };
// Single band — no neighbour to interpolate against.
if (rows.length === 1) return { ...rows[0].range };
const agnostic = ageRows.find((row) => row.sex === null);
if (agnostic) return { ...agnostic.range };
// Clamp below the youngest centre / above the oldest centre: hold the
// nearest cited band flat rather than extrapolate past the table.
const first = rows[0];
const last = rows[rows.length - 1];
if (ageYears <= bracketCentre(first)) return { ...first.range };
if (ageYears >= bracketCentre(last)) return { ...last.range };
// Sex-specific-only table but no profile sex: no honest single band.
return null;
// Find the two adjacent brackets whose centres bracket the age, then blend
// their ranges by the fractional position between the centres.
for (let i = 0; i < rows.length - 1; i++) {
const lo = rows[i];
const hi = rows[i + 1];
const loCentre = bracketCentre(lo);
const hiCentre = bracketCentre(hi);
if (ageYears >= loCentre && ageYears <= hiCentre) {
const span = hiCentre - loCentre;
const fraction = span > 0 ? (ageYears - loCentre) / span : 0;
// Round to one decimal: the cited brackets are integer-valued and the
// interpolated band reads tidily in the prompt + tile without losing the
// smoothing across the boundary.
const round1 = (n: number) => Math.round(n * 10) / 10;
return {
low: round1(lo.range.low + (hi.range.low - lo.range.low) * fraction),
high: round1(lo.range.high + (hi.range.high - lo.range.high) * fraction),
};
}
}
// Defensive — the clamp + loop above cover every finite age in range.
return { ...last.range };
}
/**