Setup guide · about 15 minutes, once

MTB Dynamics in intervals.icu

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.

Before you start

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.

Quick start (5 minutes)

The minimum to see Grit, Flow and jumps. You can add the rest later.

  1. Three streams (activity → Charts under the activity chart → Custom Streams → Add Stream):
    • MtbGrit: FIT record field mtb_grit, units grit
    • MtbFlow: script script ↓ (tick Processes fit file messages), units m
    • MtbJumpAir: FIT record field mtb_jump_air, units s
  2. Three activity fields, each reading a FIT session field directly (no script):
    • MTB Grit: code MtbGrit, units kGrit, FIT session field mtb_total_grit
    • MTB Flow: code MtbFlow, units flow, FIT session field mtb_flow_score
    • MTB Jumps: code MtbJumps, units jumps, FIT session field mtb_jumps
  3. The MTB Dynamics chart: Charts → add a custom chart → paste its script → save.
  4. On that ride: Actions → Reprocess File. New rides get everything automatically.

Step 1 · Custom streams

Per-second data on the activity chart. The interval fields and charts are built on these.

  1. Open a ride recorded with MTB Dynamics.
  2. Under the activity chart click Charts → Custom Streams → Add Stream (forum post with screenshots).
  3. Enter the name, code and units from the table. intervals.icu needs units for every stream.
  4. Source: for a FIT record field, pick it from the list. For a script, tick Processes fit file messages and paste the script.
  5. Save, and repeat for each row. Keep the script streams at the top of the list, because they are computed first.
MTB Dynamics
NameCodeUnitsSource
MTB GritMtbGritgritFIT record field mtb_grit or script ↓
Pick the record field, or use the script to include Garmin rides too.
MTB FlowMtbFlowmScript ↓ ✓ Processes fit file messages
Script: moves the values back by the 3 s the Karoo writes them late.
MTB BrakingMtbBrakem/s2Script ↓ ✓ Processes fit file messages
Script: realigned like Flow.
MTB RoughnessMtbRoughgFIT record field mtb_rough
MTB Corner GMtbLatGgFIT record field mtb_lat_g
MTB Jump AirtimeMtbJumpAirsFIT record field mtb_jump_air
MTB Jump DistanceMtbJumpDistmFIT record field mtb_jump_dist
MTB Jump HeightMtbJumpHeightmFIT record field mtb_jump_height
Flight Attendant / AXS (the Karoo records these itself when the parts are paired)
NameCodeUnitsSource
FA ForkFaFrontstateFIT record field front_suspension
Recorded by the Karoo itself: 0 Open, 1 Pedal, 2 Lock.
FA ShockFaRearstateFIT record field rear_suspension
FA Effort ZoneFaEffortzoneFIT record field suspension_effort_zone
Rear CogRearCogTScript ↓ ✓ 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.

MTB Grit stream (MtbGrit)streams/mtb_grit.js
// 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)
  }
}
MTB Flow stream (MtbFlow)streams/mtb_flow.js
// 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)
    }
  }
}
MTB Braking stream (MtbBrake)streams/mtb_brake.js
// 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)
    }
  }
}
Rear Cog stream (RearCog)streams/rear_cog.js
// 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)
    }
  }
}

Step 2 · Activity fields

Ride totals: shown in the activity summary, as columns in the activity list, and plottable over time on the Fitness and Compare pages.

  1. On the same ride, open the custom fields editor from the activity page (the Custom / Fields button by the activity chart) and add a new activity field (forum post with screenshots).
  2. Enter the name, code and units from the table.
  3. Then either:
    • Simple: let the field read the FIT session field from the table. This works for Karoo rides with MTB Dynamics.
    • Script: tick Processes fit file messages and paste the script. It also reads Garmin MTB Dynamics rides, and for the bike fields it computes the value from the Karoo's own records on rides recorded before MTB Dynamics 0.2.
MTB Dynamics
NameCodeUnitsFIT session fieldScript
MTB GritMtbGritkGritmtb_total_gritscript ↓
MTB FlowMtbFlowflowmtb_flow_scorescript ↓
MTB JumpsMtbJumpsjumpsmtb_jumpsscript ↓
MTB Max AirtimeMtbMaxAirsmtb_max_airscript ↓
MTB Total AirtimeMtbTotalAirsmtb_total_airscript ↓
MTB ScoreMtbScorescoremtb_scorescript ↓
MTB Descent BrakingMtbDescentBraking%mtb_descent_brakingscript ↓
MTB CornersMtbCornerscornersmtb_cornersscript ↓
MTB Max Corner GMtbMaxCornerGgmtb_max_lat_gscript ↓
Flight Attendant / AXS / power meter
NameCodeUnitsFIT session fieldScript
FA Open on descentsMtbFaOpenDesc%mtb_fa_open_descscript ↓
FA locked on rough groundMtbFaLockRoughsmtb_fa_lock_roughscript ↓
Shifts per kmMtbShiftsKm/kmmtb_shifts_kmscript ↓
Climbing powerMtbClimbPowerWmtb_climb_powerscript ↓
Pedalling on descentsMtbDescPedal%mtb_desc_pedalscript ↓

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.

