From 3e2d6ef4d3e932fe1ab76c781217b010f16b5911 Mon Sep 17 00:00:00 2001 From: Tanner Collin Date: Sat, 14 Feb 2026 16:12:56 -0700 Subject: [PATCH] refactor: Improve remark decoding robustness with data-driven parsing Co-authored-by: aider (gemini/gemini-2.5-pro) --- metar.html | 258 ++++++++++++++++++++++++++++------------------------- 1 file changed, 135 insertions(+), 123 deletions(-) diff --git a/metar.html b/metar.html index c22147e..8f89211 100644 --- a/metar.html +++ b/metar.html @@ -257,7 +257,8 @@ 'SC': 'Stratocumulus', 'ST': 'Stratus', 'CU': 'Cumulus', - 'CB': 'Cumulonimbus' + 'CB': 'Cumulonimbus', + 'TCU': 'Towering cumulus' }; const specialRemarks = { 'FROIN': 'Frontal passage in vicinity', @@ -270,130 +271,141 @@ 'S': 'South', 'SW': 'Southwest', 'W': 'West', 'NW': 'Northwest' }; - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - if (part.startsWith('SLP')) { - decoded.push(` - ${decodeSlp(part)}`); - } else if (obscurationRegex.test(part)) { - decoded.push(` - ${decodeObscurationRemark(part)}`); - } else if (part.match(/^([A-Z]{2}\d)+$/)) { - decoded.push(` - ${decodeCloudTypesRemark(part, metarString)}`); - } else if (part === 'VIA' && i + 1 < parts.length) { - const location = parts[i+1]; - decoded.push(` - VIA ${location}: relayed via ${location}`); - i++; // Consume location - } else if (part === 'VIS' && i + 2 < parts.length) { - const direction = parts[i+1]; - const distance = parts[i+2]; - decoded.push(` - VIS ${direction} ${distance}: Sector visibility to the ${direction} of ${distance} statute miles`); - i += 2; - } else if (part === 'VIRGA' && i + 1 < parts.length && parts[i+1] === 'ALQDS') { - decoded.push(` - VIRGA ALQDS: Virga in all quadrants`); - i++; // Consume ALQDS - } else if (part === 'VIRGA' && i + 1 < parts.length && directions[parts[i+1]]) { - const direction = directions[parts[i+1]]; - decoded.push(` - VIRGA ${parts[i+1]}: Virga to the ${direction.toLowerCase()}`); - i++; // Consume direction part - } else if (part === 'CVCTV' && i + 2 < parts.length && parts[i+1] === 'CLD' && parts[i+2] === 'EMBD') { - decoded.push(` - CVCTV CLD EMBD: Convective cloud embedded in stratiform layer`); - i += 2; // consume CLD and EMBD - } else if (part === 'TCU' && i + 1 < parts.length && parts[i+1] === 'ASOCTD') { - let remark = ' - TCU ASOCTD: Towering cumulus associated'; - let consumed = 1; - if (i + 2 < parts.length && directions[parts[i+2]]) { - const direction = directions[parts[i+2]]; - remark += ` to the ${direction.toLowerCase()}`; - consumed = 2; - } - decoded.push(remark); - i += consumed; - } else if (part === 'TCU' && i + 1 < parts.length && parts[i+1] === 'ALQDS') { - decoded.push(` - TCU ALQDS: Towering cumulus in all quadrants`); - i++; // Consume ALQDS - } else if (part === 'TCU' && i + 2 < parts.length && parts[i+1] === 'DSNT' && directions[parts[i+2]]) { - const direction = directions[parts[i+2]]; - decoded.push(` - TCU DSNT ${parts[i+2]}: Towering cumulus distant ${direction.toLowerCase()}`); - i += 2; // Consume DSNT and direction - } else if (part === 'DENSITY' && i + 2 < parts.length && parts[i+1] === 'ALT' && parts[i+2].endsWith('FT')) { - const altFeet = parts[i+2]; - const altitude = parseInt(altFeet.replace('FT', '')).toLocaleString(); - decoded.push(` - DENSITY ALT ${altFeet}: Density altitude ${altitude} feet`); - i += 2; // consume ALT and altFeet - } else if (cloudTypes[part] && i + 1 < parts.length && parts[i+1] === 'LENT') { - const cloudName = cloudTypes[part]; - let remark = ` - ${part} LENT: ${cloudName} lenticularis`; - let consumed = 1; - if (i + 2 < parts.length && directions[parts[i+2]]) { - const direction = directions[parts[i+2]]; - remark = ` - ${part} LENT ${parts[i+2]}: ${cloudName} lenticularis to the ${direction.toLowerCase()}`; - consumed = 2; - } - decoded.push(remark); - i += consumed; - } else if (cloudTypes[part] && i + 1 < parts.length && parts[i+1] === 'ASOCTD') { - const cloudName = cloudTypes[part]; - let remark = ` - ${part} ASOCTD: ${cloudName} associated`; - let consumed = 1; - if (i + 3 < parts.length && parts[i+2] === '/' && parts[i+3] === 'HALO') { - remark = ` - ${part} ASOCTD / HALO: ${cloudName} associated with Halo phenomenon`; - consumed = 3; - } else if (i + 2 < parts.length && directions[parts[i+2]]) { - const direction = directions[parts[i+2]]; - remark += ` to the ${direction.toLowerCase()}`; - consumed = 2; - } - decoded.push(remark); - i += consumed; - } else if (cloudTypes[part] && i + 1 < parts.length && parts[i+1] === 'ALQDS') { - const cloudName = cloudTypes[part]; - decoded.push(` - ${part} ALQDS: ${cloudName} in all quadrants`); - i++; // Consume ALQDS - } else if (cloudTypes[part] && i + 1 < parts.length && directions[parts[i+1]]) { - const cloudName = cloudTypes[part]; - let remark = ` - ${part} ${parts[i+1]}: ${cloudName} to the ${directions[parts[i+1]].toLowerCase()}`; - let consumed = 1; - if (i + 2 < parts.length && parts[i+2] === 'MOV' && i + 3 < parts.length && directions[parts[i+3]]) { - remark += `, moving ${directions[parts[i+3]].toLowerCase()}`; - consumed = 3; - } - decoded.push(remark); - i += consumed; - } else if (specialRemarks[part]) { - decoded.push(` - ${part}: ${specialRemarks[part]}`); - } else if (cloudTypes[part] && i + 1 < parts.length && parts[i + 1] === 'TR') { - const cloudName = cloudTypes[part]; - decoded.push(` - ${part} ${parts[i+1]}: ${cloudName} clouds are translucent (thin)`); - i++; // Consume 'TR' part - } else if (weatherRegex.test(part)) { - decoded.push(` - ${decodeWeather(part, metarString)}`); - } else if (part.match(/^(AC|CI|CC)/)) { - // This is a cloud-like remark, e.g. ACC for Altocumulus Castellanus - // It can have modifiers like other clouds. - const cloudName = cloudTypes[part] || part; // part will be ACC - let remark = ` - ${cloudName}: Cloud types and coverage details`; - let consumed = 0; - - if (i + 1 < parts.length && parts[i+1] === 'TR') { - remark = ` - ${cloudName} TR: ${cloudName} clouds are translucent (thin)`; - consumed = 1; - } else if (i + 1 < parts.length && parts[i+1] === 'ASOCTD') { - remark = ` - ${cloudName} ASOCTD: ${cloudName} associated`; - consumed = 1; - if (i + 2 < parts.length && directions[parts[i+2]]) { - remark += ` to the ${directions[parts[i+2]].toLowerCase()}`; - consumed = 2; + const decoders = [ + // SLP + { + match: p => p[0].startsWith('SLP'), + decode: p => ({ consumed: 1, text: ` - ${decodeSlp(p[0])}` }) + }, + // Obscuration + { + match: p => obscurationRegex.test(p[0]), + decode: p => ({ consumed: 1, text: ` - ${decodeObscurationRemark(p[0])}` }) + }, + // Cloud types summary + { + match: p => p[0].match(/^([A-Z]{2}\d)+$/), + decode: (p, ms) => ({ consumed: 1, text: ` - ${decodeCloudTypesRemark(p[0], ms)}` }) + }, + // VIA + { + match: p => p[0] === 'VIA' && p.length > 1, + decode: p => ({ consumed: 2, text: ` - VIA ${p[1]}: relayed via ${p[1]}` }) + }, + // VIS + { + match: p => p[0] === 'VIS' && p.length > 2, + decode: p => ({ consumed: 3, text: ` - VIS ${p[1]} ${p[2]}: Sector visibility to the ${p[1]} of ${p[2]} statute miles` }) + }, + // VIRGA + { + match: p => p[0] === 'VIRGA', + decode: p => { + if (p.length > 1 && p[1] === 'ALQDS') { + return { consumed: 2, text: ` - VIRGA ALQDS: Virga in all quadrants` }; } - } else if (i + 1 < parts.length && directions[parts[i+1]]) { - remark = ` - ${cloudName} ${parts[i+1]}: ${cloudName} to the ${directions[parts[i+1]].toLowerCase()}`; - consumed = 1; - } - decoded.push(remark); - i += consumed; - } else if (part === '/') { - // separator, ignore + if (p.length > 1 && directions[p[1]]) { + return { consumed: 2, text: ` - VIRGA ${p[1]}: Virga to the ${directions[p[1]].toLowerCase()}` }; + } + return { consumed: 1, text: ` - ${p[0]}: ${specialRemarks[p[0]]}` }; + } + }, + // CVCTV CLD EMBD + { + match: p => p[0] === 'CVCTV' && p.length > 2 && p[1] === 'CLD' && p[2] === 'EMBD', + decode: p => ({ consumed: 3, text: ` - CVCTV CLD EMBD: Convective cloud embedded in stratiform layer` }) + }, + // DENSITY ALT + { + match: p => p[0] === 'DENSITY' && p.length > 2 && p[1] === 'ALT' && p[2].endsWith('FT'), + decode: p => { + const altFeet = p[2]; + const altitude = parseInt(altFeet.replace('FT', '')).toLocaleString(); + return { consumed: 3, text: ` - DENSITY ALT ${altFeet}: Density altitude ${altitude} feet` }; + } + }, + // Cloud remarks + { + match: p => cloudTypes[p[0]] || p[0] === 'ACC', + decode: p => { + const cloudCode = p[0]; + const cloudName = cloudTypes[cloudCode] || (cloudCode === 'ACC' ? 'Altocumulus Castellanus' : cloudCode); + let consumed = 1; + let text = ` - ${cloudCode}: ${cloudName}`; + + if (p.length > 1) { + const mod = p[1]; + if (mod === 'LENT') { + text = ` - ${cloudCode} LENT: ${cloudName} lenticularis`; + consumed = 2; + if (p.length > 2 && directions[p[2]]) { + text += ` to the ${directions[p[2]].toLowerCase()}`; + consumed = 3; + } + } else if (mod === 'ASOCTD') { + text = ` - ${cloudCode} ASOCTD: ${cloudName} associated`; + consumed = 2; + if (p.length > 3 && p[2] === '/' && p[3] === 'HALO') { + text = ` - ${cloudCode} ASOCTD / HALO: ${cloudName} associated with Halo phenomenon`; + consumed = 4; + } else if (p.length > 2 && directions[p[2]]) { + text += ` to the ${directions[p[2]].toLowerCase()}`; + consumed = 3; + } + } else if (mod === 'ALQDS') { + text = ` - ${cloudCode} ALQDS: ${cloudName} in all quadrants`; + consumed = 2; + } else if (mod === 'TR') { + text = ` - ${cloudCode} TR: ${cloudName} clouds are translucent (thin)`; + consumed = 2; + } else if (mod === 'DSNT' && p.length > 2 && directions[p[2]]) { + text = ` - ${cloudCode} DSNT ${p[2]}: ${cloudName} distant ${directions[p[2]].toLowerCase()}`; + consumed = 3; + } else if (directions[mod]) { + text = ` - ${cloudCode} ${mod}: ${cloudName} to the ${directions[mod].toLowerCase()}`; + consumed = 2; + if (p.length > 3 && p[2] === 'MOV' && directions[p[3]]) { + text += `, moving ${directions[p[3]].toLowerCase()}`; + consumed = 4; + } + } + } + return { consumed, text }; + } + }, + // Simple remarks + { + match: p => specialRemarks[p[0]], + decode: p => ({ consumed: 1, text: ` - ${p[0]}: ${specialRemarks[p[0]]}` }) + }, + // Weather + { + match: p => weatherRegex.test(p[0]), + decode: (p, ms) => ({ consumed: 1, text: ` - ${decodeWeather(p[0], ms)}` }) + }, + ]; + + let i = 0; + while (i < parts.length) { + const remainingParts = parts.slice(i); + let result = null; + + for (const decoder of decoders) { + if (decoder.match(remainingParts)) { + result = decoder.decode(remainingParts, metarString); + break; + } + } + + if (result) { + if (result.text) decoded.push(result.text); + i += result.consumed; + } else if (parts[i] === '/') { + i++; // separator, ignore } else { - console.log(`Other remark: ${part} in METAR: ${metarString}`); - decoded.push(` - ${part}: Other remark`); + console.log(`Other remark: ${parts[i]} in METAR: ${metarString}`); + decoded.push(` - ${parts[i]}: Other remark`); + i++; } } return decoded.join('\n');