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

93 lines
3.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).