T05 — ride_accumulator.dart with the cross-batch anchor intact (12 tests). T06 — ride_export.dart, GPX 1.1 and GeoJSON (15 tests), parsed with real parsers rather than substring matching. T07 — parity harness extended to cover export byte output. GPX and GeoJSON are byte-identical across Kotlin and Dart: same length, same FNV hash, including the escaped hostile name and all %.7f/%.1f formatting. Two harness bugs found and fixed while building it. String.hashCode is not comparable across Java and Dart, so text comparison used FNV-1a instead. And a missing jar let a failed Kotlin compile pass as a green run -- run.sh now checks for the artifact and exits non-zero, the same failure mode as the v2 sweep that hid a compile error behind /dev/null. Phase 1 done: 79 tests passing, analyze clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
85 lines
3.8 KiB
Kotlin
85 lines
3.8 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
|
|
import com.rippr.data.Trip
|
|
import com.rippr.data.TripState
|
|
import com.rippr.export.RideExport
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
|
|
// FNV-1a 32-bit. String.hashCode() differs between Java and Dart, so it cannot be used
|
|
// to compare text across languages -- this can.
|
|
fun fnv1a(s: String): Long {
|
|
var h = 2166136261L
|
|
for (ch in s) { h = h xor ch.code.toLong(); h = (h * 16777619L) and 0xFFFFFFFFL }
|
|
return h
|
|
}
|
|
|
|
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)
|
|
|
|
// --- export byte output -------------------------------------------------
|
|
val etrip = Trip(id = 1, startedAt = 1_700_000_000_000L, endedAt = 1_700_000_600_000L,
|
|
state = TripState.COMPLETED, distanceM = 12_345.6, maxSpeedKmh = 118.4f)
|
|
val esegs = listOf(Segment(id = 1, tripId = 1, startedAt = 0, endedAt = 1),
|
|
Segment(id = 2, tripId = 1, startedAt = 2, endedAt = 3))
|
|
val epts = esegs.flatMap { sg -> (0 until 5).map {
|
|
p(sg.id, 1_700_000_000_000L + it * 1000L, 51.0447 + it * 0.0001, -114.0719, 72f, 1045.0, it.toLong() + 1) } }
|
|
val g = RideExport.gpx(etrip, esegs, epts, "Sam & Dave's <ride>")
|
|
val j = RideExport.geoJson(etrip, esegs, epts, "Sam & Dave's <ride>")
|
|
out("gpx_len", g.length)
|
|
out("gpx_fnv", fnv1a(g))
|
|
out("geojson_len", j.length)
|
|
out("geojson_fnv", fnv1a(j))
|
|
out("hist_first_millis", hist.first().millis)
|
|
}
|