Analysis and the athlete
Some answers depend on who did the activity: intensity needs their FTP,
zones need their heart rate or pace bands. Those answers come from
analyze({ athlete }), never from the activity alone, so the same recording
can be read for anyone.
The athlete's settings
Settings are dated, because they change: an FTP test in March, another in June. Each setting is a list of values with the date each took effect, and an activity uses the one in force on its own date.
interface AthleteProfileJson {
ftpWatts?: { at: string; value: number }[];
weightKg?: { at: string; value: number }[];
hrZone?: (
| { at: string; maxHr: number }
| { at: string; bounds: [number, number, number, number] }
)[];
paceThreshold?: { at: string; fiveKSeconds: number }[];
}
ftpWatts: functional threshold power. Power zones are worked out from it (seven bands, from 55 % to 150 % of FTP).weightKg: body weight, for watts per kilogram.hrZone: heart-rate zones from a maximum heart rate (five bands at 65, 81, 89 and 97 % of it) or from four boundaries you set.paceThreshold: a recent 5 km time, which sets six pace zones for running, walking and hiking.
import { readFileSync } from 'node:fs';
import { readActivity } from '@pond-ts/fit/files';
const athlete = {
ftpWatts: [
{ at: '2026-01-01', value: 240 },
{ at: '2026-05-01', value: 250 },
{ at: '2026-09-01', value: 262 },
],
weightKg: [{ at: '2026-01-01', value: 72 }],
hrZone: [{ at: '2026-01-01', maxHr: 186 }],
paceThreshold: [{ at: '2026-01-01', fiveKSeconds: 1260 }],
};
const ride = readActivity(readFileSync('hill-ride.fit'));
const analysis = ride.analyze({ athlete });
console.log(analysis.athlete.ftpWatts, analysis.athlete.weightKg);
250 72
The ride was on 14 June 2026, so it is read with the 250 W FTP set in May.
analysis.athlete says which settings were used.
What an analysis holds
const { power, load, zones } = analysis;
console.log('average ', power.average?.format());
console.log('normalized', power.normalized?.format());
console.log('IF ', power.intensityFactor?.toFixed(3));
console.log('VI ', power.variabilityIndex?.toFixed(3));
console.log('load ', load?.model, load?.value.toFixed(1));
console.log(
'zones ',
zones.power?.length,
zones.heartRate?.length,
zones.pace,
);
average 190 W
normalized 228 W
IF 0.913
VI 1.200
load cycling-tss 138.7
zones 7 5 null
power.intensityFactor: normalized power over FTP.power.variabilityIndex: normalized power over average power; how uneven the effort was. It needs no settings.load: training stress, with the model that made it ('cycling-tss'). Normalized power and training stress builds it.zones: time in each power, heart-rate and pace zone. See Time in zones.
Pace zones are for running, walking and hiking, so on a ride zones.pace is
null even with a 5 km time set.
A missing setting blocks only what needs it
With no FTP, the results that need FTP are null, and nothing else is:
const noFtp = ride.analyze({
athlete: { hrZone: [{ at: '2026-01-01', maxHr: 186 }] },
});
console.log(
noFtp.power.normalized?.format(),
noFtp.power.variabilityIndex?.toFixed(3),
);
console.log(noFtp.power.intensityFactor, noFtp.load, noFtp.zones.power);
console.log(noFtp.zones.heartRate?.map((z) => z.label).join(', '));
228 W 1.200
null null null
Recovery, Endurance, Tempo, Threshold, Anaerobic
Any stretch
analyze works on any section, the same way:
const run = readActivity(readFileSync('interval-run.fit'));
const repeat = run.laps.at(1)!;
const zonesInRepeat = repeat.analyze({ athlete }).zones.heartRate!;
for (const z of zonesInRepeat)
if (z.seconds > 0) console.log(z.label, z.seconds, 's');
Endurance 19 s
Tempo 143 s
Threshold 21 s
Without history
When you just have the numbers, Profile.of makes settings with no dates:
import { Profile } from '@pond-ts/fit';
const simple = ride.analyze({
athlete: Profile.of({ ftpWatts: 250, weightKg: 72 }),
});
console.log(
simple.power.intensityFactor?.toFixed(3),
simple.load?.value.toFixed(1),
);
0.913 138.7
Profile.of carries FTP and weight only; heart-rate and pace zones need their
basis, so use the dated form for those.