Files
OpenSquawk/shared/data/trafficTiers.ts
itsrubberduck 7d49f18cc6 feat(live-atc): add simulated AI background traffic on the tuned frequency
Implements the ai-traffic roadmap item per
docs/plans/2026-07-14-ai-traffic-architecture-design.md.

Simulated other aircraft on the user's frequency — callsigns, ATC
instructions, readbacks in their own stable voice, handovers — as pure
scenery. It never touches radioBackend: the Python backend keeps owning
the dialogue *with* the user, useAiTraffic owns the radio *around* the
user. The two share only the speech queue (arbitration) and the log.

Rules live as pure, seeded, framework-free modules under
shared/utils/aiTraffic/ so they run in tsx --test without a browser:
callsign collision rules, wake/in-trail separation, runway slots, the
speed ladder, direct validation, the §3 decision table, and the gating
chain. app/composables/useAiTraffic.ts wires them to Vue (1 Hz tick,
spawner, scheduler).

Gating is evaluated twice — before enqueue and again at playback, since
seconds pass in between. Traffic never keys up while the user holds PTT,
while their transmission is out at the backend, or inside the fresh
readback window. Off by default; the toggle surfaces the feature's v1
limitations rather than burying them in a doc.

Zero LLM calls: variance comes from seeded RNG over template variants.

Deviations from the design, both documented in the design doc:
- Adds SimAircraft.quietUntilSec. The design's rule table says "first
  matching row per tick" but never says an instruction must be allowed to
  take effect before the next one. Without it the planner re-derives the
  same unresolved condition every second and nags one aircraft with the
  same vector: 624 calls/30min measured, vs 90 with the cooldown.
- Airline pool limited to the 14 designators DEFAULT_AIRLINE_TELEPHONY
  already knows; UAE/AUA/WZZ from the design would be spelled out letter
  by letter instead of spoken as airline names.

Verified: 406 tests pass (176 new), no new typecheck errors, /live-atc
compiles and serves. The manual in-session walkthrough (audible traffic,
toggle mid-session) is NOT verified — it needs a login and the Python
backend. The 30-minute deterministic integration run stands in for it and
caught two of the three bugs found during development.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:46:52 +02:00

103 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* How busy a frequency should sound: airport tier × time-of-day band → target
* number of simultaneously active simulated aircraft.
*
* There is no traffic-volume data source anywhere in the product (the airport
* API only returns frequencies), so this is a deliberately small, hand-curated
* model per the architecture design (§ 8). Order of resolution:
* 1. curated map → 2. heuristic over the already-loaded frequency list →
* 3. hard default 'regional', so the feature never falls flat on an unknown
* airport, it is just moderately busy there.
*/
export type TrafficTier = 'major' | 'regional' | 'ga'
/** Curated starting set — trivially extendable, no completeness claim. */
export const AIRPORT_TRAFFIC_TIERS: Readonly<Record<string, TrafficTier>> = {
// Germany
EDDF: 'major', EDDM: 'major', EDDL: 'major', EDDB: 'major',
EDDH: 'regional', EDDK: 'regional', EDDS: 'regional', EDDV: 'regional',
EDDN: 'regional', EDDP: 'regional', EDDG: 'regional', EDDR: 'regional',
EDFE: 'ga', EDFH: 'ga', EDMA: 'ga', EDNY: 'ga', EDLW: 'ga', EDLP: 'ga',
// Europe
EHAM: 'major', EGLL: 'major', LFPG: 'major', LEMD: 'major', LEBL: 'major',
LIRF: 'major', LSZH: 'major', LOWW: 'major', EKCH: 'major', ESSA: 'major',
EGKK: 'regional', EGCC: 'regional', LFPO: 'regional', LIMC: 'regional',
LSGG: 'regional', EBBR: 'regional', LPPT: 'regional', EIDW: 'regional',
// Beyond
KJFK: 'major', KLAX: 'major', KORD: 'major', KATL: 'major',
OMDB: 'major', OTHH: 'major', RJTT: 'major', WSSS: 'major',
}
export const TRAFFIC_TIER_BASE: Readonly<Record<TrafficTier, number>> = {
major: 4,
regional: 2,
ga: 1,
}
/** Population cap — protects the speech queue and the TTS budget. */
export const MAX_ACTIVE_TRAFFIC = 5
/** The subset of frequency types the heuristic treats as a staffed ATC position. */
const CONTROLLER_FREQ_TYPES = new Set(['DEL', 'CLD', 'GND', 'TWR', 'APP', 'DEP'])
export interface TierHeuristicInput {
/** Frequency `type` codes already loaded for the airport (ATIS, GND, TWR, …). */
frequencyTypes: readonly string[]
}
/**
* Fallback when the airport isn't in the curated map: infer busy-ness from how
* many distinct controller positions the airport publishes. A field with
* Delivery + Ground + Tower + Approach/Departure is a major airport by any
* practical measure; a field with a Tower is at least regional.
*/
export function trafficTierFromFrequencies(input: TierHeuristicInput): TrafficTier {
const types = new Set(
input.frequencyTypes
.map(t => (t || '').toUpperCase().trim())
.filter(t => CONTROLLER_FREQ_TYPES.has(t)),
)
if (types.size >= 4) return 'major'
if (types.has('TWR')) return 'regional'
return 'ga'
}
/**
* Resolve the tier for an airport: curated map first, then the frequency
* heuristic, then the 'regional' default.
*/
export function resolveTrafficTier(
icao: string | undefined | null,
frequencyTypes: readonly string[] = [],
): TrafficTier {
const key = (icao || '').toUpperCase().trim()
const curated = AIRPORT_TRAFFIC_TIERS[key]
if (curated) return curated
if (frequencyTypes.length) return trafficTierFromFrequencies({ frequencyTypes })
return 'regional'
}
/**
* Time-of-day multiplier in coarse bands. The user's local clock stands in for
* the airport's local time — training happens "on site" in practice, and a
* longitude-derived local time isn't worth the complexity in v1.
*/
export function timeOfDayFactor(localHour: number): number {
const h = Math.floor(localHour)
if (h >= 22 || h <= 5) return 0.2 // night
if (h >= 6 && h <= 9) return 1.3 // morning bank
if (h >= 10 && h <= 15) return 1.0 // day
if (h >= 16 && h <= 20) return 1.3 // evening bank
return 0.6 // 21:00 — winding down
}
/**
* Target population of simultaneously active simulated aircraft.
* `ga` at night resolves to 0 — a dead GA frequency at 03:00 is correct.
*/
export function targetTrafficCount(tier: TrafficTier, localHour: number): number {
const raw = Math.round(TRAFFIC_TIER_BASE[tier] * timeOfDayFactor(localHour))
return Math.max(0, Math.min(MAX_ACTIVE_TRAFFIC, raw))
}