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>
This commit is contained in:
2026-08-11 08:30:25 -05:00
parent 4debdfa518
commit 280fd7f988
106 changed files with 10377 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
# 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<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:
```kotlin
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
- [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).