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:
126
rippr-src/docs/v2/05-stats.md
Normal file
126
rippr-src/docs/v2/05-stats.md
Normal 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).
|
||||
Reference in New Issue
Block a user