The port runs on Android and iOS and is feature-complete; the native Android app is superseded but kept, since it is still the only version that has recorded real rides. rippr-flutter-1.0-debug.apk is package com.rippr.port, deliberately different from the native com.rippr so both install side by side. Recording the same ride on both at once is the strongest available check that the port is faithful. Added INSTALL.md covering both platforms. Android is a one-line adb install; iOS has no APK equivalent and must be built and signed through Xcode with a free Apple ID, which gives a 7-day profile. 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)
|
|
}
|