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>
93 lines
3.9 KiB
Markdown
93 lines
3.9 KiB
Markdown
# 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<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
|
||
|
||
- [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).
|