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>
114 lines
5.0 KiB
Markdown
114 lines
5.0 KiB
Markdown
# T07 — Live aggregate accumulation
|
|
|
|
**Phase** 3 · **Depends on** T04, T06 · **Status** Done
|
|
|
|
## Goal
|
|
|
|
Keep distance, moving time, elevation gain, and max speed current on the active `Trip`
|
|
row while recording, so the Record screen shows live numbers without rescanning the
|
|
point table. Done when those values update during a ride and are reconciled exactly on
|
|
completion.
|
|
|
|
## Context
|
|
|
|
v1's `observeStats()` recomputes aggregates in SQL on every emission. That worked for
|
|
`MAX`, `AVG`, and `COUNT` over one table, but v2 needs **distance** and **elevation
|
|
gain**, which require consecutive-row differences.
|
|
|
|
**SQLite 3.18 on minSdk 26 has no window functions** — no `LAG`, no `OVER`. There is no
|
|
SQL expression for "distance from the previous point". So the maths moves into Kotlin,
|
|
into the writer loop that is already touching every point as it lands.
|
|
|
|
`TrackingService` already batches writes every ~2 s (`FLUSH_INTERVAL_MS = 2000`,
|
|
`FLUSH_SIZE = 25`), so one extra `UPDATE trips …` per flush is negligible — roughly one
|
|
write every two seconds against a table with a single active row.
|
|
|
|
## Design
|
|
|
|
The writer loop keeps the **last point of the previous batch** in memory as the anchor
|
|
for cross-batch distance. Without it, distance resets at every flush boundary and
|
|
under-reports by roughly one inter-point hop per 25 points.
|
|
|
|
**Reuse `ElevationAccumulator` from T05 verbatim.** It is already streaming (fed one
|
|
altitude at a time) precisely so the recorder can share it. Do **not** reimplement the
|
|
hysteresis here — a naive version measured 1498 m of phantom climbing on a parked bike.
|
|
Remember to call `finish()` before persisting final values.
|
|
|
|
Implemented as `com.rippr.Accumulator` in `RideAccumulator.kt` rather than a private
|
|
class inside the service, so it is unit-testable without a device.
|
|
|
|
```kotlin
|
|
private data class Accumulator(
|
|
var lastPoint: TrackPoint? = null,
|
|
var distanceM: Double = 0.0,
|
|
var movingMillis: Long = 0,
|
|
var maxSpeedKmh: Float = 0f,
|
|
var elevationGainM: Double = 0.0,
|
|
var climbBuffer: Double = 0.0, // hysteresis state, see T05
|
|
var pointCount: Int = 0,
|
|
)
|
|
```
|
|
|
|
Rules, matching T05 exactly:
|
|
- Distance accumulates only **within** a segment. On resume, `lastPoint` resets to null
|
|
so the pause gap contributes nothing.
|
|
- Moving time accumulates `dt` only where `speedKmh >= Telemetry.SPEED_NOISE_FLOOR_KMH`,
|
|
with `dt` capped (10 s) so a tunnel dropout cannot inject phantom movement.
|
|
- Elevation gain uses the same 3 m hysteresis.
|
|
|
|
### Reconciliation on completion
|
|
|
|
Incremental accumulation can drift — a process kill mid-ride loses the in-memory
|
|
accumulator, and the restarted service resumes from zero while the DB row holds a stale
|
|
partial. So on `ACTION_STOP`, after the final drain, **recompute authoritatively** with
|
|
`RideStatistics.compute()` over all stored points and overwrite the row.
|
|
|
|
That is why T05 and T07 must agree: the live number is an estimate, the finished number
|
|
is computed by T05's code. Using the same functions for both keeps them consistent by
|
|
construction rather than by discipline.
|
|
|
|
## Implementation
|
|
|
|
1. Add `Accumulator` to `TrackingService`, owned by the writer coroutine.
|
|
2. After each batch insert, fold the batch into the accumulator and `UPDATE` the trip row
|
|
in the same transaction as the point insert — so a crash cannot commit points without
|
|
their aggregate contribution.
|
|
3. Reset `lastPoint` on segment change (pause/resume).
|
|
4. On restart-with-active-trip, seed the accumulator from the persisted trip row and set
|
|
`lastPoint` to the last stored point of the open segment.
|
|
5. On stop, recompute with `RideStatistics.compute()` and overwrite.
|
|
|
|
## Acceptance criteria
|
|
|
|
- [x] Distance updates live during recording
|
|
- [x] Distance does not jump across a pause
|
|
- [x] Distance is correct across flush boundaries (no per-batch reset)
|
|
- [x] Stationary noisy-altitude recording yields ~0 m elevation gain
|
|
- [x] Post-stop values exactly match `RideStatistics.compute()` over the stored points
|
|
- [x] Restart mid-ride resumes accumulation instead of restarting from zero
|
|
|
|
## Tests
|
|
|
|
Instrumented:
|
|
- Insert 100 synthetic points across four flushes; assert accumulated distance equals the
|
|
batch computation over the same points (the cross-batch anchor regression test)
|
|
- Pause/resume with a 5 km jump between segments; assert the gap is excluded
|
|
- Simulate restart: seed accumulator from DB, continue, assert continuity
|
|
|
|
Emulator: feed a synthetic ride and compare the live displayed distance against the
|
|
post-stop recomputation — they should differ by nothing.
|
|
|
|
## Risks / gotchas
|
|
|
|
- **The cross-batch anchor is the subtle bug here.** Losing `lastPoint` between flushes
|
|
silently under-reports distance by a few percent, which is exactly the kind of error
|
|
nobody notices until they compare against a bike odometer.
|
|
- **Same transaction as the insert.** Points and their aggregate contribution must commit
|
|
atomically or a crash leaves them disagreeing.
|
|
- **Do not let the accumulator's definitions drift from T05.** Share constants; do not
|
|
copy magic numbers.
|
|
|
|
## Out of scope
|
|
|
|
Displaying any of this (T09, T11).
|