Files
OpenSquawk/shared/learn/scenario.ts

378 lines
11 KiB
TypeScript

import type { AirlineData, AirportData, Frequency, FrequencyType, Scenario } from './types'
const natoMap: Record<string, string> = {
A: 'Alpha',
B: 'Bravo',
C: 'Charlie',
D: 'Delta',
E: 'Echo',
F: 'Foxtrot',
G: 'Golf',
H: 'Hotel',
I: 'India',
J: 'Juliett',
K: 'Kilo',
L: 'Lima',
M: 'Mike',
N: 'November',
O: 'Oscar',
P: 'Papa',
Q: 'Quebec',
R: 'Romeo',
S: 'Sierra',
T: 'Tango',
U: 'Uniform',
V: 'Victor',
W: 'Whiskey',
X: 'X-ray',
Y: 'Yankee',
Z: 'Zulu'
}
const atcNumberWords: Record<string, string> = {
'0': 'zero',
'1': 'one',
'2': 'two',
'3': 'tree',
'4': 'four',
'5': 'fife',
'6': 'six',
'7': 'seven',
'8': 'eight',
'9': 'niner'
}
const runwaySuffixWords: Record<string, string> = {
L: 'left',
R: 'right',
C: 'center'
}
const readabilityScale = [
{ level: 1, word: 'one', description: 'Unreadable' },
{ level: 2, word: 'two', description: 'Barely readable' },
{ level: 3, word: 'three', description: 'Readable with difficulty' },
{ level: 4, word: 'four', description: 'Readable' },
{ level: 5, word: 'five', description: 'Perfectly readable' }
]
const airlines: AirlineData[] = [
{ code: 'DLH', call: 'Lufthansa' },
{ code: 'BAW', call: 'Speedbird' },
{ code: 'AFR', call: 'Air France' },
{ code: 'KLM', call: 'KLM' },
{ code: 'SWR', call: 'Swiss' },
{ code: 'EZY', call: 'Easyjet' }
]
const airportsData: AirportData[] = [
{
icao: 'EDDF',
name: 'Frankfurt/Main',
city: 'Frankfurt',
runways: ['25C', '25R', '07C', '07R', '18'],
stands: ['V155', 'A12', 'B24', 'G5', 'H43', 'L21'],
sids: ['ANEKI 7S', 'TOBAK 5Q', 'OBOKA 6N'],
transitions: ['ANEKI', 'TOBAK', 'OBOKA'],
approaches: ['ILS Z 25C', 'ILS Y 07C'],
taxi: ['N3 U4', 'V A', 'N7 K', 'S V12'],
freqs: {
atis: '126.350',
delivery: '121.900',
ground: '121.800',
tower: '118.700',
departure: '125.350',
approach: '120.800',
center: '134.200'
},
transLevel: 'FL070'
},
{
icao: 'EDDM',
name: 'Munich',
city: 'Munich',
runways: ['26R', '26L', '08R', '08L'],
stands: ['211', '214', '302', 'N16', 'H45'],
sids: ['OBAXA 3S', 'MERSI 6S', 'TULSI 5M'],
transitions: ['OBAXA', 'MERSI', 'TULSI'],
approaches: ['ILS Z 26R', 'ILS Y 08R'],
taxi: ['L4 N3', 'P3 W2', 'S1 D2'],
freqs: {
atis: '122.130',
delivery: '121.775',
ground: '121.800',
tower: '118.700',
departure: '129.050',
approach: '120.800',
center: '133.700'
},
transLevel: 'FL070'
},
{
icao: 'EHAM',
name: 'Amsterdam Schiphol',
city: 'Amsterdam',
runways: ['24', '36L', '18C', '09'],
stands: ['D14', 'E22', 'F8', 'H4'],
sids: ['SUGOL 2S', 'ANDIK 2S', 'ARNEM 2V'],
transitions: ['SUGOL', 'ANDIK', 'ARNEM'],
approaches: ['ILS Z 24', 'ILS Y 18C'],
taxi: ['A5 B2', 'K1 V3', 'W4 S8'],
freqs: {
atis: '136.050',
delivery: '121.800',
ground: '121.900',
tower: '119.220',
departure: '123.875',
approach: '121.200',
center: '135.050'
},
transLevel: 'FL060'
}
]
const atisLetters = 'ABCDEFGHJKLMNOPQRSTUVWXYZ'.split('')
function choice<T>(items: T[]): T {
return items[Math.floor(Math.random() * items.length)]
}
function randInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
function randomFlightNumber(): string {
const length = Math.random() < 0.5 ? 3 : 4
let value = ''
for (let i = 0; i < length; i++) {
value += String(randInt(0, 9))
}
if (value.startsWith('0')) value = '1' + value.slice(1)
return value
}
function generateSquawk(): string {
let code = ''
for (let i = 0; i < 4; i++) {
code += String(randInt(0, 7))
}
return code
}
function digitsToWords(value: string): string {
return value
.split('')
.map(char => atcNumberWords[char] ?? char)
.join(' ')
.replace(/\s+/g, ' ')
.trim()
}
function lettersToNato(value: string): string {
return value
.toUpperCase()
.split('')
.map(char => natoMap[char] ?? char)
.join(' ')
}
function runwayToWords(runway: string): string {
const digits = runway.replace(/[^0-9]/g, '').padStart(2, '0')
const suffix = runway.replace(/[0-9]/g, '')
const base = digits
.split('')
.map(char => atcNumberWords[char] ?? char)
.join(' ')
const suffixWord = suffix ? runwaySuffixWords[suffix] ?? suffix.toLowerCase() : ''
return suffixWord ? `${base} ${suffixWord}` : base
}
function frequencyToSpeech(freq: string): string {
const [intPart, decimalPart] = freq.split('.')
const intWords = digitsToWords(intPart)
if (!decimalPart) return intWords
const trimmed = decimalPart.replace(/0+$/, '')
const decWords = trimmed ? digitsToWords(trimmed) : 'zero'
return `${intWords} decimal ${decWords}`
}
export function formatTemp(temp: number): string {
const prefix = temp < 0 ? 'M' : ''
return `${prefix}${Math.abs(temp).toString().padStart(2, '0')}`
}
function temperatureToWords(temp: number): string {
const prefix = temp < 0 ? 'minus' : 'plus'
return `${prefix} ${digitsToWords(Math.abs(temp).toString())}`
}
function qnhToWords(qnh: number): string {
return digitsToWords(qnh.toString())
}
function windToWords(direction: number, speed: number): string {
const dir = direction.toString().padStart(3, '0')
const spd = speed.toString().padStart(2, '0')
return `${dir
.split('')
.map(char => atcNumberWords[char] ?? char)
.join(' ')} degrees at ${spd
.split('')
.map(char => atcNumberWords[char] ?? char)
.join(' ')} knots`
}
function visibilityToWords(vis: string): string {
if (vis === '9999') return 'ten kilometres or more'
const numeric = Number(vis)
if (!Number.isNaN(numeric)) {
if (numeric >= 1000) {
const km = Math.round(numeric / 1000)
return `${digitsToWords(km.toString())} kilometres`
}
return `${digitsToWords(vis)} metres`
}
return vis
}
function altitudeToWords(value: number): string {
const thousands = Math.floor(value / 1000)
const remainder = value % 1000
let words = thousands ? `${digitsToWords(thousands.toString())} thousand` : ''
if (remainder) {
words = `${words} ${digitsToWords(remainder.toString())}`.trim()
}
return words.trim()
}
export function createBaseScenario(): Scenario {
const airport = choice(airportsData)
const possibleDestinations = airportsData.filter(a => a.icao !== airport.icao)
const destination = choice(possibleDestinations)
const airline = choice(airlines)
const flightNumber = randomFlightNumber()
const callsign = `${airline.code}${flightNumber}`
const runway = choice(airport.runways)
const stand = choice(airport.stands)
const taxiRoute = choice(airport.taxi)
const sid = choice(airport.sids)
const transition = choice(airport.transitions)
const approach = choice(airport.approaches)
const altitude = choice([4000, 5000, 6000, 7000])
const climbAltitude = altitude + 2000
const squawk = generateSquawk()
const qnh = randInt(984, 1032)
const windDirection = randInt(0, 35) * 10
const windSpeed = randInt(3, 18)
const windDirectionStr = windDirection.toString().padStart(3, '0')
const windSpeedStr = windSpeed.toString().padStart(2, '0')
const visibility = choice(['9999', '9000', '8000', '6000'])
const cloud = choice(['SKC', 'FEW020', 'SCT025', 'BKN030'])
const temperature = randInt(-3, 28)
const dewpoint = Math.max(temperature - randInt(2, 6), -10)
const atisCode = choice(atisLetters)
const remarks = choice(['NOSIG', 'BECMG 4000', 'TEMPO -SHRA'])
const now = new Date()
const minute = Math.floor(now.getUTCMinutes() / 5) * 5
const timestamp = `${now.getUTCDate().toString().padStart(2, '0')}${now
.getUTCHours()
.toString()
.padStart(2, '0')}${minute.toString().padStart(2, '0')}Z`
const metarWindGroup = `${windDirectionStr}${windSpeedStr}KT`
const tempGroup = `${formatTemp(temperature)}/${formatTemp(dewpoint)}`
const metar = `${airport.icao} ${timestamp} ${metarWindGroup} ${visibility} ${cloud} ${tempGroup} Q${qnh
.toString()
.padStart(4, '0')} ${remarks}`
const frequencies: Frequency[] = [
{ type: 'ATIS', label: 'ATIS', value: airport.freqs.atis },
{ type: 'DEL', label: 'Delivery', value: airport.freqs.delivery },
{ type: 'GND', label: 'Ground', value: airport.freqs.ground },
{ type: 'TWR', label: 'Tower', value: airport.freqs.tower },
{ type: 'DEP', label: 'Departure', value: airport.freqs.departure },
{ type: 'APP', label: 'Approach', value: airport.freqs.approach },
{ type: 'CTR', label: 'Center', value: airport.freqs.center }
]
const frequencyWords = frequencies.reduce((acc, freq) => {
acc[freq.type] = frequencyToSpeech(freq.value)
return acc
}, {} as Record<FrequencyType, string>)
const readability = choice(readabilityScale)
const atisText = `${airport.name} information ${atisCode}, time ${timestamp.slice(2, 4)}${timestamp.slice(4, 6)}, runway ${
runwayToWords(runway)
} in use, wind ${windToWords(windDirection, windSpeed)}, visibility ${visibilityToWords(visibility)}, temperature ${
temperatureToWords(temperature)
}, dew point ${temperatureToWords(dewpoint)}, QNH ${qnh}, transition level ${airport.transLevel.replace('FL', '')}.`
return {
callsign,
airlineCode: airline.code,
airlineCall: airline.call,
radioCall: `${airline.call} ${digitsToWords(flightNumber)}`,
callsignNato: lettersToNato(airline.code),
flightNumber,
flightNumberWords: digitsToWords(flightNumber),
airport,
destination,
runway,
runwayWords: runwayToWords(runway),
stand,
taxiRoute,
sid,
transition,
approach,
altitudes: {
initial: altitude,
climb: climbAltitude,
initialWords: altitudeToWords(altitude),
climbWords: altitudeToWords(climbAltitude)
},
squawk,
squawkWords: digitsToWords(squawk),
qnh,
qnhWords: qnhToWords(qnh),
atisCode,
atisText,
atisSummary: {
runway,
wind: `${windDirectionStr}/${windSpeedStr}`,
visibility,
temperature: `${temperature}°C`,
dewpoint: `${dewpoint}°C`,
qnh: `QNH ${qnh}`
},
wind: `${windDirectionStr}/${windSpeedStr}`,
windWords: windToWords(windDirection, windSpeed),
visibility,
visibilityWords: visibilityToWords(visibility),
temperature,
temperatureWords: temperatureToWords(temperature),
dewpoint,
dewpointWords: temperatureToWords(dewpoint),
metar,
metarSegments: {
wind: metarWindGroup,
visibility,
temp: tempGroup,
qnh: `Q${qnh.toString().padStart(4, '0')}`
},
readability: readability.level,
readabilityWord: readability.word,
readabilityPhrase: `Readability ${readability.word}`,
frequencies,
frequencyWords,
atisFreq: airport.freqs.atis,
deliveryFreq: airport.freqs.delivery,
groundFreq: airport.freqs.ground,
towerFreq: airport.freqs.tower,
departureFreq: airport.freqs.departure,
approachFreq: airport.freqs.approach,
centerFreq: airport.freqs.center,
transLevel: airport.transLevel,
remarks
}
}