package com.rippr.stats import com.rippr.Telemetry import com.rippr.data.Segment import com.rippr.data.TrackPoint import com.rippr.geo.Geo import kotlin.math.abs import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt data class RideSummary( val distanceM: Double = 0.0, /** Wall clock, first fix to last. */ val elapsedMillis: Long = 0, /** Time spent above the speed noise floor. */ val movingMillis: Long = 0, val maxSpeedKmh: Float = 0f, /** Distance ÷ moving time — not the mean of the speed samples. */ val avgMovingSpeedKmh: Float = 0f, val elevationGainM: Double = 0.0, val elevationLossM: Double = 0.0, val pointCount: Int = 0, ) { val stoppedMillis: Long get() = max(0L, elapsedMillis - movingMillis) companion object { val EMPTY = RideSummary() } } data class SpeedBucket(val fromKmh: Int, val toKmh: Int, val millis: Long) { val label: String get() = "$fromKmh–$toKmh" } data class ElevationSample(val distanceM: Double, val altitudeM: Double) /** * Batch statistics over a stored ride. * * This is the authoritative computation. The recorder accumulates the same values live * as points arrive, but re-runs this on trip completion so a mid-ride process kill * cannot leave permanently skewed totals. Both paths must agree, which is why they share * the constants below rather than duplicating magic numbers. */ object RideStatistics { /** * A GPS dropout leaves a large gap between consecutive fixes. Without a cap, a * two-minute tunnel counts as two minutes of moving time at the last known speed. */ const val MAX_SAMPLE_GAP_MILLIS = 10_000L /** * Raw GPS altitude wanders by ±5–10 m even sitting still. Summing every positive * delta turns a flat ride into thousands of metres of climbing — the classic bug in * this calculation. A climb only counts once it exceeds this much in one direction. */ const val ELEVATION_HYSTERESIS_M = 3.0 fun compute(points: List, segments: List = emptyList()): RideSummary { if (points.isEmpty()) return RideSummary.EMPTY var distanceM = 0.0 var movingMillis = 0L var maxSpeedKmh = 0f val elevation = ElevationAccumulator() // Grouping by segment is what keeps a pause from inventing distance: points // either side of a gas-station stop can be kilometres apart. for (segmentPoints in points.groupBy { it.segmentId }.values) { var previous: TrackPoint? = null for (point in segmentPoints) { maxSpeedKmh = max(maxSpeedKmh, point.speedKmh) elevation.add(point.altitudeM) previous?.let { prev -> distanceM += Geo.haversineMeters( prev.latitude, prev.longitude, point.latitude, point.longitude, ) val dt = point.timestamp - prev.timestamp if (dt in 1..MAX_SAMPLE_GAP_MILLIS && point.speedKmh >= Telemetry.SPEED_NOISE_FLOOR_KMH ) { movingMillis += dt } } previous = point } } elevation.finish() val elapsedMillis = elapsedFor(points, segments) // Guard the divide: a ride that never moved would otherwise produce NaN, which // Compose happily renders as the literal text "NaN". val avgMovingSpeedKmh = if (movingMillis > 0) { (distanceM / 1000.0 / (movingMillis / 3_600_000.0)).toFloat() } else { 0f } return RideSummary( distanceM = distanceM, elapsedMillis = elapsedMillis, movingMillis = movingMillis, maxSpeedKmh = maxSpeedKmh, avgMovingSpeedKmh = avgMovingSpeedKmh, elevationGainM = elevation.gain, elevationLossM = elevation.loss, pointCount = points.size, ) } /** * Prefers segment boundaries over point timestamps: they capture the time between a * segment's last fix and the pause itself, which point timestamps cannot see. */ private fun elapsedFor(points: List, segments: List): Long { val closed = segments.mapNotNull { s -> s.endedAt?.let { it - s.startedAt } } if (closed.isNotEmpty() && closed.size == segments.size) { return closed.sum() } val timestamps = points.map { it.timestamp } return max(0L, (timestamps.maxOrNull() ?: 0L) - (timestamps.minOrNull() ?: 0L)) } /** * Time spent in each speed band. Buckets are keyed on the *interval* between fixes, * so the result is a time distribution rather than a sample count — a bike that sits * idle at 2 Hz would otherwise dominate purely by producing more samples. */ fun speedHistogram(points: List, bucketKmh: Int = 10): List { if (points.size < 2 || bucketKmh <= 0) return emptyList() val millisByBucket = sortedMapOf() for (segmentPoints in points.groupBy { it.segmentId }.values) { for (i in 1 until segmentPoints.size) { val dt = segmentPoints[i].timestamp - segmentPoints[i - 1].timestamp if (dt !in 1..MAX_SAMPLE_GAP_MILLIS) continue val bucket = (segmentPoints[i].speedKmh / bucketKmh).toInt() millisByBucket[bucket] = (millisByBucket[bucket] ?: 0L) + dt } } return millisByBucket.map { (bucket, millis) -> SpeedBucket(bucket * bucketKmh, (bucket + 1) * bucketKmh, millis) } } /** * Altitude against distance travelled, downsampled for charting. * * Sampled by distance along the path rather than by index, so a long stop does not * flatten the interesting part of the profile into a few pixels. */ fun elevationProfile(points: List, maxSamples: Int = 200): List { if (points.isEmpty()) return emptyList() if (points.size == 1) return listOf(ElevationSample(0.0, points[0].altitudeM)) val full = ArrayList(points.size) var cumulative = 0.0 var previous: TrackPoint? = null var previousSegment = points.first().segmentId for (point in points) { previous?.let { prev -> // Distance only accrues within a segment, matching compute(). if (point.segmentId == previousSegment) { cumulative += Geo.haversineMeters( prev.latitude, prev.longitude, point.latitude, point.longitude, ) } } full += ElevationSample(cumulative, point.altitudeM) previous = point previousSegment = point.segmentId } if (full.size <= maxSamples) return full val step = full.size.toDouble() / maxSamples return (0 until maxSamples).map { full[(it * step).roundToInt().coerceAtMost(full.lastIndex)] } + full.last() } } /** * Elevation gain/loss accumulator that survives GPS altitude noise. * * Two mechanisms, because one is not enough: * * 1. **A moving-average window.** Raw GPS altitude wanders by ±5–10 m while completely * stationary. Averaging over [windowSize] samples cuts the noise by roughly * sqrt(windowSize), bringing it under the threshold below. * 2. **Reversal hysteresis.** A climb is only banked once the altitude turns back down * by more than [thresholdM] from its peak. Simply summing every delta that exceeds a * threshold does *not* work — noise crosses any small threshold constantly, and a * parked bike accumulates well over a kilometre of imaginary climbing. That was * measured, not assumed. * * Streaming rather than batch so the recorder can accumulate live and the batch * computation can reuse the identical code path. */ class ElevationAccumulator( private val windowSize: Int = SMOOTHING_WINDOW, private val thresholdM: Double = RideStatistics.ELEVATION_HYSTERESIS_M, ) { private val window = ArrayDeque(windowSize) private var windowSum = 0.0 private var lastRaw: Double = 0.0 private var lastCommitted: Double? = null private var extreme: Double = 0.0 private var direction = 0 // 0 unknown, +1 climbing, -1 descending var gain: Double = 0.0 private set var loss: Double = 0.0 private set fun add(altitudeM: Double) { if (!altitudeM.isFinite()) return lastRaw = altitudeM window.addLast(altitudeM) windowSum += altitudeM if (window.size > windowSize) windowSum -= window.removeFirst() val smoothed = windowSum / window.size val committed = lastCommitted if (committed == null) { lastCommitted = smoothed extreme = smoothed return } when (direction) { 0 -> when { smoothed > committed + thresholdM -> { direction = 1; extreme = smoothed } smoothed < committed - thresholdM -> { direction = -1; extreme = smoothed } } 1 -> if (smoothed > extreme) { extreme = smoothed } else if (smoothed < extreme - thresholdM) { gain += extreme - committed lastCommitted = extreme direction = -1 extreme = smoothed } else -> if (smoothed < extreme) { extreme = smoothed } else if (smoothed > extreme + thresholdM) { loss += committed - extreme lastCommitted = extreme direction = 1 extreme = smoothed } } } /** * Gain including the run still in progress, without mutating state. * * Safe to poll while recording continues — [finish] would end the run, which is wrong * mid-ride, but reading only [gain] would report zero for a climb that has not yet * turned back down. */ fun gainIncludingPending(): Double { val committed = lastCommitted ?: return gain val tip = if (direction == 1) max(extreme, lastRaw) else extreme return if (direction == 1 && tip > committed) gain + (tip - committed) else gain } /** * Banks the run still in progress. Must be called once the last point is added, or a * steady climb to the summit with no descent afterwards reports zero gain. */ fun finish() { val committed = lastCommitted ?: return // The moving average lags the true altitude by about half a window, so the final // smoothed value clips the tail of a climb (a 100 m ascent measured 93 m before // this). Reconcile against the last raw reading to recover it. when (direction) { 1 -> extreme = max(extreme, lastRaw) -1 -> extreme = min(extreme, lastRaw) } when { direction == 1 && extreme > committed -> gain += extreme - committed direction == -1 && extreme < committed -> loss += committed - extreme } lastCommitted = extreme direction = 0 } private companion object { /** ~7 s at 2 Hz: long enough to suppress wander, short enough to keep real terrain. */ const val SMOOTHING_WINDOW = 15 } }