2cb633e99d
Clean-room reproduction of PFPX: route generation + IFPS validation + OFP. Independent design — official ICAO/EUROCONTROL data only (RAD, FRA points, IFPUV oracle); no community FPL sources. Workspace crates: core (routing/discover/rad/navdata/perf/export), cli, server, rad (Annex parser), gui (Tauri v2 + React + MapLibre). Route discovery: oracle-in-the-loop repair against Eurocontrol IFPUV. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
162 lines
7.3 KiB
JavaScript
162 lines
7.3 KiB
JavaScript
// Oracle-driven IFPS auto-correction against the public Eurocontrol IFPUV.
|
|
//
|
|
// Given a route, repeatedly validate and mechanically fix the tractable error
|
|
// classes until the plan is IFPS-ACCEPTED (or no further fix applies):
|
|
// * ROUTE130 UNKNOWN DESIGNATOR X -> drop token X from item 15
|
|
// * PROF204/205 forbidden FL band -> move the cruise level into the
|
|
// allowed window (RAD level capping)
|
|
// One browser session is reused across iterations (fast loop).
|
|
//
|
|
// Usage:
|
|
// node autofix.mjs '{"adep":"LFPG","ades":"EGLL","route":"OPALE DCT KESAX DCT DIMAL DCT ALESO","level":"F360"}'
|
|
// Prints one JSON line: { accepted, level, route, iterations, log, errors }.
|
|
import { chromium } from "playwright";
|
|
|
|
const URL = "https://www.public.nm.eurocontrol.int/PUBPORTAL/gateway/spec/";
|
|
const ROUTE_SEL =
|
|
'[id="FREE_TEXT_EDITOR.FLIGHT_DATA_AREA.GENERAL_DATA_ENTRY.INTRODUCE_FLIGHT_PLAN_FIELD"]';
|
|
const VALIDATE_SEL = '[id="FREE_TEXT_EDITOR.FLIGHT_DATA_AREA.VALIDATE_ACTION_LABEL"]';
|
|
const MAX_ITERS = 8;
|
|
|
|
function tomorrowDOF() {
|
|
const d = new Date(Date.now() + 24 * 3600 * 1000);
|
|
const p = (n) => String(n).padStart(2, "0");
|
|
return `${String(d.getUTCFullYear()).slice(2)}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}`;
|
|
}
|
|
|
|
function buildFpl(o, route, flNum) {
|
|
const type = o.actype || "A320";
|
|
const level = `F${String(flNum).padStart(3, "0")}`;
|
|
return (
|
|
`(FPL-TEST01-IS\n` +
|
|
`-${type}/${o.wtc || "M"}-${o.equip || "SDE1E2E3FGHIRWY/LB1"}\n` +
|
|
`-${o.adep}${o.eobt || "1200"}\n` +
|
|
`-${o.speed || "N0450"}${level} ${route.trim()}\n` +
|
|
`-${o.ades}${o.eet || "0040"}\n` +
|
|
`-PBN/${o.pbn || "A1B1C1D1O1S2"} REG/${o.reg || "FGKXA"} DOF/${o.dof || tomorrowDOF()})`
|
|
);
|
|
}
|
|
|
|
const HOUSEKEEPING = /^(EFPM(51|234|168|166)|PROF191)$/;
|
|
function parseErrors(body) {
|
|
const start = body.indexOf("Validation Results");
|
|
const region = start >= 0 ? body.slice(start) : body;
|
|
const re = /\b([A-Z]{2,6}\d{1,4}):\s*([^\n\t]+)/g;
|
|
const errors = [];
|
|
let m;
|
|
while ((m = re.exec(region))) {
|
|
if (!HOUSEKEEPING.test(m[1])) errors.push({ code: m[1], msg: m[2].trim() });
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
// Extract the forbidden FL band(s) from a PROF204/205 message. `Fa..Fb`:
|
|
// a==0 -> forbidden below b (a floor); b>=600 -> forbidden above a (a ceiling).
|
|
function bandOf(msg) {
|
|
const m = msg.match(/F(\d{1,3})\.\.F(\d{3})/);
|
|
if (!m) return null;
|
|
const lo = +m[1], hi = +m[2];
|
|
if (lo === 0) return { floor: hi }; // forbidden 0..hi => must fly above hi
|
|
return { ceiling: lo }; // Fa..F999 ceiling, or narrow hole Fa..Fb → fly below a
|
|
}
|
|
|
|
// Decide the next fix from the current error set. Returns { route?, fl?, note } or null.
|
|
function planFix(errors, route, fl) {
|
|
// 1) unknown designators -> strip the offending token
|
|
const unknown = errors
|
|
.filter((e) => e.code === "ROUTE130" && /UNKNOWN DESIGNATOR/i.test(e.msg))
|
|
.map((e) => e.msg.match(/UNKNOWN DESIGNATOR\s+(\S+)/i)?.[1])
|
|
.filter(Boolean);
|
|
if (unknown.length) {
|
|
const drop = new Set(unknown);
|
|
const toks = route.split(/\s+/).filter((t) => !drop.has(t));
|
|
// collapse dangling DCT left by removal
|
|
const cleaned = toks.filter((t, i) => !(t === "DCT" && (i === 0 || i === toks.length - 1 || toks[i - 1] === "DCT")));
|
|
return { route: cleaned.join(" "), note: `drop unknown designator(s): ${[...drop].join(", ")}` };
|
|
}
|
|
// 2) FL band restrictions -> move into the allowed window
|
|
let ceiling = Infinity, floor = 0, sawBand = false;
|
|
for (const e of errors) {
|
|
if (e.code !== "PROF204" && e.code !== "PROF205") continue;
|
|
const b = bandOf(e.msg);
|
|
if (!b) continue;
|
|
sawBand = true;
|
|
if (b.ceiling != null) ceiling = Math.min(ceiling, b.ceiling);
|
|
if (b.floor != null) floor = Math.max(floor, b.floor);
|
|
}
|
|
// Lower the level whenever the current FL violates a RAD ceiling — even if
|
|
// other (non-level) errors remain; clearing the band errors is strict progress
|
|
// and the loop stops afterwards if nothing else is fixable.
|
|
if (sawBand && ceiling !== Infinity && fl >= ceiling) {
|
|
const target = Math.floor((ceiling - 5) / 10) * 10; // highest 10s FL below the cap
|
|
if (target > floor && target !== fl && target >= 60) {
|
|
return { fl: target, note: `RAD level cap ${ceiling} -> retry at F${String(target).padStart(3, "0")}` };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ── run ────────────────────────────────────────────────────────────────────
|
|
const o = JSON.parse(process.argv[2] || '{"adep":"LFPG","ades":"EGLL","route":"OPALE DCT KESAX DCT DIMAL DCT ALESO","level":"F360"}');
|
|
let route = o.route.trim();
|
|
let fl = parseInt(String(o.level || "F350").replace(/\D/g, ""), 10) || 350;
|
|
|
|
const b = await chromium.launch({ headless: true });
|
|
const ctx = await b.newContext({ viewport: { width: 1400, height: 950 } });
|
|
const page = await ctx.newPage();
|
|
const out = { accepted: false, level: null, route: null, iterations: 0, log: [], errors: [], error: null };
|
|
try {
|
|
await page.goto(URL, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
await page.waitForTimeout(8000);
|
|
const popupP = ctx.waitForEvent("page", { timeout: 15000 }).catch(() => null);
|
|
await page.getByText("Free Text Editor", { exact: false }).first().click({ timeout: 15000 });
|
|
const ed = (await popupP) || page;
|
|
await ed.waitForSelector(ROUTE_SEL, { timeout: 20000 });
|
|
|
|
const submit = async (fpl) => {
|
|
await ed.locator(ROUTE_SEL).fill(fpl);
|
|
await ed.locator(VALIDATE_SEL).click({ timeout: 15000 });
|
|
await ed.waitForTimeout(6000);
|
|
return parseErrors(await ed.locator("body").innerText());
|
|
};
|
|
|
|
// Monotonic auto-correction: only *keep* a change if it strictly reduces the
|
|
// error count. A fix that makes things worse (e.g. lowering FL into a low-level
|
|
// TMA full of DCT restrictions) is reverted, and we report the best state seen.
|
|
// The result is never worse than the input.
|
|
const fl3 = (n) => `F${String(n).padStart(3, "0")}`;
|
|
let best = null; // { route, fl, errors }
|
|
let curRoute = route;
|
|
let curFl = fl;
|
|
for (let i = 1; i <= MAX_ITERS; i++) {
|
|
out.iterations = i;
|
|
const errors = await submit(buildFpl(o, curRoute, curFl));
|
|
if (errors.length === 0) {
|
|
best = { route: curRoute, fl: curFl, errors: [] };
|
|
out.log.push(`iter ${i}: ${fl3(curFl)} "${curRoute}" -> ACCEPTED`);
|
|
break;
|
|
}
|
|
if (best !== null && errors.length >= best.errors.length) {
|
|
// The last applied fix didn't help — revert to the best and stop.
|
|
out.log.push(`iter ${i}: ${fl3(curFl)} -> ${errors.length} err (not better than ${best.errors.length}) — reverting`);
|
|
break;
|
|
}
|
|
best = { route: curRoute, fl: curFl, errors };
|
|
const fix = planFix(errors, curRoute, curFl);
|
|
out.log.push(`iter ${i}: ${fl3(curFl)} -> ${errors.length} err [${errors.map((e) => e.code).join(",")}]${fix ? " | fix: " + fix.note : " | no auto-fix"}`);
|
|
if (!fix) break;
|
|
if (fix.route != null) curRoute = fix.route;
|
|
if (fix.fl != null) curFl = fix.fl;
|
|
}
|
|
best = best || { route: curRoute, fl: curFl, errors: out.errors };
|
|
out.accepted = best.errors.length === 0;
|
|
out.level = fl3(best.fl);
|
|
out.route = best.route;
|
|
out.errors = best.errors;
|
|
} catch (e) {
|
|
out.error = e.message;
|
|
} finally {
|
|
await b.close();
|
|
}
|
|
console.log(JSON.stringify(out));
|