Skip to content

Map Load Generator โ€‹

A standalone command-line tool that fires synthetic UDP datagrams directly at a map service instance, in the exact wire format message-processor actually sends (position/metadata/heartbeat, verified against map/main.py's _handle_packet()/_extract_timestamp() and message-processor/main.py's _publish_map_position/ _maybe_publish_map_metadata/_map_heartbeat_loop). It lets update frequency and simultaneous-aircraft count be pushed well past normal live traffic to find where map's backend ingestion and WebSocket relay actually start to strain โ€” without a real ADS-B feed, receiver, or message processor in the loop.

Beyond raw throughput, the simulated fleet is a believable scenario: real straight-line traffic lanes at FAA-hemispheric-correct altitudes, a varied fleet (airliners, GA singles, business jets, helicopters, a balloon, military, special livery), a VFR/IFR mix, migrating emergency squawks, and a staggered startup ramp-up โ€” so a load run also exercises map's altitude-layering, icon-shape-by-type resolution, and emergency-squawk handling, not just packet throughput.

It is a plain Python script, not a container โ€” there is no Dockerfile or Compose service for it. Run it from any host that can reach the target map instance's UDP listener (MAP_LISTEN_HOST:MAP_LISTEN_PORT).

Scope: this tool is a backend + WebSocket-relay load generator only. It never opens a browser, so it says nothing about actual frontend rendering cost (trail redraw, GeoJSONSource.setData() rebuild cost, etc.) โ€” only about whether map's UDP ingestion, its Redis writes, and its WebSocket broadcast keep up. A headless-browser rendering benchmark is a separate, meaningfully bigger tool, not something bolted onto a UDP packet sender.

Usage โ€‹

bash
pip install -r requirements.txt

python main.py --host 192.168.1.20 --port 30500 \
    --aircraft-count 200 --position-rate 2 --duration 300

python main.py --host 192.168.1.20 --port 30500 \
    --aircraft-count 50 --mode stress

