mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Add J-index and LBGI/HBGI to glucose clinical metrics
This commit is contained in:
1 parent
4884f019b7
commit
3dfc3fe515
2 files changed
+192
No files matched your search
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user