feat(glucose): add clinical-metrics core for TIR, GMI, eA1C and CV

This commit is contained in:
Marc-André Bombeck committed 2026-06-14 18:30:55 +02:00
1 parent dd8ffffd91
commit 15c0a63c22
2 files changed
+614

No files matched your search

@@ -0,0 +1,234 @@
/**
* Known-value pins for the glucose clinical-metrics core.
*
* Worked examples are computed straight from the cited primary literature:
* Battelino 2019 (TIR bands), Bergenstal 2018 (GMI), Nathan 2008 (eA1C),
* Monnier 2017 (CV% 36% cutoff). The numbers below are the formulas applied
* by hand, not back-derived from the implementation.
*/
import { describe, expect, it } from "vitest";
import {
CV_INSTABILITY_THRESHOLD,
DEFAULT_WINDOW_DAYS,
computeGlucoseClinicalMetrics,
estimatedA1c,
glucoseSD,
glucoseVariability,
gmi,
timeInRange,
type GlucoseReading,
} from "../glucose-metrics";
const DAY = 24 * 60 * 60 * 1000;
/** Build readings spaced one per day ending at `anchor`. */
function dailyReadings(values: number[], anchor: Date): GlucoseReading[] {
return values.map((mgdl, i) => ({
mgdl,
// oldest first, newest = anchor
measuredAt: new Date(anchor.getTime() - (values.length - 1 - i) * DAY),
}));
}
describe("gmi (Bergenstal 2018)", () => {
it("maps a mean of 154 mg/dL to ~6.99%", () => {
// 3.31 + 0.02392 * 154 = 6.99368
expect(gmi(154)).toBeCloseTo(6.99368, 5);
});
it("maps a mean of 100 mg/dL to 5.702%", () => {
expect(gmi(100)).toBeCloseTo(5.702, 5);
});
});
describe("estimatedA1c (Nathan 2008 ADAG inverse)", () => {
it("maps a mean of 154 mg/dL to ~6.99%", () => {
// (154 + 46.7) / 28.7 = 6.99303...
expect(estimatedA1c(154)).toBeCloseTo(6.99303, 4);
});
it("maps a mean of 100 mg/dL to ~5.11%", () => {
// (100 + 46.7) / 28.7 = 5.11149...
expect(estimatedA1c(100)).toBeCloseTo(5.11149, 4);
});
});
describe("glucoseSD", () => {
it("returns null for fewer than two values", () => {
expect(glucoseSD([])).toBeNull();
expect(glucoseSD([120])).toBeNull();
});
it("computes the sample (n-1) standard deviation", () => {
// values 2,4,4,4,5,5,7,9 → mean 5, sample SD = sqrt(32/7) = 2.13809
expect(glucoseSD([2, 4, 4, 4, 5, 5, 7, 9])).toBeCloseTo(2.13809, 4);
});
});
describe("glucoseVariability (Monnier 2017 CV cutoff)", () => {
it("flags instability at CV ≥ 36%", () => {
// mean 100, SD 36 → CV exactly 36 → unstable (>=)
const v = glucoseVariability([136, 64, 100]);
// mean = 100; SD of {136,64,100} = sqrt(((36^2)+(36^2)+0)/2) = 36
expect(v).not.toBeNull();
expect(v!.cv).toBeCloseTo(36, 6);
expect(v!.unstable).toBe(true);
expect(CV_INSTABILITY_THRESHOLD).toBe(36);
});
it("does not flag a stable low-variability set", () => {
const v = glucoseVariability([100, 102, 98, 101, 99]);
expect(v!.unstable).toBe(false);
expect(v!.cv).toBeLessThan(36);
});
});
describe("timeInRange (Battelino 2019 bands)", () => {
it("returns null for an empty set", () => {
expect(timeInRange([])).toBeNull();
});
it("computes exact per-band fractions with nested level-2 sub-bands", () => {
// 10 readings:
// 40 → <54 and <70 (TBR2 + TBR1)
// 60 → <70 only (TBR1)
// 70,120,150,180 → in range (4)
// 200,210 → >180 only (TAR1)
// 300 → >250 and >180 (TAR2 + TAR1)
const d = timeInRange([40, 60, 70, 120, 150, 180, 200, 210, 300, 90]);
expect(d).not.toBeNull();
// in range: 70,120,150,180,90 = 5
expect(d!.tir).toBeCloseTo(0.5, 10);
// <70: 40,60 = 2
expect(d!.tbrLevel1).toBeCloseTo(0.2, 10);
// <54: 40 = 1
expect(d!.tbrLevel2).toBeCloseTo(0.1, 10);
// >180: 200,210,300 = 3
expect(d!.tarLevel1).toBeCloseTo(0.3, 10);
// >250: 300 = 1
expect(d!.tarLevel2).toBeCloseTo(0.1, 10);
});
it("treats the band edges 70 and 180 as in range, 54 as below", () => {
const d = timeInRange([54, 70, 180, 181, 69]);
// in range: 70,180 = 2/5
expect(d!.tir).toBeCloseTo(0.4, 10);
// <70: 54,69 = 2/5
expect(d!.tbrLevel1).toBeCloseTo(0.4, 10);
// <54: none (54 is not < 54)
expect(d!.tbrLevel2).toBe(0);
// >180: 181 = 1/5
expect(d!.tarLevel1).toBeCloseTo(0.2, 10);
});
it("emits minutes-of-a-day equivalents (fraction × 1440)", () => {
const d = timeInRange([100, 100, 100, 300]); // 3 in range, 1 above
expect(d!.minutesEquivalent.tir).toBeCloseTo(0.75 * 1440, 6);
expect(d!.minutesEquivalent.tarLevel1).toBeCloseTo(0.25 * 1440, 6);
});
});
describe("computeGlucoseClinicalMetrics — window + adequacy", () => {
const now = new Date("2026-06-14T12:00:00Z");
it("defaults to a 14-day window", () => {
const m = computeGlucoseClinicalMetrics([], { now });
expect(m.windowDays).toBe(DEFAULT_WINDOW_DAYS);
expect(DEFAULT_WINDOW_DAYS).toBe(14);
});
it("excludes readings older than the window", () => {
const readings: GlucoseReading[] = [
{ mgdl: 120, measuredAt: new Date(now.getTime() - 2 * DAY) },
{ mgdl: 130, measuredAt: new Date(now.getTime() - 20 * DAY) }, // out
];
const m = computeGlucoseClinicalMetrics(readings, { now });
expect(m.readingCount).toBe(1);
});
it("ignores non-finite values", () => {
const readings: GlucoseReading[] = [
{ mgdl: 120, measuredAt: new Date(now.getTime() - 1 * DAY) },
{ mgdl: NaN, measuredAt: new Date(now.getTime() - 2 * DAY) },
];
const m = computeGlucoseClinicalMetrics(readings, { now });
expect(m.readingCount).toBe(1);
});
it("always marks the result as a spot estimate", () => {
const m = computeGlucoseClinicalMetrics(
dailyReadings([100], now),
{ now },
);
expect(m.isSpotEstimate).toBe(true);
});
describe("learning gate", () => {
it("fires when there are no readings", () => {
const m = computeGlucoseClinicalMetrics([], { now });
expect(m.stillLearning).toBe(true);
expect(m.stillLearningReason).toMatch(/no glucose readings/i);
expect(m.meanMgdl).toBeNull();
expect(m.distribution).toBeNull();
expect(m.gmi).toBeNull();
expect(m.estimatedA1c).toBeNull();
expect(m.variability).toBeNull();
});
it("fires below the minimum reading count, reporting N and D", () => {
// 5 readings across 5 days — below the default 14-reading floor
const m = computeGlucoseClinicalMetrics(
dailyReadings([100, 110, 120, 130, 140], now),
{ now },
);
expect(m.stillLearning).toBe(true);
expect(m.stillLearningReason).toMatch(/5 readings over 4 days/);
expect(m.readingCount).toBe(5);
// numbers are still populated for a calm preview
expect(m.meanMgdl).toBeCloseTo(120, 6);
expect(m.distribution).not.toBeNull();
});
it("fires when the span is too short even with enough readings", () => {
// 16 readings all within a single day → span < 7 days
const values = Array.from({ length: 16 }, (_, i) => 100 + i);
const readings: GlucoseReading[] = values.map((mgdl, i) => ({
mgdl,
measuredAt: new Date(now.getTime() - i * 60 * 60 * 1000), // hourly
}));
const m = computeGlucoseClinicalMetrics(readings, { now });
expect(m.readingCount).toBe(16);
expect(m.stillLearning).toBe(true);
expect(m.stillLearningReason).toMatch(/span only/i);
});
it("clears above both thresholds and asserts the panel", () => {
// 14 readings, one per day → 13-day span, count 14
const values = Array.from({ length: 14 }, () => 154);
const m = computeGlucoseClinicalMetrics(
dailyReadings(values, now),
{ now },
);
expect(m.readingCount).toBe(14);
expect(m.actualSpanDays).toBeCloseTo(13, 6);
expect(m.stillLearning).toBe(false);
expect(m.stillLearningReason).toBeNull();
// mean 154 → GMI ~6.99, eA1C ~6.99
expect(m.meanMgdl).toBeCloseTo(154, 6);
expect(m.gmi).toBeCloseTo(6.99368, 5);
expect(m.estimatedA1c).toBeCloseTo(6.99303, 4);
expect(m.distribution!.tir).toBeCloseTo(1, 10); // all in range
expect(m.variability!.sd).toBe(0);
expect(m.variability!.unstable).toBe(false);
});
it("honours configurable thresholds", () => {
const m = computeGlucoseClinicalMetrics(
dailyReadings([100, 110, 120], now),
{ now, minReadings: 3, minSpanDays: 2 },
);
// 3 readings over a 2-day span clears the lowered bars
expect(m.stillLearning).toBe(false);
});
});
});
+380
View File
@@ -0,0 +1,380 @@
/**
* Glucose clinical-metrics core — server-authoritative analytics layer.
*
* Pure, side-effect-free functions over an array of spot blood-glucose
* readings (canonical **mg/dL**, the HealthLog store unit — see
* `src/lib/glucose.ts` for unit conversion; this module never duplicates it).
* One engine: the analytics route, the AI coach, the doctor-report PDF/FHIR
* export, and the iOS client all read these numbers. iOS RENDERS the output,
* it never recomputes — keeping the clinical math in one literature-locked
* place.
*
* IMPORTANT honesty contract: HealthLog stores SPOT readings (FASTING /
* POSTPRANDIAL / RANDOM / BEDTIME), NOT a dense CGM stream. The classic
* CGM-adequacy bar (Battelino 2019: 14 days with ≥70% of possible readings)
* cannot be met by spot data. Therefore every TIR / GMI / eA1C produced here
* is a SPOT-READING ESTIMATE — a "% of readings" distribution, NOT a "% of
* time" AGP report. The {@link GlucoseClinicalMetrics.stillLearning} gate and
* the per-metric labelling exist so callers never assert a clinical AGP off
* thin spot data.
*
* Thresholds and formulas are clean-room from the primary literature (the
* consensus band values and coefficients are mathematical facts, not
* copyrightable). Each function cites its source inline. Defer the
* dense-stream indices (MAGE, CONGA, MODD, ADRR, AGP, time-weighted mean) to
* the future CGM integration — they need ordered, closely-spaced samples this
* module deliberately does not assume.
*
* Citations:
* Battelino 2019, Diabetes Care 42(8):1593-1603, DOI 10.2337/dci19-0028.
* Bergenstal 2018, Diabetes Care 41(11):2275-2280, DOI 10.2337/dc18-1581.
* Nathan 2008 (ADAG), Diabetes Care 31(8):1473-1478, DOI 10.2337/dc08-0545.
* Monnier 2017, Diabetes Care 40(7):832-838, DOI 10.2337/dc16-1769.
*/
// ── Inputs ───────────────────────────────────────────────
/** A single spot blood-glucose reading. Value is canonical mg/dL. */
export interface GlucoseReading {
/** When the sample was taken. */
measuredAt: Date;
/** Glucose value in mg/dL (HealthLog canonical store unit). */
mgdl: number;
}
/** Options for the clinical-window computation. */
export interface GlucoseMetricsOptions {
/**
* Clinical reporting window in days. Default 14 — the Battelino 2019
* consensus reporting period. Readings older than `now - windowDays` are
* excluded. Configurable so the doctor report or a longer trend view can
* widen it.
*/
windowDays?: number;
/**
* Anchor for the window. Defaults to `new Date()`. Injectable purely to keep
* the module deterministic under test — there is no other side effect.
*/
now?: Date;
/**
* Minimum number of in-window readings before TIR/GMI/eA1C/CV are asserted
* rather than gated behind `stillLearning`. Default 14 — roughly one reading
* per day across the window; below this a spot-data estimate is not
* clinically meaningful.
*/
minReadings?: number;
/**
* Minimum span (first→last reading, in days) the window's readings must
* cover. Default 7. A burst of readings inside a single day cannot stand in
* for two weeks of glycaemic behaviour.
*/
minSpanDays?: number;
}
// ── Consensus thresholds (Battelino 2019) ────────────────
// Bands in mg/dL. These are consensus FACTS, cited above.
const TBR_LEVEL2_MAX = 54; // very low: G < 54
const TBR_LEVEL1_MAX = 70; // low: G < 70 (level-1 sub-band is 54–69)
const TAR_LEVEL1_MIN = 180; // high: G > 180 (level-1 sub-band is 181–250)
const TAR_LEVEL2_MIN = 250; // very high: G > 250
// Time-in-range target band is [70, 180] inclusive.
/** Monnier 2017 variability cutoff: CV% ≥ 36 is "unstable". */
export const CV_INSTABILITY_THRESHOLD = 36;
/** Battelino 2019 reporting defaults. */
export const DEFAULT_WINDOW_DAYS = 14;
const DEFAULT_MIN_READINGS = 14;
const DEFAULT_MIN_SPAN_DAYS = 7;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
// ── Output shapes ────────────────────────────────────────
/**
* Battelino 2019 time-in-range distribution as fractions of readings (0–1).
* `*Minutes` carry the minutes-equivalent over a 24h day (fraction × 1440) so
* the UI can echo the consensus "<N min/day" goals; these are minutes-OF-A-DAY
* equivalents of a per-reading distribution, NOT measured time-on-CGM.
*/
export interface TimeInRangeDistribution {
/** % readings 70 ≤ G ≤ 180. Goal > 0.70. */
tir: number;
/** % readings G < 70 (includes the very-low sub-band). Goal < 0.04. */
tbrLevel1: number;
/** % readings G < 54. Goal < 0.01. */
tbrLevel2: number;
/** % readings G > 180 (includes the very-high sub-band). Goal < 0.25. */
tarLevel1: number;
/** % readings G > 250. Goal < 0.05. */
tarLevel2: number;
/** Minutes-of-a-day equivalents (fraction × 1440). */
minutesEquivalent: {
tir: number;
tbrLevel1: number;
tbrLevel2: number;
tarLevel1: number;
tarLevel2: number;
};
}
/** Variability summary: SD + CV% with the Monnier stability flag. */
export interface GlucoseVariability {
/** Sample standard deviation (n−1), mg/dL. */
sd: number;
/** Coefficient of variation, percent: SD / mean × 100. */
cv: number;
/** True when CV% ≥ 36 (Monnier 2017 instability cutoff). */
unstable: boolean;
}
/** The full clinical-metrics result for a window of spot readings. */
export interface GlucoseClinicalMetrics {
/**
* When true, the window has too few readings or too short a span to assert a
* clinically meaningful estimate. The numeric fields below are still
* populated from whatever data exists (so a calm preview can render) but MUST
* NOT be presented as a clinical assessment. The UI should render a
* "still learning — N readings over D days" state.
*/
stillLearning: boolean;
/** Human/UI-facing reason for the gate (null once it clears). */
stillLearningReason: string | null;
/** Declared clinical window in days (the requested `windowDays`). */
windowDays: number;
/** Actual span the in-window readings cover (first→last), in days. */
actualSpanDays: number;
/** Number of readings used (those falling inside the window). */
readingCount: number;
/** Arithmetic mean glucose over the window, mg/dL (null if no readings). */
meanMgdl: number | null;
/** Battelino 2019 TIR/TBR/TAR distribution (null if no readings). */
distribution: TimeInRangeDistribution | null;
/** Bergenstal 2018 Glucose Management Indicator, percent (null if no data). */
gmi: number | null;
/** Nathan 2008 estimated A1C, percent (null if no data). */
estimatedA1c: number | null;
/** SD + CV% + instability flag (null if < 2 readings). */
variability: GlucoseVariability | null;
/**
* Always true for this module: every number is a spot-reading estimate, not a
* CGM AGP. Surfaced explicitly so serializers (panel/coach/PDF/iOS) carry the
* caveat without re-deriving it.
*/
isSpotEstimate: true;
}
// ── Core formulas ────────────────────────────────────────
/** Arithmetic mean. Caller guarantees a non-empty array. */
function mean(values: number[]): number {
let sum = 0;
for (const v of values) sum += v;
return sum / values.length;
}
/**
* Sample standard deviation, sqrt(Σ(Gᵢ−mean)² / (n−1)).
* Standard variability foundation reported alongside mean in Battelino 2019.
* Returns null for n < 2 (undefined sample SD).
*/
export function glucoseSD(values: number[]): number | null {
const n = values.length;
if (n < 2) return null;
const m = mean(values);
let ss = 0;
for (const v of values) {
const d = v - m;
ss += d * d;
}
return Math.sqrt(ss / (n - 1));
}
/**
* Coefficient of variation (%) with the Monnier 2017 instability flag.
* %CV = SD / mean × 100; CV% ≥ 36 → unstable.
* Monnier 2017, Diabetes Care 40(7):832-838, DOI 10.2337/dc16-1769;
* endorsed in Battelino 2019.
*/
export function glucoseVariability(values: number[]): GlucoseVariability | null {
const sd = glucoseSD(values);
if (sd === null) return null;
const m = mean(values);
if (m === 0) return null;
const cv = (sd / m) * 100;
return { sd, cv, unstable: cv >= CV_INSTABILITY_THRESHOLD };
}
/**
* Glucose Management Indicator (GMI), percent, from mean glucose in mg/dL.
* GMI% = 3.31 + 0.02392 × mean(mg/dL).
* Bergenstal 2018, Diabetes Care 41(11):2275-2280, DOI 10.2337/dc18-1581.
*/
export function gmi(meanMgdl: number): number {
return 3.31 + 0.02392 * meanMgdl;
}
/**
* Estimated A1C (%) from mean glucose in mg/dL (ADAG inverse of eAG).
* eAG = 28.7 × A1C − 46.7 ⇒ A1C% = (mean + 46.7) / 28.7.
* Nathan 2008 (ADAG), Diabetes Care 31(8):1473-1478, DOI 10.2337/dc08-0545.
* Preferred for spot/SMBG data; GMI is the CGM-native sibling.
*/
export function estimatedA1c(meanMgdl: number): number {
return (meanMgdl + 46.7) / 28.7;
}
/**
* Time-in-range distribution over the Battelino 2019 consensus bands, as
* fractions of readings (NOT % of time — spot data, labelled honestly).
* TIR : 70 ≤ G ≤ 180 (goal > 0.70)
* TBR1 : G < 70 (goal < 0.04)
* TBR2 : G < 54 (goal < 0.01)
* TAR1 : G > 180 (goal < 0.25)
* TAR2 : G > 250 (goal < 0.05)
* TBR2 ⊆ TBR1 and TAR2 ⊆ TAR1 (the consensus reports level-2 as a sub-band of
* level-1), so the five fractions are NOT mutually exclusive by design.
* Battelino 2019, Diabetes Care 42(8):1593-1603, DOI 10.2337/dci19-0028.
*/
export function timeInRange(
values: number[],
): TimeInRangeDistribution | null {
const n = values.length;
if (n === 0) return null;
let inRange = 0;
let below70 = 0;
let below54 = 0;
let above180 = 0;
let above250 = 0;
for (const g of values) {
if (g < TBR_LEVEL1_MAX) {
below70 += 1;
if (g < TBR_LEVEL2_MAX) below54 += 1;
} else if (g > TAR_LEVEL1_MIN) {
above180 += 1;
if (g > TAR_LEVEL2_MIN) above250 += 1;
} else {
// 70 ≤ G ≤ 180
inRange += 1;
}
}
const tir = inRange / n;
const tbrLevel1 = below70 / n;
const tbrLevel2 = below54 / n;
const tarLevel1 = above180 / n;
const tarLevel2 = above250 / n;
const MIN_PER_DAY = 1440;
return {
tir,
tbrLevel1,
tbrLevel2,
tarLevel1,
tarLevel2,
minutesEquivalent: {
tir: tir * MIN_PER_DAY,
tbrLevel1: tbrLevel1 * MIN_PER_DAY,
tbrLevel2: tbrLevel2 * MIN_PER_DAY,
tarLevel1: tarLevel1 * MIN_PER_DAY,
tarLevel2: tarLevel2 * MIN_PER_DAY,
},
};
}
// ── Window + adequacy orchestration ──────────────────────
/**
* Compute the full clinical-metrics panel for a set of spot readings.
*
* Pins a declared clinical window (default 14d, Battelino 2019), reports the
* actual span + reading count used, and applies the learning/adequacy gate so
* the caller never presents a thin-data estimate as a clinical assessment.
*
* Pure: no Prisma, no I/O. `now` is injectable for deterministic tests.
*/
export function computeGlucoseClinicalMetrics(
readings: GlucoseReading[],
options: GlucoseMetricsOptions = {},
): GlucoseClinicalMetrics {
const windowDays = options.windowDays ?? DEFAULT_WINDOW_DAYS;
const now = options.now ?? new Date();
const minReadings = options.minReadings ?? DEFAULT_MIN_READINGS;
const minSpanDays = options.minSpanDays ?? DEFAULT_MIN_SPAN_DAYS;
const cutoff = now.getTime() - windowDays * MS_PER_DAY;
const inWindow = readings
.filter(
(r) =>
r.measuredAt.getTime() >= cutoff &&
r.measuredAt.getTime() <= now.getTime() &&
Number.isFinite(r.mgdl),
)
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());
const readingCount = inWindow.length;
const actualSpanDays =
readingCount >= 2
? (inWindow[readingCount - 1].measuredAt.getTime() -
inWindow[0].measuredAt.getTime()) /
MS_PER_DAY
: 0;
const values = inWindow.map((r) => r.mgdl);
const meanMgdl = values.length > 0 ? mean(values) : null;
const distribution = timeInRange(values);
const variability = glucoseVariability(values);
const gmiValue = meanMgdl !== null ? gmi(meanMgdl) : null;
const eA1cValue = meanMgdl !== null ? estimatedA1c(meanMgdl) : null;
// Learning/adequacy gate. Spot data cannot meet the CGM adequacy bar; this
// is the honest "is the estimate worth asserting yet" check. Order matters:
// report the most actionable reason first.
let stillLearning = false;
let stillLearningReason: string | null = null;
if (readingCount === 0) {
stillLearning = true;
stillLearningReason = `No glucose readings in the last ${windowDays} days.`;
} else if (readingCount < minReadings) {
stillLearning = true;
stillLearningReason = `Still learning — ${readingCount} reading${
readingCount === 1 ? "" : "s"
} over ${roundSpan(actualSpanDays)} day${
roundSpan(actualSpanDays) === 1 ? "" : "s"
}; at least ${minReadings} are needed for a meaningful estimate.`;
} else if (actualSpanDays < minSpanDays) {
stillLearning = true;
stillLearningReason = `Still learning — readings span only ${roundSpan(
actualSpanDays,
)} day${
roundSpan(actualSpanDays) === 1 ? "" : "s"
}; at least ${minSpanDays} days of coverage are needed.`;
}
return {
stillLearning,
stillLearningReason,
windowDays,
actualSpanDays,
readingCount,
meanMgdl,
distribution,
gmi: gmiValue,
estimatedA1c: eA1cValue,
variability,
isSpotEstimate: true,
};
}
/** Round a span to whole days for the human-facing learning message. */
function roundSpan(spanDays: number): number {
return Math.max(0, Math.round(spanDays));
}