feat(live-atc): drive ATIS letter, runway, QNH and wind from the resolved report

The broadcast and the flow variables used to read different sources: the audio
came from VATSIM while `information` was genATIS() — a random A-Z letter — and
the runway came from genRunway(), a hard-coded list unrelated to the airport.
So the pilot could hear "information Q" and be expected to call "information T".

Both now read the same AtisReport. The frequency list backfills the letter and
broadcast text from it, so an ATIS station always announces an information
letter even when no VATSIM controller is online. QNH and surface wind come from
the same observation, so the controller stops contradicting the ATIS.

A synthesised ATIS is labelled "Simulated ATIS" in the frequency picker rather
than being credited to VATSIM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-07-26 19:40:40 +02:00
parent 5de5f6c834
commit 9780ced052
4 changed files with 140 additions and 42 deletions

View File

@@ -2,6 +2,7 @@ import { ref, computed } from 'vue'
import { useApi } from '~/composables/useApi'
import useCommunicationsEngine from '../../shared/utils/communicationsEngine'
import { normalizeManualFreq } from '../../shared/utils/frequency'
import type { AtisReport, AtisStation } from '../../shared/utils/atisReport'
export type AirportFrequencyEntry = {
type: string
@@ -154,49 +155,66 @@ export function useFrequencyPresets(
return Array.from(new Set(all.map(normalizedFrequencyValue).filter(Boolean)))
}
// The resolved ATIS for this airport (see server/api/airports/[icao]/atis.get.ts).
// Always carries an information letter, whether or not VATSIM has a station.
const atisReport = ref<AtisReport | null>(null)
/** The resolved broadcast belonging to a published ATIS frequency, if any. */
const stationForEntry = (entry: AirportFrequencyEntry): AtisStation | undefined => {
const stations = atisReport.value?.stations ?? []
if (!stations.length) return undefined
const byCallsign = entry.callsign
&& stations.find(station => station.callsign?.toUpperCase() === entry.callsign!.toUpperCase())
if (byCallsign) return byCallsign
const wanted = normalizedFrequencyValue(entry.frequency)
const byFrequency = stations.find(station => normalizedFrequencyValue(station.frequency) === wanted)
if (byFrequency) return byFrequency
// A single broadcast belongs to the airport's single ATIS frequency.
return stations.length === 1 ? stations[0] : undefined
}
// All ATIS stations at the airport. Large airports broadcast separate
// Arrival and Departure ATIS on different frequencies (EDDF_A_ATIS /
// EDDF_D_ATIS on VATSIM), each with its own info letter and text.
const atisEntries = computed(() => airportFrequencies.value.filter(entry => entry.type === 'ATIS'))
//
// The published frequency list only carries live text when a VATSIM
// controller is online, so the resolved report backfills the letter and the
// broadcast — that is what stops an ATIS from playing with no information
// letter and no runway.
const atisEntries = computed<AirportFrequencyEntry[]>(() =>
airportFrequencies.value
.filter(entry => entry.type === 'ATIS')
.map((entry) => {
const station = stationForEntry(entry)
if (!station) return entry
return {
...entry,
atisCode: entry.atisCode || station.letter || atisReport.value?.letter || undefined,
atisText: entry.atisText || station.text || undefined,
lastUpdated: entry.lastUpdated || station.lastUpdated,
}
}))
// Primary ATIS entry for the quick-play button — prefer one with live text.
const atisFrequencyEntry = computed(() =>
atisEntries.value.find(entry => (entry.atisText || '').trim()) || atisEntries.value[0])
/** True when the ATIS is synthesised rather than a live VATSIM broadcast. */
const atisIsSynthetic = computed(() =>
Boolean(atisReport.value) && atisReport.value!.source !== 'vatsim')
/**
* Extract the runway in use from live ATIS text ("DEP RWY 25C", "EXPECT ILS
* APPROACH RUNWAY 25L", "RUNWAY IN USE 07"). Prefers a phrase matching the
* requested kind (departure/arrival); falls back to any runway mention.
* Returns null when no ATIS text carries a runway — callers keep their default.
* Runway in use for the given kind, from the resolved report. Null only when
* the airport has no usable runway data at all — callers keep their default.
*/
const extractAtisRunway = (kind: 'dep' | 'arr'): string | null => {
const RWY = String.raw`(?:RWY|RUNWAY)S?\s*([0-3]?\d\s*[LRC]?)\b`
const kindPatterns = kind === 'dep'
? [new RegExp(String.raw`DEP(?:ARTURE)?S?[^.]{0,40}?${RWY}`, 'i')]
: [
new RegExp(String.raw`(?:ARR(?:IVAL)?S?|LANDING)[^.]{0,40}?${RWY}`, 'i'),
new RegExp(String.raw`EXPECT[^.]{0,60}?APPROACH[^.]{0,20}?${RWY}`, 'i'),
]
const genericPattern = new RegExp(RWY, 'i')
const runwayInUse = (kind: 'dep' | 'arr'): string | null =>
(kind === 'dep' ? atisReport.value?.runwayDep : atisReport.value?.runwayArr) ?? null
const texts = atisEntries.value
.map(entry => (entry.atisText || '').trim())
.filter(Boolean)
for (const patterns of [kindPatterns, [genericPattern]]) {
for (const text of texts) {
for (const pattern of patterns) {
const match = pattern.exec(text)
if (match?.[1]) {
const designator = match[1].replace(/\s+/g, '').toUpperCase()
// Normalise "7L" -> "07L" so it matches OSM runway refs.
return /^\d[LRC]?$/.test(designator) ? `0${designator}` : designator
}
}
}
}
return null
}
/** Information letter for the given kind, or null before the report loads. */
const informationLetter = (kind: 'dep' | 'arr'): string | null =>
(kind === 'dep' ? atisReport.value?.letterDep : atisReport.value?.letterArr)
?? atisReport.value?.letter
?? null
const frequencySourceLabels = computed(() => {
const labels: string[] = []
@@ -356,9 +374,19 @@ export function useFrequencyPresets(
}
try {
const response = await api.get(`/api/airports/${encodeURIComponent(icao)}/frequencies`)
// Both in flight together: the ATIS report backfills the letter and the
// broadcast text onto the frequency entries, so a partial load would show
// an ATIS station with no information letter.
const [response, report] = await Promise.all([
api.get(`/api/airports/${encodeURIComponent(icao)}/frequencies`),
api.get(`/api/airports/${encodeURIComponent(icao)}/atis`).catch((err: unknown) => {
console.error('Failed to load ATIS report:', err)
return null
}),
])
const entries = Array.isArray(response?.frequencies) ? response.frequencies as AirportFrequencyEntry[] : []
airportFrequencies.value = entries
atisReport.value = (report as AtisReport | null) ?? null
airportName.value = typeof response?.airportName === 'string' ? response.airportName : undefined
frequencySources.value = {
vatsim: Boolean(response?.sources?.vatsim),
@@ -371,6 +399,7 @@ export function useFrequencyPresets(
if (!options.silent) {
airportFrequencies.value = []
airportName.value = undefined
atisReport.value = null
frequencySources.value = { vatsim: false, openaip: false }
}
} finally {
@@ -450,7 +479,11 @@ export function useFrequencyPresets(
? `${entry.frequency} · Info ${entry.atisCode}`
: entry.frequency,
color: entry.type === 'ATIS' ? '#f59e0b' : '#22d3ee',
sourceLabel: entry.sourceLabel,
// A synthesised ATIS is not a VATSIM broadcast — say so rather than
// crediting the source the frequency happens to come from.
sourceLabel: entry.type === 'ATIS' && atisIsSynthetic.value
? 'Simulated ATIS'
: entry.sourceLabel,
callsign: entry.callsign,
})),
)
@@ -505,7 +538,10 @@ export function useFrequencyPresets(
acceptedFrequenciesForState,
atisEntries,
atisFrequencyEntry,
extractAtisRunway,
atisReport,
atisIsSynthetic,
runwayInUse,
informationLetter,
frequencySourceLabels,
tunedAtisEntry,
frequencyDisplayKey,

View File

@@ -74,7 +74,8 @@ export function useLiveAtcSession(
const {
frequencies, airportFrequencies, frequencySources, activeAirportIcao,
expectedFrequencyForState, acceptedFrequenciesForState, extractAtisRunway,
expectedFrequencyForState, acceptedFrequenciesForState,
runwayInUse, informationLetter, atisReport,
fetchAirportFrequencies,
} = freq
@@ -556,12 +557,23 @@ export function useLiveAtcSession(
// all freq vars are resolved from live VATSIM/OpenAIP data.
await fetchAirportFrequencies(scenarioIcao)
// Runway in use from live ATIS (the flight plan carries no runway). Falls
// back to the engine-generated one when no ATIS text mentions a runway.
const atisRunway = extractAtisRunway(scenario.airport === 'arr' ? 'arr' : 'dep')
if (atisRunway) {
patchVariables({ runway: atisRunway })
pmLog.info('Runway from ATIS:', atisRunway)
// Runway and information letter from the resolved ATIS (the flight plan
// carries neither). The report always has a letter, and has a runway
// whenever the airport publishes runway data — so the pilot's initial call
// matches the broadcast they just listened to.
const atisKind = scenario.airport === 'arr' ? 'arr' : 'dep'
const atisRunway = runwayInUse(atisKind)
const atisLetter = informationLetter(atisKind)
const atisPatch: Record<string, string | number> = {}
if (atisRunway) atisPatch.runway = atisRunway
if (atisLetter) atisPatch.atis_code = atisLetter
// Quote the observed pressure and wind rather than the engine's placeholders,
// so the controller does not contradict the ATIS the pilot just heard.
if (atisReport.value?.qnhHpa) atisPatch.qnh_hpa = atisReport.value.qnhHpa
if (atisReport.value?.surfaceWind) atisPatch.surface_wind = atisReport.value.surfaceWind
if (Object.keys(atisPatch).length) {
patchVariables(atisPatch)
pmLog.info('From ATIS report:', { ...atisPatch, source: atisReport.value?.source })
}
// Pre-generate the ATIS TTS audio now so the first tune-in plays instantly

View File

@@ -55,6 +55,10 @@ export interface AtisReport {
runwayDep: string | null
runwayArr: string | null
runwaySource: 'atis' | 'wind' | 'main' | null
/** Observed QNH, so the flow quotes the same pressure the ATIS just read. */
qnhHpa: number | null
/** Observed surface wind as "260/14", or null without an observation. */
surfaceWind: string | null
observedAt: string | null
stations: AtisStation[]
}
@@ -382,6 +386,8 @@ export function resolveAtisReport(input: ResolveAtisInput): AtisReport {
runwaySource: fromText.dep || fromText.arr
? 'atis'
: byWind.dep?.source ?? byWind.arr?.source ?? null,
qnhHpa: observation?.qnhHpa ?? null,
surfaceWind: formatSurfaceWind(observation),
observedAt,
stations: liveStations,
}
@@ -413,6 +419,8 @@ export function resolveAtisReport(input: ResolveAtisInput): AtisReport {
runwayDep: dep?.designator ?? null,
runwayArr: arr?.designator ?? null,
runwaySource: dep?.source ?? arr?.source ?? null,
qnhHpa: observation?.qnhHpa ?? null,
surfaceWind: formatSurfaceWind(observation),
observedAt,
stations: [{
frequency: silentStation?.frequency || input.atisFrequency || FREQUENCY_UNKNOWN,
@@ -424,6 +432,20 @@ export function resolveAtisReport(input: ResolveAtisInput): AtisReport {
}
}
/** Surface wind in the "260/14" form the flows use, or null when unobserved. */
function formatSurfaceWind(observation: MetarObservation | null): string | null {
if (!observation || observation.windKt === null) return null
const direction = observation.windVariable
? 'VRB'
: observation.windDeg === null ? null : pad3(observation.windDeg)
if (direction === null) return null
return `${direction}/${pad2(observation.windKt)}`
}
function pad3(value: number): string {
return String(value).padStart(3, '0')
}
/**
* Absolute time for a METAR day-of-month stamp, resolved against `now`.
* A report near a month boundary belongs to the previous month.

View File

@@ -307,6 +307,34 @@ test('resolveAtisReport still yields a letter and runway with no METAR at all',
assert.ok(report.runwayDep)
})
test('resolveAtisReport carries the observed QNH and wind for the flow to quote', () => {
const report = resolveAtisReport({
icao: 'EDDF',
airportName: 'Frankfurt Main',
vatsimStations: [],
metar: EDDF_METAR, // 24016KT ... Q1006
runways: EDDF_RUNWAYS,
})
assert.equal(report.qnhHpa, 1006)
assert.equal(report.surfaceWind, '240/16')
})
test('resolveAtisReport reports variable wind rather than a false direction', () => {
const report = resolveAtisReport({
icao: 'EDDC',
vatsimStations: [],
metar: 'EDDC 261650Z VRB04KT CAVOK 24/12 Q1013',
runways: EDDC_RUNWAYS,
})
assert.equal(report.surfaceWind, 'VRB/04')
})
test('resolveAtisReport leaves QNH and wind unset without an observation', () => {
const report = resolveAtisReport({ icao: 'EDDF', vatsimStations: [], metar: null, runways: EDDF_RUNWAYS })
assert.equal(report.qnhHpa, null)
assert.equal(report.surfaceWind, null)
})
test('resolveAtisReport never returns an empty letter', () => {
const report = resolveAtisReport({ icao: 'ZZZZ', vatsimStations: [], metar: null, runways: [] })
assert.match(report.letter, /^[A-Z]$/)