refactor: Improve remark decoding robustness with data-driven parsing

Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
This commit is contained in:
2026-02-14 16:12:56 -07:00
parent f49f3f1aed
commit 3e2d6ef4d3
+135 -123
View File
@@ -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');