Files
Alexandre 2cb633e99d Initial commit: offline flight planner (Rust, PFPX-class)
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>
2026-08-21 11:10:35 +02:00

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));