T03 — telemetry.dart, format.dart, live_telemetry.dart, plus pure domain models (Trip/Segment/TrackPoint/RideStats) with no persistence dependency, so Drift can map to them in T08 rather than the domain depending on the database. T04 — ride_statistics.dart including ElevationAccumulator, ported structurally faithfully: moving average, reversal hysteresis, gainIncludingPending, and the finish() reconciliation against lastRaw. T07 (early, because T04 forced it) — tool/parity/ drives identical fixtures through the real Kotlin files and the Dart port, then diffs. Result: every value byte-identical, including noisy_gain=38.959594555022136 to the last digit. The sole difference is run_avg_speed, where Kotlin's 32-bit Float widens to double with artefacts Dart's binary64 does not reproduce. Documented, not papered over. That harness settled a real question. The ported elevation test failed at 50.9m against Kotlin's 35m bound, which looked like a porting bug. It was not: Kotlin's and Dart's Random(42) are different streams. On a shared LCG fixture both produce 39.0m -- which would also fail Kotlin's own bound. The native guard passes on seed luck rather than on a property of the algorithm. The Dart test now uses the shared LCG, asserts bit-equality with Kotlin, and sets its bound from measured behaviour (25 seeds spanned 24.7-46.7m). 52 tests passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
60 lines
2.6 KiB
Kotlin
60 lines
2.6 KiB
Kotlin
import com.rippr.data.TrackPoint
|
|
import com.rippr.data.Segment
|
|
import com.rippr.geo.Geo
|
|
import com.rippr.geo.LatLon
|
|
import com.rippr.stats.RideStatistics
|
|
|
|
// A deterministic LCG implemented identically in Kotlin and Dart, so both languages
|
|
// see the SAME noise sequence. Neither language's built-in Random can do this.
|
|
class Lcg(private var s: Long) {
|
|
fun nextDouble(): Double {
|
|
s = s * 6364136223846793005L + 1442695040888963407L
|
|
val bits = (s ushr 11) and ((1L shl 53) - 1L)
|
|
return bits.toDouble() / (1L shl 53).toDouble()
|
|
}
|
|
}
|
|
|
|
fun p(seg: Long, ts: Long, lat: Double, lon: Double, sp: Float, alt: Double, id: Long) =
|
|
TrackPoint(id = id, tripId = 1, segmentId = seg, timestamp = ts,
|
|
latitude = lat, longitude = lon, speedKmh = sp, altitudeM = alt)
|
|
|
|
fun main() {
|
|
fun out(k: String, v: Any) = println("$k=$v")
|
|
|
|
out("haversine_calgary_edmonton", Geo.haversineMeters(51.0447, -114.0719, 53.5461, -113.4938))
|
|
out("haversine_short_hop", Geo.haversineMeters(51.0447, -114.0719, 51.04480, -114.0719))
|
|
out("perp_beyond_end", Geo.perpendicularDistanceMeters(
|
|
LatLon(51.003, -114.0), LatLon(51.000, -114.0), LatLon(51.002, -114.0)))
|
|
|
|
val ride = (0 until 21_600).map { LatLon(51.0 + it * 0.00001, -114.0 + Math.sin(it / 100.0) * 0.001) }
|
|
val simplified = Geo.simplify(ride, 5.0)
|
|
out("simplify_count", simplified.size)
|
|
out("simplify_last_lat", simplified.last().lat)
|
|
out("path_length_ride", Geo.pathLengthMeters(ride))
|
|
|
|
// The disputed fixture: 600 stationary fixes with shared LCG +/-8 m altitude noise.
|
|
val rng = Lcg(42L)
|
|
val noisy = (0 until 600).map {
|
|
p(1, it * 1000L, 51.0, -114.0, 0f, 1000.0 + (rng.nextDouble() * 16.0 - 8.0), (it + 1).toLong())
|
|
}
|
|
out("noisy_gain", RideStatistics.compute(noisy).elevationGainM)
|
|
out("noisy_loss", RideStatistics.compute(noisy).elevationLossM)
|
|
|
|
val climb = (0 until 101).map { p(1, it * 1000L, 51.0 + it * 0.0001, -114.0, 50f, 1000.0 + it, (it + 1).toLong()) }
|
|
out("climb_gain", RideStatistics.compute(climb).elevationGainM)
|
|
|
|
val run = (0 until 100).map { p(1, it * 1000L, 51.0 + it * 0.0001, -114.0, 40f, 1000.0, (it + 1).toLong()) }
|
|
val s = RideStatistics.compute(run)
|
|
out("run_distance", s.distanceM)
|
|
out("run_moving_millis", s.movingMillis)
|
|
out("run_avg_speed", s.avgMovingSpeedKmh)
|
|
|
|
val prof = RideStatistics.elevationProfile(run, 200)
|
|
out("profile_size", prof.size)
|
|
out("profile_last_distance", prof.last().distanceM)
|
|
|
|
val hist = RideStatistics.speedHistogram(run, 10)
|
|
out("hist_buckets", hist.size)
|
|
out("hist_first_millis", hist.first().millis)
|
|
}
|