GeometryEnricher heading-delta fallback #29

Open
opened 2026-08-31 17:14:45 +02:00 by robert · 2 comments
robert commented 2026-08-31 17:14:45 +02:00 (Migrated from git.butzei.de)

Goal

The always-available last resort: derive turns from the shape of the track alone.

Acceptance criteria

  • Heading change computed between consecutive simplified segments
  • Over 25 degrees classified a turn, over 70 degrees a sharp turn, left and right distinguished
  • Clusters of small deltas merged so a curve does not emit a burst of cues
  • No street names - the watch shows distance and arrow only
  • Works entirely offline with no dependencies
  • Output compared against a BRouter cue sheet on a known route and the differences documented

Files

  • companion/.../route/enrich/GeometryEnricher.kt

Update — 2026-09-01: this is the default path, so it has to be less crude

D13 makes online enrichment opt-in, and BRouter needs a separate install plus segment files — so
until #64 lands, the geometric heuristic is what most new users actually get (D27).

As originally specified, heading delta above 25° between consecutive 5 m segments fires on every
curve, roundabout and switchback, while staying silent at junctions where the rider continues
straight.

  • Headings compared over a ±20 m window, not between consecutive 5 m segments
  • A turn must complete within a short distance — a sweeping bend is not a cue
  • Switchbacks and roundabouts do not each produce a fistful of cues
  • Cues emitted at NAV_CUE_CONFIDENCE = 0, and shown as guesses in the library and in #55
  • Fixtures include a winding descent and a roundabout, with hand-checked expected cue counts

It still cannot see junctions — nothing without map data can. That is why it is tier 3 and why its
cues are labelled.

## Goal The always-available last resort: derive turns from the shape of the track alone. ## Acceptance criteria - [ ] Heading change computed between consecutive simplified segments - [ ] Over 25 degrees classified a turn, over 70 degrees a sharp turn, left and right distinguished - [ ] Clusters of small deltas merged so a curve does not emit a burst of cues - [ ] No street names - the watch shows distance and arrow only - [ ] Works entirely offline with no dependencies - [ ] Output compared against a BRouter cue sheet on a known route and the differences documented ## Files - `companion/.../route/enrich/GeometryEnricher.kt` ## Update — 2026-09-01: this is the default path, so it has to be less crude D13 makes online enrichment opt-in, and BRouter needs a separate install plus segment files — so until #64 lands, the geometric heuristic is what most new users actually get (D27). As originally specified, heading delta above 25° between consecutive 5 m segments fires on every curve, roundabout and switchback, while staying silent at junctions where the rider continues straight. - [ ] Headings compared over a **±20 m window**, not between consecutive 5 m segments - [ ] A turn must **complete within a short distance** — a sweeping bend is not a cue - [ ] Switchbacks and roundabouts do not each produce a fistful of cues - [ ] Cues emitted at `NAV_CUE_CONFIDENCE = 0`, and shown as guesses in the library and in #55 - [ ] Fixtures include a winding descent and a roundabout, with hand-checked expected cue counts It still cannot see junctions — nothing without map data can. That is why it is tier 3 and why its cues are labelled.
Owner

Opened PR #122 (area/geometry-enricher -> main): #122

