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) { 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 } }