Every Karoo ride with MTB Dynamics carries its data in the FIT file as mtb_* fields.
intervals.icu only reads such fields once you tell it which ones, so there is a one-time setup: custom
streams for the per-second data, activity fields for ride totals, interval
fields for laps and selected trail sections, and charts. After that, every new ride shows them automatically.
mtb_* fields when the open ride has them.Codes matter. Use the codes exactly as shown (click any grey value to copy it).
The interval fields and charts find the streams by these codes. intervals.icu only accepts CamelCase codes and suggests one
from the name (MTBGrit): replace it with the code from the table.
intervals.icu's menus change now and then. If a label differs slightly, the official forum posts linked in each step have screenshots.
Or let a script do all of it. With your API key (intervals.icu → Settings → Developer Settings),
tools/icu_setup.py creates every stream, field and chart below and
finds your older rides that have MTB Dynamics or Flight Attendant data (see step 5):
export INTERVALS_API_KEY=...
python3 tools/icu_setup.py --dry-run && python3 tools/icu_setup.py
python3 tools/icu_setup.py --no-items --reprocess 365 --snippet reprocess.js
Then add the charts to the activity page (Charts → +) and pick the fields you want in the activity list.
The minimum to see Grit, Flow and jumps. You can add the rest later.
MtbGrit: FIT record field mtb_grit, units gritMtbFlow: script script ↓ (tick Processes fit file messages), units mMtbJumpAir: FIT record field mtb_jump_air, units sMtbGrit, units kGrit, FIT session field mtb_total_gritMtbFlow, units flow, FIT session field mtb_flow_scoreMtbJumps, units jumps, FIT session field mtb_jumpsPer-second data on the activity chart. The interval fields and charts are built on these.
| Name | Code | Units | Source |
|---|---|---|---|
| MTB Grit | MtbGrit | grit | FIT record field mtb_grit or script ↓Pick the record field, or use the script to include Garmin rides too. |
| MTB Flow | MtbFlow | m | Script ↓ ✓ Processes fit file messages Script: moves the values back by the 3 s the Karoo writes them late. |
| MTB Braking | MtbBrake | m/s2 | Script ↓ ✓ Processes fit file messages Script: realigned like Flow. |
| MTB Roughness | MtbRough | g | FIT record field mtb_rough |
| MTB Corner G | MtbLatG | g | FIT record field mtb_lat_g |
| MTB Jump Airtime | MtbJumpAir | s | FIT record field mtb_jump_air |
| MTB Jump Distance | MtbJumpDist | m | FIT record field mtb_jump_dist |
| MTB Jump Height | MtbJumpHeight | m | FIT record field mtb_jump_height |
| Name | Code | Units | Source |
|---|---|---|---|
| FA Fork | FaFront | state | FIT record field front_suspensionRecorded by the Karoo itself: 0 Open, 1 Pedal, 2 Lock. |
| FA Shock | FaRear | state | FIT record field rear_suspension |
| FA Effort Zone | FaEffort | zone | FIT record field suspension_effort_zone |
| Rear Cog | RearCog | T | Script ↓ ✓ Processes fit file messages Script: turns the AXS shift events into the cog in use. |
Power, cadence and L/R balance from the power meter are standard intervals.icu streams already.
// intervals.icu custom activity stream: MTB Grit (grit points per second)
// Settings: Code MtbGrit · Units grit · tick "Processes fit file messages"
//
// Only needed if you also ride with a Garmin: it reads the Karoo developer field mtb_grit and
// falls back to Garmin's native "grit" field. Karoo-only riders can instead create the stream
// with Record field = mtb_grit (no script).
{
for (let m of icu.fit.record || []) {
if (!m.timestamp) continue
let f = m.mtb_grit || m.grit
if (f && f.value != null) data.setAt(m.timestamp.value, f.value)
}
}
// intervals.icu custom activity stream: MTB Flow (unnecessary braking, metres per second)
// Settings: Code MtbFlow · Units m · tick "Processes fit file messages"
//
// The Karoo writes mtb_flow a few seconds late (Flow looks ahead: braking before a tight corner
// is fine). This script moves every value back to the second it belongs to.
// Garmin Edge files: falls back to the native "flow" record field.
{
let session = icu.fit.session ? icu.fit.session[0] : null
let lag = session && session.mtb_flow_lag ? session.mtb_flow_lag.value : 3
for (let m of icu.fit.record || []) {
let ts = m.timestamp
if (!ts) continue
if (m.mtb_flow && m.mtb_flow.value != null) {
data.setAt(ts.value - lag, m.mtb_flow.value)
} else if (m.flow && m.flow.value != null) {
data.setAt(ts.value, m.flow.value)
}
}
}
// intervals.icu custom activity stream: MTB Braking (deceleration, m/s²)
// Settings: Code MtbBrake · Units m/s2 · tick "Processes fit file messages"
//
// Like mtb_flow, the Karoo writes mtb_brake mtb_flow_lag seconds late; realign it here.
{
let session = icu.fit.session ? icu.fit.session[0] : null
let lag = session && session.mtb_flow_lag ? session.mtb_flow_lag.value : 3
for (let m of icu.fit.record || []) {
if (m.timestamp && m.mtb_brake && m.mtb_brake.value != null) {
data.setAt(m.timestamp.value - lag, m.mtb_brake.value)
}
}
}
// intervals.icu custom activity stream: Rear cog (teeth) from SRAM AXS shifts
// Settings: Code RearCog · Units T · tick "Processes fit file messages"
//
// The Karoo writes one FIT event per rear shift (rear_gear = teeth). This turns them into a
// second-by-second "cog in use" stream.
{
// FIT event 43 = rear_gear_change (name casing differs between FIT SDKs)
const isRearShift = m => m.event && (m.event.value === 43 || String(m.event.valueName).toLowerCase() === 'rear_gear_change')
let teeth = null
for (let m of icu.fit) {
if (m._name === 'event' && isRearShift(m) && m.rear_gear) {
teeth = m.rear_gear.value
} else if (m._name === 'record' && m.timestamp && teeth != null) {
data.setAt(m.timestamp.value, teeth)
}
}
}
Ride totals: shown in the activity summary, as columns in the activity list, and plottable over time on the Fitness and Compare pages.
| Name | Code | Units | FIT session field | Script |
|---|---|---|---|---|
| MTB Grit | MtbGrit | kGrit | mtb_total_grit | script ↓ |
| MTB Flow | MtbFlow | flow | mtb_flow_score | script ↓ |
| MTB Jumps | MtbJumps | jumps | mtb_jumps | script ↓ |
| MTB Max Airtime | MtbMaxAir | s | mtb_max_air | script ↓ |
| MTB Total Airtime | MtbTotalAir | s | mtb_total_air | script ↓ |
| MTB Score | MtbScore | score | mtb_score | script ↓ |
| MTB Descent Braking | MtbDescentBraking | % | mtb_descent_braking | script ↓ |
| MTB Corners | MtbCorners | corners | mtb_corners | script ↓ |
| MTB Max Corner G | MtbMaxCornerG | g | mtb_max_lat_g | script ↓ |
| Name | Code | Units | FIT session field | Script |
|---|---|---|---|---|
| FA Open on descents | MtbFaOpenDesc | % | mtb_fa_open_desc | script ↓ |
| FA locked on rough ground | MtbFaLockRough | s | mtb_fa_lock_rough | script ↓ |
| Shifts per km | MtbShiftsKm | /km | mtb_shifts_km | script ↓ |
| Climbing power | MtbClimbPower | W | mtb_climb_power | script ↓ |
| Pedalling on descents | MtbDescPedal | % | mtb_desc_pedal | script ↓ |
More session fields you can add the same way: mtb_avg_grit, mtb_total_flow, mtb_max_jump_dist,
mtb_max_jump_height, mtb_difficulty, mtb_smoothness, mtb_air_score, mtb_corner_speed_kept,
mtb_descent_time, mtb_descent_speed, mtb_descent_flow, mtb_avg_rough, mtb_fa_open_climb,
mtb_fa_changes, mtb_fa_reaction, mtb_shifts, mtb_climb_wkg, mtb_cog_max.
// intervals.icu custom activity field: MTB Grit (ride difficulty)
// Settings: Code MtbGrit · Units kGrit · tick "Processes fit file messages"
// Karoo MTB Dynamics session field "mtb_total_grit", Garmin MTB Dynamics fallback.
{
let s = icu.fit.session ? icu.fit.session[0] : null
let v = s && s.mtb_total_grit ? s.mtb_total_grit.value : null
if (v == null) {
v = s && s.total_grit ? s.total_grit.value : null
}
v
}
// intervals.icu custom activity field: MTB Flow (lower = smoother)
// Settings: Code MtbFlow · Units flow · tick "Processes fit file messages"
// Karoo MTB Dynamics session field "mtb_flow_score", Garmin MTB Dynamics fallback.
{
let s = icu.fit.session ? icu.fit.session[0] : null
let v = s && s.mtb_flow_score ? s.mtb_flow_score.value : null
if (v == null) {
v = s && s.avg_flow ? s.avg_flow.value : null
}
v
}
// intervals.icu custom activity field: MTB Jumps
// Settings: Code MtbJumps · Units jumps · tick "Processes fit file messages"
// Karoo MTB Dynamics session field "mtb_jumps", Garmin MTB Dynamics fallback.
{
let s = icu.fit.session ? icu.fit.session[0] : null
let v = s && s.mtb_jumps ? s.mtb_jumps.value : null
if (v == null) {
// Empty rather than 0 for rides without MTB Dynamics: intervals.icu gives every ride a jump list.
if (s && s.jump_count) v = s.jump_count.value
else if (icu.fit.jump && icu.fit.jump.length) v = icu.fit.jump.length
}
v
}
// intervals.icu custom activity field: MTB longest airtime
// Settings: Code MtbMaxAir · Units s · tick "Processes fit file messages"
// Karoo MTB Dynamics session field "mtb_max_air", Garmin MTB Dynamics fallback.
{
let s = icu.fit.session ? icu.fit.session[0] : null
let v = s && s.mtb_max_air ? s.mtb_max_air.value : null
if (v == null) {
for (let j of icu.fit.jump || []) if (j.hang_time && (v == null || j.hang_time.value > v)) v = j.hang_time.value
}
v
}
// intervals.icu custom activity field: MTB total airtime
// Settings: Code MtbTotalAir · Units s · tick "Processes fit file messages"
// Karoo MTB Dynamics session field "mtb_total_air", Garmin MTB Dynamics fallback.
{
let s = icu.fit.session ? icu.fit.session[0] : null
let v = s && s.mtb_total_air ? s.mtb_total_air.value : null
if (v == null) {
for (let j of icu.fit.jump || []) if (j.hang_time) v = (v || 0) + j.hang_time.value
}
v
}
// intervals.icu custom activity field: MTB score (0-100)
// Settings: Code MtbScore · Units score · tick "Processes fit file messages"
// Karoo MTB Dynamics session field "mtb_score".
{
let s = icu.fit.session ? icu.fit.session[0] : null
let v = s && s.mtb_score ? s.mtb_score.value : null
v
}
// intervals.icu custom activity field: MTB descent braking
// Settings: Code MtbDescentBraking · Units % · tick "Processes fit file messages"
// Karoo MTB Dynamics session field "mtb_descent_braking".
{
let s = icu.fit.session ? icu.fit.session[0] : null
let v = s && s.mtb_descent_braking ? s.mtb_descent_braking.value : null
v
}
// intervals.icu custom activity field: MTB corners
// Settings: Code MtbCorners · Units corners · tick "Processes fit file messages"
// Karoo MTB Dynamics session field "mtb_corners".
{
let s = icu.fit.session ? icu.fit.session[0] : null
let v = s && s.mtb_corners ? s.mtb_corners.value : null
v
}
// intervals.icu custom activity field: MTB max cornering g
// Settings: Code MtbMaxCornerG · Units g · tick "Processes fit file messages"
// Karoo MTB Dynamics session field "mtb_max_lat_g".
{
let s = icu.fit.session ? icu.fit.session[0] : null
let v = s && s.mtb_max_lat_g ? s.mtb_max_lat_g.value : null
v
}
// intervals.icu custom activity field: Flight Attendant Open on descents (%)
// Settings: Code MtbFaOpenDesc · Units % · tick "Processes fit file messages"
// Session field mtb_fa_open_desc (MTB Dynamics 0.2+), else computed from the Karoo's
// front_suspension record field (0 Open, 1 Pedal, 2 Lock) on records with grade <= -2.5 %.
{
let s = icu.fit.session ? icu.fit.session[0] : null
let v = s && s.mtb_fa_open_desc ? s.mtb_fa_open_desc.value : null
if (v == null) {
let open = 0, n = 0
for (let m of icu.fit.record || []) {
if (!m.front_suspension || !m.grade || m.grade.value > -2.5) continue
let speed = m.enhanced_speed || m.speed
if (speed && speed.value < 1) continue
n++
if (m.front_suspension.value === 0) open++
}
v = n >= 30 ? 100 * open / n : null
}
v
}
// intervals.icu custom activity field: Flight Attendant locked on rough ground (s)
// Settings: Code MtbFaLockRough · Units s · tick "Processes fit file messages"
// Seconds in Lock while MTB Dynamics measured >= 0.9 g trail vibration.
{
let s = icu.fit.session ? icu.fit.session[0] : null
let v = s && s.mtb_fa_lock_rough ? s.mtb_fa_lock_rough.value : null
if (v == null) {
let n = 0, seen = false
for (let m of icu.fit.record || []) {
if (!m.front_suspension || !m.mtb_rough) continue
seen = true
if (m.front_suspension.value === 2 && m.mtb_rough.value >= 0.9) n++
}
v = seen ? n : null
}
v
}
// intervals.icu custom activity field: AXS shifts per km
// Settings: Code MtbShiftsKm · Units /km · tick "Processes fit file messages"
{
// FIT event 43 = rear_gear_change (name casing differs between FIT SDKs)
const isRearShift = m => m.event && (m.event.value === 43 || String(m.event.valueName).toLowerCase() === 'rear_gear_change')
let s = icu.fit.session ? icu.fit.session[0] : null
let v = s && s.mtb_shifts_km ? s.mtb_shifts_km.value : null
if (v == null) {
let shifts = 0
for (let e of icu.fit.event || []) if (isRearShift(e)) shifts++
let km = s && s.total_distance ? s.total_distance.value / 1000 : 0
v = km > 0.5 && shifts > 0 ? shifts / km : null
}
v
}
// intervals.icu custom activity field: average power on climbs (W)
// Settings: Code MtbClimbPower · Units W · tick "Processes fit file messages"
// Session field mtb_climb_power, else records with grade >= 2.5 % and speed >= 1 m/s.
{
let s = icu.fit.session ? icu.fit.session[0] : null
let v = s && s.mtb_climb_power ? s.mtb_climb_power.value : null
if (v == null) {
let sum = 0, n = 0
for (let m of icu.fit.record || []) {
if (!m.power || !m.grade || m.grade.value < 2.5) continue
let speed = m.enhanced_speed || m.speed
if (speed && speed.value < 1) continue
sum += m.power.value
n++
}
v = n >= 30 ? sum / n : null
}
v
}
// intervals.icu custom activity field: pedalling on descents (% of descending time)
// Settings: Code MtbDescPedal · Units % · tick "Processes fit file messages"
{
let s = icu.fit.session ? icu.fit.session[0] : null
let v = s && s.mtb_desc_pedal ? s.mtb_desc_pedal.value : null
if (v == null) {
let ped = 0, n = 0
for (let m of icu.fit.record || []) {
if (!m.grade || m.grade.value > -2.5) continue
let speed = m.enhanced_speed || m.speed
if (speed && speed.value < 1) continue
n++
if (m.cadence && m.cadence.value > 0) ped++
}
v = n >= 30 ? 100 * ped / n : null
}
v
}
Grit, Flow, jumps and more for every lap, and for any stretch you select on the activity chart. Handy for comparing runs down the same trail.
| Name | Code | Units | Needs stream | Script |
|---|---|---|---|---|
| Grit | IntMtbGrit | kGrit | MtbGrit | script ↓ |
| Flow | IntMtbFlow | flow | MtbFlow | script ↓ |
| Jumps | IntMtbJumps | jumps | MtbJumpAir | script ↓ |
| Max airtime | IntMtbMaxAir | s | MtbJumpAir | script ↓ |
| Braking | IntMtbBraking | % | MtbBrake | script ↓ |
| Roughness | IntMtbRough | g | MtbRough | script ↓ |
| Name | Code | Units | Needs stream | Script |
|---|---|---|---|---|
| FA Open | IntFaOpen | % | FaFront | script ↓ |
| FA Lock | IntFaLock | % | FaFront | script ↓ |
| Shifts | IntShifts | shifts | RearCog | script ↓ |
| Main cog | IntCog | T | RearCog | script ↓ |
// intervals.icu custom interval field: Interval Grit (difficulty of the selection / lap)
// Settings: Code IntMtbGrit · Units kGrit · needs the custom stream MtbGrit
{
const stream = code => { try { const s = icu.streams.get(code); if (s && s.data) return s.data } catch (e) {} return null }
const grit = stream('MtbGrit')
let sum = 0
if (grit) for (let i = interval.start_index; i < interval.end_index; i++) sum += grit[i] || 0
grit ? sum / 1000 : null
}
// intervals.icu custom interval field: Interval Flow (unnecessary braking per 100 m, lower = smoother)
// Settings: Code IntMtbFlow · Units flow · needs the custom stream MtbFlow
{
const stream = code => { try { const s = icu.streams.get(code); if (s && s.data) return s.data } catch (e) {} return null }
const flow = stream('MtbFlow'), dist = icu.streams.distance, vel = stream('velocity_smooth')
let f = 0, d = 0
if (flow && dist) {
for (let i = Math.max(1, interval.start_index); i < interval.end_index; i++) {
if (vel && (vel[i] || 0) < 1) continue
f += flow[i] || 0
d += Math.max(0, (dist[i] || 0) - (dist[i - 1] || 0))
}
}
flow && d >= 50 ? 100 * f / d : null
}
// intervals.icu custom interval field: Interval Jumps
// Settings: Code IntMtbJumps · Units jumps · needs the custom stream MtbJumpAir
{
const stream = code => { try { const s = icu.streams.get(code); if (s && s.data) return s.data } catch (e) {} return null }
const air = stream('MtbJumpAir')
let n = 0
if (air) for (let i = interval.start_index; i < interval.end_index; i++) if ((air[i] || 0) > 0) n++
air ? n : null
}
// intervals.icu custom interval field: Interval longest airtime
// Settings: Code IntMtbMaxAir · Units s · needs the custom stream MtbJumpAir
{
const stream = code => { try { const s = icu.streams.get(code); if (s && s.data) return s.data } catch (e) {} return null }
const air = stream('MtbJumpAir')
let best = 0
if (air) for (let i = interval.start_index; i < interval.end_index; i++) best = Math.max(best, air[i] || 0)
air ? best : null
}
// intervals.icu custom interval field: Interval braking (% of moving time slowing down hard)
// Settings: Code IntMtbBraking · Units % · needs the custom stream MtbBrake
{
const stream = code => { try { const s = icu.streams.get(code); if (s && s.data) return s.data } catch (e) {} return null }
const brake = stream('MtbBrake'), vel = stream('velocity_smooth')
let moving = 0, braking = 0
if (brake) {
for (let i = interval.start_index; i < interval.end_index; i++) {
if (vel && (vel[i] || 0) < 1) continue
moving++
if ((brake[i] || 0) >= 0.6) braking++
}
}
brake && moving ? 100 * braking / moving : null
}
// intervals.icu custom interval field: Interval trail roughness (average vibration, g)
// Settings: Code IntMtbRough · Units g · needs the custom stream MtbRough
{
const stream = code => { try { const s = icu.streams.get(code); if (s && s.data) return s.data } catch (e) {} return null }
const rough = stream('MtbRough')
let sum = 0, n = 0
if (rough) for (let i = interval.start_index; i < interval.end_index; i++) if (rough[i] != null && rough[i] > 0) { sum += rough[i]; n++ }
n ? sum / n : null
}
// intervals.icu custom interval field: Flight Attendant Open (% of the selection)
// Settings: Code IntFaOpen · Units % · needs the custom stream FaFront (record field front_suspension)
{
const stream = code => { try { const s = icu.streams.get(code); if (s && s.data) return s.data } catch (e) {} return null }
const fa = stream('FaFront')
let open = 0, n = 0
if (fa) for (let i = interval.start_index; i < interval.end_index; i++) if (fa[i] != null) { n++; if (fa[i] === 0) open++ }
n ? 100 * open / n : null
}
// intervals.icu custom interval field: Flight Attendant Lock (% of the selection)
// Settings: Code IntFaLock · Units % · needs the custom stream FaFront (record field front_suspension)
{
const stream = code => { try { const s = icu.streams.get(code); if (s && s.data) return s.data } catch (e) {} return null }
const fa = stream('FaFront')
let lock = 0, n = 0
if (fa) for (let i = interval.start_index; i < interval.end_index; i++) if (fa[i] != null) { n++; if (fa[i] === 2) lock++ }
n ? 100 * lock / n : null
}
// intervals.icu custom interval field: rear shifts in the selection
// Settings: Code IntShifts · Units shifts · needs the custom stream RearCog
{
const stream = code => { try { const s = icu.streams.get(code); if (s && s.data) return s.data } catch (e) {} return null }
const cog = stream('RearCog')
let shifts = 0, prev = null
if (cog) for (let i = interval.start_index; i < interval.end_index; i++) {
if (cog[i] == null) continue
if (prev != null && cog[i] !== prev) shifts++
prev = cog[i]
}
cog ? shifts : null
}
// intervals.icu custom interval field: most used rear cog in the selection (teeth)
// Settings: Code IntCog · Units T · needs the custom stream RearCog
{
const stream = code => { try { const s = icu.streams.get(code); if (s && s.data) return s.data } catch (e) {} return null }
const cog = stream('RearCog')
const seconds = {}
if (cog) for (let i = interval.start_index; i < interval.end_index; i++) if (cog[i] != null) seconds[cog[i]] = (seconds[cog[i]] || 0) + 1
let best = null
for (const t in seconds) if (best == null || seconds[t] > seconds[best]) best = t
best == null ? null : Number(best)
}
On the activity page click Charts, add a custom chart, paste the script and save (forum post). Charts are stored per sport, so they show on every ride of that sport.
Altitude profile with Grit (60 s) and Flow (60 s) on top and every jump marked.
Needs the streams MtbGrit, MtbFlow, MtbJumpAir
// intervals.icu custom activity chart: MTB Dynamics
// Altitude profile with Grit (60 s) and Flow (60 s) on top and every jump marked.
// Needs the custom streams MtbGrit, MtbFlow and MtbJumpAir (see intervals-icu/README.md).
{
const stream = code => { try { const s = icu.streams.get(code); if (s && s.data) return s.data } catch (e) {} return null }
const imperial = !!(icu.athlete && icu.athlete.measurement_preference === 'feet')
const distF = imperial ? 1 / 1609.344 : 1 / 1000, altF = imperial ? 3.28084 : 1
const dist = stream('distance') || [], alt = stream('altitude') || stream('fixed_altitude') || []
const vel = stream('velocity_smooth') || []
const grit = stream('MtbGrit') || [], flow = stream('MtbFlow') || [], air = stream('MtbJumpAir') || []
const n = dist.length
const W = 60
const x = [], altY = [], grit60 = [], flow60 = []
const step = [], moving = []
let g = 0, f = 0, d = 0
for (let i = 0; i < n; i++) {
step[i] = i > 0 ? Math.max(0, (dist[i] || 0) - (dist[i - 1] || 0)) : 0
moving[i] = (vel[i] || 0) >= 1
g += grit[i] || 0
if (moving[i]) { f += flow[i] || 0; d += step[i] }
if (i >= W) {
g -= grit[i - W] || 0
if (moving[i - W]) { f -= flow[i - W] || 0; d -= step[i - W] }
}
x.push((dist[i] || 0) * distF)
altY.push(alt[i] == null ? null : alt[i] * altF)
grit60.push(grit.length ? g / Math.min(i + 1, W) : null)
flow60.push(flow.length && d >= 50 ? 100 * f / d : null)
}
const jx = [], jy = [], js = [], jt = []
for (let i = 0; i < n; i++) {
if ((air[i] || 0) > 0) {
jx.push(x[i]); jy.push(altY[i]); js.push(8 + air[i] * 14); jt.push(air[i].toFixed(2) + ' s')
}
}
const data = [
{ x: x, y: altY, name: 'Altitude', type: 'scatter', fill: 'tozeroy', line: { color: '#90a4ae', width: 1 }, hoverinfo: 'skip' },
{ x: x, y: grit60, name: 'Grit 60 s', yaxis: 'y2', type: 'scatter', line: { color: '#d84315', width: 2 } },
{ x: x, y: flow60, name: 'Flow 60 s', yaxis: 'y2', type: 'scatter', line: { color: '#1565c0', width: 2 } },
{ x: jx, y: jy, name: 'Jumps', mode: 'markers', type: 'scatter', text: jt, marker: { color: '#ff6d00', size: js } },
]
const layout = {
title: { text: 'MTB Dynamics' },
xaxis: { title: { text: imperial ? 'mi' : 'km' } },
yaxis: { title: { text: imperial ? 'ft' : 'm' } },
yaxis2: { title: { text: 'Grit /s · Flow' }, overlaying: 'y', side: 'right', rangemode: 'tozero', showgrid: false },
legend: { orientation: 'h', y: 1.12 },
margin: { t: 50, l: 50, r: 50, b: 40 },
}
chart = { data, layout }
}
One bar per jump: airtime, coloured by height; hover shows distance and speed.
Needs the streams MtbJumpAir, MtbJumpDist, MtbJumpHeight
// intervals.icu custom activity chart: MTB Jumps
// One bar per jump: airtime, coloured by estimated height; hover shows distance and speed.
// Needs the custom streams MtbJumpAir, MtbJumpDist and MtbJumpHeight.
{
const stream = code => { try { const s = icu.streams.get(code); if (s && s.data) return s.data } catch (e) {} return null }
const imperial = !!(icu.athlete && icu.athlete.measurement_preference === 'feet')
const lenF = imperial ? 3.28084 : 1, lenU = imperial ? 'ft' : 'm'
const air = stream('MtbJumpAir') || [], dist = stream('MtbJumpDist') || [], height = stream('MtbJumpHeight') || []
const time = stream('time') || []
const x = [], y = [], color = [], text = []
for (let i = 0; i < air.length; i++) {
if ((air[i] || 0) <= 0) continue
const t = time[i] != null ? time[i] : i
const clock = Math.floor(t / 3600) + ':' + String(Math.floor(t % 3600 / 60)).padStart(2, '0') + ':' + String(Math.floor(t % 60)).padStart(2, '0')
const d = (dist[i] || 0), h = (height[i] || 0)
x.push('#' + (x.length + 1) + ' ' + clock)
y.push(air[i])
color.push(h * lenF)
text.push((d * lenF).toFixed(1) + ' ' + lenU + ' · ~' + (h * lenF).toFixed(1) + ' ' + lenU + ' high · ' +
(air[i] > 0 ? (imperial ? (d / air[i] * 2.236936).toFixed(0) + ' mph' : (d / air[i] * 3.6).toFixed(0) + ' km/h') : ''))
}
const data = [{
x: x, y: y, type: 'bar', text: text, hovertemplate: '%{x}<br>%{y:.2f} s airtime<br>%{text}<extra></extra>',
marker: { color: color, colorscale: 'YlOrRd', showscale: true, colorbar: { title: { text: lenU } } },
}]
const layout = {
title: { text: x.length ? 'Jumps: ' + x.length + ', longest ' + Math.max(...y).toFixed(2) + ' s' : 'No jumps' },
yaxis: { title: { text: 'Airtime (s)' } },
margin: { t: 50, l: 50, r: 30, b: 80 },
}
chart = { data, layout }
}
Climbs, descents and flats with Grit, Flow, braking and jumps for each.
Needs the streams MtbGrit, MtbFlow, MtbBrake, MtbJumpAir
// intervals.icu custom activity chart: MTB Trail Segments
// Splits the ride into climbs, descents and flat sections (same rules as the Karoo app: 15 m
// elevation reversals, long flat stretches cut out) and shows Grit, Flow, braking and jumps for each.
// Needs the custom streams MtbGrit, MtbFlow, MtbBrake and MtbJumpAir.
{
const H = 15
const stream = code => { try { const s = icu.streams.get(code); if (s && s.data) return s.data } catch (e) {} return null }
const imperial = !!(icu.athlete && icu.athlete.measurement_preference === 'feet')
const dist = stream('distance') || [], rawAlt = stream('altitude') || stream('fixed_altitude') || []
const time = stream('time') || [], vel = stream('velocity_smooth') || []
const grit = stream('MtbGrit') || [], flow = stream('MtbFlow') || [], brake = stream('MtbBrake') || [], air = stream('MtbJumpAir') || []
const n = dist.length
// Smoothed altitude (±5 samples), gaps filled.
const filled = []
let last = rawAlt.find(a => a != null)
for (let i = 0; i < n; i++) { if (rawAlt[i] != null) last = rawAlt[i]; filled.push(last == null ? 0 : last) }
const alt = filled.map((_, i) => {
let s = 0, c = 0
for (let j = Math.max(0, i - 5); j <= Math.min(n - 1, i + 5); j++) { s += filled[j]; c++ }
return s / c
})
const classify = d => d >= 0.6 * H ? 'Climb' : d <= -0.6 * H ? 'Descent' : 'Flat'
// Zig-zag pivots.
const piv = [0]
let dir = 0, hi = 0, lo = 0
for (let i = 1; i < n; i++) {
if (alt[i] > alt[hi]) hi = i
if (alt[i] < alt[lo]) lo = i
if (dir === 0 && alt[hi] - alt[lo] >= H) {
if (lo < hi) { if (lo) piv.push(lo); dir = 1 } else { if (hi) piv.push(hi); dir = -1 }
} else if (dir === 1 && alt[hi] - alt[i] >= H) { piv.push(hi); dir = -1; lo = i }
else if (dir === -1 && alt[i] - alt[lo] >= H) { piv.push(lo); dir = 1; hi = i }
}
if (piv[piv.length - 1] !== n - 1) piv.push(n - 1)
// Flat runs (grade < 2.5 % over ±15 samples, >= 400 m) are cut out of climbs / descents.
const flat = []
for (let i = 0; i < n; i++) {
const a = Math.max(0, i - 15), b = Math.min(n - 1, i + 15), d = (dist[b] || 0) - (dist[a] || 0)
flat.push(d > 20 && Math.abs(100 * (alt[b] - alt[a]) / d) < 2.5)
}
const pieces = []
for (let k = 0; k + 1 < piv.length; k++) {
const a = piv[k], b = piv[k + 1]
if (b <= a) continue
const kind = classify(alt[b] - alt[a])
if (kind === 'Flat') { pieces.push([kind, a, b]); continue }
let cursor = a, i = a
while (i <= b) {
if (!flat[i]) { i++; continue }
let j = i
while (j + 1 <= b && flat[j + 1]) j++
if ((dist[j] || 0) - (dist[i] || 0) >= 400) {
if (i > cursor) pieces.push([classify(alt[i] - alt[cursor]), cursor, i])
pieces.push(['Flat', i, j]); cursor = j
}
i = j + 1
}
if (b > cursor) pieces.push([classify(alt[b] - alt[cursor]), cursor, b])
}
const segs = []
for (const p of pieces) {
const prev = segs[segs.length - 1]
if (prev && prev[0] === p[0]) prev[2] = p[2]; else segs.push(p.slice())
}
// Stats per segment.
const count = {}
const rows = { name: [], dist: [], time: [], elev: [], grade: [], grit: [], flow: [], brake: [], jumps: [] }
const colors = []
for (const [kind, a, b] of segs) {
count[kind] = (count[kind] || 0) + 1
let g = 0, f = 0, fd = 0, mov = 0, br = 0, j = 0
for (let i = a + 1; i <= b; i++) {
const step = Math.max(0, (dist[i] || 0) - (dist[i - 1] || 0))
g += grit[i] || 0
if ((air[i] || 0) > 0) j++
if ((vel[i] || 0) >= 1) { mov++; f += flow[i] || 0; fd += step; if ((brake[i] || 0) >= 0.6) br++ }
}
const d = (dist[b] || 0) - (dist[a] || 0), dz = alt[b] - alt[a], secs = (time[b] || b) - (time[a] || a)
rows.name.push(kind + ' ' + count[kind])
rows.dist.push(imperial ? (d / 1609.344).toFixed(2) + ' mi' : (d / 1000).toFixed(2) + ' km')
rows.time.push(Math.floor(secs / 60) + ':' + String(Math.round(secs % 60)).padStart(2, '0'))
rows.elev.push((dz >= 0 ? '+' : '') + (imperial ? (dz * 3.28084).toFixed(0) + ' ft' : dz.toFixed(0) + ' m'))
rows.grade.push((d > 10 ? 100 * dz / d : 0).toFixed(1) + ' %')
rows.grit.push(grit.length ? (g / 1000).toFixed(1) : '–')
rows.flow.push(flow.length && fd >= 50 ? (100 * f / fd).toFixed(1) : '–')
rows.brake.push(brake.length && mov ? (100 * br / mov).toFixed(0) + ' %' : '–')
rows.jumps.push(air.length ? String(j) : '–')
colors.push(kind === 'Climb' ? 'rgba(216,67,21,0.12)' : kind === 'Descent' ? 'rgba(21,101,192,0.12)' : 'rgba(0,0,0,0)')
}
const cols = ['name', 'dist', 'time', 'elev', 'grade', 'grit', 'flow', 'brake', 'jumps']
const data = [{
type: 'table',
header: { values: ['Segment', 'Distance', 'Time', 'Elevation', 'Grade', 'Grit (k)', 'Flow', 'Braking', 'Jumps'], align: 'left', font: { size: 12 } },
cells: { values: cols.map(c => rows[c]), align: 'left', fill: { color: [colors] }, height: 24 },
}]
const layout = { title: { text: 'Trail segments' }, margin: { t: 40, l: 10, r: 10, b: 10 } }
chart = { data, layout }
}
Flight Attendant state as a coloured band with the rear cog on top, and minutes per cog by terrain.
Needs the streams FaFront, RearCog. Set the chart height to 600px.
// intervals.icu custom activity chart: Suspension & gears
// Flight Attendant state (Open / Pedal / Lock) as a coloured band with the rear cog in use on top,
// plus minutes per cog split into climbing / flat / descending.
// Needs the custom streams FaFront (record field front_suspension) and RearCog (streams/rear_cog.js).
{
const stream = code => { try { const s = icu.streams.get(code); if (s && s.data) return s.data } catch (e) {} return null }
const imperial = !!(icu.athlete && icu.athlete.measurement_preference === 'feet')
const distF = imperial ? 1 / 1609.344 : 1 / 1000
const dist = stream('distance') || [], fa = stream('FaFront') || [], cog = stream('RearCog') || []
const grade = stream('grade_smooth') || [], vel = stream('velocity_smooth') || []
const x = dist.map(d => (d || 0) * distF)
const names = ['Open', 'Pedal', 'Lock'], colors = ['#2e7d32', '#f9a825', '#c62828']
const data = []
// Each band bar reaches to the next sample: Plotly would otherwise size every bar by the smallest
// gap (stops), leaving thin stripes instead of a band.
const dx = x.map((v, i) => Math.max((i + 1 < x.length ? x[i + 1] : v) - v, 0))
for (let code = 0; code < 3; code++) {
data.push({ x: x, y: fa.map(v => (v === code ? 1 : null)), type: 'bar', name: 'FA ' + names[code],
width: dx, offset: 0, marker: { color: colors[code], line: { width: 0 } }, hoverinfo: 'name', xaxis: 'x', yaxis: 'y' })
}
data.push({ x: x, y: cog.map(v => (v == null ? null : v)), name: 'Rear cog (T)', type: 'scatter',
line: { shape: 'hv', color: '#37474f', width: 1.5 }, xaxis: 'x', yaxis: 'y2' })
// Minutes per cog by terrain (grade from intervals.icu's smoothed grade).
const kinds = [['Climb', '#d84315'], ['Flat', '#90a4ae'], ['Descent', '#1565c0']]
const teeth = [...new Set(cog.filter(v => v != null))].sort((a, b) => a - b)
for (const [kind, color] of kinds) {
data.push({
x: teeth.map(t => t + 'T'), type: 'bar', name: kind, marker: { color: color }, xaxis: 'x2', yaxis: 'y3',
y: teeth.map(t => {
let n = 0
for (let i = 0; i < cog.length; i++) {
if (cog[i] !== t || (vel[i] || 0) < 1) continue
const g = grade[i] || 0
const k = g >= 2.5 ? 'Climb' : g <= -2.5 ? 'Descent' : 'Flat'
if (k === kind) n++
}
return n / 60
}),
})
}
// Two panels by explicit domains: a Plotly grid would put the cog axis (y2, overlaying y) in the
// second row and leave the minutes axis (y3) spanning the whole chart.
const layout = {
title: { text: 'Suspension & gears' },
barmode: 'stack', bargap: 0,
xaxis: { title: { text: imperial ? 'mi' : 'km' }, anchor: 'y' },
yaxis: { visible: false, range: [0, 1], domain: [0.58, 1] },
yaxis2: { overlaying: 'y', side: 'right', autorange: 'reversed', title: { text: 'cog (T)' } },
xaxis2: { title: { text: 'Rear cog' }, anchor: 'y3', type: 'category' },
yaxis3: { title: { text: 'minutes' }, domain: [0, 0.4], anchor: 'x2' },
legend: { orientation: 'h', y: 1.15 },
margin: { t: 60, l: 50, r: 50, b: 40 },
height: 600,
}
chart = { data, layout }
}
New rides get everything automatically. Rides uploaded before the setup have to read their FIT file again.
python3 tools/icu_setup.py --no-items --reprocess 365 --snippet reprocess.js: lists the rides of the last year whose
FIT file has MTB Dynamics or Flight Attendant / AXS data. intervals.icu only reprocesses files from its website, so paste
reprocess.js into the browser console of a logged-in intervals.icu tab: it sends the website's own Reprocess File
request for each of those rides, keeping intervals and manually entered field values.The MTB streams in the chart's stream selector, the custom charts below it, and the fields in the summary.
Add MtbGrit, MtbFlow, MtbJumps or MtbScore as columns to compare rides at a glance.
Plot MtbFlow (average) over months to see whether your descending gets smoother, or MtbGrit (sum) per week.
Grit, Flow and jumps per lap, or drag across one trail and compare it with last week's run.
| Problem | Fix |
|---|---|
| A stream or field stays empty | Check the ride was recorded with MTB Dynamics, that the code is spelled exactly, and run Actions → Reprocess File.
Locally, python3 tools/mtb_analyze.py ride.fit lists the MTB fields a FIT file contains. |
| A chart is blank | It needs its streams with these exact codes (see each chart). Add the missing stream, then reprocess the ride. |
| A script returns nothing | Processes fit file messages must be ticked for every script that reads the FIT file. |
| Flow / braking peaks a few seconds late | Use the mtb_flow / mtb_brake scripts rather than the raw record fields. The Karoo writes those 3 s late, because Flow looks ahead. |
| Flight Attendant / AXS fields empty | The parts must be paired with the Karoo. The session fields need MTB Dynamics 0.2 or later; the scripts also work on older rides. |
| Values differ a little from the ntfy summary | The FIT session fields are the Karoo's numbers. Scripts that recompute from records use intervals.icu's moving time and smoothing. |
Still stuck? Open an issue. This page is generated from the scripts in intervals-icu/ (also described in its README).