Files
samplez/rippr-src/docs/v2/07-aggregates.md
uhryniuk 280fd7f988 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>
2026-08-11 08:30:25 -05:00

5.0 KiB

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.

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

  • Distance updates live during recording
  • Distance does not jump across a pause
  • Distance is correct across flush boundaries (no per-batch reset)
  • Stationary noisy-altitude recording yields ~0 m elevation gain
  • Post-stop values exactly match RideStatistics.compute() over the stored points
  • 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).