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>
115 lines
4.0 KiB
Kotlin
115 lines
4.0 KiB
Kotlin
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
|
|
}
|
|
}
|