# 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 ```kotlin 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, epsilonMeters: Double): List fun bounds(points: List): 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 - [x] No `android.*` imports in `geo/` - [x] Haversine matches known reference distances within 0.5% - [x] `simplify()` always returns first and last points unchanged - [x] `simplify()` with epsilon 0 returns the input unchanged - [x] `simplify()` on 25,000 synthetic points completes quickly and does not stack-overflow - [x] `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).