Files
samplez/rippr-src/app/src/main/java/com/rippr/stats/RideStatistics.kt
uhryniuk 280fd7f988 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>
2026-08-11 08:30:25 -05:00

303 lines
11 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<TrackPoint>, segments: List<Segment> = 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<TrackPoint>, segments: List<Segment>): 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<TrackPoint>, bucketKmh: Int = 10): List<SpeedBucket> {
if (points.size < 2 || bucketKmh <= 0) return emptyList()
val millisByBucket = sortedMapOf<Int, Long>()
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<TrackPoint>, maxSamples: Int = 200): List<ElevationSample> {
if (points.isEmpty()) return emptyList()
if (points.size == 1) return listOf(ElevationSample(0.0, points[0].altitudeM))
val full = ArrayList<ElevationSample>(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<Double>(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
}
}