# 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 ```kotlin object RideStatistics { fun compute(points: List, segments: List): 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: ```kotlin fun speedHistogram(points: List, bucketKmh: Int = 10): List fun elevationProfile(points: List, maxSamples: Int = 200): List ``` 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 - [x] Distance excludes inter-segment gaps - [x] A stationary point cloud with ±8 m altitude noise yields ~0 m elevation gain - [x] Moving time excludes stopped periods - [x] Average speed uses moving time, not elapsed - [x] Empty input returns a zeroed summary, never a crash or NaN - [x] 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).