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:
302
rippr-src/app/src/main/java/com/rippr/stats/RideStatistics.kt
Normal file
302
rippr-src/app/src/main/java/com/rippr/stats/RideStatistics.kt
Normal file
@@ -0,0 +1,302 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user