diff --git a/IDEAS.md b/IDEAS.md index 0b167cfa..7e200a50 100644 --- a/IDEAS.md +++ b/IDEAS.md @@ -107,6 +107,27 @@ When the upstream BG fetch fails (Scout outage, missing credentials, our own bg- **Fix:** new `BGContextStatus` enum (`ok` / `upstream-error` / `no-credentials` / `no-input`) flowing from the Scout fetch in `lib/runBGContext.ts` → `getActivityStreamsWithStatus` → `/api/bg-cache` → atom → IntelScreen banner. Each state gets distinct copy: "Connect Nightscout in Settings → Account..." vs "BG history is offline — predictions paused until your CGM source is reachable again." +### Phase-Aware Fuel Modeling + +`calculateTargetFuelRates` averages BG drop rate across the entire run, then prescribes fuel by it. But per-time-bucket data (verified against 67 real activities) shows two distinct phases: + +| Bucket | Easy | Long | +|---|---|---| +| 0-15 min | -1.78 mmol/hr | -2.67 | +| 15-30 min | -3.23 | -3.47 | +| 30-45 min | -2.13 | **-0.75** | +| 45+ min | -1.69 | -0.71 | + +CHO ingested at minute 10 enters the bloodstream around minute 25 (gastric emptying + intestinal absorption ~15 min). Drop rate clearly attenuates after that — most dramatically on Long runs where the drop nearly stops in the 30-45 bucket. + +**The problem:** Easy runs (median 44 min) spend ~70% of their time in the pre-fuel window. Long runs (median 76 min) spend ~40%. So the average drop rate Easy reports is dominated by pre-fuel kinetics that **no amount of fuel during the run will fix** — that drop is a function of insulin, glycogen, and hydration, not fuel rate. + +**Today's behavior:** model sees "Easy drops fast" → recommends more fuel → extra CHO piles up post-run instead of helping in-run → post-run hyper risk. Directionally wrong for short runs. + +**Fix:** restrict fuel-rate optimization to observations with `relativeMinute >= 25` (or some configurable post-fuel threshold). The pre-fuel window becomes a constant baseline, not a fuel-tunable signal. Side benefit: the per-category recommendation becomes physiologically interpretable ("at fuel X, post-fuel drop is Y") instead of a blended average. + +**Open questions:** (1) right threshold — 20 min? 25? configurable? (2) how to surface the pre-fuel constant to the runner — accept it, or tackle it via timing/insulin/start-BG instead? (3) does pre-fuel drop correlate with start BG or entry slope (in which case it could feed pre-run readiness)? + --- ## Parked diff --git a/TODO.md b/TODO.md index 3d124af2..a619b766 100644 --- a/TODO.md +++ b/TODO.md @@ -18,6 +18,7 @@ ## Tech Debt +- [ ] **Confirm post-run hyper hypothesis with full runBGContext data.** The phase-aware fuel modeling investigation (see IDEAS.md "Phase-Aware Fuel Modeling") tested two of three predictions: drop rate attenuates after ~30 min (✓), Easy spends a larger fraction of run-time in pre-fuel territory (✓). The third — Easy runs accumulate post-run hyper because more of the ingested CHO arrives after the run ends — was **untested**: only 1 spike sample because the investigation script disabled `runBGContext` to skip Scout. Re-run with `withRunBGContext: true` (or query `bg_readings` directly for the 30-min post-end window per activity) and compare avg `spike30m` per category. If Easy spike >> Long spike, that's another argument for phase-aware modeling. - [ ] **`activity_streams.glucose` is a derived cache.** HR-aligned glucose values are computed from `bg_readings` + `hr` (same shape as the deferred `run_bg_context` derivation — see IDEAS.md "Server-Owned `runBGContext` via Scout Batch"). Currently aligned client-side in `useStreamCache.loadUncachedRuns` and shipped server-side for storage. Same anti-pattern. Fix: drop the column, compute alignment on demand alongside the runBGContext rework. After both are gone, revisit whether the `bgcache_v*` localStorage versioning is still needed — it exists to evict caches on derived-shape changes. - [x] ~~**page.tsx is doing too many things.**~~ Migrated to Jotai atoms. Screens read data from atoms via `useAtomValue`, page.tsx is layout + routing only. `useHydrateStore` bridges existing hooks to atoms. IntelScreen went from 28 props to zero. - [x] ~~**`updateWidgetLayoutAtom` swallows fetch errors.**~~ Debounced PUT now checks `res.ok` and catches network errors. Failures surface via `widgetSaveErrorAtom`, shown in IntelScreen edit mode. Clears on next successful save. diff --git a/lib/__tests__/bgModel.test.ts b/lib/__tests__/bgModel.test.ts index f06d9c40..41962b10 100644 --- a/lib/__tests__/bgModel.test.ts +++ b/lib/__tests__/bgModel.test.ts @@ -562,22 +562,65 @@ describe("calculateTargetFuelRates", () => { expect(result[0].category).toBe("easy"); }); - it("uses regression with 2+ distinct fuel rates with 3+ obs each", () => { + it("uses regression with 2+ distinct fuel rates with 3+ obs each and ≥10 g/h spread", () => { const obs: BGObservation[] = [ // Fuel 30 → high drop ...Array.from({ length: 3 }, () => makeObs({ bgRate: -0.2, fuelRate: 30 })), - // Fuel 60 → low drop + // Fuel 60 → low drop (spread = 30 g/h, well above the 10 g/h guard) ...Array.from({ length: 3 }, () => makeObs({ bgRate: -0.05, fuelRate: 60 })), ]; const result = calculateTargetFuelRates(obs); expect(result).toHaveLength(1); expect(result[0].method).toBe("regression"); - // slope = ((-0.05)-(-0.2))/(60-30) = 0.15/30 = 0.005 + // slope = ((-0.05)-(-0.2))/(60-30) = 0.005 // intercept = -0.2 - 0.005*30 = -0.35 - // Solve for y = -0.02: x = (-0.02-(-0.35))/0.005 = 0.33/0.005 = 66 - // Cap: min(66, avgFuel 45 * 1.5 = 67.5, 90) = 66 - expect(result[0].targetFuelRate).toBe(66); + // Raw target: x at y=-0.02 → x = (-0.02-(-0.35))/0.005 = 66 + // Step cap: min(currentAvg 45 + 10, 90) = 55 + // Final: min(66, 55) = 55 + expect(result[0].targetFuelRate).toBe(55); + }); + + it("falls back to extrapolation when fuel-rate spread is below the titration step", () => { + // Per-observed bug: 4 g/h spread between qualified groups, regression + // fits noise across that narrow window and extrapolates absurd targets + // (143 g/h capped to 90). Spread guard pushes to extrapolation instead. + const obs: BGObservation[] = [ + ...Array.from({ length: 5 }, () => makeObs({ bgRate: -0.07, fuelRate: 60 })), + ...Array.from({ length: 5 }, () => makeObs({ bgRate: -0.07, fuelRate: 62 })), + ...Array.from({ length: 5 }, () => makeObs({ bgRate: -0.07, fuelRate: 64 })), + ]; + + const result = calculateTargetFuelRates(obs); + expect(result).toHaveLength(1); + expect(result[0].method).toBe("extrapolation"); + // excessDrop = 0.05, target = 62 + 0.05*60 = 65, cap = min(65, 62+10) = 65 + expect(result[0].targetFuelRate).toBe(65); + }); + + it("uses regression at the spread boundary (exactly one titration step)", () => { + // Pin the boundary: spread of exactly FUEL_STEP_GH qualifies for regression. + // Guards against a future flip from `>=` to `>`. + const obs: BGObservation[] = [ + ...Array.from({ length: 3 }, () => makeObs({ bgRate: -0.2, fuelRate: 50 })), + ...Array.from({ length: 3 }, () => makeObs({ bgRate: -0.05, fuelRate: 60 })), + ]; + + const result = calculateTargetFuelRates(obs); + expect(result).toHaveLength(1); + expect(result[0].method).toBe("regression"); + }); + + it("step cap limits recommendation to current average + 10 g/h", () => { + // Single fuel rate at 30 g/h with very high drop should not jump 50% per cycle + const obs: BGObservation[] = Array.from({ length: 5 }, () => + makeObs({ bgRate: -0.5, fuelRate: 30 }), + ); + + const result = calculateTargetFuelRates(obs); + expect(result).toHaveLength(1); + // Raw extrapolation: 30 + 0.48*60 = 58.8, but step cap pulls to 30+10 = 40 + expect(result[0].targetFuelRate).toBe(40); }); it("clamps target fuel rate to >= 0", () => { @@ -650,6 +693,28 @@ describe("calculateTargetFuelRates with spike penalty", () => { expect(easy!.targetFuelRate).toBeLessThan(60); }); + it("subtracts spike penalty from raw target before the step cap clamps", () => { + // Pins cap-vs-penalty ordering. With raw target above the step cap, + // penalty subtracts from the raw target first, then the cap clamps. + // Cap-then-penalty would give 62 (70 - 8); penalty-then-cap gives 70. + const obs = Array.from({ length: 10 }, (_, i) => + makeObs({ bgRate: -0.4, fuelRate: 60, activityId: `a${i}` }), + ); + const spikes: PostRunSpikeData[] = Array.from({ length: 6 }, (_, i) => ({ + activityId: `a${i}`, + category: "easy" as const, + fuelRate: 60, + spike30m: 4.0, // (4.0 - 2.0) * 4 = 8 g/h penalty + })); + const results = calculateTargetFuelRates(obs, spikes); + const easy = results.find((r) => r.category === "easy"); + expect(easy).toBeDefined(); + // Raw extrapolation: 60 + (0.4 - 0.02) * 60 = 82.8 + // After penalty: 82.8 - 8 = 74.8. Step cap: min(74.8, 70) = 70. + expect(easy!.spikeAdjustment).toBe(8); + expect(easy!.targetFuelRate).toBe(70); + }); + it("skips penalty when fewer than MIN_POST_RUN_OBS spikes", () => { const obs = Array.from({ length: 10 }, (_, i) => makeObs({ bgRate: -0.4, fuelRate: 60, activityId: `a${i}` }), diff --git a/lib/bgModel.ts b/lib/bgModel.ts index e97d1419..d4e5d849 100644 --- a/lib/bgModel.ts +++ b/lib/bgModel.ts @@ -210,16 +210,26 @@ export interface TargetFuelResult { const EXTRAPOLATION_FACTOR = 60; // g/h per 1.0 mmol/L/min excess drop const ACCEPTABLE_DROP = -0.02; // mmol/L per min — a mild drop is normal during running const MIN_DROP_TO_SUGGEST = -0.05; // only suggest fuel increases beyond this threshold -const MAX_FUEL_MULTIPLIER = 1.5; // cap target at 1.5× current fuel -const MAX_FUEL_ABSOLUTE = 90; // absolute ceiling in g/h +// Standard CHO titration step. Sports nutrition + gut-training literature uses +// ~10 g/h increments to adapt SGLT1/GLUT5 transporter expression and avoid GI +// distress (Costa et al. 2023 systematic review; Jeukendrup & Killer 2010). +// "Rule of 15" (Riddell et al. 2017 consensus) for hypo rescue uses the same +// magnitude. Used as both: +// 1. minimum spread between tested fuel rates before regression is trusted +// 2. maximum recommendation increment above current average per cycle +const FUEL_STEP_GH = 10; +const MAX_FUEL_ABSOLUTE = 90; // gut absorption ceiling (Jeukendrup; Burke et al.) const ACCEPTABLE_SPIKE = 2.0; // mmol/L post-run 30m peak above end BG const SPIKE_PENALTY_FACTOR = 4; // g/h per 1.0 mmol/L excess spike const MIN_POST_RUN_OBS = 5; const MIN_FUEL_RATE = 20; // g/h safety floor +// Cap recommendations to one CHO titration step above current average use, +// or the absolute gut-absorption ceiling — whichever is lower. Sudden CHO +// increases cause GI distress (Costa et al. 2023). function capFuel(target: number, current: number): number { const upperBound = current > 0 - ? Math.min(current * MAX_FUEL_MULTIPLIER, MAX_FUEL_ABSOLUTE) + ? Math.min(current + FUEL_STEP_GH, MAX_FUEL_ABSOLUTE) : MAX_FUEL_ABSOLUTE; return Math.max(0, Math.round(Math.min(target, upperBound))); } @@ -256,11 +266,20 @@ export function calculateTargetFuelRates( // Check if we have 2+ distinct fuel rates with 3+ observations each const qualifiedGroups = [...fuelGroups.entries()].filter(([, obs]) => obs.length >= 3); + // Spread guard: regression requires the tested fuel rates to differ by at + // least one CHO titration step. Below that, the slope fits noise, not + // signal — solving for "ideal drop" extrapolates absurd targets. + const fuelRatesTested = qualifiedGroups.map(([fuel]) => fuel); + const fuelSpread = fuelRatesTested.length >= 2 + ? Math.max(...fuelRatesTested) - Math.min(...fuelRatesTested) + : 0; + const useRegression = qualifiedGroups.length >= 2 && fuelSpread >= FUEL_STEP_GH; + let target: number; let method: "regression" | "extrapolation"; const confidence = getConfidence(catObs.length); - if (qualifiedGroups.length >= 2) { + if (useRegression) { // Regression: fuel rate (x) vs avg BG rate (y) per group const points = qualifiedGroups.map(([fuel, obs]) => { const groupRates = obs.map((o) => o.bgRate);