How the CHO/FAT Metabolic Pro app works¶
This document explains how the data field is implemented, referencing the
theoretical basis described in
01-theoretical-basis.md and the source code in
source/CHOFATView.mc. It does not repeat the
scientific justification for each formula (that is already in document 01) — it
focuses on how the code is organized and why each engineering decision was made.
1. What it is and how it integrates with Garmin¶
It is a Connect IQ Data Field (not an app or a widget): it can only be added
as a field within an activity's data screen (Run, Bike, etc.), it does not appear
in the watch's app list. It works on both watches and Edge bike computers — the
manifest declares the compatible products, and the code adapts to each device's
screen shape (§4.2). It is written in Monkey C and implemented entirely in the
CHOFATView class, which Garmin instantiates once when the activity starts and
updates at two different moments:
compute(info): called ~once per second with the current activity data (HR, timer time...). This is where all the computation happens.onUpdate(dc): called whenever the screen needs to be redrawn. This only draws what has already been computed incompute()— no physiological calculation happens here.
2. Resolving the physiological profile (carregarPerfil())¶
When the data field is created, the 5 physiological parameters that feed the model (weight, resting HR, max HR, LT2, VO2max) are resolved. For each one, the priority is:
- Manual value (
Properties, inresources/settings/properties.xml) if the user has entered one > 0. - Otherwise, automatic estimation from the Garmin profile (
UserProfile). - If there is no Garmin data either, a reasonable default value hardcoded in the class is used.
Important note (documented because it's hard to find): manual values can only be edited from the Connect IQ Store's settings screen, which does not work for sideloaded apps (see §7) — the settings UI is downloaded dynamically based on the app's ID as registered in the Store, and a sideload has no access to it. So in practice, as long as the app is not published, it will always run in automatic mode.
For HRmax and LT2, since Garmin does not expose them directly (see §5 of the
theoretical document), they are inferred from the watch's getHeartRateZones():
zone 5 ceiling → HRmax, zone 4 ceiling → LT2. This call is already aware of the
current sport (UserProfile.getCurrentSport()), so on an Edge bike computer it
correctly reads the cycling zones, not the running ones. At the end of
carregarPerfil() there is a coherence check: if HRmax does not end up above
HRrest, or if LT2 does not end up in between, it is corrected automatically
(absurd values are never allowed to silently break the HRR/RER calculation later
on).
The automatic VO2max is also sport-aware: Garmin stores running VO2max
(profile.vo2maxRunning) and cycling VO2max (profile.vo2maxCycling)
separately — the app picks one or the other depending on whether the current
sport is Activity.SPORT_CYCLING (a real bug found while testing Edge support:
always reading vo2maxRunning meant an Edge would always fall back to the
default value, since a bike computer never produces a running VO2max).
The "onset of exercise" point \(I_{onset}\) (theoretical document §6) is always
calculated as a fraction of lt2, with the constant PCT_ACTIVACIO_CHO = 0.70 —
it has no laboratory equivalent, so it is not offered as a manual value (§6 of
the theoretical document).
\(LT_1\), on the other hand, is a real physiological threshold that an athlete may
know from their own testing, so it follows the same pattern as the rest of the
profile: if the user enters a manual value (lt1Manual, property > 0) and it is
coherent (between fcRepos and lt2), it is used directly; if not, it is
silently discarded and the estimate PCT_LT1 * lt2 (PCT_LT1 = 0.85) is used
instead.
3. Calculation cycle (compute())¶
For each valid sample (with HR available):
- Time control:
dtMsis calculated since the last sample. IfdtMs <= 0(activity paused) ordtMsexceedsDT_MAX_MS(5000 ms — typically an optical sensor dropout), the sample is discarded instead of accumulating anything. This prevents a sensor dropout of, say, 90 seconds, from being applied retroactively with the instantaneous rate at the moment of reconnection over the whole gap (a real bug found and fixed during development, and validated against a real.FITfile with a genuine sensor dropout). - Current HR zone (
zonaFCActual): determined by comparing HR against thezonesFCthresholds (saved incarregarPerfil()), for the small Z1-Z5 indicator. - HRR → VO2 and RER by zones → grams of CHO/FAT per minute, following exactly the formulas described in the theoretical document (§3-7).
- Accumulation:
totalChoGramsandtotalFatGramsadd the grams from this tick (the discretization of the CHO_total/FAT_total integral, see theoretical document §8.2). - Exponential moving average (
choAvgEmaGH): this is the specific smoothing technique this app uses for the displayed rate (theoretical document §8.3 — which technique and with what parameters is an implementation decision, not part of the model). It is updated withalpha_tick = dtMin / TAU_EMA_MIN(τ=15 min). The first valid sample initializes the EMA directly with the instantaneous value (it does not start at 0 and climb slowly).
4. Visual interface (onUpdate())¶
4.1. 3-row × 2-column grid¶
The screen is divided into 3 equal horizontal bands (⅓ of the height each):
Each row is split into a label column (40%) and a value column (60%), with no vertical separator line (only horizontal lines between rows). The middle row additionally reserves a small extra strip on the far right for the zone indicator, so it never ends up under the number (the real width of the "Z1".."Z5" text is calculated and subtracted before splitting the 40/60 columns).
4.2. Safe width on a round screen (amplaSeguraFila)¶
On a round screen, the usable width is not constant: it is maximal at the
vertical center and shrinks towards the top/bottom (it is the chord of a circle
at a given height). If fixed-width columns were used for every row equally, the
labels of the top/bottom rows would get clipped by the bezel (a real bug found
visually and fixed). The amplaSeguraFila() function calculates, for each row,
the width actually available at the most extreme height of that row:
and all the columns of that row are calculated based on this safe width, not on the screen's total width.
Rectangular-screen devices (Edge): this chord calculation only makes sense on
round or semi-round screens — a rectangular screen has no curved bezel clipping
anything, and applying the same formula would leave unnecessary margins.
calcularGeometria() queries System.getDeviceSettings().screenShape once and
stores it in esRodona; amplaSeguraFila() checks it and, on a non-round
device, simply returns 96% of the total width with no chord calculation at all.
4.3. Adaptive font size (ajustaFont)¶
For each piece of text (label or value), a list of candidate fonts is tried from
largest to smallest, and the first one that fits both the available width and
height is kept. For numeric values, the list includes Monkey C's large
digit-specific fonts (FONT_NUMBER_HOT/MEDIUM/MILD), which look noticeably
larger than generic text fonts for the same space — they are the font type
Garmin's native data fields use for their main numbers.
4.4. Geometry and font caching (performance)¶
Since onUpdate() is called very often (typically ~once/second), the geometry of
the 3 rows (positions, column widths, label font) is calculated once in
calcularGeometria() and stored in class fields, instead of recalculating it on
every call — it is only recalculated if the screen size changes from the cached
value (in practice, never during an actual activity).
Likewise, the numeric value's font for each row is only looked up again
(ajustaFont) when the text's length changes (e.g. from 2 to 3 digits), not
every frame — the vast majority of calls reuse the already-computed font instead
of retesting every candidate. This avoids unnecessary repeated work, although the
real impact on battery life is small compared to the GPS or HR sensor's
consumption, which is the same regardless of which data field is shown.
5. Color coding¶
5.1. CHO consumption (fueling)¶
The CHO avg (g/h) value is colored with colorPerCho(value, sessionMinutes)
following the time-staggered bands described in the theoretical document (§9):
| Elapsed time | Green | Orange | Red |
|---|---|---|---|
| < 45 min | (always green) | — | — |
| 45 min - 2h | < 30 g/h | 30-60 g/h | > 60 g/h |
| > 2h | < 60 g/h | 60-90 g/h | > 90 g/h |
5.2. HR zone¶
The "Z1".."Z5" text is painted with colorPerZona(zone), reproducing Garmin
Connect's standard HR zone color scheme: dark grey (Z1), blue (Z2), green (Z3),
orange (Z4), red (Z5). It is independent of the CHO value's color — both can show
different colors at the same time (e.g. "green" for fueling and "Z4" for zone),
and this is intentional: one represents carbohydrate consumption and the other
effort intensity.
6. Development tools¶
tools/Run-Simulator.ps1 automates building and
launching the app in the Connect IQ simulator: it auto-detects the active SDK (by
reading current-sdk.cfg), lets you pick the device (interactive menu or
-Device <id>), and reuses the simulator if one is already open.
7. Deployment to a real watch (sideload)¶
Tested and confirmed working on a Garmin Epix 2 Pro (47mm). An important
particularity of this model (and likely of other devices with equivalent modern
firmware): the classic sideload method (copying the .prg to an uppercase
GARMIN/APPS folder) does not work, because the device already has a
GARMIN/Apps folder (lowercase) for the firmware's internal use, and the watch's
filesystem is not case-sensitive — a separate folder cannot be created whose name
differs only by letter case.
Method that works: copy the .prg directly inside the already-existing
GARMIN/Apps folder (without creating a new one), connecting the watch via USB
(it appears as an MTP device, not as a disk drive — it must be navigated with
Shell.Application from PowerShell, not with Copy-Item). When the watch is
disconnected, the firmware detects the new file and processes it into its
internal app database.
Paths ruled out during the investigation (documented so they don't need to be
tried again): monkeydo's native pairing mode (/n) is for pairing ANT/BLE
sensors with the app, not for installing it; the simulator's adb Connection
menu is a debug bridge for the companion mobile app (Android), not for the watch;
Garmin Express only allows browsing/managing the Connect IQ Store, not manual
sideloading from a local file.
8. Known limitations¶
- The model is an approximation based exclusively on HR; it does not capture the effect of duration on relative substrate expenditure (glycogen being depleted at the same relative intensity as time passes), since it is inherently a point-in-time model.
HRmaxandLT2are estimates based on configured HR zones, not direct measurements — their accuracy depends on how the user (or Garmin) has configured those zones.- Data is not saved visibly to Garmin Connect (no
FitContributorhas been implemented); for later analysis, the activity's standard.FITfile must be reprocessed with an external tool (the app already has a reference Python script, used during development to validate the algorithm against real data).