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:
114
rippr-src/app/src/main/java/com/rippr/RideAccumulator.kt
Normal file
114
rippr-src/app/src/main/java/com/rippr/RideAccumulator.kt
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user