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>
This commit is contained in:
2026-08-21 11:10:35 +02:00
commit 2cb633e99d
108 changed files with 19460 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
// 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));
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+38
View File
@@ -0,0 +1,38 @@
// Explore the public Eurocontrol IFPUV page to find the route input / validate
// button / result area. Run: node explore.mjs
import { chromium } from "playwright";
const URL = "https://www.public.nm.eurocontrol.int/PUBPORTAL/gateway/spec/";
const b = await chromium.launch({ headless: true });
const ctx = await b.newContext({ viewport: { width: 1600, height: 1000 } });
const page = await ctx.newPage();
try {
await page.goto(URL, { waitUntil: "domcontentloaded", timeout: 60000 });
await page.waitForTimeout(9000); // let the GWT app render
console.log("TITLE:", await page.title());
console.log("URL :", page.url());
const dump = await page.$$eval(
"input, textarea, button, a, span, div[role], [class*='gwt'], [class*='Button']",
(nodes) =>
nodes
.map((n) => ({
tag: n.tagName,
type: n.getAttribute("type") || "",
id: n.id || "",
cls: (n.className && typeof n.className === "string" ? n.className : "").slice(0, 40),
text: (n.innerText || n.value || "").trim().slice(0, 50),
}))
.filter((e) => e.text && e.text.length > 1)
.slice(0, 120),
);
console.log(JSON.stringify(dump, null, 0));
await page.screenshot({ path: "ifpuv.png", fullPage: true });
console.log("screenshot -> ifpuv.png");
} catch (e) {
console.error("ERR:", e.message);
} finally {
await b.close();
}
+44
View File
@@ -0,0 +1,44 @@
// Open the IFPUV "Free Text Editor" from the public NOP portal and dump its form.
import { chromium } from "playwright";
const URL = "https://www.public.nm.eurocontrol.int/PUBPORTAL/gateway/spec/";
const b = await chromium.launch({ headless: true });
const ctx = await b.newContext({ viewport: { width: 1500, height: 950 } });
const page = await ctx.newPage();
try {
await page.goto(URL, { waitUntil: "domcontentloaded", timeout: 60000 });
await page.waitForTimeout(8000);
// Click the "Free Text Editor" link; it may open a popup.
const popupP = ctx.waitForEvent("page", { timeout: 15000 }).catch(() => null);
await page.getByText("Free Text Editor", { exact: false }).first().click({ timeout: 15000 });
const popup = await popupP;
const target = popup || page;
await target.waitForTimeout(9000);
await target.bringToFront?.();
console.log("EDITOR TITLE:", await target.title());
console.log("EDITOR URL :", target.url());
const dump = await target.$$eval(
"textarea, input, button, [role=button], .gwt-Button, span",
(nodes) =>
nodes
.map((n) => ({
tag: n.tagName,
type: n.getAttribute("type") || "",
id: n.id || "",
cls: (typeof n.className === "string" ? n.className : "").slice(0, 45),
text: (n.innerText || n.value || n.placeholder || "").trim().slice(0, 45),
}))
.filter((e) => e.tag === "TEXTAREA" || e.tag === "INPUT" || (e.text && e.text.length > 1))
.slice(0, 80),
);
console.log(JSON.stringify(dump, null, 0));
await target.screenshot({ path: "editor.png", fullPage: true });
console.log("screenshot -> editor.png");
} catch (e) {
console.error("ERR:", e.message);
} finally {
await b.close();
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 744 KiB

+59
View File
@@ -0,0 +1,59 @@
{
"name": "ifpuv-scraper",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ifpuv-scraper",
"version": "0.1.0",
"dependencies": {
"playwright": "^1.48.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "ifpuv-scraper",
"private": true,
"version": "0.1.0",
"type": "module",
"dependencies": {
"playwright": "^1.48.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

+84
View File
@@ -0,0 +1,84 @@
// Persistent IFPUV validator: keeps ONE browser + the Free Text Editor open and
// validates on demand. This drops each validation from ~25 s (relaunch + GWT
// load) to ~5-6 s (just refill + revalidate).
//
// Protocol (line-delimited JSON on stdin/stdout):
// stdout `{"ready":true}` — editor loaded, send requests
// stdin `{"adep","ades","route","level"[,"speed","actype"]}` — a validation
// stdout `{"accepted":bool,"errors":[{code,msg}]}` — its result
// stdin `QUIT` — close and exit
import { chromium } from "playwright";
import readline from "node:readline";
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"]';
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) {
const type = o.actype || "A320";
const level = o.level || "F350";
return (
`(FPL-TEST01-IS\n` +
`-${type}/${o.wtc || "M"}-${o.equip || "SDE1E2E3FGHIRWY/LB1"}\n` +
`-${o.adep}${o.eobt || "1200"}\n` +
`-${o.speed || "N0450"}${level} ${String(o.route).trim()}\n` +
`-${o.ades}${o.eet || "0040"}\n` +
`-PBN/${o.pbn || "A1B1C1D1O1S2"} REG/${o.reg || "FGKXA"} DOF/${o.dof || tomorrowDOF()})`
);
}
// Filing-form artifacts (REG/EOBD/EET), not route faults.
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;
}
const b = await chromium.launch({ headless: true });
const ctx = await b.newContext({ viewport: { width: 1400, height: 950 } });
const page = await ctx.newPage();
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 });
async function submit(payload) {
await ed.locator(ROUTE_SEL).fill(buildFpl(payload));
await ed.locator(VALIDATE_SEL).click({ timeout: 15000 });
await ed.waitForTimeout(5000);
const errors = parseErrors(await ed.locator("body").innerText());
return { accepted: errors.length === 0, errors };
}
process.stdout.write(JSON.stringify({ ready: true }) + "\n");
const rl = readline.createInterface({ input: process.stdin });
for await (const line of rl) {
const t = line.trim();
if (!t) continue;
if (t === "QUIT") break;
try {
const res = await submit(JSON.parse(t));
process.stdout.write(JSON.stringify(res) + "\n");
} catch (e) {
process.stdout.write(JSON.stringify({ accepted: false, errors: [], error: e.message }) + "\n");
}
}
await b.close();
process.exit(0);
+96
View File
@@ -0,0 +1,96 @@
// Validate a flight plan against the public Eurocontrol IFPUV (guest access).
//
// Usage:
// node validate.mjs '{"adep":"LFPG","ades":"EGLL","route":"OPALE DCT KESAX DCT DIMAL DCT ALESO","actype":"A320","level":"F360","speed":"N0450"}'
// node validate.mjs --raw "(FPL-...-...)"
//
// Prints one JSON line: { accepted, errors:[{code,msg}], warnings:[...], raw }.
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"]';
// --- build the ICAO FPL free-text -----------------------------------------
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) {
const cs = o.callsign || o.reg?.replace("-", "") || "TEST01";
const type = o.actype || "A320";
const wtc = o.wtc || "M";
const equip = o.equip || "SDE1E2E3FGHIRWY/LB1";
const reg = o.reg || "FGKXA";
const pbn = o.pbn || "A1B1C1D1O1S2";
const eet = o.eet || "0040";
const altn = o.altn ? ` ${o.altn}` : "";
const item15 = `${o.speed || "N0450"}${o.level || "F360"} ${o.route.trim()}`;
return (
`(FPL-${cs}-IS\n` +
`-${type}/${wtc}-${equip}\n` +
`-${o.adep}${o.eobt || "1200"}\n` +
`-${item15}\n` +
`-${o.ades}${eet}${altn}\n` +
`-PBN/${pbn} REG/${reg} DOF/${o.dof || tomorrowDOF()})`
);
}
// --- parse the "Validation Results" text ----------------------------------
function parseResults(body) {
const start = body.indexOf("Validation Results");
const region = start >= 0 ? body.slice(start) : body;
const codeRe = /\b([A-Z]{2,6}\d{1,4}):\s*([^\n\t]+)/g;
const all = [];
let m;
while ((m = codeRe.exec(region))) all.push({ code: m[1], msg: m[2].trim() });
// Filing-form artifacts (REG/EOBD/EET fields we'd fill correctly in a real
// filing), not route faults. PROF191 = our fixed EET differs from the calc.
const housekeeping = /^(EFPM(51|234|168|166)|PROF191)$/;
const errors = all.filter((e) => !housekeeping.test(e.code));
return { errors, all };
}
// --- run -------------------------------------------------------------------
const args = process.argv.slice(2);
let fpl;
if (args[0] === "--raw") {
fpl = args.slice(1).join(" ");
} else {
const payload = JSON.parse(args[0] || '{"adep":"LFPG","ades":"EGLL","route":"OPALE DCT KESAX DCT DIMAL DCT ALESO"}');
fpl = buildFpl(payload);
}
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: null, errors: [], raw: "", fpl, 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 });
await ed.locator(ROUTE_SEL).fill(fpl);
await ed.locator(VALIDATE_SEL).click({ timeout: 15000 });
await ed.waitForTimeout(7000);
const body = await ed.locator("body").innerText();
const { errors, all } = parseResults(body);
out.errors = errors;
out.accepted = errors.length === 0;
const s = body.indexOf("Validated ICAO FPL");
out.raw = (s >= 0 ? body.slice(s) : body).replace(/\n{3,}/g, "\n").trim().slice(0, 2500);
await ed.screenshot({ path: "result.png", fullPage: true });
} catch (e) {
out.error = e.message;
} finally {
await b.close();
}
console.log(JSON.stringify(out));