Revise fundamentals lessons and add frequency handoff

This commit is contained in:
Remi
2025-09-22 08:05:04 +02:00
parent 3c12f6d836
commit dbfa7a3044
3 changed files with 307 additions and 81 deletions

View File

@@ -12,90 +12,93 @@ function gradientArt(colors: string[]): string {
const fundamentalsLessons = [ const fundamentalsLessons = [
{ {
id: 'icao-alphabet', id: 'icao-alphabet',
title: 'ICAO Alphabet & Numbers', title: 'Decode ICAO Letters & Numbers',
desc: 'Spell letters and digits clearly', desc: 'Turn phonetic blocks into identifiers',
keywords: ['Alphabet', 'Numbers', 'Basics'], keywords: ['Alphabet', 'Numbers', 'Basics'],
hints: [ hints: [
'Spell each letter with its ICAO name (e.g. Delta, Lima, Hotel).', 'Convert the NATO letters and ATC numbers back into characters.',
'Use ATC numbers: tree, fower, fife, niner.' 'Write the identifier exactly as you would enter it into the FMS.'
], ],
fields: [ fields: [
{ {
key: 'letters', key: 'icao-code',
label: 'Letters', label: 'Identifier',
expected: scenario => scenario.callsignNato, expected: scenario => scenario.phoneticCode,
placeholder: 'Delta Lima Hotel', alternatives: scenario => {
width: 'xl', const spelled = scenario.phoneticCodeWords
threshold: 0.9 const upper = scenario.phoneticCode.toUpperCase()
}, const lower = upper.toLowerCase()
{ const spacedUpper = upper.split('').join(' ')
key: 'digits', const spacedLower = lower.split('').join(' ')
label: 'Numbers', return Array.from(
expected: scenario => scenario.flightNumberWords, new Set([
placeholder: 'one two three', upper,
width: 'lg', lower,
threshold: 0.88 spelled,
spelled.toLowerCase(),
spacedUpper,
spacedLower
])
)
},
placeholder: 'SWR59',
width: 'lg'
} }
], ],
readback: [ readback: [
{ type: 'text', text: 'Call sign: ' }, { type: 'text', text: 'Identifier ' },
{ type: 'field', key: 'letters', width: 'lg' }, { type: 'field', key: 'icao-code', width: 'lg' }
{ type: 'text', text: ' · ' },
{ type: 'field', key: 'digits', width: 'md' }
], ],
defaultFrequency: 'DEL', defaultFrequency: 'DEL',
phrase: scenario => `${scenario.callsignNato} ${scenario.flightNumberWords}`, phrase: scenario => scenario.phoneticCodeWords,
info: scenario => [ info: scenario => [
`Callsign: ${scenario.callsign}`, `Heard: ${scenario.phoneticCodeWords}`,
`Radio call: ${scenario.radioCall}`, `Identifier: ${scenario.phoneticCode}`
`ICAO spelling: ${scenario.callsignNato}`,
`Flight number spoken: ${scenario.flightNumberWords}`
], ],
generate: createBaseScenario generate: createBaseScenario
}, },
{ {
id: 'radio-call', id: 'radio-call',
title: 'Radio Call Sign', title: 'Radio Call Sign',
desc: 'Say the spoken and ICAO call sign', desc: 'Say the spoken or ICAO call sign confidently',
keywords: ['Basics', 'Callsign'], keywords: ['Basics', 'Callsign'],
hints: [ hints: [
'Use the airline telephony plus digits for the spoken version.', 'Use the airline telephony plus digits when talking to ATC.',
'ICAO format keeps the three-letter code with the numbers.' 'Readbacks can use either the spoken or ICAO format.'
], ],
fields: [ fields: [
{ {
key: 'radio-call-spoken', key: 'radio-call',
label: 'Spoken Call Sign', label: 'Call sign',
expected: scenario => scenario.radioCall, expected: scenario => scenario.radioCall,
alternatives: scenario => [ alternatives: scenario => {
scenario.radioCall, const variants = new Set<string>()
`${scenario.airlineCall} ${scenario.flightNumber}`, const add = (value?: string) => {
`${scenario.airlineCall} ${scenario.flightNumberWords}` if (!value) return
], variants.add(value)
width: 'lg' variants.add(value.toLowerCase())
}, }
{ add(scenario.radioCall)
key: 'radio-call-icao', add(`${scenario.airlineCall} ${scenario.flightNumber}`)
label: 'ICAO Identifier', add(`${scenario.airlineCall} ${scenario.flightNumberWords}`)
expected: scenario => scenario.callsign, add(scenario.callsign)
alternatives: scenario => [ add(`${scenario.airlineCode} ${scenario.flightNumber}`)
scenario.callsign, add(`${scenario.callsignNato} ${scenario.flightNumberWords}`)
`${scenario.airlineCode}${scenario.flightNumber}` return Array.from(variants)
], },
width: 'md' width: 'xl'
} }
], ],
readback: [ readback: [
{ type: 'text', text: 'Call sign ' }, { type: 'text', text: 'Call sign ' },
{ type: 'field', key: 'radio-call-spoken', width: 'lg' }, { type: 'field', key: 'radio-call', width: 'xl' }
{ type: 'text', text: ' · ICAO ' },
{ type: 'field', key: 'radio-call-icao', width: 'md' }
], ],
defaultFrequency: 'DEL', defaultFrequency: 'DEL',
phrase: scenario => `${scenario.radioCall}`, phrase: scenario => `${scenario.radioCall}`,
info: scenario => [ info: scenario => [
`Spoken call sign: ${scenario.radioCall}`, `Spoken: ${scenario.radioCall}`,
`ICAO identifier: ${scenario.callsign}` `ICAO: ${scenario.callsign}`,
`Phonetic: ${scenario.callsignNato} ${scenario.flightNumberWords}`
], ],
generate: createBaseScenario generate: createBaseScenario
}, },
@@ -105,8 +108,8 @@ const fundamentalsLessons = [
desc: 'Extract the identifier and key data from the ATIS', desc: 'Extract the identifier and key data from the ATIS',
keywords: ['ATIS', 'Weather'], keywords: ['ATIS', 'Weather'],
hints: [ hints: [
'Remember the ATIS identifier as a single letter.', 'Remember the ATIS identifier as its NATO word (e.g. Information Yankee).',
'Order: runway wind visibility temperature dew point QNH.' 'Order: runway wind visibility temperature dew point QNH; copy the numbers as digits.'
], ],
fields: [ fields: [
{ {
@@ -116,7 +119,12 @@ const fundamentalsLessons = [
alternatives: scenario => [ alternatives: scenario => [
scenario.atisCode, scenario.atisCode,
scenario.atisCode.toLowerCase(), scenario.atisCode.toLowerCase(),
`Information ${scenario.atisCode}` scenario.atisCodeWord,
scenario.atisCodeWord.toLowerCase(),
`Information ${scenario.atisCode}`,
`information ${scenario.atisCode.toLowerCase()}`,
`Information ${scenario.atisCodeWord}`,
`information ${scenario.atisCodeWord.toLowerCase()}`
], ],
placeholder: 'Letter', placeholder: 'Letter',
width: 'xs', width: 'xs',
@@ -143,22 +151,93 @@ const fundamentalsLessons = [
{ {
key: 'atis-visibility', key: 'atis-visibility',
label: 'Visibility', label: 'Visibility',
expected: scenario => scenario.atisSummary.visibility, expected: scenario => scenario.visibility,
alternatives: scenario => [scenario.visibility, scenario.visibilityWords], alternatives: scenario => {
const values = new Set<string>()
const add = (value?: string) => {
if (!value) return
values.add(value)
values.add(value.toLowerCase())
}
add(scenario.visibility)
add(scenario.atisSummary.visibility)
add(scenario.visibilityWords)
const numeric = Number(scenario.visibility)
if (!Number.isNaN(numeric)) {
if (numeric >= 1000) {
const precise = (numeric / 1000).toFixed(1).replace(/\.0$/, '')
add(precise)
add(`${precise}km`)
add(`${precise} km`)
}
if (numeric >= 1000) {
const rounded = Math.round(numeric / 1000)
add(rounded.toString())
add(`${rounded}km`)
add(`${rounded} km`)
}
}
if (scenario.visibility === '9999') {
add('10')
add('10km')
add('10 km')
}
return Array.from(values)
},
width: 'sm' width: 'sm'
}, },
{ {
key: 'atis-temp', key: 'atis-temp',
label: 'Temperature', label: 'Temperature',
expected: scenario => scenario.atisSummary.temperature, expected: scenario => scenario.temperature.toString(),
alternatives: scenario => [scenario.temperatureWords], alternatives: scenario => {
const value = scenario.temperature
const base = value.toString()
const plus = value > 0 ? `+${base}` : base
const c = `${base}°C`
const cSpaced = `${base} °C`
const shortC = value > 0 ? `${plus}C` : `${base}C`
const options = new Set<string>([
base,
plus,
c,
cSpaced,
shortC,
scenario.temperatureWords,
formatTemp(value)
])
options.add(`${plus}°C`)
options.add(`${plus} °C`)
options.add(scenario.temperatureWords.toLowerCase())
return Array.from(options)
},
width: 'sm' width: 'sm'
}, },
{ {
key: 'atis-dew', key: 'atis-dew',
label: 'Dew point', label: 'Dew point',
expected: scenario => scenario.atisSummary.dewpoint, expected: scenario => scenario.dewpoint.toString(),
alternatives: scenario => [scenario.dewpointWords], alternatives: scenario => {
const value = scenario.dewpoint
const base = value.toString()
const plus = value > 0 ? `+${base}` : base
const c = `${base}°C`
const cSpaced = `${base} °C`
const shortC = value > 0 ? `${plus}C` : `${base}C`
const options = new Set<string>([
base,
plus,
c,
cSpaced,
shortC,
scenario.dewpointWords,
formatTemp(value)
])
options.add(`${plus}°C`)
options.add(`${plus} °C`)
options.add(scenario.dewpointWords.toLowerCase())
return Array.from(options)
},
width: 'sm' width: 'sm'
}, },
{ {
@@ -188,8 +267,8 @@ const fundamentalsLessons = [
defaultFrequency: 'ATIS', defaultFrequency: 'ATIS',
phrase: scenario => scenario.atisText, phrase: scenario => scenario.atisText,
info: () => [ info: () => [
'Note the identifier, runway, wind, visibility, temperature, dew point, and QNH.', 'Note the identifier (NATO word), runway, wind, visibility, temperature, dew point, and QNH.',
'Visibility: four digits or 9999 for ≥10 km; QNH as a four-digit value.' 'Temperatures and dew point can be recorded as plain numbers; visibility may be metres or kilometres.'
], ],
generate: createBaseScenario generate: createBaseScenario
}, },
@@ -261,22 +340,32 @@ const fundamentalsLessons = [
{ {
id: 'radio-check', id: 'radio-check',
title: 'Radio Check', title: 'Radio Check',
desc: 'Report readability', desc: 'Acknowledge a requested radio check',
keywords: ['Ground', 'Comms'], keywords: ['Ground', 'Comms'],
hints: [ hints: [
'Reply with "Readability" followed by the number.', 'Reply with "Readability" plus the number you hear.',
'Always finish the check with your call sign.' 'Finish with your call sign; no need to repeat the frequency.'
], ],
fields: [ fields: [
{ {
key: 'rc-callsign', key: 'rc-callsign',
label: 'Callsign', label: 'Callsign',
expected: scenario => scenario.radioCall, expected: scenario => scenario.radioCall,
alternatives: scenario => [ alternatives: scenario => {
scenario.radioCall, const variants = new Set<string>()
`${scenario.airlineCall} ${scenario.flightNumber}`, const add = (value?: string) => {
scenario.callsign if (!value) return
], variants.add(value)
variants.add(value.toLowerCase())
}
add(scenario.radioCall)
add(`${scenario.airlineCall} ${scenario.flightNumber}`)
add(`${scenario.airlineCall} ${scenario.flightNumberWords}`)
add(scenario.callsign)
add(`${scenario.airlineCode} ${scenario.flightNumber}`)
add(`${scenario.callsignNato} ${scenario.flightNumberWords}`)
return Array.from(variants)
},
placeholder: 'Lufthansa one two three', placeholder: 'Lufthansa one two three',
width: 'lg' width: 'lg'
}, },
@@ -284,22 +373,106 @@ const fundamentalsLessons = [
key: 'rc-readability', key: 'rc-readability',
label: 'Readability', label: 'Readability',
expected: scenario => scenario.readabilityWord, expected: scenario => scenario.readabilityWord,
alternatives: scenario => [scenario.readability.toString(), scenario.readabilityWord], alternatives: scenario => [
scenario.readability.toString(),
scenario.readabilityWord,
scenario.readabilityWord.toLowerCase()
],
placeholder: 'five', placeholder: 'five',
width: 'sm' width: 'sm'
} }
], ],
readback: [ readback: [
{ type: 'text', text: 'This is ' }, { type: 'text', text: 'Readability ' },
{ type: 'field', key: 'rc-callsign', width: 'lg' }, { type: 'field', key: 'rc-readability', width: 'sm' },
{ type: 'text', text: ', readability ' }, { type: 'text', text: ', ' },
{ type: 'field', key: 'rc-readability', width: 'sm' } { type: 'field', key: 'rc-callsign', width: 'lg' }
], ],
defaultFrequency: 'GND', defaultFrequency: 'GND',
phrase: scenario => `${scenario.airport.name} Ground, ${scenario.radioCall}, radio check on ${scenario.groundFreq}.`, phrase: scenario => `${scenario.radioCall}, ${scenario.airport.name} Ground, say radio check on ${scenario.groundFreq}.`,
info: scenario => [ info: scenario => [
`Frequency: ${scenario.groundFreq} (${scenario.frequencyWords.GND})`, `Request: ${scenario.airport.name} Ground on ${scenario.groundFreq} (${scenario.frequencyWords.GND})`,
`Expected response: Readability ${scenario.readability} (${scenario.readabilityWord})` `Respond: Readability ${scenario.readability} (${scenario.readabilityWord}) with your call sign`
],
generate: createBaseScenario
},
{
id: 'frequency-change',
title: 'Frequency Change Readback',
desc: 'Confirm a handoff instruction',
keywords: ['Comms', 'Frequency'],
hints: [
'Repeat the station and frequency before your call sign.',
'State the frequency as digits or with "decimal".'
],
fields: [
{
key: 'freq-contact-frequency',
label: 'Frequency',
expected: scenario => scenario.handoff.frequency,
alternatives: scenario => {
const freq = scenario.handoff.frequency
const trimmed = freq.replace(/\.?0+$/, '')
const comma = freq.replace('.', ',')
const trimmedComma = trimmed.replace('.', ',')
const spaced = freq.replace('.', ' ')
const trimmedSpaced = trimmed.replace('.', ' ')
const words = scenario.handoff.frequencyWords
const pointWords = words.replace('decimal', 'point')
const compact = freq.replace('.', '')
const trimmedCompact = trimmed.replace('.', '')
const values = new Set<string>([
freq,
trimmed,
comma,
trimmedComma,
spaced,
trimmedSpaced,
words,
words.toLowerCase(),
pointWords,
pointWords.toLowerCase(),
compact,
trimmedCompact
])
return Array.from(values)
},
placeholder: '125.35',
width: 'md'
},
{
key: 'freq-contact-callsign',
label: 'Callsign',
expected: scenario => scenario.radioCall,
alternatives: scenario => {
const variants = new Set<string>()
const add = (value?: string) => {
if (!value) return
variants.add(value)
variants.add(value.toLowerCase())
}
add(scenario.radioCall)
add(`${scenario.airlineCall} ${scenario.flightNumber}`)
add(`${scenario.airlineCall} ${scenario.flightNumberWords}`)
add(scenario.callsign)
add(`${scenario.airlineCode} ${scenario.flightNumber}`)
add(`${scenario.callsignNato} ${scenario.flightNumberWords}`)
return Array.from(variants)
},
width: 'lg'
}
],
readback: [
{ type: 'text', text: scenario => `Contact ${scenario.handoff.facility} on ` },
{ type: 'field', key: 'freq-contact-frequency', width: 'md' },
{ type: 'text', text: ', ' },
{ type: 'field', key: 'freq-contact-callsign', width: 'lg' }
],
defaultFrequency: 'TWR',
phrase: scenario => `${scenario.radioCall}, contact ${scenario.handoff.facility} on ${scenario.handoff.frequencyWords}.`,
info: scenario => [
`Station: ${scenario.handoff.facility}`,
`Frequency: ${scenario.handoff.frequency} (${scenario.handoff.frequencyWords})`
], ],
generate: createBaseScenario generate: createBaseScenario
} }

View File

@@ -190,6 +190,30 @@ function randomFlightNumber(): string {
return value return value
} }
const phoneticCodeCharacters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'.split('')
function randomPhoneticCode(length = 5): string {
let value = ''
for (let i = 0; i < length; i++) {
value += choice(phoneticCodeCharacters)
}
return value
}
function codeToPhonetic(value: string): string {
return value
.toUpperCase()
.split('')
.map(char => {
if (natoMap[char]) return natoMap[char]
if (atcNumberWords[char]) return atcNumberWords[char]
return char
})
.join(' ')
.replace(/\s+/g, ' ')
.trim()
}
function generateSquawk(): string { function generateSquawk(): string {
let code = '' let code = ''
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
@@ -301,6 +325,8 @@ export function createBaseScenario(): Scenario {
const airline = choice(airlines) const airline = choice(airlines)
const flightNumber = randomFlightNumber() const flightNumber = randomFlightNumber()
const callsign = `${airline.code}${flightNumber}` const callsign = `${airline.code}${flightNumber}`
const phoneticCode = randomPhoneticCode()
const phoneticCodeWords = codeToPhonetic(phoneticCode)
const runway = choice(airport.runways) const runway = choice(airport.runways)
const stand = choice(airport.stands) const stand = choice(airport.stands)
const taxiRoute = choice(airport.taxi) const taxiRoute = choice(airport.taxi)
@@ -325,6 +351,7 @@ export function createBaseScenario(): Scenario {
const temperature = randInt(-3, 28) const temperature = randInt(-3, 28)
const dewpoint = Math.max(temperature - randInt(2, 6), -10) const dewpoint = Math.max(temperature - randInt(2, 6), -10)
const atisCode = choice(atisLetters) const atisCode = choice(atisLetters)
const atisCodeWord = lettersToNato(atisCode)
const remarks = choice(['NOSIG', 'BECMG 4000', 'TEMPO -SHRA']) const remarks = choice(['NOSIG', 'BECMG 4000', 'TEMPO -SHRA'])
const arrivalQnh = randInt(980, 1035) const arrivalQnh = randInt(980, 1035)
const arrivalWindDirection = randInt(0, 35) * 10 const arrivalWindDirection = randInt(0, 35) * 10
@@ -384,9 +411,15 @@ export function createBaseScenario(): Scenario {
return acc return acc
}, {} as Record<FrequencyType, string>) }, {} as Record<FrequencyType, string>)
const contactFrequencies = frequencies.filter(freq => freq.type !== 'ATIS')
const handoffTarget = choice(contactFrequencies)
const handoffCity = handoffTarget.type === 'CTR' ? destination.city : airport.city
const handoffFacility = `${handoffCity} ${handoffTarget.label}`.trim()
const handoffFrequencyWords = frequencyWords[handoffTarget.type]
const readability = choice(readabilityScale) const readability = choice(readabilityScale)
const atisText = `${airport.name} information ${atisCode}, time ${timestamp.slice(2, 4)}${timestamp.slice(4, 6)}, runway ${ const atisText = `${airport.name} information ${atisCodeWord}, time ${timestamp.slice(2, 4)}${timestamp.slice(4, 6)}, runway ${
runwayToWords(runway) runwayToWords(runway)
} in use, wind ${windToWords(windDirection, windSpeed)}, visibility ${visibilityToWords(visibility)}, temperature ${ } in use, wind ${windToWords(windDirection, windSpeed)}, visibility ${visibilityToWords(visibility)}, temperature ${
temperatureToWords(temperature) temperatureToWords(temperature)
@@ -397,6 +430,8 @@ export function createBaseScenario(): Scenario {
airlineCode: airline.code, airlineCode: airline.code,
airlineCall: airline.call, airlineCall: airline.call,
radioCall: `${airline.call} ${digitsToWords(flightNumber)}`, radioCall: `${airline.call} ${digitsToWords(flightNumber)}`,
phoneticCode,
phoneticCodeWords,
callsignNato: lettersToNato(airline.code), callsignNato: lettersToNato(airline.code),
flightNumber, flightNumber,
flightNumberWords: digitsToWords(flightNumber), flightNumberWords: digitsToWords(flightNumber),
@@ -430,6 +465,7 @@ export function createBaseScenario(): Scenario {
qnh, qnh,
qnhWords: qnhToWords(qnh), qnhWords: qnhToWords(qnh),
atisCode, atisCode,
atisCodeWord,
atisText, atisText,
atisSummary: { atisSummary: {
runway, runway,
@@ -470,6 +506,13 @@ export function createBaseScenario(): Scenario {
readabilityPhrase: `Readability ${readability.word}`, readabilityPhrase: `Readability ${readability.word}`,
frequencies, frequencies,
frequencyWords, frequencyWords,
handoff: {
type: handoffTarget.type,
facility: handoffFacility,
short: handoffTarget.label,
frequency: handoffTarget.value,
frequencyWords: handoffFrequencyWords
},
atisFreq: airport.freqs.atis, atisFreq: airport.freqs.atis,
deliveryFreq: airport.freqs.delivery, deliveryFreq: airport.freqs.delivery,
groundFreq: airport.freqs.ground, groundFreq: airport.freqs.ground,

View File

@@ -46,6 +46,8 @@ export type Scenario = {
airlineCode: string airlineCode: string
airlineCall: string airlineCall: string
radioCall: string radioCall: string
phoneticCode: string
phoneticCodeWords: string
callsignNato: string callsignNato: string
flightNumber: string flightNumber: string
flightNumberWords: string flightNumberWords: string
@@ -79,6 +81,7 @@ export type Scenario = {
qnh: number qnh: number
qnhWords: string qnhWords: string
atisCode: string atisCode: string
atisCodeWord: string
atisText: string atisText: string
atisSummary: { atisSummary: {
runway: string runway: string
@@ -119,6 +122,13 @@ export type Scenario = {
readabilityPhrase: string readabilityPhrase: string
frequencies: Frequency[] frequencies: Frequency[]
frequencyWords: Record<FrequencyType, string> frequencyWords: Record<FrequencyType, string>
handoff: {
type: FrequencyType
facility: string
short: string
frequency: string
frequencyWords: string
}
atisFreq: string atisFreq: string
deliveryFreq: string deliveryFreq: string
groundFreq: string groundFreq: string