Skip to main content

Time in zones

Zones split effort into a few bands, from easy to flat out, and count how long the activity spent in each. "40 minutes easy and 12 at threshold" says more about a session than one average heart rate.

The idea​

A zone scheme is a list of edges. Five heart-rate zones from a maximum heart rate of 186 bpm, at 65, 81, 89 and 97 % of it, are:

ZoneNameHeart rate
1Recoveryup to 121 bpm
2Endurance122 to 151 bpm
3Tempo152 to 166 bpm
4Threshold167 to 180 bpm
5Anaerobicabove 180 bpm

For each sample, find its zone, then add up the time each sample stands for: the seconds since the sample before. Two details:

  • A sample on an edge goes in the lower zone. 151 bpm is zone 2, not zone 3. That is the usual convention for training zones.
  • A gap in the recording is not time in a zone. If the watch stopped for five minutes, the sample after the stop doesn't stand for five minutes at its heart rate. Cap each sample's time (fit uses 10 seconds).

This guide uses the interval run, with a maximum heart rate of 186 bpm.

From scratch​

The edges, and each sample's seconds since the one before, capped at 10:

import { readFileSync } from 'node:fs';
import { readActivity } from '@pond-ts/fit/files';

const MAX_HR = 186;
const run = readActivity(readFileSync('interval-run.fit'));
const track = run.toSeries();

const bounds = [0.65, 0.81, 0.89, 0.97].map((f) => Math.round(f * MAX_HR));
const edges = [0, ...bounds, 1e9]; // 1e9: the top zone has no ceiling

const times = track.keyColumn().begin;
const dt = Float64Array.from(times, (ms, i) =>
i ? Math.min((ms - times[i - 1]!) / 1000, 10) : 0,
);
console.log(edges.slice(1, -1).join(' '));
121 151 166 180

byColumn buckets rows by a value, here heart rate, between the edges given. inclusive: '(]' puts a value on an edge in the bin below. Summing dt in each bin is the time in that zone:

const labels = ['Recovery', 'Endurance', 'Tempo', 'Threshold', 'Anaerobic'];
const bins = track
.withColumn('dt', dt)
.byColumn(
'hr',
{ edges, inclusive: '(]' },
{ seconds: { from: 'dt', using: 'sum' } },
);

const mmss = (s: number) =>
`${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
bins.forEach((bin, i) =>
console.log(
`Z${i + 1}`,
labels[i]!.padEnd(9),
mmss(Number(bin.seconds ?? 0)),
),
);
Z1 Recovery 6:14
Z2 Endurance 28:40
Z3 Tempo 15:28
Z4 Threshold 1:01
Z5 Anaerobic 0:00

With fit​

The zones come from analyze, given the athlete's maximum heart rate. Each zone has its edges, its seconds, and its share of the total:

const { zones } = run.analyze({
athlete: { hrZone: [{ at: '2026-01-01', maxHr: MAX_HR }] },
});
for (const z of zones.heartRate!)
console.log(
`Z${z.zone}`,
z.label.padEnd(9),
mmss(z.seconds),
`${(z.fraction * 100).toFixed(0)} %`.padStart(4),
z.openEnded ? `${z.start}+` : `${z.start}-${z.end}`,
);
Z1 Recovery 6:14 12 % 0-121
Z2 Endurance 28:40 56 % 121-151
Z3 Tempo 15:28 30 % 151-166
Z4 Threshold 1:01 2 % 166-180
Z5 Anaerobic 0:00 0 % 180+

The same times. start and end are the edges themselves, so a zone's start is the edge it sits above: zone 3 is "over 151, up to 166".

A zone scheme of your own replaces the percentages with four boundaries:

const own = run.analyze({
athlete: { hrZone: [{ at: '2026-01-01', bounds: [130, 150, 165, 175] }] },
});
console.log(own.zones.heartRate!.map((z) => mmss(z.seconds)).join(' '));
7:51 26:45 12:34 4:13 0:00

Any section has zones too. The six 800 m repeats are laps 2, 4, … 12 (the laps between are the recoveries):

const repeats = [1, 3, 5, 7, 9, 11].map((i) => run.laps.at(i)!);
let inZone4 = 0;
for (const lap of repeats)
inZone4 += lap.analyze({
athlete: { hrZone: [{ at: '2026-01-01', maxHr: MAX_HR }] },
}).zones.heartRate![3]!.seconds;
console.log('repeats', repeats.length, 'in Z4', mmss(inZone4));
repeats 6 in Z4 1:01

All of the run's time in zone 4 is in the repeats, but even there most of each repeat is in zone 3: each one peaks at 167 or 168 bpm, just over the edge, near its end.

Power zones (seven, from FTP) and pace zones (six, from a 5 km time) come back the same way, as zones.power and zones.pace. See Analysis and the athlete.

Chart it​

Heart rate through the run, with lines at the zone edges, and the time in each zone as bars, below it, laid out along heart rate (bpm across the bottom). The bars are the zone array as fit returns it: each zone already has the start and end a <BarChart> needs.

src/examples/fit-time-in-zones.tsx
import {
BarChart,
Baseline,
ChartContainer,
ChartRow,
CrosshairCursor,
Layers,
LineChart,
LineCursor,
YAxis,
} from '@pond-ts/charts';
import { Activity } from '@pond-ts/fit';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';
import { intervalRun } from './lib/fit-fixtures';

const run = Activity.from(intervalRun());
const zones = run.analyze({
athlete: { hrZone: [{ at: '2026-01-01', maxHr: 186 }] },
}).zones.heartRate!;

// The bpm axis starts a little under the run's lowest heart rate, so zone 1
// (whose edge is 0 bpm) is drawn from there.
const FLOOR = 80;
const bars = zones.map((z) => ({
start: Math.max(z.start, FLOOR),
end: z.end,
minutes: z.seconds / 60,
}));
const top = bars.at(-1)!.end;

export default function FitTimeInZones({ width = 680 }: { width?: number }) {
const theme = useSiteChartTheme();
const track = run.toSeries();
return (
<div>
<ChartContainer
range={track.timeRange()}
width={width}
theme={theme}
origin="data"
>
<CrosshairCursor />
<ChartRow height={180}>
<YAxis id="bpm" label="bpm" min={FLOOR} max={top} width={52} />
<Layers>
<LineChart series={track} column="hr" axis="bpm" as="primary" />
{zones.slice(1).map((z) => (
<Baseline
key={z.zone}
value={z.start}
axis="bpm"
label={`Z${z.zone}`}
/>
))}
</Layers>
</ChartRow>
</ChartContainer>
<ChartContainer range={[FLOOR, top]} width={width} theme={theme}>
<LineCursor />
<ChartRow height={150}>
<YAxis id="min" label="minutes" min={0} width={52} />
<Layers>
<BarChart bins={bars} column="minutes" axis="min" gap={4} />
</Layers>
</ChartRow>
</ChartContainer>
</div>
);
}

Traps​

  • Heart rate lags. It takes a minute or more to rise at the start of a hard repeat, and falls slowly after. A short repeat can finish before heart rate reaches its zone, so time in zones under-counts short, hard efforts. Power or pace zones answer straight away.
  • The top zone has no ceiling. fit's top zone carries an end so it can be drawn (openEnded is true), but that end isn't a real limit. Label it "180+".
  • Zones need the athlete's numbers. Without hrZone, zones.heartRate is null. With settings that change over the year, each activity uses the values in force on its own date.
  • Stops count. A standing recovery is time in a low zone. That is what happened, but if you want zones for the running only, use the laps you want, as in the repeats example.