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
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")
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()
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 ✓")
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")
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")
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")
Plot: speed over merged workout (no gaps)¶
plot_workout(merged_no_gaps, "Speed over merged workout (drop_gaps=True)")