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,114 @@
package com.rippr
import com.rippr.data.TrackPoint
import com.rippr.geo.Geo
import com.rippr.stats.ElevationAccumulator
import com.rippr.stats.RideStatistics
import kotlin.math.max
/**
* Running totals for the ride in progress.
*
* Exists because SQLite 3.18 (minSdk 26) has no window functions, so distance and
* elevation — both of which need consecutive-row differences — cannot be computed in SQL.
* They are folded in here as points are written and persisted onto the `Trip` row once
* per flush, so the recording screen can show live numbers without rescanning the point
* table.
*
* The values this produces are an *estimate*. They are overwritten with
* [RideStatistics.compute] when the trip completes, which is also why this class reuses
* `ElevationAccumulator` rather than reimplementing hysteresis: a naive version measured
* 1498 m of phantom climbing on a parked bike.
*
* Not thread-safe — the writer loop owns it, under the service's write mutex.
*/
class Accumulator {
/**
* The last point of the *previous* batch.
*
* Without this, distance restarts at every flush boundary and silently under-reports
* by roughly one inter-point hop per batch — a few percent, which nobody notices
* until they compare against an odometer.
*/
private var lastPoint: TrackPoint? = null
private var elevation = ElevationAccumulator()
private var restoredElevationM: Double = 0.0
var distanceM: Double = 0.0
private set
var movingMillis: Long = 0
private set
var maxSpeedKmh: Float = 0f
private set
var pointCount: Int = 0
private set
fun fold(points: List<TrackPoint>) {
for (point in points) {
pointCount++
maxSpeedKmh = max(maxSpeedKmh, point.speedKmh)
elevation.add(point.altitudeM)
val prev = lastPoint
// Distance and moving time only accrue *within* a segment. Points either side
// of a pause can be kilometres apart and that gap was never ridden.
if (prev != null && prev.segmentId == point.segmentId) {
distanceM += Geo.haversineMeters(
prev.latitude, prev.longitude, point.latitude, point.longitude,
)
val dt = point.timestamp - prev.timestamp
if (dt in 1..RideStatistics.MAX_SAMPLE_GAP_MILLIS &&
point.speedKmh >= Telemetry.SPEED_NOISE_FLOOR_KMH
) {
movingMillis += dt
}
}
lastPoint = point
}
}
/**
* Elevation gain including the run still in progress.
*
* Reads a snapshot rather than mutating, so it is safe to call on every flush while
* recording continues.
*/
fun elevationGain(): Double = restoredElevationM + elevation.gainIncludingPending()
/** Seeds from a trip already in progress after the service is restarted. */
fun restore(
distanceM: Double,
movingMillis: Long,
maxSpeedKmh: Float,
elevationGainM: Double,
pointCount: Int,
lastPoint: TrackPoint?,
) {
this.distanceM = distanceM
this.movingMillis = movingMillis
this.maxSpeedKmh = maxSpeedKmh
this.pointCount = pointCount
this.lastPoint = lastPoint
// Elevation cannot be resumed mid-run from a scalar total, so the accumulator
// restarts and only gain from here on is added to the persisted figure. The
// authoritative recomputation at trip completion corrects any drift.
this.elevation = ElevationAccumulator()
this.restoredElevationM = elevationGainM
}
fun reset() {
lastPoint = null
elevation = ElevationAccumulator()
distanceM = 0.0
movingMillis = 0
maxSpeedKmh = 0f
pointCount = 0
restoredElevationM = 0.0
}
/** Clears the cross-segment anchor so a resumed ride does not span the pause. */
fun onSegmentChanged() {
lastPoint = null
}
}