-
Notifications
You must be signed in to change notification settings - Fork 0
Shared
Not a service — no port; imported as a package by the backend services and the X-Plane plugin.
Source under shared/.
shared/ is the backend's common library — models, geo, state stores, and the A* taxi router.
Every service that needs to route an aircraft on the ground or persist its state draws from this
one package instead of re-implementing it.
| Consumed by | For |
|---|---|
| Orchestrator | the taxi router (dispatch_taxi_plan, compute_taxi_route), state reads |
| Arrival Simulator |
geo.py, and writing the same move_cmd contract the taxi router writes |
| Controller HMI | indirectly — reads what shared/ writes to Redis (hmi:chat, aircraft:state:{reg}), no direct import |
| X-Plane plugin |
AircraftStateStore, AirportDataStore, StandAssigner, AircraftTracker
|
The design point behind every module in
shared/services/taxi_router/: the LLM never parses
taxiways — deterministic parsers do. The GND pilot agent's only job is producing and
acknowledging ICAO phraseology; every taxiway letter, holding point, and pushback heading that
reaches a move_cmd is extracted, merged, and routed by plain Python, unit-tested under
tests/taxi_router/. A hallucinated waypoint can't move an aircraft —
the LLM never gets a chance to name one.
One controller instruction and one pilot readback go in:
-
destination_parser.extract_destinationreads the controller's words for where the taxi ends — holding point, runway, or stand — trying the most specific pattern first ("holding short of runway 17L" before the generic "runway 17" fallback). It never looks at the pilot's readback or the database'srunway_in_use; if the controller named no endpoint, the last via-point becomes the destination. -
readback_parserreads the pilot's readback:extract_taxiway_tokenscollapses phonetic spelling ("bravo", "golf one zero") into codes (B,G10);parse_pushback_directionturns "face north" or "face 090" into a heading. -
merge_constraintsreconciles both lists — the pilot's order wins (ICAO requires reading the clearance back), but any taxiway the controller named that the readback dropped is spliced back in at the position that keeps progression monotonic. Nothing the controller instructed is allowed to silently vanish from the route. -
router.pyloads the airport graph straight from Redis —AirportDataStore().load()readsairport:current:*— intoplugins/GND/graph.py'sAirportGraph, anetworkx.DiGraphweighted by Haversine distance.resolve_pointturns a spoken token into a graph node: runway designator, taxiway letter, or stand id, in that order. -
find_route_from_positionsnaps the aircraft's live GPS position (read fromaircraft:state:{reg}) to the nearest node in the graph;find_route_viathen forces the path through every merged via-point in order, stitching each leg withnx.astar_path. The same Haversine edge weights double as an admissible heuristic, so A* returns the same optimal path Dijkstra would while exploring less of the graph. -
plan_pushback_legcomputes the reverse-and-pivot geometry off the stand: a back-step distance clamped betweenPUSHBACK_MIN_DIST_M/PUSHBACK_MAX_DIST_M, the final heading the aircraft will face, and a pivot rate — the mover executes this as reverse-then-pivot before joining the taxi waypoints. -
dispatch_taxi_planassembles whatever legs the clearance actually contains — pushback only, waypoints only, or both — into one versioned plan and writesaircraft:{reg}:move_cmdas JSON with a TTL sized to the plan's own ETA. Whenfind_route_viacan't connect the via-points, there is nomove_cmd._shorten_reasonturns the graph's internal error into a short pilot-facing phrase instead ("taxiway B not found", "no route available", "position off the movement area"), and the router publishes it as a pilot-voice rejection — tohmi:chatfor the transcript, totts:queueso it is actually spoken, and toaircraft:{reg}:last_error(TTL 5 min) for whoever is debugging.
flowchart LR
IN["controller instruction + pilot readback"]
DP["destination_parser"]
RP["readback_parser"]
MC["merge_constraints"]
GRAPH["AirportGraph from airport:current:*"]
RES["resolve_point"]
ASTAR["find_route_via - A* via nx.astar_path"]
PB["plan_pushback_leg"]
DISP["dispatch_taxi_plan"]
MOVE["aircraft:{reg}:move_cmd"]
REJ["unroutable clearance"]
CHAT["hmi:chat"]
ERR["aircraft:{reg}:last_error"]
IN --> DP
IN --> RP
DP --> MC
RP --> MC
MC --> RES
GRAPH --> RES
RES --> ASTAR
ASTAR --> PB
PB --> DISP
DISP --> MOVE
ASTAR --> REJ
REJ --> CHAT
REJ --> ERR
A move_cmd payload is a small envelope regardless of who wrote it: version, plan_id,
started_at, delay_before_start_s, and a legs list, each leg naming its own mode. The taxi
router emits pushback and waypoints legs (with a taxiway sequence and speed_kts); the
Arrival Simulator writes the same envelope shape with
approach, landing_roll, and vacate legs instead. The plugin's AircraftMover reads
aircraft:{reg}:move_cmd and drives whichever legs it finds by branching on mode — it doesn't
know or care whether the taxi router or the arrival simulator produced the plan, only that the
shape matches. For taxi clearances specifically, shared/ owns the writer side: dispatch_taxi_plan
is the only thing that puts a move_cmd there. The return direction — motion milestones on
aircraft:{reg}:move_events — is published by the plugin mover on the other side of the
boundary; nothing in shared/ writes or reads that channel (config.py defines the key template
alongside move_cmd's, but only move_cmd is ever set from here). Execution detail — the
pushback → taxi state machine, the phase strings — lives on xplane; the full key
contract is on architecture.
AircraftStateStore.update() writes the same
AircraftState snapshot twice, on purpose, in one
pipelined Redis call. For now: a hash at aircraft:state:{reg} (TTL 1 hour), the registration
added to aircraft:active_set, and the dict PUBLISHed on aircraft:updates — this is what the
HMI's ground radar and the taxi router's own position lookup read. For history: the same
snapshot becomes an InfluxDB point (to_influx_point(), measurement aircraft_state, tagged by
session_id/registration), buffered in memory and flushed every 20 points instead of written
one at a time (and force-flushed when AircraftTracker.stop() ends the session).
query_replay(session_id) reads InfluxDB back — filtered by session and optionally by
registration — for the post-session debrief. Why both stores exist alongside PostgreSQL is on
architecture.
| Module | Role |
|---|---|
airport_data_store.py |
The parsed airport graph (nodes/edges/stands/runways) + stand occupancy + session:current, all under Redis airport:current:*
|
stand_assigner.py |
Matches each flight plan to a free, compatible stand at session start — checks ICAO width code and props/jets vs. gate-only airline traffic |
geo.py |
Pure geodesy, no dependencies: haversine, bearing, advance, project_on_localizer. Imported directly by the plugin's mover and by the arrival simulator; the taxi router keeps its own small haversine/advance duplicates in router.py/pushback.py rather than importing it |
aircraft_tracker.py |
In-sim 2 Hz dataref reader feeding AircraftStateStore; imports XPPython3 directly, so it only runs inside X-Plane |
models/aircraft_state.py |
The AircraftState dataclass — to_dict() for the Redis hash, to_influx_point() for history |
models/phases.py |
The motion phase enum — parked, pushback, taxi_out, approach, landing_roll, vacating, and the rest of what AircraftMover and AircraftStateStore emit. This is not the controller-phase state machine (APP/DEL/GND/TWR, owned by the orchestrator in PostgreSQL) — see architecture for the distinction |
There is exactly one AirportGraph class:
plugins/GND/graph.py, a networkx.DiGraph built from parsed apt.dat
data — nodes, edges, stands, runway thresholds — with lookup indices for taxiway letters, stand
ids, and node names. It lives under the in-sim plugin tree, since apt.dat parsing only happens on
the host where X-Plane's scenery files are, but the backend doesn't keep its own copy. router.py's
_load_graph() inserts plugins/GND onto sys.path — the module "is not a package", per the
code's own comment — and imports graph directly, then builds AirportGraph(data=...) from
whatever AirportDataStore.load() returned. Same class, two callers: the in-sim/dev tooling that
produced the JSON in the first place, and the backend taxi router that resolves and routes over
it. The apt.dat parsing story itself — row codes, active-zone filtering — lives on
xplane.
tests/taxi_router/ runs against real airport data — LEBL's parsed
graph, committed as a fixture and regenerated via python -m plugins.GND.data_parser LEBL. It is
split into graph_construction/ (building AirportGraph from a JSON file or a dict),
token_resolution/ (resolve_point, extract_destination, extract_taxiway_tokens,
merge_constraints), and taxi_routing/ (end-to-end routing and the pushback leg), plus a
root-level test_hmi_chat.py for the rejection path.
architecture · xplane · orchestrator · arrival simulator · index
Getting Started
Help
Modules
- System-Overview
- Agents
- Shared
- X-Plane
- Service-Arrival-Simulator
- Service-Asr
- Service-Controller-Hmi
- Service-Flight-Plan
- Service-Orchestrator
- Service-Weather
Internals