From b104f5d94cb027a2ed69dc2e62117a42d73992be Mon Sep 17 00:00:00 2001 From: Tabish Mustufa Date: Sat, 5 Sep 2026 12:12:09 -0700 Subject: [PATCH] Read Activity fields directly in summarize_archive.py TCX/GPX now populate total_elapsed_time/total_distance, so the script no longer re-derives them from records. summarize_activity returns Activity directly instead of a near-duplicate ActivitySummary, and the per-sport rollup gains total_ascent, avg_power, and avg_heart_rate. Co-Authored-By: Claude Sonnet 5 --- README.md | 2 +- examples/summarize_archive.py | 58 +++++++++-------------------------- tests/test_examples.py | 16 +++++----- 3 files changed, 24 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index c9d596a..19eb5ca 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ Many TCX/GPX fields are computed from record and lap data since they are not pro [`examples/`](examples/) has runnable scripts for processing a directory of activity files, built on a shared `parse_directory()` helper: - `check_archive.py [--all-columns]` — a health check, broken down by file type: parsing failures, record column coverage, and file creators. -- `summarize_archive.py ` — a per-sport summary rollup, illustrating how a typical downstream analysis might use the library's output. +- `summarize_archive.py ` — a per-sport summary rollup, displaying key metrics across the archive. ## Parser notes diff --git a/examples/summarize_archive.py b/examples/summarize_archive.py index adf20bb..bacac44 100644 --- a/examples/summarize_archive.py +++ b/examples/summarize_archive.py @@ -4,61 +4,29 @@ """ import argparse -from dataclasses import dataclass, fields +from dataclasses import fields from pathlib import Path import pandas as pd from _parse_directory import parse_directory -from activity_parser import ActivityParser +from activity_parser import Activity, ActivityParser # Built once per worker process and reused for every file it handles PARSER = ActivityParser() -@dataclass(frozen=True) -class ActivitySummary: - path: Path - sport: str | None - start_time: pd.Timestamp | None - total_calories: float | None - duration_s: float | None - distance_km: float | None - - -def summarize_activity(path: Path) -> ActivitySummary: - """Processing one activity file into summary metrics. - - Picklable and suitable for use inside multiprocessing pools. +def summarize_activity(path: Path) -> Activity: + """Parses one activity file, picklable for use inside multiprocessing pools. Args: path: File to parse. Returns: - An ActivitySummary of activity attributes and metrics. + The file's Activity summary. """ - records, _, activity = PARSER.parse(path) - - # Directly compute duration and distance, instead of picking them out of Activity, - # since TCX/GPX do not populate these Activity fields. - - duration_s = None - if isinstance(records.index, pd.DatetimeIndex) and not records.index.empty: - duration_s = (records.index.max() - records.index.min()).total_seconds() - - distance_km = None - if "distance" in records.columns: - # distance is cumulative, so its range over the file is the total distance. - distance_km = records["distance"].max() - records["distance"].min() - - return ActivitySummary( - path=path, - sport=activity.sport, - start_time=activity.start_time, - total_calories=activity.total_calories, - duration_s=duration_s, - distance_km=distance_km, - ) + _, _, activity = PARSER.parse(path) + return activity def main() -> None: @@ -68,7 +36,7 @@ def main() -> None: metrics = [metric for _, metric in parse_directory(args.directory, summarize_activity)] - df = pd.DataFrame(metrics, columns=[f.name for f in fields(ActivitySummary)]) + df = pd.DataFrame(metrics, columns=[f.name for f in fields(Activity)]) print(f"{len(df)} activities parsed.") if df.empty: @@ -80,9 +48,13 @@ def main() -> None: summary = pd.DataFrame( { "activities": by_sport.size(), - "distance_km": by_sport["distance_km"].sum(), - "duration_h": by_sport["duration_s"].sum() / 3600, - "total_calories": by_sport["total_calories"].sum(), + # min_count=1 so an all-None group sums to NaN, not 0.0. + "distance_km": by_sport["total_distance"].sum(min_count=1), + "duration_h": by_sport["total_elapsed_time"].sum(min_count=1) / 3600, + "total_ascent": by_sport["total_ascent"].sum(min_count=1), + "avg_power": by_sport["avg_power"].mean(), + "avg_heart_rate": by_sport["avg_heart_rate"].mean(), + "total_calories": by_sport["total_calories"].sum(min_count=1), } ) print("\nPer sport:") diff --git a/tests/test_examples.py b/tests/test_examples.py index bb986c8..f4350f7 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -111,10 +111,10 @@ def test_check_activity_falls_back_to_lenient_parsing(tmp_path, make_bad_file): assert check.n_records >= 1 -def test_summarize_activity_derives_duration_and_distance(): +def test_summarize_activity_reads_elapsed_time_and_distance_off_activity(): row = summarize_activity(FIT_FILES / "garmin-fenix-5-bike.fit") - assert row.duration_s is not None and row.duration_s > 0 - assert row.distance_km is not None and row.distance_km > 0 + assert row.total_elapsed_time is not None and row.total_elapsed_time > 0 + assert row.total_distance is not None and row.total_distance > 0 def test_summarize_activity_raises_on_unparseable_file(tmp_path): @@ -125,15 +125,15 @@ def test_summarize_activity_raises_on_unparseable_file(tmp_path): summarize_activity(bad) -def test_summarize_activity_handles_missing_timestamp_index(): +def test_summarize_activity_leaves_elapsed_time_none_without_timestamp_index(): # DeveloperData.fit has no timestamp field, so records keeps a plain RangeIndex. row = summarize_activity(FIT_FILES / "DeveloperData.fit") - assert row.duration_s is None - assert row.distance_km is not None and row.distance_km > 0 + assert row.total_elapsed_time is None + assert row.total_distance is not None and row.total_distance > 0 def test_summarize_activity_leaves_distance_none_without_distance_column(): # Plain GPX has no native per-point distance field, so there's no "distance" column. row = summarize_activity(GPX_FILES / "sample.gpx") - assert row.distance_km is None - assert row.duration_s is not None and row.duration_s > 0 + assert row.total_distance is None + assert row.total_elapsed_time is not None and row.total_elapsed_time > 0