Skip to content

Merging Workouts

WorkoutData supports merging two or more separately recorded workout files into a single continuous object using merge_many() (class method) or merge() (instance method).

This is useful when a session was split across multiple files — e.g. a warm-up recorded separately from a main set, or a workout that was accidentally paused and restarted as a new file.

Key behaviours: - Workouts are sorted by start timestamp before merging. - If two workouts overlap in time, the later workout's data takes precedence. - Time gaps between workouts are preserved as NaN rows after resampling (when interpolate=False). - The time column runs continuously from 0, including any gap period.

%load_ext autoreload
%autoreload 2

import chironpy
from chironpy import WorkoutData
The autoreload extension is already loaded. To reload it, use:
  %reload_ext autoreload

Load workout files

Load the three example files — warm-up, track workout, and warm-down — which together make up a single session recorded as three separate Strava activities.

import pandas as pd
import matplotlib.pyplot as plt

warm_up_example = chironpy.examples(path="strava_17497731832_warm_up.json")
track_example = chironpy.examples(path="strava_17497955000_track_workout.json")
warm_down_example = chironpy.examples(path="strava_17498168651_warm_down.json")

# note the strava activity streams dont contain information about the start time and indexed from the start of the activity. We can set the start time here using set_start_time.

# Update these with the actual start times from your records (ISO 8601 with timezone)
warm_up_started_at    = "2026-02-23T18:45:34"
track_started_at      = "2026-02-23T19:27:33"
warm_down_started_at  = "2026-02-23T19:48:10"

workout_a = WorkoutData.from_file(warm_up_example).set_start_time(pd.Timestamp(warm_up_started_at))
workout_b = WorkoutData.from_file(track_example).set_start_time(pd.Timestamp(track_started_at))
workout_c = WorkoutData.from_file(warm_down_example).set_start_time(pd.Timestamp(warm_down_started_at))

print(f"Warm-up:       {len(workout_a)} rows, {workout_a.index[0]}{workout_a.index[-1]}")
print(f"Track workout: {len(workout_b)} rows, {workout_b.index[0]}{workout_b.index[-1]}")
print(f"Warm-down:     {len(workout_c)} rows, {workout_c.index[0]}{workout_c.index[-1]}")

def plot_workout(workout, title):
    ax = workout.plot(x="time", y="speed", title=title, xlabel="Duration (s)", ylabel="Speed (m/s)", zorder=2)
    ax2 = ax.twinx()
    ax2.plot(workout["time"], workout["is_moving"].astype(float), color="orange", label="is_moving", alpha=0.5, zorder=1)
    ax2.set_ylabel("Is Moving")
    lines1, labels1 = ax.get_legend_handles_labels()
    lines2, labels2 = ax2.get_legend_handles_labels()
    ax.legend(lines1 + lines2, labels1 + labels2)
    plt.show()

print("\n--- Warm-up ---")
display(workout_a.head())
plot_workout(workout_a, "Warm-up")

print("\n--- Track workout ---")
display(workout_b.head())
plot_workout(workout_b, "Track workout")

print("\n--- Warm-down ---")
display(workout_c.head())
plot_workout(workout_c, "Warm-down")
Warm-up:       2379 rows, 2026-02-23 18:45:34 → 2026-02-23 19:25:12
Track workout: 990 rows, 2026-02-23 19:27:33 → 2026-02-23 19:44:02
Warm-down:     996 rows, 2026-02-23 19:48:10 → 2026-02-23 20:04:45

--- Warm-up ---

elevation latitude longitude temperature cadence speed heartrate distance time is_moving grade
datetime
2026-02-23 18:45:34 12.8 -27.361190 153.063225 30.0 0.0 0.00000 82.0 0.600 0 False 0.000000
2026-02-23 18:45:35 12.8 -27.361211 153.063230 30.0 0.0 0.60625 82.0 3.025 1 True 0.016981
2026-02-23 18:45:36 12.8 -27.361232 153.063236 30.0 0.0 1.21250 82.0 5.450 2 True 0.015566
2026-02-23 18:45:37 12.8 -27.361254 153.063242 30.0 0.0 1.81875 82.0 7.875 3 True 0.014151
2026-02-23 18:45:38 12.8 -27.361275 153.063247 30.0 0.0 2.42500 82.0 10.300 4 True 0.012735
No description has been provided for this image

--- Track workout ---

elevation latitude longitude temperature cadence speed heartrate distance time is_moving grade
datetime
2026-02-23 19:27:33 12.8 -27.361362 153.063209 25.0 91.0 0.00 132.0 3.10 0 False 0.000000
2026-02-23 19:27:34 12.8 -27.361343 153.063181 26.0 91.0 0.00 135.0 6.60 1 False 0.010851
2026-02-23 19:27:35 12.9 -27.361297 153.063145 25.5 91.0 2.65 137.5 12.80 2 True 0.006444
2026-02-23 19:27:36 13.0 -27.361251 153.063110 25.0 91.0 5.30 140.0 19.00 3 True 0.006763
2026-02-23 19:27:37 13.0 -27.361213 153.063060 25.5 91.0 5.55 142.0 25.55 4 True 0.006703
No description has been provided for this image

--- Warm-down ---

