mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Derive glucose isSpotEstimate from reading density
Hard-coding isSpotEstimate: true mislabels a dense Nightscout CGM stream (~288 readings/day) as a spot estimate and undersells the data in the clinical panel and coach. Compute readings-per-day over the covered span and treat the series as continuous below the spot caveat once it clears a ~hourly bar (24 readings/day); sparse spot fingersticks keep the caveat. The panel only renders the spot-reading line when isSpotEstimate, and the OpenAPI field relaxes from a literal-true to a boolean.
This commit is contained in:
1 parent
c5a7e06e35
commit
c268ace58c
6 files changed
+98
-31
No files matched your search
@@ -14126,7 +14126,6 @@ components:
|
||||
- type: "null"
|
||||
isSpotEstimate:
|
||||
type: boolean
|
||||
const: true
|
||||
required:
|
||||
- stillLearning
|
||||
- stillLearningReason
|
||||
@@ -14141,8 +14140,9 @@ components:
|
||||
- advanced
|
||||
- isSpotEstimate
|
||||
additionalProperties: false
|
||||
description: Server-authoritative glucose clinical panel over the trailing 30-day window. Every figure is a SPOT-READING
|
||||
ESTIMATE (a % of readings), not a CGM time-in-range AGP — `isSpotEstimate` is always true and `stillLearning`
|
||||
description: Server-authoritative glucose clinical panel over the trailing 30-day window. Figures from sparse spot data
|
||||
are a SPOT-READING ESTIMATE (a % of readings), not a CGM time-in-range AGP; `isSpotEstimate` is derived from
|
||||
reading density (true below ~hourly, false for a continuous CGM stream such as Nightscout) and `stillLearning`
|
||||
gates assertion when the window has too few readings or too short a span. `distribution` carries the Battelino
|
||||
2019 TIR/TBR/TAR fractions (level-2 nested in level-1) plus minutes-of-a-day equivalents; `gmi` (Bergenstal
|
||||
2018) + `estimatedA1c` (Nathan 2008 ADAG) derive from the mean; `variability` is SD + CV% with the Monnier 2017
|
||||
|
||||
@@ -206,10 +206,15 @@ export function GlucoseClinicalPanel() {
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* Spot-reading honesty caveat — always present. */}
|
||||
<p className="text-muted-foreground/80 text-xs" data-slot="glucose-spot-caveat">
|
||||
{t("insights.bloodGlucose.clinical.spotCaveat")}
|
||||
</p>
|
||||
{/* Spot-reading honesty caveat — only for sparse spot data. A dense
|
||||
continuous stream (a CGM such as Nightscout) clears the density bar
|
||||
and reports `isSpotEstimate: false`, so the caveat would undersell
|
||||
the data. */}
|
||||
{clinical.isSpotEstimate ? (
|
||||
<p className="text-muted-foreground/80 text-xs" data-slot="glucose-spot-caveat">
|
||||
{t("insights.bloodGlucose.clinical.spotCaveat")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Advanced indices behind a disclosure. */}
|
||||
{clinical.advanced ? (
|
||||
|
||||
@@ -1539,9 +1539,10 @@ async function buildCoachSnapshotImpl(
|
||||
? Math.round(clinicalRaw.variability.cv)
|
||||
: null,
|
||||
unstable: clinicalRaw.variability?.unstable ?? null,
|
||||
// Spot-reading estimate, never a CGM AGP — pinned so the model
|
||||
// never narrates these as continuous-trace clinical figures.
|
||||
isSpotEstimate: true as const,
|
||||
// Density-derived: a sparse spot series stays a spot-reading
|
||||
// estimate, a continuous CGM stream (Nightscout) reads false so the
|
||||
// model can narrate the TIR/GMI as continuous-trace figures.
|
||||
isSpotEstimate: clinicalRaw.isSpotEstimate,
|
||||
};
|
||||
// The display unit travels with the block so the prompt renders
|
||||
// "<value> <unit>" and the EVIDENCE BLOCK tags glucose lines correctly.
|
||||
|
||||
@@ -222,12 +222,48 @@ describe("computeGlucoseClinicalMetrics — window + adequacy", () => {
|
||||
expect(m.advanced).not.toBeNull();
|
||||
});
|
||||
|
||||
it("always marks the result as a spot estimate", () => {
|
||||
const m = computeGlucoseClinicalMetrics(
|
||||
dailyReadings([100], now),
|
||||
{ now },
|
||||
);
|
||||
expect(m.isSpotEstimate).toBe(true);
|
||||
describe("isSpotEstimate — density-derived", () => {
|
||||
it("marks sparse spot data as a spot estimate (one reading/day)", () => {
|
||||
// 14 readings across 14 days = 1/day, far below the hourly CGM bar.
|
||||
const m = computeGlucoseClinicalMetrics(
|
||||
dailyReadings(Array(14).fill(120), now),
|
||||
{ now },
|
||||
);
|
||||
expect(m.readingCount).toBe(14);
|
||||
expect(m.isSpotEstimate).toBe(true);
|
||||
});
|
||||
|
||||
it("marks a dense CGM-density stream as continuous (false)", () => {
|
||||
// ~288 readings/day over 2 days, every 5 minutes — a Nightscout CGM.
|
||||
const FIVE_MIN = 5 * 60 * 1000;
|
||||
const total = 2 * 288;
|
||||
const readings: GlucoseReading[] = Array.from(
|
||||
{ length: total },
|
||||
(_, i) => ({
|
||||
mgdl: 110,
|
||||
measuredAt: new Date(now.getTime() - (total - 1 - i) * FIVE_MIN),
|
||||
}),
|
||||
);
|
||||
const m = computeGlucoseClinicalMetrics(readings, { now });
|
||||
expect(m.readingCount).toBe(total);
|
||||
expect(m.isSpotEstimate).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let a single-day burst masquerade as continuous", () => {
|
||||
// 30 readings all inside one day → span floors at 1 day, so density is
|
||||
// 30/day. That clears the bar legitimately, but a handful in a day must
|
||||
// not: 10 readings in one day = 10/day < 24 → still a spot estimate.
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const readings: GlucoseReading[] = Array.from(
|
||||
{ length: 10 },
|
||||
(_, i) => ({
|
||||
mgdl: 115,
|
||||
measuredAt: new Date(now.getTime() - i * HOUR),
|
||||
}),
|
||||
);
|
||||
const m = computeGlucoseClinicalMetrics(readings, { now });
|
||||
expect(m.isSpotEstimate).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("learning gate", () => {
|
||||
|
||||
@@ -9,14 +9,17 @@
|
||||
* 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.
|
||||
* IMPORTANT honesty contract: HealthLog ingests both SPOT readings (manual
|
||||
* FASTING / POSTPRANDIAL / RANDOM / BEDTIME entries) AND, since v1.17, a dense
|
||||
* Nightscout CGM stream (~288 readings/day). The classic CGM-adequacy bar
|
||||
* (Battelino 2019: 14 days with ≥70% of possible readings) cannot be met by
|
||||
* spot data, so for a sparse series every TIR / GMI / eA1C is a SPOT-READING
|
||||
* ESTIMATE — a "% of readings" distribution, NOT a "% of time" AGP report.
|
||||
* {@link GlucoseClinicalMetrics.isSpotEstimate} is derived from reading DENSITY
|
||||
* (see {@link CGM_READINGS_PER_DAY_THRESHOLD}): a continuous CGM stream reads
|
||||
* `false` and the spot caveat comes off; sparse spot data stays `true`. The
|
||||
* {@link GlucoseClinicalMetrics.stillLearning} gate additionally holds back any
|
||||
* assertion off thin data regardless of source.
|
||||
*
|
||||
* Thresholds and formulas are clean-room from the primary literature (the
|
||||
* consensus band values and coefficients are mathematical facts, not
|
||||
@@ -91,6 +94,17 @@ export const DEFAULT_WINDOW_DAYS = 14;
|
||||
const DEFAULT_MIN_READINGS = 14;
|
||||
const DEFAULT_MIN_SPAN_DAYS = 7;
|
||||
|
||||
/**
|
||||
* Readings-per-day at or above which the series is treated as a CONTINUOUS
|
||||
* stream (a CGM such as Nightscout, ~288/day) rather than spot fingersticks (a
|
||||
* handful/day). Roughly hourly — wide enough that a real CGM (≈288/day) clears
|
||||
* it by an order of magnitude while no realistic spot-checking cadence reaches
|
||||
* it. Above the threshold the TIR/GMI/eA1C are no longer framed as a
|
||||
* spot-reading estimate (`isSpotEstimate: false`); below it the conservative
|
||||
* spot caveat stays.
|
||||
*/
|
||||
const CGM_READINGS_PER_DAY_THRESHOLD = 24;
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
// ── Output shapes ────────────────────────────────────────
|
||||
@@ -212,11 +226,15 @@ export interface GlucoseClinicalMetrics {
|
||||
} | 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.
|
||||
* Whether every number here should be framed as a SPOT-READING ESTIMATE (a
|
||||
* "% of readings") rather than a CGM time-in-range AGP. Derived from reading
|
||||
* DENSITY, not hard-coded: a continuous stream (a Nightscout CGM at ~288
|
||||
* readings/day) clears the {@link CGM_READINGS_PER_DAY_THRESHOLD} hourly bar
|
||||
* and reports `false`; sparse spot fingersticks stay `true`. Surfaced
|
||||
* explicitly so serializers (panel/coach/PDF/iOS) carry the caveat without
|
||||
* re-deriving it.
|
||||
*/
|
||||
isSpotEstimate: true;
|
||||
isSpotEstimate: boolean;
|
||||
}
|
||||
|
||||
// ── Core formulas ────────────────────────────────────────
|
||||
@@ -488,6 +506,13 @@ export function computeGlucoseClinicalMetrics(
|
||||
}; at least ${minSpanDays} days of coverage are needed.`;
|
||||
}
|
||||
|
||||
// Density-derived spot vs continuous framing. Readings/day = count over the
|
||||
// covered span (floored at one day so a single-day burst can't divide by a
|
||||
// sub-day span and look continuous). At or above the hourly CGM bar the
|
||||
// series is a continuous stream and the spot-reading caveat comes off.
|
||||
const readingsPerDay = readingCount / Math.max(actualSpanDays, 1);
|
||||
const isSpotEstimate = readingsPerDay < CGM_READINGS_PER_DAY_THRESHOLD;
|
||||
|
||||
return {
|
||||
stillLearning,
|
||||
stillLearningReason,
|
||||
@@ -500,7 +525,7 @@ export function computeGlucoseClinicalMetrics(
|
||||
estimatedA1c: eA1cValue,
|
||||
variability,
|
||||
advanced,
|
||||
isSpotEstimate: true,
|
||||
isSpotEstimate,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -587,12 +587,12 @@ const glucoseClinicalSchema = z
|
||||
hbgi: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
isSpotEstimate: z.literal(true),
|
||||
isSpotEstimate: z.boolean(),
|
||||
})
|
||||
.meta({
|
||||
id: "GlucoseClinicalMetrics",
|
||||
description:
|
||||
"Server-authoritative glucose clinical panel over the trailing 30-day window. Every figure is a SPOT-READING ESTIMATE (a % of readings), not a CGM time-in-range AGP — `isSpotEstimate` is always true and `stillLearning` gates assertion when the window has too few readings or too short a span. `distribution` carries the Battelino 2019 TIR/TBR/TAR fractions (level-2 nested in level-1) plus minutes-of-a-day equivalents; `gmi` (Bergenstal 2018) + `estimatedA1c` (Nathan 2008 ADAG) derive from the mean; `variability` is SD + CV% with the Monnier 2017 ≥36% instability flag; `advanced` is the disclosure tier — J-index (Wojcicki 1995) + LBGI/HBGI (Kovatchev hypo/hyper risk). All blocks are null when there are no readings; `advanced.jIndex` is null for a single-reading window.",
|
||||
"Server-authoritative glucose clinical panel over the trailing 30-day window. Figures from sparse spot data are a SPOT-READING ESTIMATE (a % of readings), not a CGM time-in-range AGP; `isSpotEstimate` is derived from reading density (true below ~hourly, false for a continuous CGM stream such as Nightscout) and `stillLearning` gates assertion when the window has too few readings or too short a span. `distribution` carries the Battelino 2019 TIR/TBR/TAR fractions (level-2 nested in level-1) plus minutes-of-a-day equivalents; `gmi` (Bergenstal 2018) + `estimatedA1c` (Nathan 2008 ADAG) derive from the mean; `variability` is SD + CV% with the Monnier 2017 ≥36% instability flag; `advanced` is the disclosure tier — J-index (Wojcicki 1995) + LBGI/HBGI (Kovatchev hypo/hyper risk). All blocks are null when there are no readings; `advanced.jIndex` is null for a single-reading window.",
|
||||
});
|
||||
|
||||
const dashboardSnapshotResponse = z
|
||||
|
||||
Reference in new issue
Block a user