Osaka Marathon 2025 Example¶
Load the FIT file into a WorkoutData object. WorkoutData is a subclass of pandas.DataFrame — it has all the same columns, indexing, and plotting behaviour, but adds higher-level methods for computing metrics like elevation gain, best intervals, and fastest splits directly on the object.
from pathlib import Path
import chironpy
osaka = chironpy.examples(path="18360138543_ACTIVITY_Osaka_Marathon_2025.fit")
data = chironpy.WorkoutData.from_file(osaka, resample=True, interpolate=True)
data.head()
WorkoutData normalises data to a consistent set of standard columns regardless of the source file format. For example, enhanced_speed and enhanced_altitude from Garmin devices are normalised to speed and elevation. Any other additional columns present in the original file are included alongside the standard ones.
Plot data over workout duration¶
WorkoutData adds a time column — integer seconds since workout start — use it as the x-axis for duration-based plots.
data.plot(x="time", y="speed", title="Speed over time", xlabel="Duration (s)", ylabel="Speed (m/s)")
data.plot(x="time", y="heartrate", title="Heart Rate over Time", xlabel="Duration (s)", ylabel="Heart Rate (bpm)", color="red")
Plot data over workout distance¶
Use the distance column (metres) as the x-axis.
data.plot(x="distance", y="speed", title="Speed over distance", xlabel="Distance (m)", ylabel="Speed (m/s)")
data.plot(x="distance", y="cadence", title="Cadence over distance", xlabel="Distance (m)", ylabel="Cadence (rpm)", color="green")
Mean max¶
mms = data["speed"].chironpy.mean_max()
mms
mm = data.chironpy.mean_max(["speed", "heartrate"])
mm
mm["mean_max_heartrate"].plot(
title="Mean Max Heart Rate", xlabel="Duration (s)", ylabel="Heart Rate (bpm)", color="red"
)
Performance Benchmarks¶
Best efforts (fastest time) and average heart rate over key race distances, computed using fastest_distance_intervals() and best_distance_intervals().
from IPython.display import Markdown
distances = [1000, 5000, 10000, 21100, 42200]
labels = ["1 km", "5 km", "10 km", "21.1 km", "42.2 km"]
fastest = data.fastest_distance_intervals(distances)
best_hr = data.best_distance_intervals(distances, "heartrate")
rows = []
for i, label in enumerate(labels):
f = fastest[i]
hr = best_hr[i]
if f is None:
rows.append(f"| {label} | — | — | — |")
else:
secs = int(f["value"])
h, rem = divmod(secs, 3600)
m, s = divmod(rem, 60)
time_str = f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
pace_secs = f["value"] / distances[i] * 1000 # sec/km
pm, ps = divmod(int(pace_secs), 60)
pace_str = f"{pm}:{ps:02d} /km"
hr_str = f"{hr['value']:.0f} bpm" if hr else "—"
rows.append(f"| {label} | {time_str} | {pace_str} | {hr_str} |")
table = "| Distance | Time | Pace | Heart Rate |\n"
table += "|----------|------|------|------------|\n"
table += "\n".join(rows)
Markdown(table)
| Distance | Time | Pace | Heart Rate |
|---|---|---|---|
| 1 km | 3:10 | 3:10 /km | 194 bpm |
| 5 km | 16:47 | 3:21 /km | 192 bpm |
| 10 km | 34:03 | 3:24 /km | 191 bpm |
| 21.1 km | 1:12:31 | 3:26 /km | 189 bpm |
| 42.2 km | 2:26:16 | 3:27 /km | 184 bpm |
Laps¶
> Work in progress — WorkoutData does not yet have a dedicated .laps() method. The lap column is included from the FIT file (zero-indexed lap counter), but because the DataFrame is resampled and interpolated at 1 Hz, the column contains fractional values at lap boundaries. Round to integer before grouping.
Laps can be accessed by grouping on the lap column:
from IPython.display import Markdown
from chironpy.metrics.vert import elevation_gain as _elevation_gain
data["lap_int"] = data["lap"].round().astype(int)
laps = data.groupby("lap_int")
rows = []
for lap_num, group in laps:
dist = group["distance"].max() - group["distance"].min()
secs = int(group["time"].max() - group["time"].min())
h, rem = divmod(secs, 3600)
m, s = divmod(rem, 60)
time_str = f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
pace_secs = secs / dist * 1000 if dist > 0 else None
if pace_secs:
pm, ps = divmod(int(pace_secs), 60)
pace_str = f"{pm}:{ps:02d} /km"
else:
pace_str = "—"
avg_hr = f"{group['heartrate'].mean():.0f} bpm" if "heartrate" in group else "—"
avg_cad = f"{group['cadence'].mean():.0f} rpm" if "cadence" in group else "—"
net_elev = group["elevation"].iloc[-1] - group["elevation"].iloc[0] if "elevation" in group.columns else None
elev_str = f"{net_elev:+.0f} m" if net_elev is not None else "—"
rows.append(f"| {int(lap_num) + 1} | {dist:.0f} m | {time_str} | {pace_str} | {avg_hr} | {avg_cad} | {elev_str} |")
table = "| Lap | Distance | Time | Avg Pace | Avg Heart Rate | Avg Cadence | Net Elevation |\n"
table += "|-----|----------|------|----------|----------------|-------------|---------------|\n"
table += "\n".join(rows)
Markdown(table)
| Lap | Distance | Time | Avg Pace | Avg Heart Rate | Avg Cadence | Net Elevation |
|---|---|---|---|---|---|---|
| 1 | 5046 m | 17:22 | 3:26 /km | 164 bpm | 94 rpm | -13 m |
| 2 | 5038 m | 17:15 | 3:25 /km | 178 bpm | 93 rpm | -3 m |
| 3 | 5051 m | 17:23 | 3:26 /km | 189 bpm | 93 rpm | -5 m |
| 4 | 5034 m | 17:32 | 3:28 /km | 192 bpm | 93 rpm | +1 m |
| 5 | 1093 m | 3:48 | 3:28 /km | 191 bpm | 93 rpm | +1 m |
| 6 | 3900 m | 13:28 | 3:27 /km | 188 bpm | 92 rpm | +1 m |
| 7 | 4983 m | 17:10 | 3:26 /km | 187 bpm | 92 rpm | +2 m |
| 8 | 5005 m | 17:19 | 3:27 /km | 188 bpm | 92 rpm | -1 m |
| 9 | 5000 m | 17:45 | 3:33 /km | 183 bpm | 90 rpm | -4 m |
| 10 | 2239 m | 8:03 | 3:35 /km | 186 bpm | 90 rpm | +5 m |