14 KiB
codriver
A deterministic, local navigation daemon that emits turn directives ("Turn left") at the right moment. No LLM on the hot path. The LLM is a later, off-path layer that decides whether to speak and how to phrase; this repo is the dumb, fast substrate it sits on top of.
Target hardware: NVIDIA Jetson Orin Nano with bolt-on GPS (NMEA-over-UART, wrapped
by gpsd) and IMU (I2C). Visual input and LLM co-driver are explicitly out of scope
for the current milestone.
Scope (current milestone — "step 0")
In: Consume a pre-computed route, poll position, emit a maneuver string at a speed-adaptive lead time, detect going off-route and reroute.
Out (deferred): TTS, LLM phrasing/decision layer, IMU sensor fusion, visual input, rally pace-note generation, recce-run note authoring.
The deliverable of step 0 is: a process that prints "Turn left" (or the configured
flavour) when the driver should turn, not too late, and recomputes the route when the
driver deviates.
SWE-1 — Software Requirements
Requirements are tagged SWR-n for traceability into the SWE-2 architecture and
later test cases (SWE-4/5/6).
Functional
- SWR-1 The system shall obtain a route from an origin to a destination as an ordered geometry (polyline) plus a sparse, ordered list of maneuvers.
- SWR-2 The system shall acquire the vehicle's current position, speed, and heading by polling a position source.
- SWR-3 The system shall project the current position onto the active route geometry, yielding distance-along-route and cross-track distance.
- SWR-4 The system shall identify the next not-yet-announced maneuver ahead of the current along-route position.
- SWR-5 The system shall compute a trigger distance as a function of current speed and a configured lead time (constant time to maneuver, not constant distance).
- SWR-6 The system shall emit exactly one directive string per maneuver, when the distance to that maneuver first falls within the trigger distance.
- SWR-7 The system shall detect an off-route condition when sustained cross-track distance exceeds a threshold (debounced over N consecutive fixes).
- SWR-8 On off-route, the system shall request a new route from current position to the unchanged destination and atomically replace the active route.
- SWR-9 The directive string content shall be configurable ("flavour"), so the phrasing layer is decoupled from the trigger logic.
Non-functional / constraints
- SWR-10 The decision path (acquire → match → decide → emit) shall contain no network call and no LLM invocation; route acquisition (incl. reroute) is the only component permitted to block on I/O and shall run off the decision path.
- SWR-11 The position source has no native subscribe/interrupt mechanism; the architecture shall treat polling as the normative interface.
- SWR-12 Routing shall be performable fully offline from a periodically-updated local dataset (no per-trip dependency on a cloud routing service).
- SWR-13 The route source shall be replaceable (local engine, cloud API, or recorded file) without change to the decision path.
- SWR-14 "Not too late": at the configured lead time, the directive shall be emitted with enough distance for the driver to act at the current speed.
- SWR-15 A single noisy position fix shall not by itself cause a reroute or a spurious/duplicate directive.
SWE-2 — Software Architectural Design
Layering
Two tiers. The decision tier is the hot loop: pure arithmetic over an
in-memory route, runs at the position-source cadence (~1–10 Hz), never blocks on
network. The planning tier is off-path: it produces a Route artifact and hands
it to the decision tier through a single shared, atomically-swappable slot.
planning tier (slow, blocking I/O OK)
┌─────────────────────────────────────────────────────────┐
│ RouteProvider ──► Route artifact ──► ActiveRouteStore │
└───────────────────────────────────────────▲──────────────┘
│ atomic swap
┌────────────────────────────────────────────┼─────────────┐
│ PositionSource ─► StateStore ─► [ poll loop: MapMatcher │
│ ─► ManeuverTracker │
│ ─► TriggerPolicy │
│ ─► OffRouteMonitor ] │
│ ─► DirectiveEmitter ─► │
└────────────────────────────────── decision tier (fast) ───┘
Module decomposition
Each module below maps to a Python package/module. Interfaces are given as the contract; types reference the data model in the next section.
route_provider/ — route acquisition (planning tier)
- Responsibility: turn
(origin, destination)into a normalizedRoute. Isolates all backend messiness (OSRM/Valhalla/Google/file) behind one contract. - Interface:
RouteProvider.get_route(origin: LatLon, dest: LatLon) -> Route
- Implementations (selected at config time):
route_provider/osrm.py—OsrmProvider: HTTP client to a local OSRM instance; normalizeslegs[].steps[].maneuver+ decodedgeometryinto the internalRoute/Maneuvermodel. Primary backend.route_provider/file.py—FileProvider: loads a pre-decoded route from disk. For desk testing and reproducible runs.route_provider/google.py—GoogleProvider: optional online fallback.
- Satisfies: SWR-1, SWR-12, SWR-13. Normalization point also serves SWR-9's decoupling (maneuver semantics fixed here, phrasing fixed downstream).
- Note: Owns no routing algorithm. Preprocessing of OSM extracts
(
osrm-extract/partition/customize) is an operational step documented inops/, not code in this repo.
position/ — position acquisition (decision tier, ingress)
- Responsibility: poll the position source, parse to a
Fix, publish the freshest fix. - Interface:
PositionSource.read() -> Fix(blocking until next fix; the GPS cadence is the loop clock)
- Implementations:
position/gpsd_source.py—GpsdSource: client of localgpsd;gpsd.next()as the blocking read. Primary.position/nmea_source.py—NmeaSource: direct/dev/ttyUSB*NMEA parse (fallback ifgpsdis undesired).position/replay_source.py—ReplaySource: replays a recorded NMEA/fix log at wall-clock or accelerated rate. For desk testing without hardware.
- Satisfies: SWR-2, SWR-11.
state/ — shared state slots
- Responsibility: last-write-wins handoff between threads. Two slots:
current vehicle
Fix, and theActiveRouteStorefor the route. - Interface:
StateStore.put(fix: Fix)/StateStore.latest() -> Fix | NoneActiveRouteStore.swap(route: Route)/ActiveRouteStore.current() -> Route | None
- Satisfies: SWR-8 (atomic swap), SWR-10 (decouples blocking planning tier from the non-blocking decision tier).
matching/ — map matching
- Responsibility: project a
Fixonto the activeRoutepolyline. - Interface:
MapMatcher.match(fix: Fix, route: Route) -> MatchResultwhereMatchResult = {along_dist_m, cross_track_m, segment_index, snapped: LatLon}
- Step-0 algorithm: nearest-segment + perpendicular projection. No probabilistic HMM matching yet.
- Satisfies: SWR-3. Produces the cross-track value consumed by OffRouteMonitor for free.
maneuvers/ — maneuver tracking
- Responsibility: given
along_dist, find the next not-yet-announced maneuver and the along-route distance to it; own the per-maneuver "announced" markers. - Interface:
ManeuverTracker.next_pending(along_dist_m: float) -> tuple[Maneuver, float] | NoneManeuverTracker.mark_announced(maneuver_id) -> NoneManeuverTracker.reset() -> None(called on route swap)
- Satisfies: SWR-4, SWR-6 (exactly-once via markers).
trigger/ — trigger policy
- Responsibility: decide whether the next maneuver should fire now.
- Interface:
TriggerPolicy.should_fire(distance_to_maneuver_m: float, fix: Fix) -> bool
- Step-0 policy:
trigger_distance = speed_mps * lead_time_s + reaction_buffer_m; fire whendistance_to_maneuver <= trigger_distance. - Config:
lead_time_s,reaction_buffer_m. (A later second-stage early pre-announce — "in 300 meters…" — slots in here as an additional threshold without touching other modules.) - Satisfies: SWR-5, SWR-14.
offroute/ — off-route monitor
- Responsibility: debounced off-route detection; request reroute.
- Interface:
OffRouteMonitor.update(match: MatchResult) -> OffRouteState(ON_ROUTE|OFF_ROUTE)
- Step-0 algorithm:
cross_track_m > threshold_msustained forNconsecutive fixes (hysteresis on recovery). On transition toOFF_ROUTE, signals the planning tier to callRouteProvider.get_route(current, dest)andActiveRouteStore.swap, thenManeuverTracker.reset. - Config:
threshold_m,confirm_fixes_n. - Satisfies: SWR-7, SWR-8, SWR-15.
directive/ — directive emitter (decision tier, egress)
- Responsibility: render a
Maneuverinto the output string and emit it. - Interface:
DirectiveEmitter.emit(maneuver: Maneuver, fix: Fix) -> NonePhrasebook.render(maneuver: Maneuver) -> str
- Step-0 implementation: table-driven phrasebook (
maneuver.type+modifier→ string); emit =print()/ write to stdout or a socket. TTS and the LLM phrasing layer subscribe here later; the seam isPhrasebook. - Satisfies: SWR-6, SWR-9.
app/ — composition root
- Responsibility: wire modules from config, own the two threads (position poller, decision loop) and the planning-tier reroute worker, handle lifecycle.
- Interface:
main()+config.py(dataclass-validated config: provider selection, lead time, thresholds, phrasebook flavour, source selection). - Decision loop (pseudocode):
fix = state.latest() route = active_route.current() m = matcher.match(fix, route) if offroute.update(m) == OFF_ROUTE: request_reroute(fix.pos, dest) # hands off to planning tier pending = tracker.next_pending(m.along_dist_m) if pending: maneuver, dist = pending if trigger.should_fire(dist, fix): emitter.emit(maneuver, fix) tracker.mark_announced(maneuver.id) - Satisfies: SWR-10 (keeps blocking reroute off this loop).
Data model (model/)
@dataclass(frozen=True)
class LatLon:
lat: float
lon: float
@dataclass(frozen=True)
class Fix:
pos: LatLon
speed_mps: float
heading_deg: float | None # may be unreliable at low speed
t_monotonic: float
valid: bool
@dataclass(frozen=True)
class Maneuver:
id: int
location: LatLon
type: str # turn | keep | uturn | roundabout | arrive ...
modifier: str | None # left | right | slight_left ...
entry_heading: float | None
exit_heading: float | None
road_name: str | None
@dataclass(frozen=True)
class Route:
polyline: list[LatLon] # dense shape points
maneuvers: list[Maneuver] # sparse, ordered along polyline
destination: LatLon
Maneuver.type/modifier is the fixed internal vocabulary every provider
normalizes to; the phrasebook and (later) the LLM phrasing layer key off it. This is
the single contract that keeps backend messiness out of the rest of the system.
Concurrency model
Three threads, no shared mutable state except the two state/ slots (last-write-wins,
guarded by a lock or an atomic reference):
- Position poller —
PositionSource.read()in a loop →StateStore.put. - Decision loop — reads both slots, runs the pipeline above at its cadence, emits directives. Never blocks on I/O.
- Reroute worker — waits for an off-route signal, calls the (blocking)
RouteProvider, swapsActiveRouteStore. Keeps SWR-10 intact.
Traceability summary
| Module | Requirements |
|---|---|
| route_provider | SWR-1, 9, 12, 13 |
| position | SWR-2, 11 |
| state | SWR-8, 10 |
| matching | SWR-3 |
| maneuvers | SWR-4, 6 |
| trigger | SWR-5, 14 |
| offroute | SWR-7, 8, 15 |
| directive | SWR-6, 9 |
| app | SWR-10 |
Repository layout
codriver/
├── README.md
├── pyproject.toml
├── codriver/
│ ├── app/ # composition root, config, main()
│ ├── model/ # LatLon, Fix, Maneuver, Route
│ ├── route_provider/ # osrm.py, file.py, google.py
│ ├── position/ # gpsd_source.py, nmea_source.py, replay_source.py
│ ├── state/ # StateStore, ActiveRouteStore
│ ├── matching/ # MapMatcher
│ ├── maneuvers/ # ManeuverTracker
│ ├── trigger/ # TriggerPolicy
│ ├── offroute/ # OffRouteMonitor
│ └── directive/ # DirectiveEmitter, Phrasebook
├── ops/ # OSM extract + OSRM preprocessing runbook (not app code)
└── tests/
├── fixtures/ # recorded NMEA logs, pre-decoded routes
└── ...
Out of scope, but the seams are placed for them
- TTS — subscribes at
DirectiveEmitter; today's emit isprint(). - LLM co-driver — a gate before
DirectiveEmitter.emit(decide whether to speak) and/or aPhrasebookimplementation (decide phrasing). Off the hot path by construction. - IMU fusion — a
PositionSourcedecorator that fuses I2C accelerometer with GPS to stabilize heading/speed at low speed; theFixcontract is unchanged. - Rally pace notes — a different
RouteProvider(notes authored from a recce run) plus a richerManeuvervocabulary; the decision tier is reused as-is.