Version: May 2026 (revision 3 — carb burn from physiology) Files:
CarbsTracker.kt,HydrationTracker.kt,IntensityZoneCalculator.kt,CarbBurnEstimator.ktSensors: Karoo SDK heart-rate stream + power stream +streamUserProfile()(weight / maxHr / restingHr / FTP / HR zones / power zones) + rider-entered age + sex
Important
Model rewrite (May 2026) — the carb tracker no longer multiplies a rider-configured "target g/h" by an intensity multiplier. It now computes real carb burn from physiology:
- kcal/h from the highest-confidence tier whose sensors are paired:
- Tier 1 — power:
kcal/h = power_W × 3.6(standard cycling formula; Coyle 1992, Moseley & Jeukendrup 2001; ~5-10 % error). - Tier 2 — Keytel et al. 2005: HR + age + sex + weight (~10-15 % error in cycling 50-80 % VO2max). Requires the rider's age and sex from Settings.
- Tier 3 — Swain & Leutholtz 1997: HRR → METs →
kcal/h = METs × weight(~20-30 % error; fallback when age/sex are not entered). - Tier 4 — none: no usable inputs; the integrator stops and the data fields display
Pair HR/Pwr.
- Tier 1 — power:
- CHO fraction from the current intensity zone, mapped linearly from 0.30 (Z1) to 0.95 (Z5+) — anchors from Romijn 1993, Achten & Jeukendrup 2003, Jeukendrup 2014.
- g/h carb burn = kcal/h × CHO_fraction / 4, integrated over active movement time.
The deleted carbTargetGperHour config field used to drive the integrator (multiplied by IntensityZoneCalculator.multiplier). That model produced misleading data ("burned" was actually "planned intake") and required the rider to set a target that conflated their fueling plan with their physiology. The new model has no rider-tunable rate input on the carb side; the rider only sets the deficit threshold (when to alert) and reminder cadence (how often).
Hydration retains the older target-based model — there is no biosensor for sweat rate to replace it with. The optional dynamic estimator (HR + power + weight + ambient temperature + humidity) still applies. See SweatEstimator.kt.
IntensityZoneCalculator.multiplier is no longer read by the carb integrator but is kept in the data class for backwards compatibility; the classifier itself (source / index / total) is still used to derive the CHO fraction.
The nutrition and hydration tracker is KSafe's preventive safety layer. The reactive layers (crash detection, manual SOS, medical episode) catch incidents after something has gone wrong. The fueling layer tries to prevent the underlying causes of many incidents:
- Bonking (carb depletion → blood glucose drop → impaired cognition, balance, reaction time)
- Dehydration (fatigue, cramps, heat stress, loss of focus)
A rider who is properly fueled and hydrated has clearer judgment and faster reaction time, and is less likely to crash, blow up, or need to be rescued. This is the framing that justifies bundling fueling into a safety extension.
┌──────────────────────────────────────────────────────────────────────┐
│ Rider physiology + sensors │
│ - power (preferred) - HR + age + sex + weight (Keytel) │
│ - HR + maxHr + restingHr + weight (Swain HRR fallback) │
└──────┬───────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ CarbBurnEstimator │ │ Configured per-hour │
│ → kcal/h (Tier 1-4) │ │ target (ml/h) │
│ → × CHO_fraction(zone) │ │ (no biosensor for sweat │
│ → ÷ 4 kcal/g │ │ rate — flat per-hour) │
│ = real burn (g/h) │ │ │
│ + 90 g/h gut cap │ │ │
└──────┬───────────────────┘ └──────┬───────────────────┘
│ │
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ CarbsTracker │ │ HydrationTracker │
│ cumBurnedG = ∫ burn dt │ │ cumTargetMl = rate × t │
│ (movement-gated) │ │ (movement-gated) │
└──────┬───────────────────┘ └──────┬───────────────────┘
│ │
▼ ▼
Deficit alert + Time alert + InRideAlert dispatch (each tracker independently)
Both trackers are fully optional and disabled by default. The carb side has no rider-tunable rate — burn comes from physiology directly. Riders set their deficit threshold (g behind which an alert fires) and reminder cadence (how often the same alert can re-fire). The hydration side retains a rider-set hydrationTargetMlPerHour until a sensor for sweat rate exists.
| Source | Used by | Used for |
|---|---|---|
streamDataFlow(POWER) |
Carbs (Tier 1) | kcal/h = W × 3.6 — highest-confidence burn estimate |
streamDataFlow(HEART_RATE) |
Carbs (Tier 2/3) | Keytel kcal/h with rider's weight + age + sex; Swain METs fallback when age/sex not entered |
streamUserProfile() |
Carbs | Weight (Keytel/Swain), max HR + resting HR (Swain HRR), HR zones (5) + power zones (7) for the CHO fraction |
riderAge (Settings) |
Carbs (Tier 2) | Required input to Keytel — entered in Settings → Fueling |
riderSex (Settings) |
Carbs (Tier 2) | Required input to Keytel — entered in Settings → Fueling |
| (none) | Hydration | Pure time integration; no sensor input |
The Karoo profile is the single source of truth for weight, FTP, max HR and zones — KSafe never asks for those. The rider only adds age and sex in Settings (Keytel needs them; without them the carb estimator falls back to the rougher Swain Tier 3). When neither HR nor power is paired, the data fields render "Pair HR/Pwr" and the integrator freezes.
A pure helper (CarbBurnEstimator.kt) computes the rider's instantaneous carb burn rate in g/h from whichever physiological inputs are available. The model has three steps:
- kcal/h from the highest-confidence tier whose inputs are paired (see Tier 1-4 below).
- CHO fraction (% of kcal coming from carbs) read from the current intensity zone — linear from 0.30 (Z1 recovery, mostly fat) to 0.95 (Z5+, almost pure carb).
- g/h = kcal/h × CHO_fraction / 4 (1 g CHO ≈ 4 kcal Atwater factor).
The output is BurnEstimate(gph, kcalPerHour, choFraction, confidence, zoneSnapshot) — the tracker integrates gph over active movement time and surfaces confidence to the rider via the data field's "Pair HR/Pwr" label when no usable tier fires.
| Tier | Inputs | Formula | Typical error | When it runs |
|---|---|---|---|---|
| POWER (1) | Power meter | kcal/h = power_W × 3.6 (gross efficiency ~22 %; Coyle 1992, Moseley & Jeukendrup 2001) |
5–10 % | Power meter paired AND emitting |
| KEYTEL (2) | HR + age + sex + weight | Keytel et al. 2005 sex-specific HR regressions (output in kJ/min, converted to kcal/h) | 10–15 % @ 50-80 % VO2max | No power; HR + rider age + sex + weight from Karoo profile all available |
| SWAIN (3) | HR + maxHr + restingHr + weight | %HRR ≈ %VO2R (Swain & Leutholtz 1997); METs = 6 × %HRR + 1; kcal/h = METs × weight |
20–30 % | No power; rider hasn't entered age/sex yet |
| NONE (4) | — | gph = 0, integrator frozen |
— | None of HR / power paired or no zones configured |
Male: EE_kJ_per_min = -55.0969 + 0.6309·HR + 0.1988·W + 0.2017·A
Female: EE_kJ_per_min = -20.4022 + 0.4472·HR - 0.1263·W + 0.0740·A
kcal/h = EE_kJ_per_min × 60 / 4.184
The formula is validated for HR 90–170 bpm, weight 40–120 kg, age 18–65. Outside this range the regression still returns a number, but error grows — the data field surfaces Confidence.KEYTEL regardless so the rider knows the tier. Keytel can produce a negative kJ/min at very low HR with atypical weight; that case clamps to null and the estimator falls through to Swain.
The CHO fraction maps the rider's intensity zone index linearly to [0.30, 0.95]:
ratio = zone.index / (zone.total - 1) // 0.0 .. 1.0
choFraction = 0.30 + ratio × (0.95 − 0.30)For a 5-zone HR model: Z1 → 0.30, Z2 → 0.4625, Z3 → 0.625, Z4 → 0.7875, Z5 → 0.95. For a 7-zone Coggan power model: Z1 → 0.30, Z4 → ~0.625, Z7 → 0.95. The end-anchors come from Romijn et al. 1993 (substrate utilization at 25/65/85 % VO2max), Achten & Jeukendrup 2003 (maximal fat oxidation), and Jeukendrup 2014 (review).
When no zones are classifiable (NONE source), the CHO fraction falls back to the midpoint (0.625) so a rider with power but no configured zones still gets a sensible carb estimate.
| Scenario | Inputs | Tier | kcal/h | CHO | g/h |
|---|---|---|---|---|---|
| Strong rider on tempo | power=200 W, HR irrelevant, Z3 (index 2 of 5) | POWER | 200 × 3.6 = 720 | 0.625 | 112 → clamped to 90 |
| Rider with HR only, age/sex entered | HR=150, W=70 kg, age 40, MALE, Z3 | KEYTEL | 882 | 0.625 | 138 → clamped to 90 |
| Rider with HR only, no age/sex | HR=150, W=70, maxHR=190, restHR=50, Z3 | SWAIN | 370 | 0.625 | 58 |
| No sensors | — | NONE | 0 | — | 0 (data field shows "Pair HR/Pwr") |
IntensityZoneCalculator.kt continues to classify the rider's current intensity zone from HR or power against the user profile's zone table. Power is preferred when available (cleaner intensity proxy than HR, which lags 30–60 s and drifts on long rides). The returned ZoneSnapshot(source, index, total, multiplier) still carries a legacy multiplier field for backwards-compatibility with the calibration CSV column — it is no longer read by the integrator and will be removed in a future schema bump. Only source / index / total feed the new CHO-fraction lookup.
Modern recreational gut-absorption ceiling for a glucose+fructose mix is ~90 g/h (Jeukendrup 2014; ISSN 2017; IOC 2019 consensus). Race-trained gut adapts to 120-150 g/h after months of training, but recreational riders cannot absorb more than ~90 g/h sustainably without GI distress. The integrator clamps the computed burn rate at this ceiling (ABSORPTION_CAP_GPH = 90 g/h) — see CarbIntegrator below. If the rider's actual physiological burn exceeds 90 g/h (very common at Z4-Z5 with power > ~150 W), the on-screen deficit just keeps growing honestly; clamping the integration rate prevents the cumulative total from racing ahead of any plausible intake plan.
Pre-v18 KSafe asked the rider to set a carbTargetGperHour value (Casual/Endurance/Race presets) and multiplied it by an intensity multiplier. That model produced misleading numbers — "burned" was actually "planned intake adjusted for effort", and required the rider to conflate their fueling plan with their physiology. v18 removes the field entirely. Burn is real now; the only carb-side knob the rider sets is how many grams behind to alert at (carbDeficitThresholdG) and how often to repeat that alert (carbDeficitReminderIntervalMin). The hydration side keeps its target field because there's no biosensor for sweat rate.
CarbsTracker.kt integrates the carb target rate over time, tracks logged intake, and dispatches InRideAlerts when the rider falls behind.
@Volatile private var cumBurnedG = 0f // float for integration precision
@Volatile private var cumLoggedG = 0 // int — exact sum of logs
@Volatile private var sessionStartMs = 0L
@Volatile private var lastTickMs = 0L // for dt
@Volatile private var lastRealLogMs = 0L // last actual rider log (drives `{elapsed}`)
@Volatile private var lastLogMs = 0L // bumped on logs AND time-alert fires (F1)
@Volatile private var lastTimeAlertFireMs = 0L // pure time-grid clock (v17)
@Volatile private var lastDeficitAlertFireMs = 0L // deficit reminder cooldown clock (v17)
@Volatile private var activeIntegrationMs = 0L // for session-average burn rate
@Volatile private var lastZoneSnapshot = ZoneSnapshot(NONE, -1, 0, 1f)
@Volatile private var lastPeriodicLogMs = 0LAll fields are @Volatile because they're written from Karoo SDK callbacks and read from the tick coroutine. lastRealLogMs and lastLogMs deliberately diverge: a time-alert fire bumps lastLogMs (so the next interval gate measures from the alert, not from the last meal — F1) while lastRealLogMs only moves when the rider actually taps a log, so the {elapsed} token in alert text reflects time since the rider last ate.
Every 15 s the tick coroutine delegates the gates to two pure helpers — CarbBurnEstimator for the burn rate and CarbIntegrator for the movement gate, GPS-stale freeze, absorption-cap clamp, and the active-time accumulator:
val burn = CarbBurnEstimator.estimate(
hrBpm = lastHrBpm,
powerW = lastPowerW,
profile = lastUserProfile,
riderAge = config.riderAge,
riderSex = config.riderSex,
)
lastZoneSnapshot = burn.zoneSnapshot
val stale = lastSpeedChangeMs > 0 && (now - lastSpeedChangeMs) > SPEED_STALE_MS
val dtMs = if (lastTickMs == 0L) 0L else (now - lastTickMs).coerceAtLeast(0L)
val step = CarbIntegrator.integrate(
burnGph = burn.gph,
dtMs = dtMs,
speedKmh = lastSpeedKmh,
speedStale = stale,
)
cumBurnedG += step.deltaG
activeIntegrationMs += step.deltaActiveMs
lastTickMs = nowThe lastTickMs == 0L guard makes CarbIntegrator return all-zero deltas on the first tick (no prior timestamp to bracket the dt). NTP stepping the clock backwards is also handled — dtMs.coerceAtLeast(0L) floors the dt at 0.
| Gate | Effect | Why |
|---|---|---|
Movement (speedKmh >= 2.0) |
Below 2 km/h → freeze | Cycling only burns what you replace when moving. Bench tests and traffic-light stops don't accumulate. |
GPS-stale (now − lastSpeedChangeMs > 10 s) |
Stuck speed → freeze | The Karoo SDK replays the last known value when GPS lock is lost in a tunnel / forest. Trusting the stuck value would integrate during a long tunnel even after the rider stopped inside it. |
| Absorption cap | effectiveGph = min(burn.gph, 90) |
Recreational gut ceiling. |
| Active-time gate | activeIntegrationMs += only when effectiveGph > 0 |
A movement-gate-passing tick with confidence=NONE adds 0 to cumBurnedG AND must NOT count toward the session-average denominator, or the "Pair HR/Pwr" hint never fires for HR-less riders. |
Two combinable alert modes, each with its own cooldown clock. Per-source clocks were introduced in v17 — pre-v17 a single shared lastAlertMs meant a deficit fire reset the time-alert clock and vice versa.
Deficit alert (CarbsTracker.evaluateDeficitAlert):
if carbDeficitAlertEnabled
AND (cumBurnedG − cumLoggedG) >= carbDeficitThresholdG
AND (now − lastDeficitAlertFireMs) >= carbDeficitReminderIntervalMin × 60_000 (default 10 min)
AND (deficit-initial-delay grace passed; see below)
→ fire
Time alert (CarbsTracker.evaluateTimeAlert via FuelingAlertScheduler):
Pure-interval grid: ticks at sessionStartMs + N × intervalMs.
The rider's logs no longer shift the grid. The initial-delay
FILTERS ticks whose timestamp would be earlier than
sessionStartMs + initialDelayMs (the grid stays anchored to
session start).
if carbTimeAlertEnabled AND a grid tick is due AND no fire stamped at this tick
→ fire
Coincidence resolution. When both a deficit AND a time tick are due in the same physical tick, the deficit alert wins — its numeric "behind N g" is more actionable than a "X min since last" reminder, and both ask for the same rider action (eat). The time tick is consumed silently (the grid is still advanced) so the rider doesn't hear two beeps in quick succession.
Both alert paths have a per-tracker initial grace period. The motivation:
- Time alert: most riders don't eat or drink in the first 20-30 minutes of a multi-hour ride; firing a "time to eat!" alert at minute 25 of a 4-hour effort is a nag, not safety.
- Deficit alert: the integrator runs from t=0, so on a fresh ride the deficit crosses threshold purely from elapsed time without any rider misconduct. Without this gate the rider sees a "behind 25 g" nag at minute ~25 of a fresh ride, which reads as the app malfunctioning.
// Deficit alert (CarbsTracker.evaluateDeficitAlert):
val isFirstDeficitAlert = lastDeficitAlertFireMs == 0L && cumLoggedG == 0
if (isFirstDeficitAlert && carbDeficitInitialDelayMin > 0) {
if ((now - sessionStartMs) < carbDeficitInitialDelayMin × 60_000) return
}
// Time alert (via FuelingAlertScheduler.currentDueTimeTick):
// initialDelayMs FILTERS grid ticks earlier than (sessionStartMs + initialDelayMs).
// The grid itself stays anchored at sessionStartMs + N × intervalMs — rider logs
// do not shift it.Per-source clocks (v17). The grace period applies to the first alert of each source. A deficit fire bumps lastDeficitAlertFireMs only; the time-alert initial-delay gate continues to evaluate against lastTimeAlertFireMs == 0L independently. Pre-v17 a single shared lastAlertMs meant a deficit fire effectively released the time-alert gate too — that coupling is gone, the source clocks are fully independent. The shared release condition is still rider logging: once cumLoggedG > 0 (or cumLoggedMl > 0), the grace gate of BOTH sources falls open because the rider is now actively fueling and the "fresh-ride grace" rationale no longer applies.
| Field | Default | Effect when default |
|---|---|---|
carbTimeInitialDelayMin |
30 | First time-alert can't fire before minute 30 of the session |
carbDeficitInitialDelayMin |
30 | First deficit-alert can't fire before minute 30 of the session |
hydrationTimeInitialDelayMin |
30 | (mirror for hydration) |
hydrationDeficitInitialDelayMin |
30 | (mirror for hydration) |
All four can be set to 0 to disable the grace and fire as soon as the trigger condition is met (original pre-v11 behaviour).
Both the title and the detail line of the InRideAlert are per-rider customisable, with the rider's templates rendered through extension/managers/AlertTextRenderer.renderAlertText at fire time:
| Config field | Default | Purpose |
|---|---|---|
carbAlertCustomTitle |
"" → R.string.fueling_carb_alert_title ("Eat something") |
Title shown at the top of the popup. |
carbAlertCustomDetail |
"" → source-specific defaults (fueling_carb_alert_detail_deficit "Behind by {deficit}g" / fueling_carb_alert_detail_time "{elapsed} min since last log") |
Detail line. When the rider sets a custom template it is used for both alert sources (deficit and time); the source-specific defaults only apply when the field is empty. |
The renderer substitutes {token} placeholders with current data when the alert fires. Tokens not supplied are left literal so a typo is visible to the rider rather than silently blanked.
After substitution, the rendered string is capped at the call site so the popup cannot run off the Karoo screen: titles at ALERT_TITLE_MAX_CHARS = 40 and details at ALERT_DETAIL_MAX_CHARS = 34 (defined in extension/util/AlertTextRenderer.kt; the detail cap was reduced from 90 after on-hardware measurement of where the popup truncates). When the cap kicks in the last visible char is replaced with …. The cap applies only to the on-screen InRideAlert — the outgoing emergency message sent through the configured provider (Pushover / Telegram / ntfy / CallMeBot) uses its own separate template (config.message etc.) and has no such limit.
| Token | Substituted with |
|---|---|
{deficit} |
Current carb deficit in grams (cumBurnedG − cumLoggedG, integer) |
{elapsed} |
Minutes since last real rider log (lastRealLogMs, not bumped by time-alert fires) |
{target} |
Instantaneous burn rate in g/h (post-absorption-cap). Pre-v18 this was the rider-configured carbTargetGperHour; that field is gone — {target} now reflects the physiological burn the rider is producing right now, which is the closest semantic match. |
Examples:
- Default deficit alert at 35 g behind → "Behind by 35g".
- Custom template
"You're {deficit}g down — eat something now"at 25 g behind → "You're 25g down — eat something now".
The same pattern applies to hydration via hydrationAlertCustomTitle / hydrationAlertCustomDetail with the same token vocabulary ({deficit} in ml, {elapsed} in min, {target} in ml/h).
Each of the three carb slots and two hydration slots carries an idle background colour and an optional emoji icon prefix, both customisable in the Fueling tab via screens/FieldColorPicker and screens/FieldEmojiPicker:
| Config field | Default | Used by |
|---|---|---|
carbNColor (N=1..3) |
FIELD_COLOR_AUTO (the Karoo-theme passthrough sentinel = Color.TRANSPARENT = 0) |
CarbLogDataType.idleColorFromConfig for the IDLE state's background. When the value is the AUTO sentinel the field inflates field_view_auto.xml and skips setBackgroundColor; any other value uses field_view.xml and paints the bg with that ARGB int. |
carbNIcon (N=1..3) |
FUEL_GEL_DRAWABLE, 🍫, 🍌 |
Prepended to the field's main label ("$emoji $label"); empty string = no prefix; the FUEL_GEL_DRAWABLE sentinel renders as a real vector drawable instead — see below. |
drinkNColor (N=1..2) |
FIELD_COLOR_AUTO |
HydrationLogDataType.idleColorFromConfig. Same AUTO-vs-painted layout-switch as carbs. |
drinkNIcon (N=1..2) |
💧, FUEL_BOTTLE_DRAWABLE |
Same prefix logic, with the bottle sentinel rendering the bidón vector drawable. |
The colour palette is shared across the whole app (FIELD_COLOR_PALETTE = 1 Karoo-default sentinel + 20 dark hues — see field-colours.md for the exact swatches and per-row layout). The reserved state colours (bright red / orange / amber / bright dark green / mid grey — used by SOS, Timer, CustomMessage's SENT/SENDING/ERROR/OFF flashes and the LOGGED flash here) are deliberately excluded so a rider's idle pick can never collide with a state-machine signal.
The emoji palettes (FUEL_EMOJI_CARB, FUEL_EMOJI_DRINK) sit in data/ConfigData.kt and start with "" so riders can opt out of the prefix entirely. Emojis render in colour even though the surrounding TextView is white, so they pop against the coloured background without drawable bundling.
Unicode has no emoji that resembles a sports gel pouch or a cyclist's bidón, so KSafe ships two custom vector drawables for those specific shapes — the only items in the palette that aren't standard emoji:
| Sentinel constant | Resource | Default for | Rendered |
|---|---|---|---|
FUEL_GEL_DRAWABLE = "<gel>" |
res/drawable/ic_fuel_gel.xml |
carb1Icon (slot label "Gel") |
First entry of FUEL_EMOJI_CARB after "". |
FUEL_BOTTLE_DRAWABLE = "<bottle>" |
res/drawable/ic_fuel_bottle.xml |
drink2Icon (slot label "Bottle") |
First entry of FUEL_EMOJI_DRINK after "". |
Mechanism: when iconFromConfig(c) returns one of the sentinels:
labelFromConfigskips the emoji prefix — the drawable is the icon, not text.buildViewsets the corresponding drawable as the mainTextView's left compound drawable viaRemoteViews.setTextViewCompoundDrawables(R.id.field_text_main, leftDrawableRes, 0, 0, 0). Setting all four sides explicitly clears any drawable carried over from a previous IDLE→LOGGED transition, so the green "+25g ✓" flash never carries the gel icon along.- The IDLE state passes the drawable resource id; the OFF and LOGGED states pass
0.
In screens/FieldEmojiPicker, a private drawableForSentinel(s: String): Int? helper centralises the sentinel→resource mapping, so both the trigger preview and the dialog grid render the bundled drawables consistently. Adding a third bundled icon later (e.g. a real granola bar shape) is a one-line when extension plus the new SVG.
The sentinel strings are angle-bracketed (<gel>, <bottle>) so they can never collide with a real emoji codepoint sequence and so the alert-text token-substitution renderer (extension/managers/AlertTextRenderer.renderAlertText, which does String.replace("{$k}", v)) cannot accidentally consume them as tokens. Once shipped these strings are saved verbatim into rider DataStore configs — they MUST NOT change in future versions or existing riders' slot icons would silently revert to the default.
When the master tracker toggle is off, CarbLogDataType / HydrationLogDataType short-circuit the state machine and render a gray OFF non-clickable view — the rider sees that the field exists but cannot interact with it, and the in-app Fueling settings collapse the now-irrelevant sub-fields. This matches the same disabled-state pattern used by Custom Messages and Webhook fields.
fun logEntry(slot: Int) { // slot ∈ {1, 2, 3}
cumLoggedG += grams(slot)
lastLogMs = now
}Called from the data-field tap or the SRAM AXS BonusAction (slot 1 only).
fun getStatus(): CarbStatus // for the on-ride status data field
fun getSummary(): CarbSummary // for the post-ride summary InRideAlertgetStatus() builds a snapshot from current Volatile reads. Internally consistent within one tick's worth of integration (~5 s). Polled by CarbStatusDataType once per second.
getSummary() is read after stop() for the post-ride summary. State is intentionally retained across stop() so the summary can read final totals; the next start() resets everything.
HydrationTracker.kt mirrors CarbsTracker structurally but is simpler — there is no biosensor for sweat rate so the model stays target-based:
- No physiological burn estimator. The rider sets
hydrationTargetMlPerHour(default 750 ml/h) directly. Raising it for hot days remains a manual step. - Optional dynamic estimator.
dynamicHydrationEnabledswitches onSweatEstimator(HR + power + weight + ambient temperature + humidity from Headwind, when available); otherwise the flat per-hour rate applies. Anchors target the literature median (Sawka 2007 / Baker 2017) with a small (~5–10 %) conservative bias — comparable to Garmin's Firstbeat HeatStress targeting. SeeSweatEstimator.ktheatFactorfor the WBGT-anchored curve. - No HR / power consumed in the default path (the dynamic estimator does consume them).
- 2 logging slots instead of 3.
- Same dual-mode alerts (deficit + time) with the same configurable reminder cooldown (
hydrationDeficitReminderIntervalMin, default 10 min) and the same grid-aligned time alert. - Same initial-delay grace period and same custom-title option as carbs, with their own per-tracker config fields.
if (lastTickMs != 0L && moving) {
val dtSec = (now - lastTickMs).coerceAtLeast(0L) / 1000f
val ratePerSec = effectiveMlPerHour / 3600f // flat target OR SweatEstimator
cumTargetMl += dtSec * ratePerSec
}effectiveMlPerHour is hydrationTargetMlPerHour in the static path; in the dynamic path it comes from SweatEstimator.estimate(...). The movement gate (speedKmh >= 2.0) and GPS-stale freeze apply on the hydration side too — same rationale as carbs. The two trackers are kept as separate classes because the burn-estimator path materially differs between them (real physiology on the carb side, target on the hydration side).
Two complementary mechanisms:
| Field type | Slots | Display | Tap action |
|---|---|---|---|
CarbLogDataType |
3 (carb-log-1 / -2 / -3) | Configured label + amount (e.g. "Gel 25g") | Logs configured grams to its slot |
HydrationLogDataType |
2 (hyd-log-1 / -2) | Same pattern in ml | Logs configured ml |
CarbStatusDataType |
1 (carb-status) | Current deficit (color-coded) | Read-only |
HydrationStatusDataType |
1 (hyd-status) | Same in ml | Read-only |
Tap behaviour: a PendingIntent fires a unique broadcast action (com.enderthor.kSafe.TAP_CARB_LOG_$slot) → FieldTapReceiver → KSafeExtension.handleCarbLogTap(slot). The state machine for the slot is IDLE → LOGGED (6 s window, tappable, hint "TAP UNDO") → IDLE on timeout, or IDLE → LOGGED → (tap during window) → UNDONE (1.5 s red flash) → IDLE if the rider taps the same slot a second time within the 6 s window. The undo path calls tracker.undoLastForSlot(slot) which reverses the cumulative grams and restores the previous lastLogMs so a time-based alert clock isn't perturbed by the bad entry. The pending revert Job is stored per slot in KSafeExtension.carbTapRevertJobs[] and cancelled before launching a new one, so a stale LOGGED → IDLE timer from an earlier tap cannot clobber a fresher state set by a subsequent tap on the same slot.
Two BonusActions registered: "KSafe: Log Carb" and "KSafe: Log Drink", both wired to slot 1 of each category. The rider maps them to AXS shifter buttons. Logging without looking at the screen.
When RideState transitions to Idle, KSafe captures totals (before stopping the trackers) and dispatches a single InRideAlert:
"Carbs: 85/120g (71%) • Hyd: 1100/1500ml (73%)"
- Fires only if
fuelingPostRideSummaryEnabledis on AND at least one tracker had a non-zero target during the session. - No beep, no contact alert — purely a personal recap.
- Auto-dismiss after 15 s.
Open question: the
InRideAlertSDK class name suggests in-ride use. Whether the alert reliably renders during the brief Recording → Idle transition is verified by on-device testing. If on-device testing shows the summary is sometimes dropped, the fallback is to use aSystemNotificationfor the summary specifically (during-ride alerts continue to useInRideAlert).
KSafe writes seven developer fields into the Karoo's FIT file so the rider's activity in Strava / Intervals.icu / TrainingPeaks carries native graphs of fueling and cardiac decoupling alongside HR / power / cadence — coaches can correlate substrate / hydration / wellness with effort directly without exporting a separate CSV.
The karoo-ext SDK exposes KarooExtension.startFit(emitter: Emitter<FitEffect>). The Karoo OS calls this once at FIT-pipeline start (typically a moment before RideState.Recording); the extension keeps emitting FitEffect instances for as long as the ride lives, and registers a cancellation hook for tear-down. Two effect types are used:
| Effect | When | Lands in |
|---|---|---|
WriteToRecordMesg(values) |
Tracker / wellness value changes | A FIT record message — a per-timestamp sample alongside HR / power |
WriteToSessionMesg(values) |
Tracker / wellness summary changes | The FIT session message — the activity's headline / summary entry (last value wins) |
Both take a List<FieldValue>, where each FieldValue(developerField, value: Double) pairs a custom field with its current value.
All seven fields are float32 (fitBaseTypeId = 136) and live in developer-data index 0. The field-definition numbers are public API — once shipped they cannot move because tools that learned the schema from a rider's earlier FIT file would otherwise misinterpret new files.
| # | Field name | Units | In record | In session | Source |
|---|---|---|---|---|---|
| 0 | ksafe_carbs_g |
g | ✅ | ✅ | CarbsTracker.cumLoggedG — total rider-logged carbs |
| 1 | ksafe_hyd_ml |
ml | ✅ | ✅ | HydrationTracker.cumLoggedMl — total rider-logged fluid |
| 2 | ksafe_hr_drift_pct |
% | ✅ | — | WellnessMonitor.currentDriftPct — instantaneous cardiac decoupling |
| 3 | ksafe_max_drift_pct |
% | — | ✅ | Peak cardiac decoupling reached during the ride |
| 4 | ksafe_wellness_fires |
count | — | ✅ | Number of wellness alerts that fired |
| 5 | ksafe_carbs_burned_g |
g | ✅ | ✅ | CarbsTracker.cumBurnedG — total estimated physiological carb burn |
| 6 | ksafe_carb_burn_rate_gph |
g/h | ✅ | — | CarbsTracker.burnRateGph — instantaneous burn rate (post-cap) |
#7 is reserved — the session-average burn rate is derivable downstream from the #6 time series, so writing it again would just duplicate information for 4 bytes.
The collector pulses on the Karoo's DataType.Type.ELAPSED_TIME stream (1 Hz native cadence — same tick as the HR / power records), but each write is gated on actual value change:
val recChanged =
carbsG != lastRecCarbsG ||
hydMl != lastRecHydMl ||
carbsBurnedG != lastRecCarbsBurnedG ||
burnRateGph != lastRecBurnRateGph ||
driftPct != lastRecDriftPct
if (recChanged) {
emitter.onNext(WriteToRecordMesg(listOf(...)))
// ... update cached values ...
}The cache initial value is Double.NaN — NaN != NaN is true in IEEE 754, so the first tick of every ride always emits. The session message uses the same idiom against its own cache.
Why write-on-change instead of 1 Hz:
cumLoggedG/cumLoggedMlare step curves — they only move on rider taps. Re-writing the same value every second produces ~18 000 identical records per 5 h ride.cumBurnedG/burnRateGphonly update every 15 s (the integrator's tick cadence). 14 of every 15 same-second writes carry no new information.currentDriftPctupdates every 30 s (WellnessMonitor.MONITOR_TICK_MS). 29 of 30 same-second writes are redundant.- FIT consumers (Strava, Intervals.icu, TrainingPeaks) plot developer fields at the emitted timestamps and interpolate. A sparse series renders identically to a dense series that repeats values — but the dense series wastes the FIT file size and the per-record allocation budget.
- Audit 2026-05-25: pre-throttle the FIT writer accounted for ~50 K allocations/hour (record + session messages + 10 FieldValue per tick + listOf wrappers). Write-on-change brings this to ~3 K allocations/hour without touching the contract.
ELAPSED_TIME stops emitting while RideState.Paused, so a Paused-only branch would never fire. Both write paths sit inside the Recording branch; the session message therefore writes from the same tick as the record. Whatever value is current at FIT close is what becomes the activity-summary header in Strava et al. A side benefit of streaming off ELAPSED_TIME (rather than a delay() loop) is that when the Karoo sits idle on a desk between rides, no work happens at all — the previous delay(1_000L) ran ~86 k iterations/day even outside a ride.
Notes:
- Float32 rather than uint16 so future enhancements (additional decimals, ride-fraction percentages) don't need a schema migration. Integer values up to a single ride's load (~1500 g, ~65 L) convert to float32 exactly.
nativeFieldNum = nullon every field because no native FIT field carries these semantics — they're pure developer fields.
startFit may be called before the rider opted into fueling — the CarbsTracker, HydrationTracker and WellnessMonitor are still null in that case. The collector reads via the existing *OrNull() accessors with ?: 0 fallback:
val carbsG = (carbStatus?.cumLoggedG ?: 0).toDouble()
val carbsBurnedG = (carbStatus?.cumBurnedG ?: 0).toDouble()
val burnRateGph = (carbStatus?.burnRateGph ?: 0).toDouble()
val hydMl = (hydrationTrackerOrNull()?.getStatus()?.cumLoggedMl ?: 0).toDouble()
val driftPct = wellness?.currentDriftPct?.toDouble() ?: 0.0A flat-zero column in the FIT is honest data ("no fueling logged") and lets a rider who enables fueling mid-season backfill cleanly without a config drift.
KSafeConfig.fuelingFitExportEnabled (default true) gates the writer. When false, startFit calls setCancellable { } and returns immediately — no coroutine spawned. The toggle is sampled once at FIT-pipeline start; flipping it mid-ride takes effect only on the next ride. A hot-toggle would be premature complexity for a setting riders almost never flip mid-ride.
| Concept | Per ride (5 h, post-throttle) |
|---|---|
| Record + session IPCs (write-on-change, ~1.2 K record + ~0.3 K session writes) | ~0.1 s CPU total |
Allocations from FIT writer (FieldValue + WriteToRecordMesg/SessionMesg + lists) |
~3 K/hour (was ~50 K/hour before write-on-change) |
| Disk: ~28 bytes extra per FIT record (5 float32) | ~3 KB total — sparse series |
| Battery overhead | <0.05 % (imperceptible) |
Negligible against the ride app's own write throughput. The toggle exists for riders who don't want extra developer columns in their FIT, not for battery reasons.
Same model as the medical/wellness detectors:
- Sensor input writes (
updateHr,updatePower,updateUserProfile,updateSpeed) happen on Karoo SDK callback threads. Volatile-only writes. - Tick coroutine (every 15 s) runs on the extension's
Main + SupervisorJobscope. Reads Volatiles, callsCarbBurnEstimator.estimate+CarbIntegrator.integrate, evaluates alerts. @Volatileis required for cross-thread visibility. No locks.
fun start(config: KSafeConfig) {
val oldJob = monitorJob // snapshot before reset
// ... reset state inline (Volatile writes, immediately visible) ...
monitorJob = scope.launch {
oldJob?.cancelAndJoin() // wait for previous tick loop to fully stop
while (true) { delay(MONITOR_TICK_MS); tick() }
}
}The cancelAndJoin inside the new coroutine ensures the previous tick loop is fully stopped before the new one runs. Without this, a stale tick from the cancelled coroutine could observe partially-reset state and emit a spurious calibration log row.
- Tick allocation: one
BurnEstimate+ oneZoneSnapshot+ oneIntegrationStepper tick (carbs only) + at most oneStringper alert dispatch. Hot path is <100 µs. The zone classifier runs once per tick — pre-v18.1 it ran twice (CarbsTracker called it directly andCarbBurnEstimator.estimatere-classified internally); fixed byBurnEstimate.zoneSnapshot. - Status data field updates: push-based via
CarbsTracker.statusFlow/HydrationTracker.statusFlow. Emissions are tick-rate (15 s) plus event-driven (rider logs, movement-gate transitions). Pre-v18.0 the data fields polled at 1 Hz and allocated a freshCarbStatusevery second; the push flow cut that to ~4 emissions/min per visible field. CarbBurnEstimator.estimate()/CarbIntegrator.integrate(): pure functions, no state, no I/O.- Calibration logging lambdas: inert when disabled (Volatile boolean check, lambda body never evaluated).
| Event (CSV tag) | Fields |
|---|---|
FUELING_CARB_START (CARB_START) |
cum_burned_g, cum_logged_g, tier_at_start |
FUELING_CARB_LOGGED (CARB_LOG) |
slot, grams, cum_logged, cum_burned |
FUELING_CARB_UNDONE (CARB_UNDO) |
slot, grams (negative — the reversal amount), cum_logged, cum_burned |
FUELING_CARB_FIRED (CARB_FIRE) |
`source(deficit |
FUELING_CARB_PERIODIC (CARB_PERIODIC) |
every 2 min: cum_burned, cum_logged, deficit, zone_source, zone_idx, zone_total, confidence, kcal_h, cho_fraction, hr, power |
FUELING_HYDRATION_LOGGED (HYD_LOG) |
slot, ml, cum_logged, cum_target |
FUELING_HYDRATION_UNDONE (HYD_UNDO) |
slot, ml (negative — the reversal amount), cum_logged, cum_target |
FUELING_HYDRATION_FIRED (HYD_FIRE) |
source, deficit_ml, since_log_min, cum_target, cum_logged |
FUELING_HYDRATION_PERIODIC (HYD_PERIODIC) |
every 2 min: cum_target, cum_logged, deficit |
v18: the legacy multiplier= field is gone from CARB_FIRE and CARB_PERIODIC. It was vestigial after the integrator switched from base × multiplier to the physiological estimator; the new load-bearing signals are confidence (which tier ran), kcal_h (the kcal/h that drove the integration step) and cho_fraction (Romijn / Jeukendrup table lookup at the current zone). The CSV column header was updated; older logs still parse — the column slots a multiplier value into a confidence header which is wrong but is also recognizable as legacy v17 data.
Counting intakes: a parser that wants "how many times did the rider tap log" should filter by the
_LOGGEDtags only —_UNDONErows are reversals, not intakes. A parser that sumsgrams/mlacross both_LOGGEDand_UNDONErows nets out correctly (the negative undo cancels the original positive log). The distinct tag exists precisely so the two analyses don't conflict.
The 2-minute cadence of *_PERIODIC matches the existing crash-detection PERIODIC event so calibration analysis can correlate timelines by timestamp without modifying crash code.
- Soft-fall detection via fueling state. A rider with high carb deficit + low recent intake who suddenly has an accel impact below the smoothed crash threshold could be a candidate for HR-confirmed soft-fall handling. Requires expanding the crash detector's trigger paths — out of scope for v1.
- Power-meter battery awareness. If the power meter sensor reports low battery, the burn estimator should explicitly downgrade from Tier 1 (POWER) to Tier 2/3 (HR-based). Currently it uses whichever data is flowing; a dying power meter that emits 0 W is read as "rider is freewheeling", under-counting burn.
- Adaptive deficit threshold. A future iteration could learn from logged intake across rides ("you consistently let the deficit grow to 40 g before logging — consider lowering threshold"). Out of scope for v1.
- Dynamic sweat-rate refinement.
SweatEstimatoralready accepts ambient temperature and humidity when Headwind is paired. Future: validate the formula against real-rider field data and expose tuning knobs. - GI-distress upper bound. Currently nothing alerts the rider if they over-consume. The intestinal absorption ceiling is ~90 g/h; sustained intake above that often causes GI issues. Out of scope per the original spec, but worth re-evaluating with calibration data.
ZoneSnapshot.multiplierfield removal. Still present in the data class for backwards compat with the v17 calibration CSV column header. Schedule for removal once enough v18-shipped logs have accumulated that the historical analysis pipeline can drop the legacy column.- Inter-app integration. Other Karoo extensions might want to consume the carb / hydration state. Requires a defined contract — see future spec.
All config fields live in KSafeConfig (data/ConfigData.kt).
| Field | Default | UI exposed |
|---|---|---|
riderAge |
0 (= not entered → Tier 2 unavailable, falls back to Tier 3 Swain) |
✅ — Settings → Fueling |
riderSex |
RiderSex.NOT_SET (= same fallback as above) |
✅ — radio (Male / Female / Unset) |
Riders who don't fill these in still get a useful carb estimate via Swain. Both fields landed in v18; old saved configs deserialise with both at the default and the tracker silently runs Tier 3 until the rider opens Settings.
| Field | Default | UI exposed |
|---|---|---|
carbsTrackerEnabled |
false (opt-in master — gates all sub-fields and collapses them when off) |
✅ |
carbDeficitAlertEnabled |
true |
✅ |
carbDeficitThresholdG |
25 g | ✅ |
carbDeficitInitialDelayMin |
30 | ✅ (0 = off — fire as soon as threshold crossed) |
carbDeficitReminderIntervalMin |
10 | ✅ (5 / 10 / 15 / 20 / 30 — replaces the pre-v17 hard-coded 5 min cooldown) |
carbTimeAlertEnabled |
false |
✅ |
carbTimeIntervalMin |
25 | ✅ (1-60 min) |
carbTimeInitialDelayMin |
30 | ✅ (0 = off; filters grid ticks below this offset) |
carbAlertBgColor |
FUELING_ALERT_COLOR_ORANGE |
✅ — swatch picker (6 colours) |
carbBeepPattern |
SINGLE_LONG |
✅ — beep pattern picker |
carbAlertCustomTitle |
"" (use default) |
✅ |
carbAlertCustomDetailTime |
"" (use source-specific default) |
✅ — supports {deficit}, {elapsed}, {target} |
carbAlertCustomDetailDeficit |
"" (use source-specific default) |
✅ — same tokens |
carb1Label / carb1Grams / carb1Color / carb1Icon |
"Gel" / 25 / palette-blue / 🧴 | ✅ (per-slot row + colour & icon pickers) |
carb2Label / carb2Grams / carb2Color / carb2Icon |
"Bar" / 30 / palette-blue / 🍫 | ✅ |
carb3Label / carb3Grams / carb3Color / carb3Icon |
"Fruit" / 20 / palette-blue / 🍌 | ✅ |
Removed in v18: carbTargetGperHour, CarbRidePreset enum, the Casual / Endurance / Race preset chips. The rider no longer sets a per-hour target — burn comes from physiology.
| Field | Default | UI exposed |
|---|---|---|
hydrationTrackerEnabled |
false (opt-in master — same gating as carbs) |
✅ |
hydrationTargetMlPerHour |
750 | ✅ |
dynamicHydrationEnabled |
false |
✅ — switches to SweatEstimator (HR + power + weight + ambient temperature + humidity from Headwind, when paired) |
hydrationDeficitAlertEnabled |
true |
✅ |
hydrationDeficitThresholdMl |
300 ml | ✅ |
hydrationDeficitInitialDelayMin |
30 | ✅ (0 = off) |
hydrationDeficitReminderIntervalMin |
10 | ✅ (5 / 10 / 15 / 20 / 30 — mirror of carb side) |
hydrationTimeAlertEnabled |
false |
✅ |
hydrationTimeIntervalMin |
20 | ✅ (1-60 min) |
hydrationTimeInitialDelayMin |
30 | ✅ (0 = off) |
hydrationAlertBgColor |
FUELING_ALERT_COLOR_BLUE (water-coloured by default) |
✅ — swatch picker (6 colours) |
hydBeepPattern |
SINGLE_LONG |
✅ — beep pattern picker |
hydrationAlertCustomTitle |
"" |
✅ |
hydrationAlertCustomDetailTime |
"" (use source-specific default) |
✅ — supports {deficit}, {elapsed}, {target} |
hydrationAlertCustomDetailDeficit |
"" (use source-specific default) |
✅ — same tokens |
drink1Label / drink1Ml / drink1Color / drink1Icon |
"Sip" / 100 / palette-blue / 💧 | ✅ |
drink2Label / drink2Ml / drink2Color / drink2Icon |
"Bottle" / 500 / palette-blue / 🥤 | ✅ |
| Field | Default | UI exposed |
|---|---|---|
fuelingPostRideSummaryEnabled |
true |
✅ Switch — Fueling tab |
| Field | Default | UI exposed |
|---|---|---|
fuelingFitExportEnabled |
true |
✅ Switch — Settings tab. Sampled once at FIT-pipeline start; mid-ride toggle takes effect on the next ride. |
Internal: developer-field definitions and pacing live in extension/KSafeExtension.startFit. Field names ksafe_carbs_g / ksafe_hyd_ml / ksafe_hr_drift_pct / ksafe_max_drift_pct / ksafe_wellness_fires / ksafe_carbs_burned_g / ksafe_carb_burn_rate_gph and field definition numbers 0..6 are stable identifiers — do not change once shipped. #7 is reserved.
Internal constants (CarbIntegrator.MOVING_GATE_KMH, CarbIntegrator.SPEED_STALE_MS, ABSORPTION_CAP_GPH, MONITOR_TICK_MS, PERIODIC_LOG_INTERVAL_MS, the Keytel / Swain coefficients in CarbBurnEstimator, etc.) are NOT exposed. Calibrated in code from the spec-defined values described above.