Files
samplez/rippr-src/docs/v2/04-geo.md
uhryniuk 280fd7f988 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>
2026-08-11 08:30:25 -05:00

3.9 KiB
Raw Permalink Blame History

T04 — Geo utilities

Phase 2 · Depends on T01 · Status Done

Goal

Pure geographic maths with no Android dependencies: distance between fixes, polyline simplification for rendering, and bounding boxes for map auto-fit. Done when geo/Geo.kt exists, imports nothing from android.*, and is covered by JVM unit tests that run without a device.

Context

This is the pattern Telemetry.kt already establishes and the reason its logic is cheaply testable — the conversion and sanitisation functions there are covered by TelemetryTest with no emulator involved. Geo follows the same rule.

Three consumers depend on this task: T05 (statistics), T14 (path rendering), T15 (export). It is on nobody's blocking path from the UI side, so it can be built in parallel with the whole data-layer track.

Design

object Geo {
    const val EARTH_RADIUS_M = 6_371_008.8   // IUGG mean radius

    fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double

    /** Douglas–Peucker. Always preserves first and last points. */
    fun simplify(points: List<LatLon>, epsilonMeters: Double): List<LatLon>

    fun bounds(points: List<LatLon>): Bounds?      // null for empty input
}

data class LatLon(val lat: Double, val lon: Double)
data class Bounds(val minLat: Double, val minLon: Double,
                  val maxLat: Double, val maxLon: Double)

Haversine, not Vincenty. Haversine assumes a sphere and is accurate to roughly 0.5% — a few metres per kilometre. For motorcycle ride distances that is far below GPS noise, and Vincenty's iterative solution would be false precision at real cost.

Douglas–Peucker with a metre epsilon, not a degree epsilon. A degree of longitude is ~111 km at the equator and ~0 at the poles, so a degree-based tolerance behaves differently depending where you ride. Perpendicular distance is computed with a local equirectangular approximation — valid over the short spans between consecutive fixes and far cheaper than a full geodesic.

Recursion depth. A naive recursive Douglas–Peucker on 20,000+ points can blow the stack in a pathological case. Implement iteratively with an explicit work stack.

Implementation

  1. Create geo/Geo.kt with LatLon, Bounds, and the three functions.
  2. Haversine in double precision throughout — float loses metres over a long ride.
  3. Iterative Douglas–Peucker with an explicit stack.
  4. bounds() returns null on empty rather than a degenerate zero box, so callers must handle "no path" explicitly instead of silently centring on Null Island.
  5. Add an extension to map TrackPoint → LatLon so callers stay tidy.

Acceptance criteria

  • No android.* imports in geo/
  • Haversine matches known reference distances within 0.5%
  • simplify() always returns first and last points unchanged
  • simplify() with epsilon 0 returns the input unchanged
  • simplify() on 25,000 synthetic points completes quickly and does not stack-overflow
  • bounds() returns null for an empty list

Tests

JVM unit tests (app/src/test/…), no device needed:

  • Known pairs: Calgary→Edmonton ≈ 280.9 km (great-circle, not road); a 1° latitude step ≈ 111.2 km; identical points → 0
  • Antimeridian and equator crossings behave sanely
  • Douglas–Peucker: a straight line collapses to two points; a zigzag above epsilon is preserved; endpoints always survive
  • 25,000-point performance and stack-safety check
  • Bounds across a mixed-sign coordinate set

Risks / gotchas

  • Float vs double. TrackPoint.latitude/longitude are already Double; keep them that way through every calculation. speedKmh is Float, which is fine — it is display data, not accumulated.
  • Epsilon tuning is a T14 concern, not this one. Expose it as a parameter and let the render layer choose.

Out of scope

Anything that consumes these functions — statistics (T05), rendering (T14), export (T15).