diff --git a/src/lib/analytics/__tests__/glucose-metrics.test.ts b/src/lib/analytics/__tests__/glucose-metrics.test.ts index 0fdfbd685..0678a4803 100644 --- a/src/lib/analytics/__tests__/glucose-metrics.test.ts +++ b/src/lib/analytics/__tests__/glucose-metrics.test.ts @@ -10,11 +10,13 @@ import { describe, expect, it } from "vitest"; import { CV_INSTABILITY_THRESHOLD, DEFAULT_WINDOW_DAYS, + bloodGlucoseRiskIndices, computeGlucoseClinicalMetrics, estimatedA1c, glucoseSD, glucoseVariability, gmi, + jIndex, timeInRange, type GlucoseReading, } from "../glucose-metrics"; @@ -128,6 +130,51 @@ describe("timeInRange (Battelino 2019 bands)", () => { }); }); +describe("jIndex (Wojcicki 1995)", () => { + it("returns null for fewer than two values", () => { + expect(jIndex([])).toBeNull(); + expect(jIndex([120])).toBeNull(); + }); + + it("folds mean + SD: J = 0.001 × (mean + SD)²", () => { + // 14 identical 154 readings → SD 0 → J = 0.001 × 154² = 23.716 + expect(jIndex(Array.from({ length: 14 }, () => 154))).toBeCloseTo( + 23.716, + 3, + ); + // {100,110,120,130,140}: mean 120, sample SD √250 = 15.81139 → + // J = 0.001 × 135.81139² = 18.444733 + expect(jIndex([100, 110, 120, 130, 140])).toBeCloseTo(18.444733, 5); + }); +}); + +describe("bloodGlucoseRiskIndices (Kovatchev 1997/2006)", () => { + it("returns null when no usable reading remains", () => { + expect(bloodGlucoseRiskIndices([])).toBeNull(); + // non-positive / non-finite readings are skipped (ln undefined) + expect(bloodGlucoseRiskIndices([0, -5, NaN])).toBeNull(); + }); + + it("splits a low/high pair onto the correct branches", () => { + // f(50) = -1.500015 → low branch r = 22.500445 + // f(300) = 1.842607 → high branch r = 33.951988 + // n = 2 → LBGI = 22.500445/2 = 11.250223, HBGI = 33.951988/2 = 16.975994 + const r = bloodGlucoseRiskIndices([50, 300]); + expect(r).not.toBeNull(); + expect(r!.lbgi).toBeCloseTo(11.250223, 5); + expect(r!.hbgi).toBeCloseTo(16.975994, 5); + }); + + it("reads near-zero hypo risk and zero hyper risk for a stable normoglycaemic set", () => { + // every reading sits below the ~112.5 symmetrization centre, so HBGI = 0 + const r = bloodGlucoseRiskIndices([100, 110, 95, 105, 100]); + expect(r!.hbgi).toBe(0); + expect(r!.lbgi).toBeCloseTo(0.427869, 5); + // minimal-risk band (< 1.1) + expect(r!.lbgi).toBeLessThan(1.1); + }); +}); + describe("computeGlucoseClinicalMetrics — window + adequacy", () => { const now = new Date("2026-06-14T12:00:00Z"); @@ -173,6 +220,7 @@ describe("computeGlucoseClinicalMetrics — window + adequacy", () => { expect(m.gmi).toBeNull(); expect(m.estimatedA1c).toBeNull(); expect(m.variability).toBeNull(); + expect(m.advanced).toBeNull(); }); it("fires below the minimum reading count, reporting N and D", () => { @@ -220,6 +268,26 @@ describe("computeGlucoseClinicalMetrics — window + adequacy", () => { expect(m.distribution!.tir).toBeCloseTo(1, 10); // all in range expect(m.variability!.sd).toBe(0); expect(m.variability!.unstable).toBe(false); + // advanced tier: J-index from mean+SD, LBGI/HBGI from the risk model. + // mean 154, SD 0 → J = 0.001 × 154² = 23.716. + expect(m.advanced).not.toBeNull(); + expect(m.advanced!.jIndex).toBeCloseTo(23.716, 3); + // 154 mg/dL sits above the symmetrization centre → all hyper risk, + // zero hypo risk. + expect(m.advanced!.lbgi).toBe(0); + expect(m.advanced!.hbgi).toBeGreaterThan(0); + }); + + it("resolves LBGI/HBGI but holds J-index null for a single reading", () => { + const m = computeGlucoseClinicalMetrics(dailyReadings([100], now), { + now, + }); + expect(m.advanced).not.toBeNull(); + // one reading → sample SD undefined → J held null + expect(m.advanced!.jIndex).toBeNull(); + // the risk indices still resolve from the lone reading + expect(m.advanced!.hbgi).toBe(0); + expect(m.advanced!.lbgi).toBeGreaterThan(0); }); it("honours configurable thresholds", () => { diff --git a/src/lib/analytics/glucose-metrics.ts b/src/lib/analytics/glucose-metrics.ts index 67d5de31b..10d4675eb 100644 --- a/src/lib/analytics/glucose-metrics.ts +++ b/src/lib/analytics/glucose-metrics.ts @@ -30,6 +30,10 @@ * 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. + * Wojcicki 1995 (J-index), Horm Metab Res 27(1):41-42, DOI 10.1055/s-2007-979906. + * Kovatchev 1997 (symmetrization + BGI), Diabetes Care 20(11):1655-1658, + * DOI 10.2337/diacare.20.11.1655; Kovatchev 2006 (LBGI/HBGI risk model), + * Diabetes Care 29(11):2433-2438, DOI 10.2337/dc06-1085. */ // ── Inputs ─────────────────────────────────────────────── @@ -118,6 +122,41 @@ export interface TimeInRangeDistribution { }; } +/** + * Advanced glycaemic indices — the "advanced" disclosure tier of the panel. + * Each is a single scalar over the in-window readings; all are derivable from + * spot samples (unlike the dense-stream MAGE/CONGA/MODD family this module + * deliberately defers). Surfaced behind a progressive disclosure because they + * read as research-grade composites a casual user does not need up front. + */ +export interface GlucoseAdvancedIndices { + /** + * J-index — a single number folding central tendency AND variability: + * J = 0.001 × (mean + SD)², mean & SD in mg/dL. + * Reference bands (Wojcicki 1995): ~10–20 ideal/non-diabetic, + * 20–30 good control, 30–40 fair, > 40 poor. Reported here as an estimate + * over spot data, not a continuous-trace J. + * Wojcicki 1995, Horm Metab Res 27(1):41-42, DOI 10.1055/s-2007-979906. + */ + jIndex: number; + /** + * Low Blood Glucose Index — Kovatchev hypoglycaemia risk. Mean of the + * left-branch risk values `rl(BG)` over all readings; higher = more / deeper + * lows. Interpretation bands (Kovatchev 2006): < 1.1 minimal, 1.1–2.5 low, + * 2.5–5 moderate, > 5 high hypo risk. + * Kovatchev 2006, Diabetes Care 29(11):2433-2438, DOI 10.2337/dc06-1085. + */ + lbgi: number; + /** + * High Blood Glucose Index — Kovatchev hyperglycaemia risk. Mean of the + * right-branch risk values `rh(BG)`; higher = more / higher highs. + * Interpretation bands (Kovatchev 2006): < 4.5 low, 4.5–9 moderate, + * > 9 high hyper risk. + * Kovatchev 2006, Diabetes Care 29(11):2433-2438, DOI 10.2337/dc06-1085. + */ + hbgi: number; +} + /** Variability summary: SD + CV% with the Monnier stability flag. */ export interface GlucoseVariability { /** Sample standard deviation (n−1), mg/dL. */ @@ -160,6 +199,18 @@ export interface GlucoseClinicalMetrics { /** SD + CV% + instability flag (null if < 2 readings). */ variability: GlucoseVariability | null; + /** + * Advanced indices (J-index + LBGI/HBGI). Null when there are no readings; + * the J-index additionally needs ≥ 2 readings (it consumes the sample SD), so + * `jIndex` is held back to null inside the object when only one reading + * exists. Shown behind the panel's "advanced" disclosure. + */ + advanced: { + jIndex: number | null; + lbgi: number; + hbgi: number; + } | 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 @@ -228,6 +279,65 @@ export function estimatedA1c(meanMgdl: number): number { return (meanMgdl + 46.7) / 28.7; } +/** + * J-index — a single composite of central tendency and variability: + * J = 0.001 × (mean + SD)², both in mg/dL. + * Returns null for n < 2 (the sample SD is undefined). Approximation bands are + * documented on {@link GlucoseAdvancedIndices.jIndex}. + * Wojcicki 1995, Horm Metab Res 27(1):41-42, DOI 10.1055/s-2007-979906. + */ +export function jIndex(values: number[]): number | null { + const sd = glucoseSD(values); + if (sd === null) return null; + const m = mean(values); + const s = m + sd; + return 0.001 * s * s; +} + +/** + * Kovatchev symmetrization transform for a single mg/dL reading: + * f(BG) = 1.509 × ( (ln BG)^1.084 − 5.381 ). + * The transform maps the asymmetric mg/dL scale onto a symmetric axis where + * the clinical centre (~112.5 mg/dL) is 0; lows go negative, highs positive. + * Kovatchev 1997, Diabetes Care 20(11):1655-1658, + * DOI 10.2337/diacare.20.11.1655. + */ +function kovatchevSymmetrize(bgMgdl: number): number { + return 1.509 * (Math.pow(Math.log(bgMgdl), 1.084) - 5.381); +} + +/** + * Low / High Blood Glucose Index (LBGI / HBGI) — Kovatchev risk model. + * Per reading the risk is `r(BG) = 10 × f(BG)²` (f = symmetrization above); + * it is assigned to the LOW branch when f < 0 and the HIGH branch when f > 0 + * (f = 0 contributes 0 to both). LBGI = mean of the low-branch risks across + * ALL readings (zeros included), HBGI = mean of the high-branch risks. + * Readings ≤ 0 mg/dL (ln undefined) are skipped from the denominator. + * Returns null when no usable reading remains. + * Kovatchev 2006, Diabetes Care 29(11):2433-2438, DOI 10.2337/dc06-1085. + */ +export function bloodGlucoseRiskIndices( + values: number[], +): { lbgi: number; hbgi: number } | null { + let lowSum = 0; + let highSum = 0; + let n = 0; + for (const bg of values) { + if (!Number.isFinite(bg) || bg <= 0) continue; + const f = kovatchevSymmetrize(bg); + const r = 10 * f * f; + if (f < 0) { + lowSum += r; + } else if (f > 0) { + highSum += r; + } + // f === 0 contributes 0 to both branches but still counts in n. + n += 1; + } + if (n === 0) return null; + return { lbgi: lowSum / n, hbgi: highSum / n }; +} + /** * Time-in-range distribution over the Battelino 2019 consensus bands, as * fractions of readings (NOT % of time — spot data, labelled honestly). @@ -334,6 +444,19 @@ export function computeGlucoseClinicalMetrics( const gmiValue = meanMgdl !== null ? gmi(meanMgdl) : null; const eA1cValue = meanMgdl !== null ? estimatedA1c(meanMgdl) : null; + // Advanced indices — null when no readings. The risk indices need ≥ 1 usable + // reading; the J-index needs ≥ 2 (sample SD), so it is held to null inside + // the object for a single-reading window while LBGI/HBGI still resolve. + const risk = bloodGlucoseRiskIndices(values); + const advanced = + risk !== null + ? { + jIndex: jIndex(values), + lbgi: risk.lbgi, + hbgi: risk.hbgi, + } + : 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. @@ -370,6 +493,7 @@ export function computeGlucoseClinicalMetrics( gmi: gmiValue, estimatedA1c: eA1cValue, variability, + advanced, isSpotEstimate: true, }; }