Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions IDEAS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
77 changes: 71 additions & 6 deletions lib/__tests__/bgModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Comment thread
psjostrom marked this conversation as resolved.
// 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", () => {
Comment thread
psjostrom marked this conversation as resolved.
// 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", () => {
Expand Down Expand Up @@ -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}` }),
Expand Down
27 changes: 23 additions & 4 deletions lib/bgModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}
Expand Down Expand Up @@ -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);
Expand Down
Loading