mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(glucose): add clinical-metrics core for TIR, GMI, eA1C and CV
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user