Implements the tightened (2026-09-01) design: +/-20m windowed heading delta (reusing RouteGeodesy's bearingDegrees/pointAtDistanceMeters from #55), 25 deg/70 deg turn/sharp thresholds, same-side cluster merging within 30m so a curve/roundabout/hairpin doesn't burst into multiple cues, tier=GEOMETRY on every cue (-> NAV_CUE_CONFIDENCE=0), streetName always null. Wired into RouteStore's tier3 slot; CURRENT_ENRICHER_VERSION bumped 1->2.

Fixtures (hand-checked via an independent Python re-implementation of the bearing/window math, not by trusting the Kotlin code): winding-descent.gpx (4 alternating r=8m/140deg hairpins -> 4 cues), roundabout.gpx (one r=15m/200deg arc -> 1 merged cue), sweeping-bend-vs-sharp-turn.gpx (r=300m/57deg gentle bend -> 0 cues, vs r=8m/60deg sharp turn of near-identical total magnitude -> 1 cue - the explicit 'must complete within a short distance' regression case), straight-route.gpx.

One acceptance criterion is out of reach right now and I did not fake it: 'output compared against a BRouter cue sheet on a known route' needs BRouterEnricher (#27), which doesn't exist yet. Added a dependency edge (this issue now depends on #27) instead of a stub comparison - that check belongs in a follow-up once #27 lands.

Real test run: ./gradlew :companion:core:test --rerun-tasks, 353 tests, 0 failures.

Opened PR #122 (area/geometry-enricher -> main): https://git.butzei.de/robert/PedalPebble/pulls/122 Implements the tightened (2026-09-01) design: +/-20m windowed heading delta (reusing RouteGeodesy's bearingDegrees/pointAtDistanceMeters from #55), 25 deg/70 deg turn/sharp thresholds, same-side cluster merging within 30m so a curve/roundabout/hairpin doesn't burst into multiple cues, tier=GEOMETRY on every cue (-> NAV_CUE_CONFIDENCE=0), streetName always null. Wired into RouteStore's tier3 slot; CURRENT_ENRICHER_VERSION bumped 1->2. Fixtures (hand-checked via an independent Python re-implementation of the bearing/window math, not by trusting the Kotlin code): winding-descent.gpx (4 alternating r=8m/140deg hairpins -> 4 cues), roundabout.gpx (one r=15m/200deg arc -> 1 merged cue), sweeping-bend-vs-sharp-turn.gpx (r=300m/57deg gentle bend -> 0 cues, vs r=8m/60deg sharp turn of near-identical total magnitude -> 1 cue - the explicit 'must complete within a short distance' regression case), straight-route.gpx. One acceptance criterion is out of reach right now and I did not fake it: 'output compared against a BRouter cue sheet on a known route' needs BRouterEnricher (#27), which doesn't exist yet. Added a dependency edge (this issue now depends on #27) instead of a stub comparison - that check belongs in a follow-up once #27 lands. Real test run: ./gradlew :companion:core:test --rerun-tasks, 353 tests, 0 failures.
Owner

Closed by PR #122 (area/geometry-enricher) — well, not quite closed: this issue depends on #27 (still open) for its own "compared against a BRouter cue sheet" criterion, so Forgejo will correctly refuse to close it until that lands. Leaving it open with that dependency edge in place rather than fabricating a comparison.

Everything else built and verified for real:

  • Windowed heading-delta, not consecutive-5m-segment comparison: reuses #55's bearingDegrees/pointAtDistanceMeters directly — a ±20m window per vertex (D27's own corrected figure), 25° turn / 70° sharp thresholds, left/right by sign.
  • "A turn must complete within a short distance" comes from the window itself, not a separate check: since nothing accumulates heading change across vertices, a sharp corner reads its full angle inside one 40m window while a gradual bend spread over hundreds of meters never shows more than a fraction of its total change in any single window. Proven with a real fixture (sweeping-bend-vs-sharp-turn.gpx): a gentle r=300m/57.3° bend and a sharp r=8m/60° turn of near-identical total magnitude — only the concentrated one produces a cue.
  • Cluster-merging (CLUSTER_MERGE_GAP_METERS = 30) is derived from RDP's own sagitta math, not picked by feel: solving the chord-sagitta formula at the 5m import tolerance and a 10-20m real-curve radius gives ~24-25m as the widest spacing RDP can leave between kept vertices on one physical curve — 30m is a small margin over that. A side-flip always breaks a cluster even within the gap, since that's a real second turn (an S-curve), not resampling of one curve.
  • Independently re-verified the fixture math myself before accepting it (not just trusting the agent's claim): re-derived the roundabout's stated 76.5° peak windowed delta from its r=15m/200° construction using the closed-form "chord bearing = tangent at arc-segment-midpoint" relationship (own calc: 20/15 rad = 76.4°, matching almost exactly) and the sweeping bend's 3.8° peak from its r=300m construction (20/300 rad = 3.82°, matching exactly). Both check out independently of the PR's own Python side-calculation.
  • Wired into RouteStore's tier3 slot for real — a route with no embedded cues and no BRouter/Valhalla now gets a real, confidence-0 geometric cue sheet instead of silently falling through to nothing. CueSheetCache.CURRENT_ENRICHER_VERSION bumped 1→2 with a proper history log entry.
  • Every cue correctly carries tier = EnrichmentTier.GEOMETRY (confirmed this is what NavEngine/the wire layer reads as NAV_CUE_CONFIDENCE = 0) and streetName = null — tier 3 has nothing to name a road from.

Real verification: genuine ./gradlew :companion:core:test :companion:route:assembleDebug --rerun-tasks full rebuild, all green — GeometryEnricherTest 6/6 against real synthetic-but-closed-form GPX fixtures (winding descent: 4 hairpins → 4 cues; roundabout: one 200° arc → 1 merged cue; sweeping bend vs. sharp turn: 0 then 1 cue; straight route: 0 cues as a genuine positive result, not a fallback signal).

Closed by PR #122 (`area/geometry-enricher`) — well, not quite closed: this issue depends on #27 (still open) for its own "compared against a BRouter cue sheet" criterion, so Forgejo will correctly refuse to close it until that lands. Leaving it open with that dependency edge in place rather than fabricating a comparison. Everything else built and verified for real: - **Windowed heading-delta, not consecutive-5m-segment comparison**: reuses #55's `bearingDegrees`/`pointAtDistanceMeters` directly — a ±20m window per vertex (D27's own corrected figure), 25° turn / 70° sharp thresholds, left/right by sign. - **"A turn must complete within a short distance" comes from the window itself, not a separate check**: since nothing accumulates heading change across vertices, a sharp corner reads its full angle inside one 40m window while a gradual bend spread over hundreds of meters never shows more than a fraction of its total change in any single window. Proven with a real fixture (`sweeping-bend-vs-sharp-turn.gpx`): a gentle r=300m/57.3° bend and a sharp r=8m/60° turn of near-identical *total* magnitude — only the concentrated one produces a cue. - **Cluster-merging** (`CLUSTER_MERGE_GAP_METERS = 30`) is derived from RDP's own sagitta math, not picked by feel: solving the chord-sagitta formula at the 5m import tolerance and a 10-20m real-curve radius gives ~24-25m as the widest spacing RDP can leave between kept vertices on one physical curve — 30m is a small margin over that. A side-flip always breaks a cluster even within the gap, since that's a real second turn (an S-curve), not resampling of one curve. - **Independently re-verified the fixture math myself** before accepting it (not just trusting the agent's claim): re-derived the roundabout's stated 76.5° peak windowed delta from its r=15m/200° construction using the closed-form "chord bearing = tangent at arc-segment-midpoint" relationship (own calc: 20/15 rad = 76.4°, matching almost exactly) and the sweeping bend's 3.8° peak from its r=300m construction (20/300 rad = 3.82°, matching exactly). Both check out independently of the PR's own Python side-calculation. - **Wired into `RouteStore`'s tier3 slot for real** — a route with no embedded cues and no BRouter/Valhalla now gets a real, confidence-0 geometric cue sheet instead of silently falling through to nothing. `CueSheetCache.CURRENT_ENRICHER_VERSION` bumped 1→2 with a proper history log entry. - Every cue correctly carries `tier = EnrichmentTier.GEOMETRY` (confirmed this is what `NavEngine`/the wire layer reads as `NAV_CUE_CONFIDENCE = 0`) and `streetName = null` — tier 3 has nothing to name a road from. **Real verification**: genuine `./gradlew :companion:core:test :companion:route:assembleDebug --rerun-tasks` full rebuild, all green — `GeometryEnricherTest` 6/6 against real synthetic-but-closed-form GPX fixtures (winding descent: 4 hairpins → 4 cues; roundabout: one 200° arc → 1 merged cue; sweeping bend vs. sharp turn: 0 then 1 cue; straight route: 0 cues as a genuine positive result, not a fallback signal).
Sign in to join this conversation.
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
robert/PedalPebble#29
No description provided.