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:
2026-08-11 08:30:25 -05:00
parent 4debdfa518
commit 280fd7f988
106 changed files with 10377 additions and 0 deletions

View 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
}
}