Files
samplez/rippr-src/docs/v2/05-stats.md
uhryniuk 280fd7f988 Add Rippr source snapshot and full-history bundle
Two copies for two jobs. rippr-src/ is a browsable git archive export of
the tracked tree at 2f76983 - no build outputs, no local.properties, no
nested .git - which is convenient to read in gitea but carries no history
and will drift.

rippr-full-history.bundle is the real backup: all 18 commits, verified as
"records a complete history" and test-cloned before committing. This
matters because ~/dojo/rippr has no git remote and otherwise exists only
on one machine.

rippr-src/SNAPSHOT.md explains the difference and how to restore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 08:30:25 -05:00

5.3 KiB

T05 — Ride statistics

Phase 2 · Depends on T04 · Status Done

Goal

Derive every number the trip detail screen shows from a list of points, as pure functions. Done when distance, moving time, elevation gain, speed histogram, and elevation profile are computed and unit-tested with no device.

Context

v1 shows max speed, point count, and a naive lastTimestamp - firstTimestamp duration — computed in SQL by TrackPointDao.observeStats(). That query cannot survive into v2 for the derived values, because minSdk 26 means SQLite 3.18, which has no window functions, and distance/elevation both need consecutive-row differences.

So these move to Kotlin. T07 accumulates them live during recording; this task provides the authoritative batch computation used on trip completion and on the detail screen.

Reuse from Telemetry.kt:

  • SPEED_NOISE_FLOOR_KMH (1.5) — the moving/stopped threshold, so "moving" means the same thing here as it does to the recorder
  • formatDuration — already tested, used for display

Available from T04

com.rippr.geo.Geo provides haversineMeters (both Double and LatLon overloads), simplify, perpendicularDistanceMeters, bounds, and pathLengthMeters. LatLon, Bounds (with isDegenerate for the stationary-ride case) and a TrackPoint.toLatLon() extension are there too. All Android-free and unit-tested.

Use Geo.pathLengthMeters per segment rather than summing across the whole ride — it has no notion of pause boundaries and will happily span them.

Design

object RideStatistics {
    fun compute(points: List<TrackPoint>, segments: List<Segment>): RideSummary
}

data class RideSummary(
    val distanceM: Double,
    val elapsedMillis: Long,      // wall clock, first fix to last
    val movingMillis: Long,       // time above the speed noise floor
    val maxSpeedKmh: Float,
    val avgMovingSpeedKmh: Float, // distance / movingMillis, not the mean of samples
    val elevationGainM: Double,
    val elevationLossM: Double,
    val pointCount: Int,
)

Distance never crosses a segment boundary. Points either side of a pause may be kilometres apart; summing across the gap would invent distance the rider never covered. Accumulate per segment and total.

Elevation gain needs two mechanisms, not one. A threshold alone is not enough — measured, not assumed: a parked bike with ±8 m noise reported 1498 m of climbing with simple thresholding, because noise crosses any small threshold constantly. The shipped implementation combines a 15-sample moving average (cuts noise by ~sqrt(window)) with reversal hysteresis (a climb banks only once altitude turns back down by more than 3 m from its peak). That brings the same fixture to ~30 m.

The moving average lags the true altitude by about half a window, which clipped a 100 m climb to 93 m, so finish() reconciles the final run against the last raw reading.

Average speed is distance ÷ moving time, not the arithmetic mean of speedKmh samples. The mean of samples over-weights the time spent stopped and under-reports the real pace.

Speed histogram and elevation profile are series for T11's charts:

fun speedHistogram(points: List<TrackPoint>, bucketKmh: Int = 10): List<Bucket>
fun elevationProfile(points: List<TrackPoint>, maxSamples: Int = 200): List<ElevationSample>

The profile is downsampled by distance-along-path, not by index, so a stretch where the bike sat idle at 2 Hz does not dominate the chart.

Implementation

  1. Create stats/RideStatistics.kt.
  2. compute() walks points grouped by segmentId, using Geo.haversineMeters.
  3. Moving time accumulates dt only where speedKmh >= SPEED_NOISE_FLOOR_KMH; cap any single dt (say 10 s) so a GPS dropout does not inject phantom moving time.
  4. Elevation gain/loss with the 3 m hysteresis state machine.
  5. Histogram and profile helpers.
  6. Unit tests throughout.

Acceptance criteria

  • Distance excludes inter-segment gaps
  • A stationary point cloud with ±8 m altitude noise yields ~0 m elevation gain
  • Moving time excludes stopped periods
  • Average speed uses moving time, not elapsed
  • Empty input returns a zeroed summary, never a crash or NaN
  • Single-point input returns zero distance, not NaN

Tests

JVM unit tests:

  • Synthetic straight-line ride: distance matches Haversine within rounding
  • Two segments separated by a large jump: distance excludes the gap
  • Stationary noisy-altitude fixture: elevation gain ≈ 0 (the regression test that matters most)
  • A genuine 100 m climb registers ~100 m
  • Half-stopped ride: moving time ≈ half of elapsed
  • Empty and single-point inputs
  • avgMovingSpeedKmh on a known distance and duration

Risks / gotchas

  • NaN propagation. Division by zero moving time must return 0, not NaN — a NaN reaching Compose renders as literal "NaN" on screen.
  • dt capping matters. Without it, a two-minute tunnel dropout counts as two minutes of moving time at the last known speed.
  • Keep this in sync with T07. The live accumulator and this batch computation must agree, or the number changes when a ride finishes. T07 recomputes with this code on completion, which keeps them consistent by construction.

Out of scope

Charts and rendering (T11); live accumulation during recording (T07).