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>
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 recorderformatDuration— 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
- Create
stats/RideStatistics.kt. compute()walks points grouped bysegmentId, usingGeo.haversineMeters.- Moving time accumulates
dtonly wherespeedKmh >= SPEED_NOISE_FLOOR_KMH; cap any singledt(say 10 s) so a GPS dropout does not inject phantom moving time. - Elevation gain/loss with the 3 m hysteresis state machine.
- Histogram and profile helpers.
- 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
avgMovingSpeedKmhon 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.
dtcapping 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).