Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <directory> [--all-columns]` — a health check, broken down by file type: parsing failures, record column coverage, and file creators.
- `summarize_archive.py <directory>` — a per-sport summary rollup, illustrating how a typical downstream analysis might use the library's output.
- `summarize_archive.py <directory>` — a per-sport summary rollup, displaying key metrics across the archive.

## Parser notes

Expand Down
58 changes: 15 additions & 43 deletions examples/summarize_archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:")
Expand Down
16 changes: 8 additions & 8 deletions tests/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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