Airport Layout & Taxi Routing
pkg/airport turns SimConnect facility data into a typed model of an airport’s ground layout (runways, parking stands, taxi points, taxi paths and taxiway names) and builds a routable taxi graph from it.
import "github.com/mrlm-net/simconnect/pkg/airport"
| Type | What it is |
|---|---|
Loader |
Requests an airport’s facility data and assembles it from your message loop |
Layout |
The decoded ground layout of one airport |
Graph |
The routable taxi network of a Layout |
Route |
A taxi route: points, length, taxiway names, runway crossings |
Cache |
Layouts by ICAO code, each with a lazily built Graph |
Loading a layout
Loader never reads engine.Client.Stream() itself. You call Request, then hand it every message your loop receives; Handle reports the airport when all of its data has arrived. This lets it run alongside any other code that consumes the same stream, including pkg/manager.
cache := airport.NewCache()
loader := airport.NewLoader(client, airport.LoaderWithCache(cache))
if err := loader.Request("LKPR"); err != nil {
return err
}
tick := time.NewTicker(time.Second)
defer tick.Stop()
for {
select {
case msg := <-client.Stream():
if res, done := loader.Handle(msg); done {
if res.Err != nil {
return res.Err
}
fmt.Println(res.Layout.Name, len(res.Layout.TaxiPaths), "taxi paths")
}
// ... the rest of your message handling
case now := <-tick.C:
for _, res := range loader.Expire(now) {
fmt.Println("timed out:", res.ICAO) // res.Err wraps airport.ErrTimeout
}
}
}
With pkg/manager, feed the loader from a message handler. The manager satisfies airport.FacilityClient:
loader := airport.NewLoader(mgr, airport.LoaderWithCache(cache))
mgr.OnMessage(func(msg engine.Message) {
if res, done := loader.Handle(msg); done {
// res.Layout or res.Err
}
})
Loader details
| Frequencies | Layout.Frequencies: the airport’s radio frequencies (kind, MHz, name) and FrequencyFor(kind) with ATC’s fallbacks (#416). |
| IDs | 7 facility definition IDs from DefaultLoaderDefinitionBase (7100) and 112 request IDs from DefaultLoaderRequestBase (7200). Move them with LoaderWithIDs(defBase, reqBase) if they clash with your own. |
| Concurrency | Up to 16 airports in flight at once. Pending() lists them. |
| Timeout | LoaderWithTimeout (default 30 s). SimConnect sends nothing for an unknown ICAO code, so an unknown airport ends in ErrTimeout via Expire. |
| Reconnect | Call Reset(newClient) so the definitions are registered again on the new connection. |
| Raw data | Result.Raw holds the decoded facility records (RawAirport); save it as JSON to replay later with BuildLayout. |
The Layout model
l := res.Layout
rwy, end, ok := l.RunwayEnd("24") // also "6", "06", "RW27R"
fmt.Println(rwy.Name(), end.Heading, end.Threshold)
idx, err := l.ParkingIndex("C22") // ErrUnknownParking / ErrAmbiguousParking
stand := l.Parking[idx]
fmt.Println(stand.Label(), stand.Type, stand.Radius, stand.IsGate())
for _, hs := range l.HoldShortPoints() {
fmt.Println(hs.Index, hs.Position, hs.IsILSHoldShort())
}
Every slice is indexed by SimConnect list index (TaxiPoints[i].Index == i), and positions carry both the raw BiasX/BiasZ offsets and the resolved LatLon. Enumerations use the existing pkg/types facility enums (SIMCONNECT_FACILITY_TAXI_PATH_TYPE, …_TAXI_POINT_TYPE, …_TAXI_PARKING_TYPE, …_TAXI_PARKING_NAME, …_RUNWAY_DESIGNATOR).
Runways expose both ends (Primary, Secondary) with name, heading and threshold. Thresholds are the ends of the runway surface; displaced thresholds are not applied.
Parking labels combine NAME, NUMBER and SUFFIX: GATE_C + 22 → C22, S_PARKING + 22 + suffix GATE_A → S22A. Labels are usually but not guaranteed unique, which is why ParkingByLabel returns every match.
Stands: size, conflicts, airlines
for _, i := range l.SuitableStands(18) { // RADIUS ≥ 18 m: an A320 (half span 17.9 m)
p := l.Parking[i]
fmt.Println(p.Label(), p.Size(), p.Airlines, l.ParkingConflicts(i))
}
heavy := l.SuitableStands(30, types.SIMCONNECT_FACILITY_TAXI_PARKING_TYPE_GATE_HEAVY)
ok := l.Parking[heavy[0]].ServesAirline("DLH")
Parking.Size()classes a spot asStandSmall,StandMediumorStandHeavy: byTYPEwhere the scenery names a size (GATE_SMALL/MEDIUM/HEAVY,RAMP_GA_SMALL/MEDIUM/LARGE), otherwise byRADIUS(below 15 m small, below 25 m medium). Fuel and vehicle spots areStandNone.Layout.SuitableStands(minRadius, types...)lists the spots with at least thatRADIUSand, if given, one of theTYPEs; never fuel or vehicle spots.RADIUSis half the space the spot offers, so pass half the aircraft span plus a margin.Layout.ParkingConflicts(i)lists the spots whoseRADIUScircles overlap spotiby more than half a meter: split and alternate stands (LKPRS22/S22A) and tightly packed gates. At LKPR 20 pairs overlap (two more only touch), at EDDM 4, at LROP 38. Whether two aircraft actually clash depends on their spans; the stand allocator (#292) decides that.Parking.Airlinesare the airline codes the scenery assigns to the stand (TAXI_PARKING_AIRLINEchild records; EDDM assigns 10–23 airlines to 119 of its 175 stands, LKPR none).ServesAirline(code)is true for a listed code (case-insensitive) and for stands without airlines.
Facility data semantics
These were verified against MSFS 2024 data for LKPR (the fixture in pkg/airport/testdata) and differ from what older code in this repository assumed:
TAXI_PATH.START/ENDindex the taxi point list, except forPARKINGpaths, whoseENDindexes the parking list. At LKPR all 133 parking paths resolve that way (median 43 m); read as taxi points they would be 0.5–3.3 km phantom segments.Layout.PathEndpointsandTaxiPath.EndsAtParkingapply this.- Hold-short points are identified by
TAXI_POINT.TYPE:HOLD_SHORT(2),ILS_HOLD_SHORT(4) and their_NO_DRAWvariants (5, 6). LKPR uses only theNO_DRAWtypes. They sit on taxiways, never on runway paths. - Taxiways are
TAXI(1) andPATH(4) paths, and an airport may use only one of them: LKPR has noTAXIpaths at all. - Parking
SUFFIXtells apart stands that shareNAMEandNUMBER(LKPR:S22andS22A).
Taxi graph and routes
g, err := airport.BuildGraph(l) // or cache.Graph("LKPR")
route, err := g.RouteToRunway(idx, "24", airport.RouteOptions{})
fmt.Printf("%.0f m via %v, crossing %v\n", route.Length, route.Taxiways, route.RunwayCrossings)
// 1595 m via [H1 H A], crossing []
The graph has one node per taxi point (node i = taxi point i) and one per parking spot (node len(TaxiPoints)+k).
| Path type | In the graph |
|---|---|
TAXI, PATH |
Taxiway edges |
PARKING |
Edge from its START taxi point to the parking spot at END |
RUNWAY |
Runway edges, used only with RouteOptions.UseRunwayPaths |
CLOSED, VEHICLE, ROAD, PAINTEDLINE |
Excluded |
Hold-short nodes are associated with the runway whose centreline is nearest (within 300 m), with HoldShort.ILS, the distance along the runway from the primary threshold and the offset from the centreline.
Routing API
| Method | Route |
|---|---|
Route(from, to, opts) |
Shortest route between any two nodes |
RouteToRunway(parking, runwayEnd, opts) |
Stand → hold-short of a runway end (departure) |
RouteToParking(from, parking, opts) |
Any node → stand (taxi-in) |
RouteToRunwayEntry(parking, runwayEnd, entry, opts) |
Stand → hold-short of a runway end at a named entry: “24 at B” (empty entry = RouteToRunway) |
RouteFromRunway(exit, parking, opts) |
Runway exit → stand, continuing in the exit’s direction |
RouteToRunwayFrom(from, prev, runwayEnd, entry, opts) |
From a node reached via prev (no turning back into it) → hold-short: a taxi-out after a pushback onto prev |
RouteFromNodes(nodes) |
A route along given adjacent nodes, e.g. a pushback joined to its taxi-out |
Route.Cost is what the search minimised (length plus turn, crossing and apron penalties), to compare alternatives. RouteOptions.OwnApronMeters (default 250 m) waives the apron penalty around the start: an aircraft leaving its own apron uses its taxilanes (LKPR C17 leaves by JB, the nearest), while through traffic still keeps off them.
Aircraft size. With RouteOptions.HalfSpan (half the wing span, meters) a route keeps to taxiway edges the aircraft fits:
Edge.Clearanceis the free half-width beside each taxiway edge: the distance from its centreline to the nearest stand circle (Parking.Radius, the space of the largest aircraft the stand takes). An edge fits withWingtipMargin(default 3 m) to spare.OwnStands, the stands the aircraft leaves or enters, are not obstacles; the stand-based routing functions add theirs.TaxiwayMaxSpanlimits taxiways by name to a largest span, for published restrictions the scenery does not carry. It defaults to the airport’s entry inKnownTaxiwayMaxSpan(a first seed of the airport limits, #335): LKPR’s apron taxilanes JO and JB are code C (36 m), so a 777 leaves B14 by J while an A320 from C17 takes JB.- When no route fits, the route is found without the size check and marked
Route.Tight. A custom route returnsErrTooNarrowinstead.
pkg/traffic controllers set HalfSpan from the aircraft’s MotionProfile.
RouteToRunway prefers runway holding points over ILS holds, and among the hold-shorts within RouteOptions.IntersectionTolerance (default 300 m) of the one nearest the threshold, picks the shortest route, so aircraft depart from (or near) the full runway length.
A route never passes through a parking stand, and crossing a runway on a taxiway is allowed but reported in RunwayCrossings. Errors: ErrNoTaxiNetwork, ErrUnknownParking, ErrAmbiguousParking, ErrUnknownRunway, ErrNoHoldShort, ErrNoRoute (e.g. vehicle-only stands).
Route cost: fewer turns, no crossings
Routes are not simply the shortest. Pilots and ATC prefer fewer and gentler turns even when a route is a little longer, so the search tracks which way the aircraft arrives at every node and adds a cost to the length:
| Cost | Default | RouteOptions field |
|---|---|---|
Turn at a taxiway junction, per 90° above TurnFreeAngle (15°) |
60 m | TurnPenalty |
| Turning onto a differently named taxiway (unnamed connectors inherit the name; going straight on where the name changes is free) | 40 m | TaxiwayChangePenalty |
| Each runway crossing | 1000 m | RunwayCrossingPenalty |
| Taxiway edge running along a runway surface (e.g. crossing at a runway end, LROP) | ×20 its length | UseRunwayPaths removes it |
| Edge at a taxi point where a stand connects (apron taxilane), so through traffic keeps to taxiways without stands | +50 % of its length | ApronPenalty |
| Turning back (≥ 150°) | 2000 m | — |
Zero selects the default and a negative value disables a cost. Route.Length is always the real length.
Custom routes: via points and taxiways
Two RouteOptions fields shape the route itself (#340). Every routing function honours them (Route, RouteToRunway, RouteToRunwayEntry, RouteToRunwayFrom, RouteToParking, RouteFromRunway):
Via []NodeID: the route passes these nodes in order. Every leg uses the same search and costs, and at a via point the route goes on the way it arrived. It never turns back there, so a via point at a dead end cannot be passed.Taxiways []string(“via F, L”): the names must appear inRoute.Taxiwaysin this order, matched case-insensitively. Until the last one is joined, other named taxiways costOffTaxiwaysFactor(×10) their length, so they serve only as connectors. After the last one the route goes on freely to its destination.
route, err := g.RouteToRunway(c22, "30", airport.RouteOptions{Taxiways: []string{"F", "L"}})
// 2503 m via [F L] (the default is [H1 K L])
var re *airport.RouteError
_, err = g.RouteToRunway(b14, "24", airport.RouteOptions{Taxiways: []string{"JO"}, HalfSpan: 32.4})
if errors.As(err, &re) && errors.Is(err, airport.ErrTooNarrow) {
fmt.Println("aircraft too big for", re.Taxiway) // JO
}
A custom route never skips the size check. Where only a route the aircraft does not fit would follow the request, the error is ErrTooNarrow and the route is not returned Tight. Failures come as a *RouteError naming the via point (Via index and Node) or the taxiway (Taxiway), so a UI can say “does not connect” or “too big for JO”:
| Error | Meaning |
|---|---|
ErrViaUnreachable |
Via point Via cannot be reached in order without turning back at the previous one (or the node does not exist) |
ErrNoRoute with Via == len(opts.Via) |
The destination cannot be reached from the last via point |
ErrTaxiwaysNotFollowed |
No route follows the taxiways in order; Taxiway is the first one it cannot reach |
ErrTooNarrow |
Only a route the aircraft does not fit would work; Taxiway is where it does not fit |
ErrUnknownTaxiway |
The airport has no taxiway of that name (TaxiwayNames() lists them) |
All except ErrUnknownTaxiway wrap ErrNoRoute. These errors appear only when the request is at fault: a destination that cannot be reached at all returns the plain error. ValidateRouteOptions(opts) checks via nodes and taxiway names before routing. RemainingOptions(opts, walked) drops the via points and taxiways that a walk (a pushback, a runway exit) already passed, so a search can continue from the walk’s end.
Runway entries and exits
RunwayEntries("24") lists the taxiways onto a runway end for departures, nearest the threshold first, with the runway remaining ahead of each (Remaining) and the turn onto the runway (Angle). RunwayExits("24") lists the exits for landings on it. An entry onto 24 is an exit for landings on 06 driven the other way. Exits turning more than MaxExitAngle (90°) point back along the runway and are left out. A departure at taxi speed turns sharper: entries may turn up to MaxEntryAngle (135°), because threshold entries often meet the runway square or slightly back (LKPR 12 at L, 120°).
Scenery data sometimes draws a taxiway ending on another node without sharing it: at LKPR the F lead-in ends on the 06 centreline beside the runway node. BuildGraph joins a dead end to another node within 3 m, so the lead-in reaches the runway.
entries, _ := g.RunwayEntries("24") // A (3510 m ahead), B (2406 m), L (1549 m) at LKPR
route, err := g.RouteToRunwayEntry(idx, "24", "B", airport.RouteOptions{})
// 954 m via [H1 H JO G B]; route.Entry == "B"
An unknown entry name returns ErrUnknownEntry.
On LKPR, BuildGraph takes about 0.2 ms and a route a few milliseconds.
GeoJSON
b, err := l.GeoJSON() // FeatureCollection: runway Polygons, taxiPath LineStrings,
// parking and taxiPoint Points, raw fields in properties
f := route.Feature() // a route as a LineString Feature
Coordinates are [longitude, latitude] per RFC 7946. Every feature has a kind property (runway, taxiPath, parking, taxiPoint, route).
Procedures: SIDs, STARs, approaches
ProcedureLoader reads an airport’s departures, arrivals and approaches with their runway, enroute and approach transitions and every leg (ARINC 424 leg type, fix and position, turn direction, course, distance, altitude and speed constraints, IAF/FAF/MAP). Feed it messages like the layout Loader:
pl := airport.NewProcedureLoader(client)
pl.Request("LKPR")
for msg := range client.Stream() {
if p, ok := pl.Handle(msg); ok {
fmt.Println(len(p.Departures), "SIDs", len(p.Arrivals), "STARs", len(p.Approaches), "approaches")
}
}
A SID is flown runway transition → Legs → enroute transition; a STAR enroute transition → Legs → runway transition. Courses are magnetic; Procedures.MagVar is the facility’s MAGVAR (356 = 4° east, true = magnetic + 4).
ProcedurePath(legs, start, startAlt, magVar, turnRadius) turns legs into points for a map: straight between fixes, turns (Dubins, in the charted direction) where the aircraft turns by heading (a charted turn, a course intercept, a course reversal), open legs (to an altitude, DME distance, manual termination) approximated from the climb gradient. Leg.Constraint() prints a leg’s constraint as charts do (≥4000, FL070, ≤210KT).
To fly a procedure rather than draw it, resolve it into NavPoints: one per fix (ident, kind, position, IAF/FAF/MAP, fly-over) plus a computed point where a leg without a fix ends (a climb to an altitude at 5%, a DME distance, an intercept of the next course, or a heading to radar vectors, Vectors). Each carries its altitude window in meters (AltMin/AltMax, 0 = none: AT sets both, at-or-above the minimum, at-or-below the maximum, between Alt2–Alt1), SpeedMax and the true Course flown to it; the same fix twice in a row (a STAR ending at the approach’s IAF) is merged. ResolveSID(name, runway, enroute, start, startAlt), ResolveSTAR(name, enroute, runway), ResolveApproach(name, transition) and MissedApproach(name) return ErrNoProcedure or ErrNoTransition when a name is unknown. For ATC-style assignment, SIDsFor, STARsFor and ApproachesFor list a runway’s procedures, SIDToward(runway, exitFix) and STARFrom(runway, entryFix) pick one by the flight plan’s first or last fix, BestApproach(runway) prefers ILS, then RNAV, LOC, VOR, NDB, and Arrival(runway, entryFix) chains the STAR and the best approach through the transition where the STAR ends:
sid, enroute, _ := p.SIDToward("24", "VOZ")
dep, _ := p.ResolveSID(sid.Name, "24", enroute, der, elevation) // VOZ4A: PR402 PR403 PR404 VOZ
arr, _ := p.Arrival("06", "GOLOP")
// GOLO4T + ILS 06 via KUVIX: GOLOP PR711 PR712 PR513 KUVIX PR741 PR742 CI06 FF06 RW06,
// at or above 1219 m (4000 ft) from PR741 to FF06, the threshold RW06 the MAP.
Not in the simulator’s data: STAR altitude constraints at LKPR are empty (the AIP chart has them), and the HOLDING_PATTERN fields are rejected by MSFS 2024 — do not request them. pkg/traffic builds its own holds on STAR fixes instead (Holding).
Airport limits
LimitsFor(layout, procedures) returns the values that belong to the airport rather than to the aircraft (#335), taken from the facility data where it has them and from KnownLimits (published values, keyed by ICAO code) otherwise:
| Field | Source | Default |
|---|---|---|
TransitionAltitudeFt |
KnownLimits (LKPR 5000, EDDF/EDDM 5000, LOWW 10000, EGLL 6000, LFPG 5000, EHAM 3000, EPWA 6500, LSZH 7000) |
5000; 18000 for K… and C… codes |
ClimbHandoverFt (above the field) |
the SIDs’ initial climb: the highest CA/VA/FA leg starting a runway transition, minus the elevation, at least MinClimbHandoverFt (1500) |
DefaultClimbHandoverFt (1500) without procedures |
TaxiMaxKts / ApronMaxKts |
KnownLimits |
30 / 15 |
PreferredRunways |
KnownLimits (LKPR 24, then 06) |
none |
MSAFt, NoReverseThrust |
KnownLimits |
unknown / false |
At LKPR the SIDs climb to 1700 ft on the runway heading first; the field is at about 1200 ft, so the hand-over stays at the 1500 ft floor. Pass the limits to traffic.TaxiRequest.Airport / ArrivalRequest.Airport, and nav.RunwayLimitsFrom(lim) gives the preferential runways to nav.ActiveRunways (or to a nav.RunwaySelector, which keeps the runway in use, see Keeping the runway in use). Graph.Apron(node) reports a stand’s junction with the taxilane, where ApronMaxKts applies.
lim := airport.LimitsFor(layout, &procs) // LKPR: TA 5000, hand-over 1500 ft, 24 then 06
use := nav.ActiveRunways(layout, weather, nav.RunwayLimitsFrom(lim))
Seeing it on a map
cmd/airport-map serves the layout on a Leaflet map with every feature’s raw values, a route viewer and overlapping-stand highlighting. The route viewer has a departure mode (stand → runway, full length or at an entry) and an arrival mode (runway exit → stand, with the vacate stop and the stop point on the stand). Pick the entry or exit in the panel or click its marker on the map. Run it with -dump to save an airport’s raw records, and with -file to view them without the simulator. The Procedures panel draws the SIDs, STARs and approaches of a runway as charts do: pick one from the list to see its fixes (VOR, NDB, waypoint symbols), constraints, tracks and distances, direction arrows, and where a STAR ends in radar vectors.
The sidebar has one tab per task: Traffic (new flights, aircraft cards with their clearances, the ATC game), Airport (airport info, weather, ATIS, de-icing pads, procedures), Layers (airport data, live traffic, safe zones, raw TYPE tables) and ? (a quick reference). Map buttons: ✈ your aircraft, ⛶ full screen with the panel, ◨ hide or show the panel.
To drive an AI aircraft along a route, see Departure Taxi. The map’s scheduled traffic, world view and landing sequence are described in Traffic Manager, Traffic Picture and Airborne Separation.