--port defaults to 30500 (the installer's suggested MAP_LISTEN_PORT/MAP_UDP_PORT) and can be omitted if the target map instance uses it.

There are no third-party runtime dependencies โ€” requirements.txt exists only so this tool's tests are picked up by CI's per-component discovery.

Stop early at any time with Ctrl+C (or SIGTERM); a final summary (datagrams sent per type, average packet rate) prints on exit either way. Progress (datagrams sent per type, current rate) prints every 5 seconds while running.

No --seed: start/finish points along each lane, squawk-code selection, and emergency-squawk migration are all true per-run randomness by design โ€” this tool's older "fully reproducible, zero randomness" property was intentionally given up in favor of a fleet that looks and behaves differently, and realistically, on every run.

Simulated Fleet โ€‹

Aircraft fly straight, parallel traffic lanes instead of a shared circular orbit โ€” one E-W lane per fixed latitude, one N-S lane per fixed longitude, each spanning 2 * --area-radius-nm nautical miles across the test area centered on --center-lat/--center-lon. Within a lane, aircraft are strung 7 nautical miles apart in sequence; each aircraft's start/finish point along its lane is randomized per run (see "No --seed" above).

--aircraft-count sizes the fleet; one aircraft is always the balloon (see below), and the rest occupy lane seats โ€” persistent slots that keep their lane and role (see "Fleet variety" below) for the life of the run. When a seat's current occupant reaches the end of its lane, it simply stops sending โ€” no synthetic "eviction" packet exists on the real wire protocol โ€” and a fresh, never-reused synthetic icao_hex immediately takes the same seat, keeping the concurrently-active fleet near --aircraft-count.

Altitude and direction โ€” the FAA hemispheric rule โ€‹

Per 14 CFR 91.159 (VFR cruising altitude) and 91.179 (IFR cruising altitude/flight level): magnetic course 000โ€“179 ("eastbound") flies odd thousands (VFR adds 500ft); course 180โ€“359 ("westbound") flies even thousands (VFR +500). At/above FL180 (Class A โ€” IFR only), the same odd/even split continues in flight levels, with no VFR variant:

Below FL180Eastbound (000โ€“179ยฐ)Westbound (180โ€“359ยฐ)
IFR3,000 / 5,000 / 7,000 / 9,000 / 11,000 / 13,000 / 15,000 / 17,0004,000 / 6,000 / 8,000 / 10,000 / 12,000 / 14,000 / 16,000
VFR (+500)3,500 / 5,500 / 7,500 / 9,500 / 11,500 / 13,500 / 15,500 / 17,5004,500 / 6,500 / 8,500 / 10,500 / 12,500 / 14,500 / 16,500
At/above FL180 (IFR only)EastboundWestbound
Flight levelsFL190 / FL210 / FL230 / FL250 / FL270 / FL290FL200 / FL220 / FL240 / FL260 / FL280

Every lane's altitude comes directly from this table, so every aircraft's displayed altitude is always hemispherically correct for its heading โ€” except while a climbing/descending aircraft (see below) is transiting between two such altitudes. There is no separate "flight rules" field: a VFR aircraft is identified the real-world way, by squawking the standard US VFR code 1200 (see "Squawk assignment" below). An aircraft above FL180 is always IFR and always carries an operator (Class A airspace requires an IFR clearance); below FL180, VFR (no operator, no origin/destination/registrant) and IFR (operator, registrant, one fixed shared origin/destination pair) are both represented.

Speed and vertical rate โ€‹

Vertical speed is clamped to ยฑ800ft/min. Groundspeed is a function of altitude, linearly interpolated between 100ft โ†’ 60kt and 40,000ft โ†’ 500kt (clamped at both ends) โ€” so low-altitude traffic (light GA, helicopters) comes out around real-world light-aircraft speeds, and high-altitude lanes come out around real airliner cruise speeds, with no per-type speed table. The balloon is the one exception โ€” see below.

A small subset (--climbing-count, default 2) transitions between two altitudes over the run โ€” its lane's own altitude and the next hemispherically-valid altitude up in the same direction โ€” climbing then descending at a steady rate comfortably inside the ยฑ800ft/min clamp, instead of holding level the whole time.

Squawk assignment โ€‹

  • VFR aircraft (below-FL180, +500 altitude) always squawk 1200, and never carry an operator, origin/destination, or registrant.
  • IFR aircraft squawk a synthetic discrete-looking code instead, and always carry an operator, a registrant, and the one fixed origin/destination pair shared by every IFR aircraft in the run.
  • A small subset (2, fixed) instead squawk one of the real emergency codes 7500/7600/7700/7777, chosen randomly, overriding whatever squawk they'd otherwise have. When an aircraft holding an emergency code exits its lane, a different, randomly chosen active aircraft picks up a (also randomly chosen) emergency code next โ€” so there's always roughly the same small number of "emergency" flights active, just never the same tail number for long.

Wake turbulence, receiver sources โ€‹

wake_turbulence_category is sent for every aircraft with metadata (not the no-metadata subset โ€” see below), randomly one of light/medium/ heavy. receiver_sources is a randomly chosen, randomly sized (1โ€“3) subset of 978/1090/EXTERNAL per aircraft.

Fleet variety โ€‹

Each lane seat is assigned one role for the life of the run (only the occupant โ€” hex/ident/squawk/etc. โ€” changes on a lane-exit replacement):

  • Regular (the majority): a real ICAO type designator cycled from a pool spanning airliners, GA singles, and business jets.
  • Helicopter (3, fixed): one each of three distinct real helicopter type designators โ€” R44 (piston GA), EC35 (light-twin EMS/H135), S76 (medium-twin offshore/EMS).
  • Climbing (--climbing-count, default 2): see "Speed and vertical rate" above.
  • No-metadata (2, fixed): simulates an enrichment-database miss โ€” the aircraft dict carries only icao_hex and emitter_category (the raw ADS-B-broadcast category), nothing else.
  • Military (1, fixed): military: true, with a real military type designator (F16/F15).
  • Livery (1, fixed): special_livery: "Overclocked Osprey" on a V-22 Osprey (type_designator: "V22", tiltrotor description code "T").

One further aircraft, outside the lane seat pool entirely, is the balloon: a diagonal heading off the E-W/N-S lane grid, centered around 60,000ft at a slow ~15โ€“25kt drift, with a gentle organic wander in both heading and altitude โ€” not part of the hemispheric-rule lane grid, and not governed by the altitude-interpolation groundspeed formula above.

Every type designator used is one map/frontend/src/lib/ aircraftIconResolver.ts actually resolves to a dedicated icon shape, either directly or through its TYPE_ALIASES/DESCRIPTION_SHAPES tables.

Startup ramp-up โ€‹

The fleet joins staggered over --ramp-up-seconds (default 60) instead of all appearing at tick 0 โ€” aircraft i doesn't start sending until i * (ramp_up_seconds / aircraft_count) seconds into the run, matching how a real message-processor discovers aircraft one at a time rather than all at once. --ramp-up-seconds 0 disables this explicitly (today's instant-appearance behavior). Ramp-up is disabled outright (not scaled down) whenever --duration is nonzero and shorter than --ramp-up-seconds โ€” a short, deliberate run shouldn't spend its whole duration still ramping up. This is a one-time run-start behavior only; a lane-exit replacement always spawns immediately regardless.

