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
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "flightplanner-cli"
description = "Command-line interface for the local flight planner."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[[bin]]
name = "flightplanner"
path = "src/main.rs"
[dependencies]
flightplanner-core = { workspace = true }
flightplanner-rad = { path = "../rad" }
clap = { workspace = true }
anyhow = { workspace = true }
serde_json = { workspace = true }
+386
View File
@@ -0,0 +1,386 @@
//! `flightplanner` CLI — thin `clap` layer over `flightplanner-core`.
use std::path::PathBuf;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(
name = "flightplanner",
version,
about = "Local, offline flight planner (X-Plane navdata, MSFS/P3D/X-Plane export)"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Import X-Plane navdata (.dat) into the local SQLite database.
ImportNavdata {
/// Directory containing earth_fix.dat, earth_nav.dat, earth_awy.dat.
#[arg(long)]
source: PathBuf,
/// SQLite database file to create/update.
#[arg(long, default_value = "flightplanner.db")]
db: PathBuf,
},
/// Import PFPX `.route` files as unvalidated seeds into our route DB.
ImportPfpx {
/// PFPX `Routes` directory (e.g. `C:\Users\Public\Documents\PFPX Data\Routes`).
#[arg(long)]
source: PathBuf,
/// SQLite database file to update.
#[arg(long, default_value = "flightplanner.db")]
db: PathBuf,
},
/// Compute a route between two waypoints/airports.
Route {
/// Departure identifier (e.g. LFPG).
#[arg(long)]
from: String,
/// Destination identifier (e.g. EGLL).
#[arg(long)]
to: String,
/// SQLite database file produced by `import-navdata`.
#[arg(long, default_value = "flightplanner.db")]
db: PathBuf,
/// Aircraft type (e.g. A320) — enables the fuel plan.
#[arg(long)]
aircraft: Option<String>,
/// Directory holding `<icao>.json` aircraft profiles.
#[arg(long, default_value = "data/aircraft")]
aircraft_dir: PathBuf,
/// Cruise flight level (×100 ft); defaults to the profile's value.
#[arg(long)]
cruise_fl: Option<i32>,
/// Alternate airport identifier — adds alternate fuel.
#[arg(long)]
alternate: Option<String>,
/// Payload (pax + cargo) in kg; defaults to a medium load factor.
#[arg(long)]
payload: Option<f64>,
/// Run the offline IFPS pre-check on the computed route.
#[arg(long)]
check_ifps: bool,
/// Write an MSFS/P3D `.pln` to this path.
#[arg(long)]
pln: Option<PathBuf>,
/// Write an X-Plane `.fms` to this path.
#[arg(long)]
fms: Option<PathBuf>,
/// Print a text OFP to stdout.
#[arg(long)]
ofp: bool,
},
/// Discover an IFPS-valid route via the oracle loop (live Eurocontrol IFPUV).
Discover {
/// Departure ICAO (e.g. LFRN).
#[arg(long)]
from: String,
/// Destination ICAO (e.g. LFMN).
#[arg(long)]
to: String,
/// SQLite database (navdata).
#[arg(long, default_value = "real.db")]
db: PathBuf,
/// Aircraft type (for the cruise FL default).
#[arg(long, default_value = "A320")]
aircraft: String,
/// Aircraft profiles dir.
#[arg(long, default_value = "data/aircraft")]
aircraft_dir: PathBuf,
/// CIFP directory (SID/STAR).
#[arg(long, default_value = "navdata/CIFP")]
cifp: PathBuf,
/// Requested cruise FL (×100 ft); capped by RAD.
#[arg(long, default_value_t = 360)]
fl: i32,
},
/// Offline IFPS pre-check of a route string (best-effort, non-authoritative).
IfpsCheck {
/// Route string, e.g. "LFPG DCT PON UT300 ELCOB ... EGLL".
#[arg(long)]
route: String,
/// Cruise flight level (×100 ft).
#[arg(long)]
fl: i32,
/// SQLite database file produced by `import-navdata`.
#[arg(long, default_value = "flightplanner.db")]
db: PathBuf,
},
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Command::ImportNavdata { source, db } => {
use flightplanner_core::db;
let mut conn = db::open(&db)?;
let stats = db::import_navdata(&mut conn, &source)?;
println!(
"Imported navdata from {} into {}:",
source.display(),
db.display()
);
println!(" waypoints: {}", stats.waypoints);
println!(" navaids: {}", stats.navaids);
println!(" airway segments: {}", stats.airway_segments);
println!(" airports: {}", stats.airports);
}
Command::ImportPfpx { source, db } => {
use flightplanner_core::{db, pfpx};
let mut conn = db::open(&db)?;
let (parsed, inserted) = pfpx::import_dir(&mut conn, &source)?;
println!(
"Imported PFPX routes from {} into {}:",
source.display(),
db.display()
);
println!(" parsed: {parsed}");
println!(" inserted: {inserted} (new seeds; existing rows kept)");
}
Command::Discover { from, to, db, aircraft, aircraft_dir, cifp, fl } => {
use flightplanner_core::api::{self, PlanRequest};
let rad = load_rad_full();
let req = PlanRequest {
from: from.clone(),
to: to.clone(),
db_path: db.to_string_lossy().into(),
aircraft: Some(aircraft),
aircraft_dir: aircraft_dir.to_string_lossy().into(),
cifp_dir: Some(cifp.to_string_lossy().into()),
cruise_fl: Some(fl),
alternate: None,
payload_kg: Some(16_000.0),
};
let validator = CliIfps;
let r = api::discover_route(&req, rad.as_ref(), &validator)?;
println!("\n{from}{to} {}", if r.accepted { "✓ IFPS ACCEPTED" } else { "✗ not accepted" });
println!(" FL{:03} {:.0} nm ({} iterations)", r.fl, r.total_nm, r.iterations);
println!(" route : {}", r.route_string);
println!(" log:");
for l in &r.log { println!(" {l}"); }
if !r.accepted {
println!(" remaining errors:");
for e in &r.errors { println!(" {} {}", e.code, e.msg); }
}
}
Command::Route {
from,
to,
db,
aircraft,
aircraft_dir,
cruise_fl,
alternate,
payload,
check_ifps,
pln,
fms,
ofp,
} => {
use flightplanner_core::{db as database, export, ifps, perf, routing};
let conn = database::open(&db)?;
let profile = match &aircraft {
Some(icao) => Some(perf::AircraftProfile::load(&aircraft_dir, icao)?),
None => None,
};
// Effective cruise FL: explicit flag, else the aircraft's default.
let cruise_fl = cruise_fl.or_else(|| profile.as_ref().map(|p| p.default_cruise_fl));
let route =
routing::plan_route(&conn, &from.to_uppercase(), &to.to_uppercase(), cruise_fl)?;
println!("{}", route.route_string());
println!(
"{} legs, {:.0} nm ({})",
route.legs.len(),
route.total_nm,
if route.via_airways {
"via airways"
} else {
"direct great-circle fallback"
}
);
let fuel_plan = if let Some(profile) = &profile {
let fl = cruise_fl.unwrap_or(profile.default_cruise_fl);
let alternate_nm = match &alternate {
Some(alt) => Some(
routing::plan_route(
&conn,
&to.to_uppercase(),
&alt.to_uppercase(),
cruise_fl,
)?
.total_nm,
),
None => None,
};
let plan = perf::compute_fuel_plan(profile, &route, fl, alternate_nm, payload);
print_fuel_plan(profile, &plan, alternate.as_deref());
Some(plan)
} else {
None
};
if check_ifps {
let ifps_fl = cruise_fl.unwrap_or(350);
let report = ifps::prevalidate(&conn, &route.route_string(), ifps_fl, None)?;
print_ifps_report(&report, ifps_fl);
}
let cruise_alt_ft = cruise_fl.unwrap_or(350) * 100;
if let Some(path) = &pln {
std::fs::write(path, export::pln::to_pln(&conn, &route, cruise_alt_ft)?)?;
println!("wrote {}", path.display());
}
if let Some(path) = &fms {
std::fs::write(path, export::fms::to_fms(&conn, &route, cruise_alt_ft)?)?;
println!("wrote {}", path.display());
}
if ofp {
print!(
"{}",
export::ofp::to_ofp(&route, fuel_plan.as_ref(), aircraft.as_deref(), cruise_fl)
);
}
}
Command::IfpsCheck { route, fl, db } => {
use flightplanner_core::{db as database, ifps};
let conn = database::open(&db)?;
let report = ifps::prevalidate(&conn, &route, fl, None)?;
print_ifps_report(&report, fl);
}
}
Ok(())
}
/// Format minutes as `H:MM`.
fn hm(minutes: f64) -> String {
let total = minutes.round() as i64;
format!("{}:{:02}", total / 60, total % 60)
}
/// Load the RAD workbook + official FRA points (env `FP_RAD` / `FP_FRA_POINTS`).
fn load_rad_full() -> Option<flightplanner_core::rad::RadData> {
let path = std::env::var("FP_RAD").unwrap_or_else(|_| "rad/RAD_current.xlsx".into());
let mut rad = flightplanner_rad::parse(&path).ok()?;
let fp = std::env::var("FP_FRA_POINTS").unwrap_or_else(|_| "rad/fra-points.xlsx".into());
rad.fra_points = flightplanner_rad::parse_fra_points(&fp).unwrap_or_default();
eprintln!("RAD: {} level caps, {} FRA points", rad.level_caps.len(), rad.fra_points.len());
Some(rad)
}
/// IFPS validator spawning the IFPUV scraper (`tools/ifpuv/validate.mjs`).
struct CliIfps;
impl flightplanner_core::routing::discover::IfpsValidator for CliIfps {
fn validate(
&self,
adep: &str,
ades: &str,
route: &str,
fl: i32,
) -> flightplanner_core::error::Result<flightplanner_core::routing::discover::IfpsVerdict> {
use flightplanner_core::error::CoreError;
use flightplanner_core::routing::discover::{IfpsErr, IfpsVerdict};
eprint!(" · checking @ FL{fl:03}");
let dir = std::env::var("FP_IFPUV_DIR").unwrap_or_else(|_| "tools/ifpuv".into());
let payload = serde_json::json!({
"adep": adep, "ades": ades, "route": route, "level": format!("F{:03}", fl),
});
let out = std::process::Command::new("node")
.arg("validate.mjs")
.arg(payload.to_string())
.current_dir(&dir)
.output()?;
let stdout = String::from_utf8_lossy(&out.stdout);
let line = stdout
.lines()
.rev()
.find(|l| l.trim_start().starts_with('{'))
.ok_or_else(|| CoreError::Other(format!("validator: no result. stderr: {}", String::from_utf8_lossy(&out.stderr).trim())))?;
let v: serde_json::Value = serde_json::from_str(line)?;
let errors: Vec<IfpsErr> = v["errors"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|e| Some(IfpsErr { code: e["code"].as_str()?.to_string(), msg: e["msg"].as_str()?.to_string() }))
.collect()
})
.unwrap_or_default();
eprintln!("{} err", errors.len());
Ok(IfpsVerdict { accepted: v["accepted"].as_bool().unwrap_or(false), errors })
}
}
fn print_fuel_plan(
profile: &flightplanner_core::perf::AircraftProfile,
plan: &flightplanner_core::perf::FuelPlan,
alternate: Option<&str>,
) {
println!();
println!(
"Aircraft {} ({}) CRZ FL{:03}",
profile.icao, profile.name, plan.cruise_fl
);
println!(
"Trip: {:.0} nm {} {:.0} kg",
plan.climb.dist_nm + plan.cruise.dist_nm + plan.descent.dist_nm,
hm(plan.trip_time_min),
plan.trip_fuel_kg
);
for (label, p) in [
("climb ", &plan.climb),
("cruise ", &plan.cruise),
("descent", &plan.descent),
] {
println!(
" {label}: {:>5.0} nm {:>5} {:>6.0} kg",
p.dist_nm,
hm(p.time_min),
p.fuel_kg
);
}
println!(
"Reserves: taxi {:.0} contingency {:.0} alternate{} {:.0} final {:.0}",
plan.taxi_kg,
plan.contingency_kg,
alternate.map(|a| format!(" ({a})")).unwrap_or_default(),
plan.alternate_kg,
plan.final_reserve_kg
);
println!("Block fuel: {:.0} kg", plan.block_fuel_kg);
if let Some(m) = &plan.masses {
println!(
"Mass: payload {:.0} ZFW {:.0} TOW {:.0} LDW {:.0} kg",
m.payload_kg, m.zfw_kg, m.takeoff_kg, m.landing_kg
);
if m.over_mtow {
println!(" ! TOW exceeds MTOW — reduce payload or fuel");
}
if m.over_mlw {
println!(" ! LDW exceeds MLW");
}
}
}
fn print_ifps_report(report: &flightplanner_core::ifps::IfpsReport, fl: i32) {
println!();
println!("IFPS pre-check (local, non-authoritative) at FL{fl}:");
if report.accepted {
println!(" ACCEPTED");
} else {
println!(" REJECTED ({} error(s))", report.errors.len());
}
for e in &report.errors {
println!("{e}");
}
for w in &report.warnings {
println!(" ! {w}");
}
if !report.expanded.is_empty() {
println!(" expanded: {}", report.expanded.join(" "));
}
}