elevation latitude longitude temperature cadence speed heartrate distance time is_moving grade
datetime
2026-02-23 19:48:10 12.8 -27.361259 153.063255 26.0 0.0 0.0000 111.0 0.40 0 False 0.000000
2026-02-23 19:48:11 12.8 -27.361278 153.063263 26.0 0.0 0.0000 111.0 2.60 1 False -0.015081
2026-02-23 19:48:12 12.8 -27.361304 153.063272 26.0 15.8 0.5834 111.2 5.66 2 True -0.010098
2026-02-23 19:48:13 12.8 -27.361331 153.063280 26.0 31.6 1.1668 111.4 8.72 3 True -0.009353
2026-02-23 19:48:14 12.8 -27.361357 153.063289 26.0 47.4 1.7502 111.6 11.78 4 True -0.008609
No description has been provided for this image

Merge with merge_many()

Merge a list of workouts. Gaps between workouts are preserved as NaN when interpolate=False (the default).

merged = WorkoutData.merge_many([workout_a, workout_b, workout_c])

print(f"Merged: {len(merged)} rows, {merged.index[0]}{merged.index[-1]}")
merged.head()
Merged: 4752 rows, 2026-02-23 18:45:34 → 2026-02-23 20:04:45

elevation latitude longitude temperature cadence speed heartrate distance is_moving grade time
datetime
2026-02-23 18:45:34 12.8 -27.361190 153.063225 30.0 0.0 0.00000 82.0 0.600 False 0.000000 0
2026-02-23 18:45:35 12.8 -27.361211 153.063230 30.0 0.0 0.60625 82.0 3.025 True 0.016981 1
2026-02-23 18:45:36 12.8 -27.361232 153.063236 30.0 0.0 1.21250 82.0 5.450 True 0.015566 2
2026-02-23 18:45:37 12.8 -27.361254 153.063242 30.0 0.0 1.81875 82.0 7.875 True 0.014151 3
2026-02-23 18:45:38 12.8 -27.361275 153.063247 30.0 0.0 2.42500 82.0 10.300 True 0.012735 4

Merge with instance method .merge()

Equivalent to merge_many() but called on one of the workout objects directly.

merged2 = workout_a.merge(workout_b).merge(workout_c)

# Should be identical to merged above
assert len(merged) == len(merged2)
print("merge() and merge_many() produce identical results ✓")
merge() and merge_many() produce identical results ✓

Inspect the gap

Check that data columns are NaN in the gap between workouts while the time column continues uninterrupted.

for label, start, end in [
    ("Gap 1 (warm-up → track)", workout_a.index[-1], workout_b.index[0]),
    ("Gap 2 (track → warm-down)", workout_b.index[-1], workout_c.index[0]),
]:
    duration = (end - start).total_seconds()
    gap_rows = merged[(merged.index > start) & (merged.index < end)]
    print(f"{label}: {duration:.0f}s ({duration/60:.1f} min), {len(gap_rows)} NaN rows in merged")
Gap 1 (warm-up → track): 141s (2.4 min), 140 NaN rows in merged
Gap 2 (track → warm-down): 248s (4.1 min), 247 NaN rows in merged

Plot the merged workout

Speed over the full merged duration. The gap shows as a flat NaN region.

plot_workout(merged, "Speed over merged workout duration")
No description has been provided for this image

Merge without gaps (drop_gaps=True)

Pass drop_gaps=True to shift each workout back-to-back in time, eliminating the gap rows entirely. Total duration shrinks by the sum of the gap durations; all data values are contiguous with no NaN gap rows.

merged_no_gaps = WorkoutData.merge_many([workout_a, workout_b, workout_c], drop_gaps=True)

print(f"  {'':30s}  {'rows':>5}  {'duration (s)':>12}")
print(f"  {'merged (gaps kept)':30s}  {len(merged):>5}  {merged['time'].iloc[-1]:>12.0f}")
print(f"  {'merged (drop_gaps=True)':30s}  {len(merged_no_gaps):>5}  {merged_no_gaps['time'].iloc[-1]:>12.0f}")
print(f"\n  Rows removed: {len(merged) - len(merged_no_gaps)}")

print("\n  Gap sizes (from merged with gaps kept):")
for label, start, end in [
    ("Gap 1 (warm-up → track)", workout_a.index[-1], workout_b.index[0]),
    ("Gap 2 (track → warm-down)", workout_b.index[-1], workout_c.index[0]),
]:
    duration = (end - start).total_seconds()
    gap_rows = merged[(merged.index > start) & (merged.index < end)]
    print(f"    {label}: {duration:.0f}s ({duration/60:.1f} min), {len(gap_rows)} NaN rows")
                                   rows  duration (s)
  merged (gaps kept)               4752          4751
  merged (drop_gaps=True)          4365          4364

  Rows removed: 387

  Gap sizes (from merged with gaps kept):
    Gap 1 (warm-up → track): 141s (2.4 min), 140 NaN rows
    Gap 2 (track → warm-down): 248s (4.1 min), 247 NaN rows

Plot: speed over merged workout (no gaps)

plot_workout(merged_no_gaps, "Speed over merged workout (drop_gaps=True)")
No description has been provided for this image