MTB Grit field (MtbGrit)activity-fields/mtb_grit.js
// 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
}
MTB Flow field (MtbFlow)activity-fields/mtb_flow.js
// 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
}
MTB Jumps field (MtbJumps)activity-fields/mtb_jumps.js
// 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
}
MTB Max Airtime field (MtbMaxAir)activity-fields/mtb_max_air.js
// 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
}
MTB Total Airtime field (MtbTotalAir)activity-fields/mtb_total_air.js
// 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
}
MTB Score field (MtbScore)activity-fields/mtb_score.js
// 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
}
MTB Descent Braking field (MtbDescentBraking)activity-fields/mtb_descent_braking.js
// 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
}
MTB Corners field (MtbCorners)activity-fields/mtb_corners.js
// 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
}
MTB Max Corner G field (MtbMaxCornerG)activity-fields/mtb_max_corner_g.js
// 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
}
FA Open on descents field (MtbFaOpenDesc)activity-fields/mtb_fa_open_desc.js
// 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
}
FA locked on rough ground field (MtbFaLockRough)activity-fields/mtb_fa_lock_rough.js
// 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
}
Shifts per km field (MtbShiftsKm)activity-fields/mtb_shifts_km.js
// 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
}
Climbing power field (MtbClimbPower)activity-fields/mtb_climb_power.js
// 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
}
Pedalling on descents field (MtbDescPedal)activity-fields/mtb_desc_pedal.js
// 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
}

Step 3 · Interval fields

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.

  1. Add a field of type interval field in the same custom fields editor (forum post).
  2. Enter the name, code and units, then paste the script. Each needs the stream shown in the table, from step 1.
  3. The values appear as columns in the intervals table and in the summary when you drag across the chart. Karoo laps become intervals automatically.
MTB Dynamics
NameCodeUnitsNeeds streamScript
GritIntMtbGritkGritMtbGritscript ↓
FlowIntMtbFlowflowMtbFlowscript ↓
JumpsIntMtbJumpsjumpsMtbJumpAirscript ↓
Max airtimeIntMtbMaxAirsMtbJumpAirscript ↓
BrakingIntMtbBraking%MtbBrakescript ↓
RoughnessIntMtbRoughgMtbRoughscript ↓
Flight Attendant / AXS
NameCodeUnitsNeeds streamScript
FA OpenIntFaOpen%FaFrontscript ↓
FA LockIntFaLock%FaFrontscript ↓
ShiftsIntShiftsshiftsRearCogscript ↓
Main cogIntCogTRearCogscript ↓
Grit interval field (IntMtbGrit)interval-fields/interval_grit.js
// 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
}
Flow interval field (IntMtbFlow)interval-fields/interval_flow.js
// 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
}
Jumps interval field (IntMtbJumps)interval-fields/interval_jumps.js
// 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
}
Max airtime interval field (IntMtbMaxAir)interval-fields/interval_max_air.js
// 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
}
Braking interval field (IntMtbBraking)interval-fields/interval_braking.js
// 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
}
Roughness interval field (IntMtbRough)interval-fields/interval_roughness.js
// 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
}
FA Open interval field (IntFaOpen)interval-fields/interval_fa_open.js
// 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
}
FA Lock interval field (IntFaLock)interval-fields/interval_fa_lock.js
// 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
}
Shifts interval field (IntShifts)interval-fields/interval_shifts.js
// 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
}
Main cog interval field (IntCog)interval-fields/interval_cog.js
// 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)
}

Step 4 · Activity charts

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.

MTB Dynamics

Altitude profile with Grit (60 s) and Flow (60 s) on top and every jump marked.

Needs the streams MtbGrit, MtbFlow, MtbJumpAir

Chart scriptcharts/mtb_dynamics.js
// 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 }
}

MTB Jumps

One bar per jump: airtime, coloured by height; hover shows distance and speed.

Needs the streams MtbJumpAir, MtbJumpDist, MtbJumpHeight

Chart scriptcharts/mtb_jumps.js
// 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 }
}

MTB Trail Segments

Climbs, descents and flats with Grit, Flow, braking and jumps for each.

Needs the streams MtbGrit, MtbFlow, MtbBrake, MtbJumpAir

Chart scriptcharts/mtb_segments.js
// 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 }
}

Suspension & gears bike

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.

Chart scriptcharts/mtb_bike.js
// 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 }
}

Step 5 · Older rides

New rides get everything automatically. Rides uploaded before the setup have to read their FIT file again.

Where to look

Activity page

The MTB streams in the chart's stream selector, the custom charts below it, and the fields in the summary.

Activity list

Add MtbGrit, MtbFlow, MtbJumps or MtbScore as columns to compare rides at a glance.

Fitness & Compare

Plot MtbFlow (average) over months to see whether your descending gets smoother, or MtbGrit (sum) per week.

Laps & selections

Grit, Flow and jumps per lap, or drag across one trail and compare it with last week's run.

Troubleshooting

ProblemFix
A stream or field stays emptyCheck 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 blankIt needs its streams with these exact codes (see each chart). Add the missing stream, then reprocess the ride.
A script returns nothingProcesses fit file messages must be ticked for every script that reads the FIT file.
Flow / braking peaks a few seconds lateUse 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 emptyThe 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 summaryThe 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).