W' Balance history chart + Skiba model improvements#5
Open
lgangitano wants to merge 18 commits into
Open
Conversation
The Skiba differential model continues to predict balance below zero whenever
power exceeds CP for long enough. Today WPrimeEngine.update clamps balance to
[0, wMax] and silently erases that signal — but the negative excursion is the
model's only honest way of saying 'W'max is calibrated too low for this
rider.' This commit promotes that signal to a first-class observable: the
engine emits the unclamped value (sanity-bounded at -wMax), a new DEFICIT
status zone activates when percentage drops below 0, and a per-tick history
ring buffer enables forthcoming chart + future surge-evaluation features.
Files changed:
data/model/WPrimeState.kt
- WPrimeStatus gains a 7th value: DEFICIT (< 0%).
- WPrimeState.fromBalance: no longer coerces pct to [0, 100].
- New companion factory statusForPercentage(pct) shared with the engine.
- New WPrimeSample data class for per-tick history snapshots
(timestampMs, balance, percentage, power) — sized for ~32 bytes/sample
so 24h of 1Hz history fits in ~2.8 MB.
engine/WPrimeEngine.kt
- wBalance.coerceIn(0.0, wMax) -> wBalance.coerceIn(-wMax, wMax). The
sanity floor at -wMax keeps a stuck-high power sensor from driving the
value to absurd negatives over a long ride; balance can otherwise
represent the model's full predicted depletion.
- timeToEmpty gated on wBalance > 0 (no point computing 'time to empty'
when already negative).
- Status mapping delegated to WPrimeState.statusForPercentage.
- New _wPrimeHistory: StateFlow<List<WPrimeSample>> appends on every
successful tick. Ring-buffered at MAX_HISTORY_SAMPLES = 86400.
- reset() clears the history buffer.
- restore(balance) accepts negative values; documents that history is
NOT restored (the chart resumes from the restored value with no prior
samples — a future PR can reconstruct history from the in-progress
FIT file if needed).
datatypes/glance/GlanceColors.kt
- New WPrimeDeficit = 0xFF9C27B0 (purple) — distinct from existing
WPrime palette + grade/zone palette.
- wPrimeColor(percentage) extended: returns WPrimeDeficit when pct < 0.
datatypes/fit/ClimbFitRecording.kt
- W_Prime_Percent field type: UINT8 -> SINT8 (FIT base type id 1).
Required so the negative range survives FIT recording for post-ride
analysis in intervals.icu / Garmin Connect / etc.
- Saturation in buildRecordValues and buildSessionValues: coerceIn(0.0,
100.0) -> coerceIn(-128.0, 127.0). Honest values within type bounds.
- W_Prime_Balance (FLOAT32) was already negative-capable; only its
consumer-side .coerceIn was masking it.
Pacing/Alert handling for negative pct lands in PR yrkan#3 + a follow-up commit
in this PR. WPrimeGlance widget update + new wprime-history data field +
chart renderer land in the next commit on this branch.
…s: chart settings
Three coordinated additions atop the W'-balance foundation commit:
WPrimeGlance.kt
- statusText() handles the new WPrimeStatus.DEFICIT case ('DEFICIT').
- Color, ring-bar, and text rendering already adapt automatically: the
existing GlanceColors.wPrimeColor(pct) now returns WPrimeDeficit
purple for pct < 0; the ProgressBar already coerces progress to
[0, 1] so the bar visually clips at 0% while the text below it
shows the actual (negative) percentage. No layout-shape change.
AlertManager.kt
- New ALERT_WPRIME_DEFICIT id, wPrimeDeficitLastAlert atomic,
alertWPrimeDeficitEnabled flag, wasWPrimeBelowZero tracker.
- Collects alertWPrimeDeficitFlow from prefs.
- In the W' state collector, after the existing critical-threshold
block, a parallel one-shot fires on crossing below 0%. Independent
of the W' critical alert — both can fire in sequence during a deep
drawdown. Different message: the deficit signals W'max may be
under-tuned, not just 'reduce power now.'
- reset() clears both wasWPrimeBelowThreshold and wasWPrimeBelowZero.
PreferencesRepository.kt
- KEY_ALERT_WPRIME_DEFICIT (default true), KEY_WPRIME_CHART_WINDOW_MIN
(default 0 = full ride; 5–240 = rolling window), and
KEY_WPRIME_CHART_REDRAW_SEC (default 2, range 1–10).
- Matching alertWPrimeDeficitFlow / wPrimeChartWindowMinutesFlow /
wPrimeChartRedrawSecondsFlow flows.
- Setter functions updateAlertWPrimeDeficit, updateWPrimeChartWindowMinutes,
updateWPrimeChartRedrawSeconds. Window setter snaps 1–4 → 5 so the
rolling-window range stays meaningful while 0 cleanly means
'since ride start.'
res/values/strings.xml
- alert_wprime_deficit_title and alert_wprime_deficit_detail strings
for the new alert. English baseline only; other locales fall back
until translations land in a follow-up locale-coverage pass.
New WPrimeChartRenderer object + ChartMode sealed class. Renders the W'
balance history into a Bitmap via off-screen Canvas — the only practical
way to draw smooth curves inside a Glance widget. Pattern is new for the
codebase (existing 'chart-like' helpers in GlanceComponents.kt — e.g.
SegmentProfileBar — are Row-of-Box bar segments, hopeless for curves).
Public API:
ChartMode.{Sparkline, MiniWithCurrent, FullDualAxis, Vertical}
WPrimeChartRenderer.renderChart(samples, width, height, mode, wMax) -> Bitmap
Rendering rules match the intervals.icu reference + Luigi's 'one per
side' dual-axis call:
- Single line, color split at the zero baseline:
above zero -> WPrimeFresh (green)
below zero -> WPrimeDeficit (purple, 'reserve depth')
Implementation: stroke the same path twice with different clipRect
bounds (no need to split paths at zero crossings — clipping handles
it pixel-accurately).
- Fill between line and zero baseline at ~20% alpha, color-matched
to its side (green above, purple below). Same clip-region pattern.
- Dashed zero reference line stretched across the plot.
- Y range autoscales but always shows zero plus at least 20% of wMax
above and 10% below — even on a sustained-positive ride the zero
crossing is visible, even on a deep-deficit ride the positive
headroom is.
Mode-specific composition:
- Sparkline : line + fill only. No axes, no labels.
- MiniWithCurrent : line + fill + current-value pill (right edge,
colored by current sign). For SMALL_WIDE / MEDIUM /
MEDIUM_WIDE.
- FullDualAxis : line + fill + axes + time labels. kJ labels on the
LEFT axis, percent labels on the RIGHT — same data
labeled in two units at chart edges. Time labels
(MM:SS or H:MM) at start/mid/end along the bottom.
For LARGE.
- Vertical : rotated 90° via canvas.rotate. Same Sparkline core
with swapped width/height. For NARROW.
Pure (no state). Same inputs -> byte-equal Bitmap. Renderer is the
sole consumer of GlanceColors.WPrimeDeficit added in the foundation
commit. Caller is responsible for trimming samples to the visible
window before invocation (the renderer plots whatever it's given);
the window pref + redraw throttle live in the glance widget — added
in the next commit on this branch.
The chart renderer added in f27b576 now has a home. Six layout-size composables wire the renderer into Glance via Image(ImageProvider(bitmap)), the new field is registered everywhere a Karoo extension needs: datatypes/glance/WPrimeHistoryGlance.kt (new) - WPrimeHistoryGlanceDataType class extending GlanceDataType with typeId 'wprime-history'. - Six size composables call WPrimeChartRenderer.renderChart(...) with pre-chosen pixel dimensions per layout and pass the appropriate ChartMode (Sparkline / MiniWithCurrent / FullDualAxis / Vertical). - Renders '—' placeholder when history is empty or no athlete data. - v1 keeps it simple: re-renders every 1Hz tick on the full-ride history. The window-trim and redraw-throttle prefs landed in the previous commit but are not yet honored here — they'll get plumbed after ride feedback motivates them. 80KB per bitmap at 1Hz is acceptable on Karoo. datatypes/GlanceDataType.kt - ClimbDisplayState gains a wPrimeHistory: List<WPrimeSample> field (default emptyList()). Lets every datatype consume the history if desired; for now only the new widget reads it. - collectDisplayState() pulls climbExtension.wPrimeEngine.wPrimeHistory.value each 1Hz tick. ClimbIntelligenceExtension.kt - Import + register WPrimeHistoryGlanceDataType(this) at the end of the types list. res/xml/extension_info.xml - <DataType typeId="wprime-history" ...> entry under the existing DataType registry, with strings keyed via @string references. res/values/strings.xml - datatype_wprime_history ('W' History') and datatype_wprime_history_desc ('W' balance chart over the ride — green above zero, purple in deficit (reserve depth)') strings. English baseline; locales fall back until a follow-up locale pass.
Lets the Karoo data-field picker show a meaningful chart preview when a rider browses 'W' History' — 30 samples over 5 minutes shaped like the intervals.icu reference (smooth bleed, deficit dip below zero, recovery). Without this the picker shows '—' because the empty default doesn't exercise the renderer. Helps non-riding bench testing too: a developer viewing the field in any Glance preview tool sees the chart in its intended visual state.
…ting Two coupled fixes to WPrimeEngine.update — both are bug fixes against the Skiba model (which says recovery applies whenever power ≤ CP, including 0) plus a real-world bug Luigi hit on his first ride test of PR yrkan#4: the W' history chart showed no data because the engine returned early whenever state.power == 0, never recording a sample. Fix 1 — remove the power == 0 gate Before: if (!state.hasData || state.power == 0) return After: if (!state.hasData) return power == 0 is a legitimate observation (rider coasting / not pedaling while sensors are still streaming), not a 'no data' sentinel — that's what state.hasData is for. The Skiba math handles power == 0 correctly: the recovery branch applies because power ≤ CP. The prior code froze the model AND the history during coasting periods, hiding the natural recovery curve and leaving the W' history chart empty for riders who hadn't yet pedaled at full power. Fix 2 — restructure first-tick handling Before: if (lastUpdateTime == 0L) { lastUpdateTime = now; return } After: compute dW only when lastUpdateTime != 0L; always emit state + recordHistorySample at the end. The prior first-tick branch returned before emitting any state OR recording any history, so the chart started one tick late. Now the first tick seeds lastUpdateTime, emits the baseline W' state, and records the first history sample (at the unchanged starting balance) — the chart gets its first data point immediately. Behavioral change for existing 7climb users: W' will now recover during coasting periods, matching the Skiba model. Prior behavior was a model underestimation that left riders looking 'fresher than they actually were' after a hard surge if they then stopped pedaling instead of continuing at sub-CP. Conservative direction.
Karoo's RideState.Paused stops the data stream that drives WPrimeEngine, which previously left the model frozen for the duration of the pause. Per Skiba, recovery applies whenever P <= CP — and during pause the rider's effective power is 0, so pause time must accumulate toward W' refill at the same rate as coasting. WPrimeEngine: new setPaused(b) flag + tickPauseRecovery(now) running the recovery branch with P = 0. update() short-circuits while paused so the ticker is the sole source of state changes. RideStateMonitor: on RideState.Paused, mark engine paused and start a 1 Hz ticker. On Recording (covers fresh start AND resume-from-pause) and Idle, stop the ticker and clear the pause flag. destroy() also cancels the ticker. The chart now advances continuously through pause, with a power=0 history sample appended at each tick — the rider sees W' climbing back toward 100% while the bike is parked.
Three improvements layered on top of the W' chart + pause-recovery work, inspired by currand/karoo-wprimebalance's calculator. Karoo FTP integration --------------------- A new ToggleRow 'Use FTP from Karoo' (default ON) makes the W' / pacing engines read FTP from the Karoo's rider profile via UserProfile stream instead of the rider's locally-typed value. Single source of truth. - AthleteProfile.useKarooFtp + AthleteProfile.karooFtp fields - AthleteProfile.effectiveFtp picks karooFtp when toggled on and non-zero - ClimbIntelligenceExtension subscribes to karooSystem UserProfile and persists ftp into preferences via updateKarooFtp - AthleteScreen shows the toggle; when on, the manual FTP input is replaced by an InfoRow showing the value read from the Karoo Dynamic tau ----------- Replaces the fixed TAU = 546.0 constant with a recovery time-constant that adapts to how far below CP the rider is coasting: tau = 546 * exp(-0.01 * (CP - avgPowerBelowCP)) + 316 A rider drifting just under CP recovers slower; a rider truly resting at 50W recovers faster. The rolling stats sum/count are kept inside the engine, reset() clears them, and the pause-recovery ticker feeds 0W samples into the same accumulator so tau adapts during pause too. Morton 3-parameter MPA ---------------------- A new AthleteProfile.maxPower + useThreeParamModel toggle enables the Morton MPA computation: MPA = Pmax - (Pmax - CP) * fatigueRatio^2 where fatigueRatio = (Wmax - Wbalance) / Wmax. At full W' MPA = Pmax; as W' depletes MPA decays quadratically toward CP. Exposed on WPrimeState as the new mpa: Int field (0 when disabled or Pmax not configured), ready for pacing-calculator integration in a follow-up. Both update() and the pause-recovery branch publish it. Settings UI additions --------------------- - 'Use FTP from Karoo' toggle + value display (always visible) - P-max numeric input + 'Use 3-parameter model' toggle (under Advanced) - 5 new strings in res/values/strings.xml
Extends the prior Karoo-FTP-from-UserProfile read to also pick up body weight, and renames the toggle so it covers both fields cohesively. - AthleteProfile.useKarooFtp -> useKarooProfile; new karooWeight field; new effectiveWeight property mirrors effectiveFtp pattern. isConfigured + totalMass now use effective values throughout. - PreferencesRepository KEY_USE_KAROO_FTP -> KEY_USE_KAROO_PROFILE; new KEY_KAROO_WEIGHT + updateKarooWeight; renamed updater. - ClimbIntelligenceExtension UserProfile consumer also persists weight alongside ftp on every Karoo profile event. - AthleteScreen renders FTP and weight rows independently — each shows read-only InfoRow when the toggle is on AND Karoo has a non-zero value, falls back to editable NumericRow/DecimalRow otherwise. Single toggle controls both. - Engines / display sites migrated to effective values: MetricsEngine.ftp -> p.effectiveFtp; PacingCalculator -> profile.effectiveFtp; ClimbStatsTracker -> profile.effectiveWeight; MainMenuScreen subtitle shows effective values so the visible 'FTP / kg' matches what engines use. - String resources: settings_use_karoo_ftp -> settings_use_karoo_profile with adjusted hint mentioning both fields; new settings_karoo_weight.
The W'bal numeric field's TTE/TTF time label was flipping between the two metrics every second because the engine selects depletion vs recovery branches off the *instantaneous* power sample, which oscillates around CP with every cadence stroke or terrain micro-change. Fix: keep the per-tick depletion/recovery rates for the chart and status indicator (they reflect what the rider is doing right now), but compute the TTE/TTF time horizons from a 30 s rolling-mean power. The TTE -> TTF transition then fires only when sustained effort crosses CP, which is how a rider would describe the change out loud. - Add recentPowerWindow ArrayDeque<(ts, power)> trimmed every update() to the 30 s window. - smoothedPower drives smoothedDepletion / smoothedRecovery used in the TTE/TTF computation only. WPrimeState.depletionRate / recoveryRate stay instantaneous. - Cleared in reset() alongside the other rolling stats.
The W' history chart bitmap was rendered at the caller's logical pixel dimensions (e.g. 280x160 for LARGE), then Glance's Image(fillMaxSize) scaled it UP to the widget slot on the Karoo screen. Bilinear upscaling made text and lines visibly soft compared to Glance Text composables elsewhere, which render directly at screen DPI. Fix: allocate the bitmap at RENDER_SCALE x logical dimensions and apply canvas.scale(RENDER_SCALE) once at the top. Existing drawing code uses logical units everywhere and stays untouched; the transform makes every stroke and text proportionally larger in the bitmap, then Glance scales DOWN to fit — a clean operation. Memory cost: a LARGE chart's bitmap grows from 280x160x4 = 179 kB to 1.6 MB. Well within the per-tick budget.
The right-axis labels already include the % suffix (100%, 0%, -10%), so the small % hint drawn just below the top label was duplicating information and reading as a separate stacked label. Removed it. Kept the left-axis kJ hint — left labels are pure numbers (20.0, 0, -2.0), so the hint is the one that tells the rider what unit they're in. Bumped AXIS_LABEL_TEXT_SIZE_PX 11 -> 13 to take advantage of the new 3x bitmap resolution; labels are now visibly bigger AND crisper on the Karoo screen.
computeYRange always padded the Y-axis with -10% of wMax below zero so the zero crossing stayed visible 'even on a sustained-positive ride'. On rides where W' never dips into deficit, that meant a wasted 10% of plot height + a misleading '-10%' label for data that never existed. Fix: only pad below zero when the visible window actually contains a negative sample. Otherwise yMin = 0 — the plot bottom IS the zero line, the bottom right label is '0%', and the curve uses the full vertical height. drawDualAxes' existing zero-baseline-label suppression (when yZero is flush against the plot edge) handles the new geometry without further changes — no duplicate '0%' inside the plot.
…iden chart right pad Follow-up to the first TTE/TTF smoothing pass, which fixed the computed *values* but left the 1 Hz flicker because WPrimeGlance.timeLabel() still selected between TTE and TTF using the *instantaneous* depletionRate / recoveryRate. Those flip every tick as power crosses CP, and when both were zero the label blanked for a tick. - timeLabel() now selects on timeToEmpty / timeToFull directly. Those are already derived from 30 s-smoothed power and are mutually exclusive, so the symbol is stable and never blanks mid-ride. - Replace 'TTE'/'TTF' (only an E vs F apart, hard to read on a small field) with ▼ (heading to empty) / ▲ (heading to full). - Engine rounds timeToEmpty / timeToFull to the nearest 5 s (roundTo5, 5 s floor) so the countdown doesn't jitter ±1-2 s from rate-window noise. - WPrimeChartRenderer FULL_RIGHT_PAD 32 → 44 so '100%' at the new 13px label size no longer clips the right edge of the chart.
…/ green full) The countdown still changed every second because update() republished the horizon each tick even though the rounded value barely moved. Now the engine holds the published timeToEmpty/timeToFull steady for 5 s (HORIZON_PUBLISH_INTERVAL_MS) and refreshes on that cadence, so the field reads as stable rather than counting. Color the time label by direction, matching the ▼/▲ symbol: - ▼ heading to empty -> red (GlanceColors.Problem) — you're spending W' - ▲ heading to full -> green (GlanceColors.Optimal) — recovering LabelText gains an optional color param (defaults to the previous gray) so existing callers are unaffected.
The countdown 'started from 1:00:00 then decreased' because at the instant the horizon direction flips, the 30s power window still holds the previous regime's samples — smoothed depletion is near zero, so the projection hits the 1h cap before the new effort registers. Fix: after a direction change, withhold the number for HORIZON_SETTLE_MS (5s) while the window fills. During the settle the field shows the direction symbol with a '--' placeholder (▼ -- / ▲ --) so the colour and direction are right immediately and the layout doesn't jump, but no misleading capped value appears. Once settled, the real value shows and refreshes on the existing 5s hold cadence. Encoded with a 0 sentinel in the published timeToEmpty/timeToFull (0 = this direction, settling; -1 = not this direction; >0 = value). Parameter-free per Luigi — no athlete-specific threshold to configure. Leaves the 1h cap as-is: a genuinely-sustainable effort still reads 1:00:00 after settling, which is honest enough (>= 1h).
Two refinements to the TTE/TTF field: 1. During the 5s settle after a direction change, keep showing the PREVIOUS direction's last value (e.g. the green ▲ recovery time) instead of a '▼ --' placeholder, then swap to the new direction once its value is real. Cleaner read, no placeholder. This also drops the 0-sentinel encoding — a positive published value is always real, so the Glance label/colour logic simplifies back to >0 checks. 2. Hide the horizon entirely at full W' (pct >= FULL_PCT = 99.5). At 100% there's nothing to recover to and the rider isn't spending, so the line is meaningless — show nothing. Recovery asymptotes toward wMax so 99.5 (what the field rounds to 100%) is the practical 'full'.
…moothed power
Bug: at ride start the field showed 'TTF 10:00' (recovering) while W'bal
fell 100%→80%→70% (clearly depleting). Root cause: the horizon direction
came from 30s-smoothed *power*, and at ride start that window is full of
pre-effort low/zero power, so smoothed power read <= CP ('recovering')
even as instantaneous effort drained the balance.
Fix: derive BOTH direction and rate from the observed slope of W'balance
over a 30s trailing window (TREND_WINDOW_MS). Balance is the integral of
(power − CP), so its slope is the naturally-smooth, correct quantity and
can never contradict the % the rider watches move:
trendRate = (wBalance_now − wBalance_window_start) / span [J/s]
trendRate < −deadband -> depleting -> TTE = wBalance / −trendRate
trendRate > +deadband -> recovering -> TTF = (wMax−wBalance) / trendRate
|trendRate| <= deadband -> steady, no horizon
A 3 J/s deadband (TREND_DEADBAND_JPS) suppresses a flickery horizon when
W' is essentially flat.
Replaces the recentPowerWindow / smoothedPower / smoothedDepletion /
smoothedRecovery machinery with recentBalanceWindow + trendRate.
Knock-on for the notification mismatch: the W' field and the AlertManager
notification both read state.timeToEmpty, so they share one source. The
prior smoothed-power TTE was volatile (90s at fire time, 20s moments
later); the trend-based value is stable, so the notification snapshot and
the live field now track each other instead of diverging.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a W'-balance history chart data field plus a set of W'-engine model improvements. Built and ride-tested on a Karoo 3 (via FIT replay) across many iterations.
W' history chart (new
wprime-historydata field)W' engine improvements
546·exp(-0.01·(CP - avgPowerBelowCP)) + 316.MPA = Pmax - (Pmax - CP)·((W'max - W'bal)/W'max)². Off by default.W' Balance field — TTE/TTF
Notes