diff --git a/src/lib/insights/derived/__tests__/fitness-age.test.ts b/src/lib/insights/derived/__tests__/fitness-age.test.ts index 08879d6b4..8189941a8 100644 --- a/src/lib/insights/derived/__tests__/fitness-age.test.ts +++ b/src/lib/insights/derived/__tests__/fitness-age.test.ts @@ -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); } }); diff --git a/src/lib/insights/derived/__tests__/norms.test.ts b/src/lib/insights/derived/__tests__/norms.test.ts index 6f539385f..4b1a11971 100644 --- a/src/lib/insights/derived/__tests__/norms.test.ts +++ b/src/lib/insights/derived/__tests__/norms.test.ts @@ -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(); diff --git a/src/lib/insights/derived/norms.ts b/src/lib/insights/derived/norms.ts index 20090fdff..717102021 100644 --- a/src/lib/insights/derived/norms.ts +++ b/src/lib/insights/derived/norms.ts @@ -112,15 +112,49 @@ const NORM_TABLES: Partial> = { 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 }; } /**