Skip to content

File-level aggregated stats on GpxFile #84

@Sibyx

Description

@Sibyx

Problem

There is no way to get aggregated statistics across all tracks and routes in a GPX file. Users who have GPX files with multiple <trkseg> (e.g., devices that create new segments on pause) need to manually merge stats to get a unified view.

Related discussion: #67 (comment)

Current state

Stats exist at the segment, track, and route level, but not at the file level:

GpxFile                    ← no stats
├── Track[0]               ← stats (aggregated from segments)
│   ├── Segment[0]         ← stats
│   └── Segment[1]         ← stats
├── Track[1]               ← stats
└── Route[0]               ← stats

Proposed solution

Add a ?Stats $stats property to GpxFile and a FileStatsAnalyzer that aggregates all track + route stats in finalizeFile().

Model change

// src/phpGPX/Models/GpxFile.php
class GpxFile implements \JsonSerializable
{
    // ... existing properties ...
    public ?Stats $stats = null;
}

New analyzer

// src/phpGPX/Analysis/FileStatsAnalyzer.php
class FileStatsAnalyzer extends AbstractPointAnalyzer
{
    public function finalizeFile(GpxFile $gpxFile): void
    {
        /** @var Stats[] $allStats */
        $allStats = [];

        foreach ($gpxFile->tracks as $track) {
            if ($track->stats !== null) {
                $allStats[] = $track->stats;
            }
        }

        foreach ($gpxFile->routes as $route) {
            if ($route->stats !== null) {
                $allStats[] = $route->stats;
            }
        }

        if (empty($allStats)) {
            return;
        }

        $fileStats = new Stats();

        // Sum: distance, realDistance, elevation, duration, movingDuration
        foreach ($allStats as $stats) {
            $fileStats->distance = ($fileStats->distance ?? 0) + ($stats->distance ?? 0);
            $fileStats->realDistance = ($fileStats->realDistance ?? 0) + ($stats->realDistance ?? 0);
            $fileStats->cumulativeElevationGain = ($fileStats->cumulativeElevationGain ?? 0) + ($stats->cumulativeElevationGain ?? 0);
            $fileStats->cumulativeElevationLoss = ($fileStats->cumulativeElevationLoss ?? 0) + ($stats->cumulativeElevationLoss ?? 0);
            $fileStats->duration = ($fileStats->duration ?? 0) + ($stats->duration ?? 0);
            $fileStats->movingDuration = ($fileStats->movingDuration ?? 0) + ($stats->movingDuration ?? 0);
        }

        // Extrema: altitude, timestamps
        foreach ($allStats as $stats) {
            if ($stats->minAltitude !== null && ($fileStats->minAltitude === null || $stats->minAltitude < $fileStats->minAltitude)) {
                $fileStats->minAltitude = $stats->minAltitude;
                $fileStats->minAltitudeCoords = $stats->minAltitudeCoords;
            }
            if ($stats->maxAltitude !== null && ($fileStats->maxAltitude === null || $stats->maxAltitude > $fileStats->maxAltitude)) {
                $fileStats->maxAltitude = $stats->maxAltitude;
                $fileStats->maxAltitudeCoords = $stats->maxAltitudeCoords;
            }
            if ($stats->startedAt !== null && ($fileStats->startedAt === null || $stats->startedAt < $fileStats->startedAt)) {
                $fileStats->startedAt = $stats->startedAt;
                $fileStats->startedAtCoords = $stats->startedAtCoords;
            }
            if ($stats->finishedAt !== null && ($fileStats->finishedAt === null || $stats->finishedAt > $fileStats->finishedAt)) {
                $fileStats->finishedAt = $stats->finishedAt;
                $fileStats->finishedAtCoords = $stats->finishedAtCoords;
            }
        }

        // Derived: speed, pace (same formula as Engine::computeDerivedStats)
        if ($fileStats->duration > 0 && $fileStats->distance !== null) {
            $fileStats->averageSpeed = $fileStats->distance / $fileStats->duration;
        }
        if ($fileStats->distance > 0) {
            $fileStats->averagePace = $fileStats->duration / ($fileStats->distance / 1000);
        }
        if ($fileStats->movingDuration > 0 && $fileStats->distance !== null) {
            $fileStats->movingAverageSpeed = $fileStats->distance / $fileStats->movingDuration;
        }

        // Bounds from BoundsAnalyzer (already on metadata)
        $fileStats->bounds = $gpxFile->metadata?->bounds;

        $gpxFile->stats = $fileStats;
    }
}

Registration in Engine::default()

// Added to Engine::default() — after all other analyzers
->addAnalyzer(new FileStatsAnalyzer())

Architecture fit

This follows the exact same pattern as BoundsAnalyzer which already uses finalizeFile() to merge bounds across the whole file. The FileStatsAnalyzer only implements finalizeFile() — all other lifecycle methods are no-ops via AbstractPointAnalyzer.

Usage

$gpx = new phpGPX();
$file = Engine::default()->process($gpx->load('track.gpx'));

// File-level stats (merged across all tracks/routes)
echo $file->stats->distance;         // Total distance
echo $file->stats->duration;         // Total duration
echo $file->stats->startedAt;        // Earliest start time
echo $file->stats->finishedAt;       // Latest end time

// Per-track stats still available
echo $file->tracks[0]->stats->distance;

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions