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:
@@ -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 }
|
||||
@@ -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(" "));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user