Second processor (roster staleness) โ€‹

A second, one-shot processor_id (--processor-id + -secondary) sends exactly one heartbeat packet at the very start of the run, then nothing further โ€” its roster entry ages from green to amber to red purely from real wall-clock silence over a long-enough run, exercising a code path the primary (continuously healthy) --processor-id never touches.

Metadata fields (operator/origin/destination/registrant/squawk/ receiver_sources) are randomized per the rules above; --matched-rule, if given, is stamped into every aircraft's matched_rules list unchanged, exercising the map's rule-match indicator too.

Wire Format โ€‹

Each UDP datagram is a single UTF-8 JSON object, no framing โ€” matching what message-processor actually sends and what map/main.py's _udp_loop() expects.

position โ€‹

Sent --position-rate times per second, per aircraft:

json
{
  "type": "position",
  "icao_hex": "FF0000",
  "ts": 1717200000.123,
  "processor_id": "load-gen-1",
  "lat": 39.8283,
  "lon": -98.7203,
  "alt": 5000.0,
  "velocity": 140.6,
  "hdg": 90.0,
  "vs": 0.0
}

ts is the send-time Unix epoch float โ€” map's out-of-order guard (_extract_timestamp) requires a numeric ts for this packet type.

metadata โ€‹

Sent once per aircraft at startup, then again every --metadata-interval seconds (real message-processor sends this on first-known-metadata and again only on change, plus an unconditional 60-second resend loop โ€” this tool always resends on a fixed interval, since there's no real "change" to detect against synthetic data):

json
{
  "type": "metadata",
  "aircraft": {
    "icao_hex": "FF0000",
    "type_designator": "B738",
    "category": "Land",
    "wake_turbulence_category": "medium",
    "registration": "N0000",
    "manufacturer_model": "BOEING 737-800",
    "description_code": "L2J"
  },
  "ident": "LOAD0000",
  "processor_id": "load-gen-1",
  "last_message": "2024-06-01T12:00:00.000Z",
  "operator": {"airline_designator": "LG1", "name": "Load Generator One", "callsign": "LOADGEN", "country": "Load Country"},
  "origin": {"icao_code": "ZZ00", "iata_code": "Z00", "name": "Load Test Airport ZZ00", "city": "Load City", "region": "Load Region", "country": "Load Country"},
  "destination": {"icao_code": "ZZ01", "iata_code": "Z01", "name": "Load Test Airport ZZ01", "city": "Load Destination City", "region": "Load Destination Region", "country": "Load Destination Country"},
  "registrant": {"names": ["Load Generator Holdings LLC"]},
  "squawk": "4703",
  "matched_rules": [],
  "receiver_sources": ["1090", "EXTERNAL"]
}

A VFR aircraft carries "squawk": "1200" and omits operator/origin/ destination/registrant entirely; the no-metadata subset's aircraft dict carries only icao_hex and emitter_category (no registration/ manufacturer_model/description_code, simulating a genuine enrichment miss); the military/livery seats add military: true / special_livery alongside the fields above (every role with a type_designator, livery included, gets registration/manufacturer_model/description_code the same way).

Note icao_hex is nested under aircraft, never top-level โ€” this packet type is shaped like message-processor's CompletedFlight notification payload (_build_flight_notification_payload()) with "type": "metadata" added. It carries no ts; map's out-of-order guard uses last_message (an ISO-8601 timestamp) as this packet type's clock instead โ€” verified directly against map/main.py's _extract_timestamp(). Optional fields are omitted entirely (not sent as null/empty) when there's nothing to report.

heartbeat โ€‹

Sent independently of aircraft traffic, on the same cadence as message-processor's own _map_heartbeat_loop (MAP_HEARTBEAT_INTERVAL_SECONDS, 5s) for the primary --processor-id, plus exactly once at run start for the second, one-shot processor_id (see "Second processor" above):

json
{"type": "heartbeat", "processor_id": "load-gen-1", "ts": 1717200000.123}

processor_id is stamped on every packet type above, not just heartbeat โ€” map updates its per-processor liveness roster from any of the three, matching the real traffic-reduction design (see map/README.md's "Processor Roster" section).

Arguments โ€‹

FlagRequiredDefaultDescription
--hostYesโ€”Target map instance's UDP listen host.
--portNo30500Target map instance's UDP listen port (MAP_LISTEN_PORT).
--aircraft-countNo10Number of simulated aircraft, including the one balloon.
--position-rateNo2.0Position datagrams per second, per aircraft. Deliberately allowed to exceed the message processor's fixed 1/s per-aircraft position throttle โ€” finding where that breaks is the point.
--metadata-intervalNo30.0Seconds between metadata resends per aircraft.
--processor-idNoload-gen-1Fake processor_id stamped on every packet. A second, one-shot processor_id (this value + -secondary) sends exactly one heartbeat at run start.
--durationNo0Run length in seconds. 0 (or omitted) runs indefinitely until Ctrl+C/SIGTERM.
--modeNosteadysteady: hold --position-rate to wall-clock time. stress: send as fast as the socket accepts, no rate limiting at all โ€” for finding an absolute ceiling rather than a sustained-rate one. Simulated motion still advances one tick per round in stress mode, so aircraft keep moving realistically even when massively outrunning real time.
--center-latNo39.8283Lane grid center latitude.
--center-lonNo-98.5795Lane grid center longitude.
--area-radius-nmNo15.0Lane grid size โ€” half the length of every lane, nautical miles.
--climbing-countNo2Number of aircraft that transition between two altitudes over their run instead of holding level.
--ramp-up-secondsNo60.0Seconds over which the fleet joins staggered instead of all appearing at tick 0. 0 disables ramp-up explicitly. Disabled outright (not scaled down) if --duration is nonzero and shorter than this.
--matched-ruleNononeIf given, every simulated aircraft's metadata reports this identifier in matched_rules.

Modes โ€‹

  • steady โ€” the primary use case. Holds --position-rate datagrams/sec/aircraft to wall-clock time, for sustained-rate soak testing (does map keep up indefinitely at a given rate?). If a round falls behind schedule (e.g. slow local socket send under a very high aircraft count), later rounds send immediately rather than sleeping further โ€” this tool never tries to "catch up" by bursting.
  • stress โ€” sends every datagram back-to-back as fast as the local UDP socket accepts, with no delay at all. Use this to find an absolute ingestion ceiling rather than a sustained-rate one. --position-rate still governs how fast simulated motion advances per round (so aircraft positions remain realistic), it just no longer paces wall-clock sends.

Tests โ€‹

bash
python -m pytest tools/map-load-generator/tests/

The lane-motion, altitude/hemispheric, speed/vertical-rate, fleet-variety, squawk-migration, and packet-construction logic is pure (no socket I/O) and covered directly โ€” including FleetSimulator, the piece that decides which aircraft are active and where, independent of real sockets or wall-clock time. Because start/finish points, squawk selection, and emergency-squawk migration are true per-run randomness (see "No --seed" above), tests check bounds and rules โ€” altitude is always one of the valid hemispheric values for a lane's direction, vs never exceeds ยฑ800ft/min, VFR aircraft always squawk 1200, a lane-exit replacement gets a never-reused icao_hex, the emergency-squawk count stays constant across many exits โ€” rather than exact values.

run()/main() (the live-send loop, socket creation, argument parsing) is intentionally not covered by a separate unit test โ€” a real UDP send isn't meaningfully unit-testable, and this glue has no branching logic beyond what's already exercised above. It was instead verified against a real local UDP listener during development (packet counts, types, and field values, both steady and stress mode).

Documentation Site โ€‹

Tool directories under tools/ are picked up automatically by the documentation site's docs/scripts/discover.mjs tools/* scan, which generates a page per tool from this README with no change to discover.mjs needed. The hand-authored docs/tools/index.md overview page lists each tool by name and is updated alongside this file.