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(" "));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "flightplanner-core"
|
||||
description = "Core logic for the local flight planner: navdata parsing, routing, performance, export."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
rusqlite = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
petgraph = { workspace = true }
|
||||
geo = { workspace = true }
|
||||
geographiclib-rs = { workspace = true }
|
||||
@@ -0,0 +1,492 @@
|
||||
//! Shared planning API — one `plan(request) -> result` used by both the local
|
||||
//! (in-process, Tauri) and the custom-server (HTTP) backends, so the two modes
|
||||
//! run the exact same engine.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::{db, export, perf, routing};
|
||||
|
||||
/// Everything the UI form sends to plan a flight.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlanRequest {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub db_path: String,
|
||||
/// Aircraft ICAO (e.g. "A320"); `None`/empty ⇒ route only, no fuel plan.
|
||||
pub aircraft: Option<String>,
|
||||
pub aircraft_dir: String,
|
||||
/// Directory of CIFP airport files (for SID/STAR connectors); `None` ⇒ no
|
||||
/// SID/STAR-aware routing (falls back to nearest-airway-point connection).
|
||||
#[serde(default)]
|
||||
pub cifp_dir: Option<String>,
|
||||
/// Cruise flight level (×100 ft); `None` ⇒ the profile's default.
|
||||
pub cruise_fl: Option<i32>,
|
||||
/// Alternate airport ICAO; adds alternate fuel when set.
|
||||
pub alternate: Option<String>,
|
||||
/// Payload (pax + cargo) in kg; `None` ⇒ a medium default load.
|
||||
pub payload_kg: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LegDto {
|
||||
pub from: String,
|
||||
pub via: String,
|
||||
pub to: String,
|
||||
pub dist_nm: f64,
|
||||
pub cum_dist_nm: f64,
|
||||
pub time_min: Option<f64>,
|
||||
pub cum_time_min: Option<f64>,
|
||||
pub fuel_kg: Option<f64>,
|
||||
pub cum_fuel_kg: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PhaseDto {
|
||||
pub dist_nm: f64,
|
||||
pub time_min: f64,
|
||||
pub fuel_kg: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MassDto {
|
||||
pub payload_kg: f64,
|
||||
pub zfw_kg: f64,
|
||||
pub takeoff_kg: f64,
|
||||
pub landing_kg: f64,
|
||||
pub over_mtow: bool,
|
||||
pub over_mlw: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FuelDto {
|
||||
pub aircraft: String,
|
||||
pub cruise_fl: i32,
|
||||
pub climb: PhaseDto,
|
||||
pub cruise: PhaseDto,
|
||||
pub descent: PhaseDto,
|
||||
pub trip_fuel_kg: f64,
|
||||
pub trip_time_min: f64,
|
||||
pub taxi_kg: f64,
|
||||
pub contingency_kg: f64,
|
||||
pub alternate_kg: f64,
|
||||
pub final_reserve_kg: f64,
|
||||
pub block_fuel_kg: f64,
|
||||
pub mass: Option<MassDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PointDto {
|
||||
pub ident: String,
|
||||
pub lat: f64,
|
||||
pub lon: f64,
|
||||
}
|
||||
|
||||
/// A full SID/STAR procedure track, for drawing on the map.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProcTrackDto {
|
||||
pub name: String,
|
||||
/// `"SID"` or `"STAR"`.
|
||||
pub kind: String,
|
||||
pub points: Vec<PointDto>,
|
||||
}
|
||||
|
||||
/// Offline IFPS pre-check outcome (best-effort, non-authoritative).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IfpsDto {
|
||||
pub accepted: bool,
|
||||
pub errors: Vec<String>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A stored flight plan from our own route database.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CachedRouteDto {
|
||||
pub dep: String,
|
||||
pub dest: String,
|
||||
pub cruise_fl: i32,
|
||||
pub route_string: String,
|
||||
pub dist_nm: f64,
|
||||
pub ifps_ok: bool,
|
||||
pub source: String,
|
||||
pub generated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlanResult {
|
||||
pub route_string: String,
|
||||
pub via_airways: bool,
|
||||
pub total_nm: f64,
|
||||
pub cruise_fl: Option<i32>,
|
||||
pub legs: Vec<LegDto>,
|
||||
pub fuel: Option<FuelDto>,
|
||||
/// Ordered waypoint positions for the map (dep → … → dest).
|
||||
pub geometry: Vec<PointDto>,
|
||||
/// Full SID/STAR procedure tracks (terminal), for the map.
|
||||
pub procedures: Vec<ProcTrackDto>,
|
||||
/// Ready-to-read operational flight plan (plain text).
|
||||
pub ofp: String,
|
||||
/// Offline IFPS pre-check result for the computed route.
|
||||
pub ifps: IfpsDto,
|
||||
}
|
||||
|
||||
fn phase_dto(p: &perf::PhaseResult) -> PhaseDto {
|
||||
PhaseDto {
|
||||
dist_nm: p.dist_nm,
|
||||
time_min: p.time_min,
|
||||
fuel_kg: p.fuel_kg,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a SID/STAR fix sequence to map points (airport prepended for a SID,
|
||||
/// appended for a STAR), reusing the route-geometry resolver.
|
||||
fn resolve_track(
|
||||
conn: &rusqlite::Connection,
|
||||
airport: &str,
|
||||
fixes: &[String],
|
||||
is_sid: bool,
|
||||
) -> Vec<PointDto> {
|
||||
if fixes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut idents: Vec<String> = Vec::new();
|
||||
if is_sid {
|
||||
idents.push(airport.to_uppercase());
|
||||
}
|
||||
idents.extend(fixes.iter().cloned());
|
||||
if !is_sid {
|
||||
idents.push(airport.to_uppercase());
|
||||
}
|
||||
let legs = idents
|
||||
.windows(2)
|
||||
.map(|w| routing::Leg {
|
||||
from: w[0].clone(),
|
||||
to: w[1].clone(),
|
||||
airway: "DCT".to_owned(),
|
||||
dist_nm: 0.0,
|
||||
})
|
||||
.collect();
|
||||
let route = routing::Route {
|
||||
legs,
|
||||
total_nm: 0.0,
|
||||
via_airways: false,
|
||||
};
|
||||
routing::resolve_geometry(conn, &route)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|(ident, pos)| PointDto {
|
||||
ident,
|
||||
lat: pos.lat,
|
||||
lon: pos.lon,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Run the full pipeline (route → fuel plan → geometry → OFP) for `req`.
|
||||
/// When `rad` is supplied, the IFPS pre-check also flags RAD DCT violations.
|
||||
pub fn plan(req: &PlanRequest, rad: Option<&crate::rad::RadData>) -> Result<PlanResult> {
|
||||
let conn = db::open(Path::new(&req.db_path))?;
|
||||
let from = req.from.trim().to_uppercase();
|
||||
let to = req.to.trim().to_uppercase();
|
||||
|
||||
let aircraft = req.aircraft.as_deref().filter(|s| !s.trim().is_empty());
|
||||
let profile = match aircraft {
|
||||
Some(icao) => Some(perf::AircraftProfile::load(
|
||||
Path::new(&req.aircraft_dir),
|
||||
icao,
|
||||
)?),
|
||||
None => None,
|
||||
};
|
||||
let cruise_fl = req
|
||||
.cruise_fl
|
||||
.or_else(|| profile.as_ref().map(|p| p.default_cruise_fl));
|
||||
// Respect RAD city-pair level caps (Annex 2A) up front, so airways are chosen
|
||||
// valid at the filed FL and the route is PROF204-clean by construction.
|
||||
let rad_cap = rad.and_then(|r| r.max_cruise_fl(&from, &to));
|
||||
let cruise_fl = match (cruise_fl, rad_cap) {
|
||||
// Snap to a valid cruise level (multiple of 10 = 1000 ft); an odd cap like
|
||||
// FL235 filed verbatim is rejected by IFPS as SYN101 INVALID LEVEL.
|
||||
(Some(fl), Some(cap)) => Some(fl.min(cap) / 10 * 10),
|
||||
(fl, _) => fl,
|
||||
};
|
||||
|
||||
// SID exits (dep) / STAR entries (dest): (procedure name, connector fix).
|
||||
use crate::navdata::procedure::{connectors, ProcKind};
|
||||
let (dep_sid, dest_star): (Vec<(String, String)>, Vec<(String, String)>) =
|
||||
match req.cifp_dir.as_deref().filter(|s| !s.trim().is_empty()) {
|
||||
Some(cifp) => {
|
||||
let dir = Path::new(cifp);
|
||||
(
|
||||
connectors(dir, &from, ProcKind::Sid).unwrap_or_default(),
|
||||
connectors(dir, &to, ProcKind::Star).unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
None => (Vec::new(), Vec::new()),
|
||||
};
|
||||
// Route to file (FRA graph → FRA heuristic → airways); see `plan_preferred`.
|
||||
let route = routing::plan_preferred(&conn, &from, &to, &dep_sid, &dest_star, cruise_fl, rad)?;
|
||||
|
||||
let fuel = if let Some(profile) = &profile {
|
||||
let fl = cruise_fl.unwrap_or(profile.default_cruise_fl);
|
||||
let alternate_nm = match req.alternate.as_deref().filter(|s| !s.trim().is_empty()) {
|
||||
Some(alt) => Some(
|
||||
routing::plan_route_best(&conn, &to, &alt.trim().to_uppercase(), cruise_fl, &[], &[])?
|
||||
.total_nm,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let p = perf::compute_fuel_plan(profile, &route, fl, alternate_nm, req.payload_kg);
|
||||
Some((profile.clone(), fl, p))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let geometry = routing::resolve_geometry(&conn, &route)?
|
||||
.into_iter()
|
||||
.map(|(ident, pos)| PointDto {
|
||||
ident,
|
||||
lat: pos.lat,
|
||||
lon: pos.lon,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Full SID/STAR procedure tracks (chosen SID = first non-DCT leg, STAR = last).
|
||||
let mut procedures: Vec<ProcTrackDto> = Vec::new();
|
||||
if let Some(cifp) = req.cifp_dir.as_deref().filter(|s| !s.trim().is_empty()) {
|
||||
let dir = Path::new(cifp);
|
||||
if let Some(first) = route.legs.first() {
|
||||
if first.airway != "DCT" {
|
||||
let fixes = crate::navdata::procedure::procedure_track(
|
||||
dir, &from, ProcKind::Sid, &first.airway, &first.to,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let pts = resolve_track(&conn, &from, &fixes, true);
|
||||
if pts.len() >= 2 {
|
||||
procedures.push(ProcTrackDto {
|
||||
name: first.airway.clone(),
|
||||
kind: "SID".to_owned(),
|
||||
points: pts,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(last) = route.legs.last() {
|
||||
if last.airway != "DCT" {
|
||||
let fixes = crate::navdata::procedure::procedure_track(
|
||||
dir, &to, ProcKind::Star, &last.airway, &last.from,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let pts = resolve_track(&conn, &to, &fixes, false);
|
||||
if pts.len() >= 2 {
|
||||
procedures.push(ProcTrackDto {
|
||||
name: last.airway.clone(),
|
||||
kind: "STAR".to_owned(),
|
||||
points: pts,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ofp = export::ofp::to_ofp(&route, fuel.as_ref().map(|(_, _, p)| p), aircraft, cruise_fl);
|
||||
|
||||
let mut cum_dist = 0.0;
|
||||
let legs = match &fuel {
|
||||
Some((_, _, plan)) => plan
|
||||
.legs
|
||||
.iter()
|
||||
.map(|l| {
|
||||
cum_dist += l.dist_nm;
|
||||
LegDto {
|
||||
from: l.from.clone(),
|
||||
via: l.airway.clone(),
|
||||
to: l.to.clone(),
|
||||
dist_nm: l.dist_nm,
|
||||
cum_dist_nm: cum_dist,
|
||||
time_min: Some(l.time_min),
|
||||
cum_time_min: Some(l.cum_time_min),
|
||||
fuel_kg: Some(l.fuel_kg),
|
||||
cum_fuel_kg: Some(l.cum_fuel_kg),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
None => route
|
||||
.legs
|
||||
.iter()
|
||||
.map(|l| {
|
||||
cum_dist += l.dist_nm;
|
||||
LegDto {
|
||||
from: l.from.clone(),
|
||||
via: l.airway.clone(),
|
||||
to: l.to.clone(),
|
||||
dist_nm: l.dist_nm,
|
||||
cum_dist_nm: cum_dist,
|
||||
time_min: None,
|
||||
cum_time_min: None,
|
||||
fuel_kg: None,
|
||||
cum_fuel_kg: None,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let fuel_dto = fuel.map(|(profile, fl, plan)| FuelDto {
|
||||
aircraft: format!("{} ({})", profile.icao, profile.name),
|
||||
cruise_fl: fl,
|
||||
climb: phase_dto(&plan.climb),
|
||||
cruise: phase_dto(&plan.cruise),
|
||||
descent: phase_dto(&plan.descent),
|
||||
trip_fuel_kg: plan.trip_fuel_kg,
|
||||
trip_time_min: plan.trip_time_min,
|
||||
taxi_kg: plan.taxi_kg,
|
||||
contingency_kg: plan.contingency_kg,
|
||||
alternate_kg: plan.alternate_kg,
|
||||
final_reserve_kg: plan.final_reserve_kg,
|
||||
block_fuel_kg: plan.block_fuel_kg,
|
||||
mass: plan.masses.map(|m| MassDto {
|
||||
payload_kg: m.payload_kg,
|
||||
zfw_kg: m.zfw_kg,
|
||||
takeoff_kg: m.takeoff_kg,
|
||||
landing_kg: m.landing_kg,
|
||||
over_mtow: m.over_mtow,
|
||||
over_mlw: m.over_mlw,
|
||||
}),
|
||||
});
|
||||
|
||||
// Offline IFPS pre-check (structural, non-authoritative), then store the plan
|
||||
// as an UNVALIDATED draft. `ifps_ok` is reserved for routes the real IFPUV
|
||||
// oracle has accepted (`discover_route`), so the DB never claims a route is
|
||||
// IFPS-valid on the strength of the offline check (it has false positives).
|
||||
let route_str = route.route_string();
|
||||
let ifps_fl = cruise_fl.unwrap_or(350);
|
||||
let report = crate::ifps::prevalidate(&conn, &route_str, ifps_fl, rad)?;
|
||||
let _ = crate::routes::record(
|
||||
&conn,
|
||||
&from,
|
||||
&to,
|
||||
ifps_fl,
|
||||
&route_str,
|
||||
route.total_nm,
|
||||
route.via_airways,
|
||||
false, // never authoritative — only the oracle sets ifps_ok
|
||||
&report.errors,
|
||||
"draft",
|
||||
);
|
||||
|
||||
Ok(PlanResult {
|
||||
route_string: route_str,
|
||||
via_airways: route.via_airways,
|
||||
total_nm: route.total_nm,
|
||||
cruise_fl,
|
||||
legs,
|
||||
fuel: fuel_dto,
|
||||
geometry,
|
||||
procedures,
|
||||
ofp,
|
||||
ifps: IfpsDto {
|
||||
accepted: report.accepted,
|
||||
errors: report.errors,
|
||||
warnings: report.warnings,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Discover an IFPS-valid route via the oracle loop, then store it. `validator`
|
||||
/// is supplied by the app layer (spawns the IFPUV scraper). The accepted route is
|
||||
/// saved to our route DB (`source="ifps"`) so it's reused instantly next time.
|
||||
pub fn discover_route(
|
||||
req: &PlanRequest,
|
||||
rad: Option<&crate::rad::RadData>,
|
||||
validator: &dyn routing::discover::IfpsValidator,
|
||||
) -> Result<routing::discover::DiscoverResult> {
|
||||
use crate::navdata::procedure::{connectors, ProcKind};
|
||||
let conn = db::open(Path::new(&req.db_path))?;
|
||||
let from = req.from.trim().to_uppercase();
|
||||
let to = req.to.trim().to_uppercase();
|
||||
// Start at the requested FL, capped by any RAD city-pair level cap (Annex 2A).
|
||||
let requested = req.cruise_fl.unwrap_or(360);
|
||||
let start_fl = rad
|
||||
.and_then(|r| r.max_cruise_fl(&from, &to))
|
||||
.map_or(requested, |cap| requested.min(cap) / 10 * 10); // valid cruise level
|
||||
|
||||
// Reuse: instant return if our DB already holds an oracle-validated (no-error)
|
||||
// route for this pair — this is how the self-built IFPS route DB pays off.
|
||||
if let Ok(stored) = crate::routes::recent(&conn, &from, &to, 25) {
|
||||
if let Some(r) = stored.iter().find(|r| r.ifps_ok && r.source == "ifps") {
|
||||
return Ok(routing::discover::DiscoverResult {
|
||||
accepted: true,
|
||||
route_string: r.route_string.clone(),
|
||||
item15: r.route_string.clone(),
|
||||
fl: r.cruise_fl,
|
||||
total_nm: r.dist_nm,
|
||||
errors: Vec::new(),
|
||||
iterations: 0,
|
||||
log: vec![format!("reused stored IFPS-valid route ({})", r.generated_at)],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let (dep_sid, dest_star) = match req.cifp_dir.as_deref().filter(|s| !s.trim().is_empty()) {
|
||||
Some(cifp) => {
|
||||
let dir = Path::new(cifp);
|
||||
(
|
||||
connectors(dir, &from, ProcKind::Sid).unwrap_or_default(),
|
||||
connectors(dir, &to, ProcKind::Star).unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
None => (Vec::new(), Vec::new()),
|
||||
};
|
||||
|
||||
let result = routing::discover::find_valid_route(
|
||||
&conn, &from, &to, &dep_sid, &dest_star, start_fl, rad, validator,
|
||||
)?;
|
||||
|
||||
// Only store routes the oracle actually accepted (no-error) — the DB is the
|
||||
// set of IFPS-valid routes, nothing else.
|
||||
if result.accepted {
|
||||
let _ = crate::routes::record(
|
||||
&conn,
|
||||
&from,
|
||||
&to,
|
||||
result.fl,
|
||||
&result.route_string,
|
||||
result.total_nm,
|
||||
true,
|
||||
true,
|
||||
&[],
|
||||
"ifps",
|
||||
);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Stored routes for a city pair (our own FPL database), most recent first.
|
||||
pub fn recent_routes(
|
||||
db_path: &str,
|
||||
dep: &str,
|
||||
dest: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<CachedRouteDto>> {
|
||||
let conn = db::open(Path::new(db_path))?;
|
||||
Ok(crate::routes::recent(&conn, dep, dest, limit)?
|
||||
.into_iter()
|
||||
.map(|r| CachedRouteDto {
|
||||
dep: r.dep,
|
||||
dest: r.dest,
|
||||
cruise_fl: r.cruise_fl,
|
||||
route_string: r.route_string,
|
||||
dist_nm: r.dist_nm,
|
||||
ifps_ok: r.ifps_ok,
|
||||
source: r.source,
|
||||
generated_at: r.generated_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Total number of stored routes in `db_path`.
|
||||
pub fn route_db_count(db_path: &str) -> Result<i64> {
|
||||
let conn = db::open(Path::new(db_path))?;
|
||||
crate::routes::count(&conn)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! SQLite persistence via `rusqlite` (feature `bundled`, no system dependency).
|
||||
//!
|
||||
//! [`import_navdata`] streams the X-Plane `.dat` files from a source directory
|
||||
//! into the local database inside a single transaction.
|
||||
|
||||
pub mod schema;
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::{params, Connection, Transaction};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::navdata::{
|
||||
airport::parse_airport_from_cifp, airway::parse_awy_line, fix::parse_fix_line,
|
||||
nav::parse_nav_line, stream_file,
|
||||
};
|
||||
|
||||
/// Row counts produced by an import.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ImportStats {
|
||||
pub waypoints: usize,
|
||||
pub navaids: usize,
|
||||
pub airway_segments: usize,
|
||||
pub airports: usize,
|
||||
}
|
||||
|
||||
/// Open (creating if needed) a database file and ensure the schema exists.
|
||||
pub fn open(path: &Path) -> Result<Connection> {
|
||||
let conn = Connection::open(path)?;
|
||||
init_schema(&conn)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Apply the schema (idempotent).
|
||||
pub fn init_schema(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(schema::SCHEMA_SQL)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Import `earth_fix.dat`, `earth_nav.dat`, `earth_awy.dat` and (if present) the
|
||||
/// `CIFP/` airports from `source` into `conn`.
|
||||
pub fn import_navdata(conn: &mut Connection, source: &Path) -> Result<ImportStats> {
|
||||
init_schema(conn)?;
|
||||
let tx = conn.transaction()?;
|
||||
let stats = ImportStats {
|
||||
waypoints: import_fixes(&tx, &source.join("earth_fix.dat"))?,
|
||||
navaids: import_navaids(&tx, &source.join("earth_nav.dat"))?,
|
||||
airway_segments: import_airways(&tx, &source.join("earth_awy.dat"))?,
|
||||
airports: import_airports(&tx, &source.join("CIFP"))?,
|
||||
};
|
||||
tx.commit()?;
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
fn reader(path: &Path) -> Result<BufReader<File>> {
|
||||
Ok(BufReader::new(File::open(path)?))
|
||||
}
|
||||
|
||||
fn import_fixes(tx: &Transaction, path: &Path) -> Result<usize> {
|
||||
let mut stmt =
|
||||
tx.prepare("INSERT INTO waypoints (ident, region, lat, lon) VALUES (?1, ?2, ?3, ?4)")?;
|
||||
stream_file(reader(path)?, "earth_fix.dat", parse_fix_line, |wp| {
|
||||
stmt.execute(params![wp.ident, wp.region, wp.pos.lat, wp.pos.lon])?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn import_navaids(tx: &Transaction, path: &Path) -> Result<usize> {
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO navaids (ident, region, kind, freq, lat, lon, name) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
)?;
|
||||
stream_file(reader(path)?, "earth_nav.dat", parse_nav_line, |nv| {
|
||||
stmt.execute(params![
|
||||
nv.ident,
|
||||
nv.region,
|
||||
nv.kind.as_str(),
|
||||
nv.freq,
|
||||
nv.pos.lat,
|
||||
nv.pos.lon,
|
||||
nv.name
|
||||
])?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn import_airways(tx: &Transaction, path: &Path) -> Result<usize> {
|
||||
let mut seg_stmt = tx.prepare(
|
||||
"INSERT INTO airway_segments \
|
||||
(airway_name, from_ident, from_region, to_ident, to_region, direction, layer, base_fl, top_fl) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
)?;
|
||||
let mut awy_stmt = tx.prepare("INSERT OR IGNORE INTO airways (name, layer) VALUES (?1, ?2)")?;
|
||||
let mut rows = 0usize;
|
||||
stream_file(reader(path)?, "earth_awy.dat", parse_awy_line, |seg| {
|
||||
let layer = seg.layer.as_str();
|
||||
let dir = seg.direction.to_string();
|
||||
for name in &seg.airways {
|
||||
seg_stmt.execute(params![
|
||||
name,
|
||||
seg.from_ident,
|
||||
seg.from_region,
|
||||
seg.to_ident,
|
||||
seg.to_region,
|
||||
dir,
|
||||
layer,
|
||||
seg.base_fl,
|
||||
seg.top_fl
|
||||
])?;
|
||||
awy_stmt.execute(params![name, layer])?;
|
||||
rows += 1;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn import_airports(tx: &Transaction, cifp_dir: &Path) -> Result<usize> {
|
||||
if !cifp_dir.is_dir() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut stmt =
|
||||
tx.prepare("INSERT OR REPLACE INTO airports (icao, lat, lon) VALUES (?1, ?2, ?3)")?;
|
||||
let mut n = 0usize;
|
||||
for entry in std::fs::read_dir(cifp_dir)? {
|
||||
let path = entry?.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("dat") {
|
||||
continue;
|
||||
}
|
||||
let icao = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
if let Some(ap) = parse_airport_from_cifp(&icao, reader(&path)?)? {
|
||||
stmt.execute(params![ap.icao, ap.pos.lat, ap.pos.lon])?;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! SQLite schema. Kept in one place so `init_schema` and future migrations stay
|
||||
//! in sync.
|
||||
|
||||
/// DDL applied on every `open`/import (idempotent).
|
||||
pub const SCHEMA_SQL: &str = r#"
|
||||
CREATE TABLE IF NOT EXISTS waypoints (
|
||||
id INTEGER PRIMARY KEY,
|
||||
ident TEXT NOT NULL,
|
||||
region TEXT NOT NULL,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_waypoints_ident ON waypoints(ident);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS navaids (
|
||||
id INTEGER PRIMARY KEY,
|
||||
ident TEXT NOT NULL,
|
||||
region TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
freq INTEGER NOT NULL,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_navaids_ident ON navaids(ident);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS airports (
|
||||
icao TEXT PRIMARY KEY,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS airways (
|
||||
name TEXT PRIMARY KEY,
|
||||
layer TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS airway_segments (
|
||||
id INTEGER PRIMARY KEY,
|
||||
airway_name TEXT NOT NULL,
|
||||
from_ident TEXT NOT NULL,
|
||||
from_region TEXT NOT NULL,
|
||||
to_ident TEXT NOT NULL,
|
||||
to_region TEXT NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
layer TEXT NOT NULL,
|
||||
base_fl INTEGER NOT NULL,
|
||||
top_fl INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_awyseg_from ON airway_segments(from_ident);
|
||||
CREATE INDEX IF NOT EXISTS idx_awyseg_to ON airway_segments(to_ident);
|
||||
|
||||
-- Our own database of flight plans: routes we generated and IFPS-pre-checked.
|
||||
CREATE TABLE IF NOT EXISTS routes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
dep TEXT NOT NULL,
|
||||
dest TEXT NOT NULL,
|
||||
cruise_fl INTEGER NOT NULL,
|
||||
route_string TEXT NOT NULL,
|
||||
dist_nm REAL NOT NULL,
|
||||
via_airways INTEGER NOT NULL,
|
||||
ifps_ok INTEGER NOT NULL,
|
||||
ifps_errors TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
generated_at TEXT NOT NULL,
|
||||
UNIQUE(dep, dest, cruise_fl, route_string)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_routes_pair ON routes(dep, dest);
|
||||
"#;
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Crate-wide error type. Library code returns [`Result`]; the CLI wraps these
|
||||
//! with `anyhow` for user-facing context.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// All fallible operations in `flightplanner-core` return this error.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CoreError {
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] rusqlite::Error),
|
||||
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
/// A navdata line could not be parsed. Carries enough context to locate it.
|
||||
#[error("parse error in {file} (line {line}): {reason}")]
|
||||
Parse {
|
||||
file: String,
|
||||
line: usize,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
#[error("routing error: {0}")]
|
||||
Routing(String),
|
||||
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Convenience alias used throughout the crate.
|
||||
pub type Result<T> = std::result::Result<T, CoreError>;
|
||||
@@ -0,0 +1,40 @@
|
||||
//! X-Plane 11 `.fms` flight plan (version 1100).
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use super::{build_points, PointKind};
|
||||
use crate::error::Result;
|
||||
use crate::routing::Route;
|
||||
|
||||
/// Render `route` as an X-Plane `.fms` document, cruising at `cruise_alt_ft`.
|
||||
pub fn to_fms(conn: &Connection, route: &Route, cruise_alt_ft: i32) -> Result<String> {
|
||||
let points = build_points(conn, route)?;
|
||||
let dep = &points[0];
|
||||
let dest = &points[points.len() - 1];
|
||||
|
||||
let mut s = String::new();
|
||||
s.push_str("I\n");
|
||||
s.push_str("1100 Version\n");
|
||||
s.push_str("CYCLE 2608\n");
|
||||
s.push_str(&format!("ADEP {}\n", dep.ident));
|
||||
s.push_str(&format!("ADES {}\n", dest.ident));
|
||||
s.push_str(&format!("NUMENR {}\n", points.len()));
|
||||
|
||||
for p in &points {
|
||||
let alt = if p.kind == PointKind::Airport {
|
||||
0.0
|
||||
} else {
|
||||
cruise_alt_ft as f64
|
||||
};
|
||||
s.push_str(&format!(
|
||||
"{} {} {} {:.6} {:.6} {:.6}\n",
|
||||
p.kind.fms_type(),
|
||||
p.ident,
|
||||
p.via,
|
||||
alt,
|
||||
p.pos.lat,
|
||||
p.pos.lon
|
||||
));
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Route/plan exporters: `.pln` (MSFS/P3D), `.fms` (X-Plane) and a text OFP.
|
||||
//!
|
||||
//! The sim formats need a coordinate per point, so [`build_points`] resolves each
|
||||
//! route ident against the local navdata (airports, navaids, waypoints) and
|
||||
//! annotates it with the inbound airway (or `ADEP`/`ADES`/`DCT`).
|
||||
|
||||
pub mod fms;
|
||||
pub mod ofp;
|
||||
pub mod ofp_template;
|
||||
pub mod pln;
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
|
||||
use crate::error::{CoreError, Result};
|
||||
use crate::model::LatLon;
|
||||
use crate::routing::Route;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PointKind {
|
||||
Airport,
|
||||
Vor,
|
||||
Ndb,
|
||||
Fix,
|
||||
}
|
||||
|
||||
impl PointKind {
|
||||
fn pln_type(self) -> &'static str {
|
||||
match self {
|
||||
PointKind::Airport => "Airport",
|
||||
PointKind::Vor => "VOR",
|
||||
PointKind::Ndb => "NDB",
|
||||
PointKind::Fix => "Intersection",
|
||||
}
|
||||
}
|
||||
|
||||
fn fms_type(self) -> i32 {
|
||||
match self {
|
||||
PointKind::Airport => 1,
|
||||
PointKind::Ndb => 2,
|
||||
PointKind::Vor => 3,
|
||||
PointKind::Fix => 11,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A resolved route point ready for export.
|
||||
pub struct ExportPoint {
|
||||
pub ident: String,
|
||||
pub pos: LatLon,
|
||||
pub kind: PointKind,
|
||||
/// Inbound leg: `ADEP`, `ADES`, `DCT`, or an airway name.
|
||||
pub via: String,
|
||||
}
|
||||
|
||||
/// Resolve a route into an ordered list of positioned points (dep first, dest
|
||||
/// last).
|
||||
pub fn build_points(conn: &Connection, route: &Route) -> Result<Vec<ExportPoint>> {
|
||||
let first = route
|
||||
.legs
|
||||
.first()
|
||||
.ok_or_else(|| CoreError::Other("cannot export an empty route".to_owned()))?;
|
||||
|
||||
// (ident, inbound airway) in order.
|
||||
let mut seq: Vec<(String, String)> = vec![(first.from.clone(), "ADEP".to_owned())];
|
||||
for leg in &route.legs {
|
||||
let via = if leg.airway == "DCT" {
|
||||
"DCT".to_owned()
|
||||
} else {
|
||||
leg.airway.clone()
|
||||
};
|
||||
seq.push((leg.to.clone(), via));
|
||||
}
|
||||
|
||||
let n = seq.len();
|
||||
let mut points = Vec::with_capacity(n);
|
||||
for (i, (ident, via)) in seq.into_iter().enumerate() {
|
||||
let (pos, kind) = resolve_point(conn, &ident)?;
|
||||
let via = if i == 0 {
|
||||
"ADEP".to_owned()
|
||||
} else if i == n - 1 {
|
||||
"ADES".to_owned()
|
||||
} else {
|
||||
via
|
||||
};
|
||||
points.push(ExportPoint {
|
||||
ident,
|
||||
pos,
|
||||
kind,
|
||||
via,
|
||||
});
|
||||
}
|
||||
Ok(points)
|
||||
}
|
||||
|
||||
fn resolve_point(conn: &Connection, ident: &str) -> Result<(LatLon, PointKind)> {
|
||||
if let Some(p) = q_airport(conn, ident)? {
|
||||
return Ok((p, PointKind::Airport));
|
||||
}
|
||||
if let Some(pk) = q_navaid(conn, ident)? {
|
||||
return Ok(pk);
|
||||
}
|
||||
if let Some(p) = q_waypoint(conn, ident)? {
|
||||
return Ok((p, PointKind::Fix));
|
||||
}
|
||||
Err(CoreError::NotFound(format!("point '{ident}' for export")))
|
||||
}
|
||||
|
||||
fn q_airport(conn: &Connection, ident: &str) -> Result<Option<LatLon>> {
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT lat, lon FROM airports WHERE icao = ?1",
|
||||
params![ident],
|
||||
|r| Ok(LatLon::new(r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
fn q_navaid(conn: &Connection, ident: &str) -> Result<Option<(LatLon, PointKind)>> {
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT lat, lon, kind FROM navaids WHERE ident = ?1 LIMIT 1",
|
||||
params![ident],
|
||||
|r| Ok((LatLon::new(r.get(0)?, r.get(1)?), r.get::<_, String>(2)?)),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(row.map(|(pos, kind)| {
|
||||
let kind = if kind == "NDB" {
|
||||
PointKind::Ndb
|
||||
} else {
|
||||
PointKind::Vor
|
||||
};
|
||||
(pos, kind)
|
||||
}))
|
||||
}
|
||||
|
||||
fn q_waypoint(conn: &Connection, ident: &str) -> Result<Option<LatLon>> {
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT lat, lon FROM waypoints WHERE ident = ?1 LIMIT 1",
|
||||
params![ident],
|
||||
|r| Ok(LatLon::new(r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
/// Degrees/minutes/seconds string like `N49° 0' 34.93"`.
|
||||
pub(crate) fn dms(value: f64, is_lat: bool) -> String {
|
||||
let hemi = match (is_lat, value >= 0.0) {
|
||||
(true, true) => 'N',
|
||||
(true, false) => 'S',
|
||||
(false, true) => 'E',
|
||||
(false, false) => 'W',
|
||||
};
|
||||
let v = value.abs();
|
||||
let deg = v.trunc() as i64;
|
||||
let minutes = (v - deg as f64) * 60.0;
|
||||
let min = minutes.trunc() as i64;
|
||||
let sec = (minutes - min as f64) * 60.0;
|
||||
format!("{hemi}{deg}° {min}' {sec:.2}\"")
|
||||
}
|
||||
|
||||
/// MSFS `WorldPosition` string: `lat,lon,+000000.00`.
|
||||
pub(crate) fn world_position(pos: LatLon, alt_ft: i32) -> String {
|
||||
format!(
|
||||
"{},{},{:+010.2}",
|
||||
dms(pos.lat, true),
|
||||
dms(pos.lon, false),
|
||||
alt_ft as f64
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Human-readable Operational Flight Plan (plain text).
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::perf::FuelPlan;
|
||||
use crate::routing::Route;
|
||||
|
||||
fn hm(minutes: f64) -> String {
|
||||
let t = minutes.round() as i64;
|
||||
format!("{}:{:02}", t / 60, t % 60)
|
||||
}
|
||||
|
||||
/// Render a text OFP. When `plan` is provided, per-leg time/fuel and a fuel
|
||||
/// summary are included; otherwise only route and distances are shown.
|
||||
pub fn to_ofp(
|
||||
route: &Route,
|
||||
plan: Option<&FuelPlan>,
|
||||
aircraft: Option<&str>,
|
||||
cruise_fl: Option<i32>,
|
||||
) -> String {
|
||||
let dep = route
|
||||
.legs
|
||||
.first()
|
||||
.map(|l| l.from.as_str())
|
||||
.unwrap_or("????");
|
||||
let dest = route.legs.last().map(|l| l.to.as_str()).unwrap_or("????");
|
||||
let bar = "=".repeat(64);
|
||||
let sep = "-".repeat(64);
|
||||
|
||||
let mut s = String::new();
|
||||
let _ = writeln!(s, "{bar}");
|
||||
let _ = writeln!(s, " OPERATIONAL FLIGHT PLAN (unofficial, sim use)");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
" {dep} -> {dest} {}",
|
||||
aircraft.unwrap_or("(no aircraft)")
|
||||
);
|
||||
let _ = writeln!(
|
||||
s,
|
||||
" Cruise {} AIRAC 2608 routing: {}",
|
||||
cruise_fl
|
||||
.map(|f| format!("FL{f:03}"))
|
||||
.unwrap_or_else(|| "n/a".to_owned()),
|
||||
if route.via_airways {
|
||||
"airways"
|
||||
} else {
|
||||
"direct"
|
||||
}
|
||||
);
|
||||
let _ = writeln!(s, "{sep}");
|
||||
let _ = writeln!(s, " ROUTE");
|
||||
let _ = writeln!(s, " {}", route.route_string());
|
||||
let _ = writeln!(s, "{sep}");
|
||||
|
||||
if let Some(plan) = plan {
|
||||
let _ = writeln!(
|
||||
s,
|
||||
" {:<7} {:<6} {:<7} {:>5} {:>6} {:>7} {:>8}",
|
||||
"FROM", "VIA", "TO", "DIST", "ETE", "FUEL", "CUMFUEL"
|
||||
);
|
||||
for leg in &plan.legs {
|
||||
let _ = writeln!(
|
||||
s,
|
||||
" {:<7} {:<6} {:<7} {:>5.0} {:>6} {:>7.0} {:>8.0}",
|
||||
leg.from,
|
||||
leg.airway,
|
||||
leg.to,
|
||||
leg.dist_nm,
|
||||
hm(leg.time_min),
|
||||
leg.fuel_kg,
|
||||
leg.cum_fuel_kg
|
||||
);
|
||||
}
|
||||
let _ = writeln!(s, "{sep}");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
" Trip: {:>6.0} nm {} {:>6.0} kg",
|
||||
plan.climb.dist_nm + plan.cruise.dist_nm + plan.descent.dist_nm,
|
||||
hm(plan.trip_time_min),
|
||||
plan.trip_fuel_kg
|
||||
);
|
||||
let _ = writeln!(
|
||||
s,
|
||||
" Reserves: taxi {:.0} contingency {:.0} alternate {:.0} final {:.0}",
|
||||
plan.taxi_kg, plan.contingency_kg, plan.alternate_kg, plan.final_reserve_kg
|
||||
);
|
||||
let _ = writeln!(s, " BLOCK FUEL: {:.0} kg", plan.block_fuel_kg);
|
||||
if let Some(m) = &plan.masses {
|
||||
let _ = writeln!(s, "{sep}");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
" 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 {
|
||||
let _ = writeln!(s, " ! TOW exceeds MTOW — infeasible as loaded");
|
||||
}
|
||||
if m.over_mlw {
|
||||
let _ = writeln!(s, " ! LDW exceeds MLW");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let _ = writeln!(s, " {:<7} {:<6} {:<7} {:>5}", "FROM", "VIA", "TO", "DIST");
|
||||
for leg in &route.legs {
|
||||
let _ = writeln!(
|
||||
s,
|
||||
" {:<7} {:<6} {:<7} {:>5.0}",
|
||||
leg.from, leg.airway, leg.to, leg.dist_nm
|
||||
);
|
||||
}
|
||||
let _ = writeln!(s, "{sep}");
|
||||
let _ = writeln!(s, " Total distance: {:.0} nm", route.total_nm);
|
||||
}
|
||||
let _ = writeln!(s, "{bar}");
|
||||
s
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Token-based OFP template engine (PFPX-style, clean-room).
|
||||
//!
|
||||
//! PFPX renders its Operational Flight Plan from a **text template** full of
|
||||
//! `<&Token>` placeholders and repeating `<&Section_Begin>…<&Section_End>` blocks
|
||||
//! (e.g. the nav-log). This module reproduces that *system* — our own engine and
|
||||
//! our own default template — so the OFP layout is configurable, not hard-coded.
|
||||
//!
|
||||
//! Token grammar:
|
||||
//! * `<&Name>` — the value of `Name`
|
||||
//! * `<&Name:W>` — right-justified in a field of width `W`
|
||||
//! * `<&Name:W:L>` — left-justified in width `W`
|
||||
//! * `<&NavLog_Begin>…<&NavLog_End>` — the inner block repeated once per leg
|
||||
//!
|
||||
//! Values come from an [`OfpContext`] built from a [`Route`] + optional
|
||||
//! [`FuelPlan`]; inside the nav-log section, per-row tokens shadow scalars.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::perf::FuelPlan;
|
||||
use crate::routing::Route;
|
||||
|
||||
/// Values available to a template: scalar tokens + repeating nav-log rows.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct OfpContext {
|
||||
scalars: HashMap<String, String>,
|
||||
navlog: Vec<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl OfpContext {
|
||||
fn set(&mut self, key: &str, val: impl Into<String>) {
|
||||
self.scalars.insert(key.to_string(), val.into());
|
||||
}
|
||||
}
|
||||
|
||||
fn hm(minutes: f64) -> String {
|
||||
let t = minutes.round() as i64;
|
||||
format!("{}:{:02}", t / 60, t % 60)
|
||||
}
|
||||
|
||||
fn kg(v: f64) -> String {
|
||||
format!("{:.0}", v)
|
||||
}
|
||||
|
||||
fn truncate(s: &str, w: usize) -> String {
|
||||
if s.chars().count() > w {
|
||||
s.chars().take(w).collect()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace `<&Name[:W[:L]]>` tokens using `get`. Section markers must already be
|
||||
/// expanded away before this runs.
|
||||
fn render_scalars(s: &str, get: &dyn Fn(&str) -> Option<String>) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(i) = rest.find("<&") {
|
||||
out.push_str(&rest[..i]);
|
||||
rest = &rest[i + 2..];
|
||||
let Some(j) = rest.find('>') else {
|
||||
out.push_str("<&");
|
||||
break;
|
||||
};
|
||||
let tok = &rest[..j];
|
||||
rest = &rest[j + 1..];
|
||||
let mut parts = tok.split(':');
|
||||
let name = parts.next().unwrap_or("");
|
||||
let width: Option<usize> = parts.next().and_then(|w| w.parse().ok());
|
||||
let left = parts.next() == Some("L");
|
||||
let val = get(name).unwrap_or_default();
|
||||
match width {
|
||||
Some(w) if left => out.push_str(&format!("{:<w$}", truncate(&val, w), w = w)),
|
||||
Some(w) => out.push_str(&format!("{:>w$}", truncate(&val, w), w = w)),
|
||||
None => out.push_str(&val),
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Expand `<&{name}_Begin>…<&{name}_End>`, repeating the inner block per row.
|
||||
fn expand_section(
|
||||
template: &str,
|
||||
name: &str,
|
||||
rows: &[HashMap<String, String>],
|
||||
scalars: &HashMap<String, String>,
|
||||
) -> String {
|
||||
let begin = format!("<&{name}_Begin>");
|
||||
let end = format!("<&{name}_End>");
|
||||
let (Some(b), Some(e)) = (template.find(&begin), template.find(&end)) else {
|
||||
return template.to_string();
|
||||
};
|
||||
let inner = &template[b + begin.len()..e];
|
||||
let mut body = String::new();
|
||||
for row in rows {
|
||||
let get = |k: &str| row.get(k).cloned().or_else(|| scalars.get(k).cloned());
|
||||
body.push_str(&render_scalars(inner, &get));
|
||||
}
|
||||
format!("{}{}{}", &template[..b], body, &template[e + end.len()..])
|
||||
}
|
||||
|
||||
/// Render `template` with `ctx`.
|
||||
pub fn render(template: &str, ctx: &OfpContext) -> String {
|
||||
let expanded = expand_section(template, "NavLog", &ctx.navlog, &ctx.scalars);
|
||||
let get = |k: &str| ctx.scalars.get(k).cloned();
|
||||
render_scalars(&expanded, &get)
|
||||
}
|
||||
|
||||
/// Build the render context from a computed route/plan.
|
||||
pub fn context(
|
||||
route: &Route,
|
||||
plan: Option<&FuelPlan>,
|
||||
aircraft: Option<&str>,
|
||||
cruise_fl: Option<i32>,
|
||||
airac: &str,
|
||||
) -> OfpContext {
|
||||
let mut c = OfpContext::default();
|
||||
let dep = route.legs.first().map(|l| l.from.as_str()).unwrap_or("????");
|
||||
let dest = route.legs.last().map(|l| l.to.as_str()).unwrap_or("????");
|
||||
c.set("DEP", dep);
|
||||
c.set("DEST", dest);
|
||||
c.set("AIRCRAFT", aircraft.unwrap_or("---"));
|
||||
c.set("CRUISE", cruise_fl.map(|f| format!("FL{f:03}")).unwrap_or_else(|| "---".into()));
|
||||
c.set("AIRAC", airac);
|
||||
c.set("ROUTE", route.route_string());
|
||||
c.set("TOTALDIST", format!("{:.0}", route.total_nm));
|
||||
c.set("ROUTING", if route.via_airways { "airways" } else { "direct" });
|
||||
|
||||
if let Some(p) = plan {
|
||||
for leg in &p.legs {
|
||||
let mut row = HashMap::new();
|
||||
row.insert("FROM".into(), leg.from.clone());
|
||||
row.insert("VIA".into(), leg.airway.clone());
|
||||
row.insert("TO".into(), leg.to.clone());
|
||||
row.insert("DIST".into(), format!("{:.0}", leg.dist_nm));
|
||||
row.insert("ETE".into(), hm(leg.time_min));
|
||||
row.insert("FUEL".into(), kg(leg.fuel_kg));
|
||||
row.insert("CUMFUEL".into(), kg(leg.cum_fuel_kg));
|
||||
c.navlog.push(row);
|
||||
}
|
||||
c.set("TRIPDIST", format!("{:.0}", p.climb.dist_nm + p.cruise.dist_nm + p.descent.dist_nm));
|
||||
c.set("TRIPTIME", hm(p.trip_time_min));
|
||||
c.set("TRIPFUEL", kg(p.trip_fuel_kg));
|
||||
c.set("TAXI", kg(p.taxi_kg));
|
||||
c.set("CONTINGENCY", kg(p.contingency_kg));
|
||||
c.set("ALTERNATE", kg(p.alternate_kg));
|
||||
c.set("FINALRES", kg(p.final_reserve_kg));
|
||||
c.set("BLOCKFUEL", kg(p.block_fuel_kg));
|
||||
if let Some(m) = &p.masses {
|
||||
c.set("PAYLOAD", kg(m.payload_kg));
|
||||
c.set("ZFW", kg(m.zfw_kg));
|
||||
c.set("TOW", kg(m.takeoff_kg));
|
||||
c.set("LDW", kg(m.landing_kg));
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Our own default PFPX-style OFP template.
|
||||
pub const DEFAULT_TEMPLATE: &str = "\
|
||||
================================================================
|
||||
OPERATIONAL FLIGHT PLAN (unofficial, sim use)
|
||||
<&DEP> -> <&DEST> <&AIRCRAFT> <&CRUISE> AIRAC <&AIRAC>
|
||||
routing: <&ROUTING> distance: <&TOTALDIST> nm
|
||||
----------------------------------------------------------------
|
||||
ROUTE
|
||||
<&ROUTE>
|
||||
----------------------------------------------------------------
|
||||
FROM VIA TO DIST ETE FUEL CUMFUEL
|
||||
<&NavLog_Begin> <&FROM:7:L> <&VIA:6:L> <&TO:7:L> <&DIST:5> <&ETE:6> <&FUEL:6> <&CUMFUEL:8>
|
||||
<&NavLog_End>----------------------------------------------------------------
|
||||
TRIP <&TRIPDIST:6> nm <&TRIPTIME:6> <&TRIPFUEL:6> kg
|
||||
RES taxi <&TAXI> / cont <&CONTINGENCY> / altn <&ALTERNATE> / final <&FINALRES>
|
||||
BLOCK FUEL <&BLOCKFUEL> kg
|
||||
MASS payload <&PAYLOAD> ZFW <&ZFW> TOW <&TOW> LDW <&LDW> kg
|
||||
================================================================
|
||||
";
|
||||
|
||||
/// Render the default OFP for a route/plan.
|
||||
pub fn render_default(
|
||||
route: &Route,
|
||||
plan: Option<&FuelPlan>,
|
||||
aircraft: Option<&str>,
|
||||
cruise_fl: Option<i32>,
|
||||
airac: &str,
|
||||
) -> String {
|
||||
render(DEFAULT_TEMPLATE, &context(route, plan, aircraft, cruise_fl, airac))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::routing::{Leg, Route};
|
||||
|
||||
fn leg(from: &str, via: &str, to: &str, nm: f64) -> Leg {
|
||||
Leg { from: from.into(), airway: via.into(), to: to.into(), dist_nm: nm }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_tokens_and_width() {
|
||||
let mut c = OfpContext::default();
|
||||
c.set("A", "LFPG");
|
||||
c.set("N", "1234");
|
||||
assert_eq!(render("<&A> x<&N:6>|", &c), "LFPG x 1234|");
|
||||
assert_eq!(render("<&A:6:L>|", &c), "LFPG |"); // left-justified
|
||||
assert_eq!(render("<&MISSING>!", &c), "!"); // unknown → empty
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navlog_section_repeats_per_leg() {
|
||||
let route = Route {
|
||||
legs: vec![leg("LFPG", "DCT", "OPALE", 20.0), leg("OPALE", "UN491", "EGLL", 180.0)],
|
||||
total_nm: 200.0,
|
||||
via_airways: true,
|
||||
};
|
||||
let out = render_default(&route, None, Some("A320"), Some(360), "2608");
|
||||
assert!(out.contains("LFPG -> EGLL"));
|
||||
assert!(out.contains("A320"));
|
||||
assert!(out.contains("FL360"));
|
||||
// one nav-log line per leg
|
||||
assert!(out.contains("OPALE"));
|
||||
assert!(out.contains("UN491"));
|
||||
assert!(out.matches("EGLL").count() >= 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! MSFS / Prepar3D `.pln` flight plan (AceXML).
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use super::{build_points, world_position, PointKind};
|
||||
use crate::error::Result;
|
||||
use crate::routing::Route;
|
||||
|
||||
/// Render `route` as a `.pln` XML document, cruising at `cruise_alt_ft`.
|
||||
pub fn to_pln(conn: &Connection, route: &Route, cruise_alt_ft: i32) -> Result<String> {
|
||||
let points = build_points(conn, route)?;
|
||||
let dep = &points[0];
|
||||
let dest = &points[points.len() - 1];
|
||||
|
||||
let mut s = String::new();
|
||||
s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
s.push_str("<SimBase.Document Type=\"AceXML\" version=\"1,0\">\n");
|
||||
s.push_str(" <Descr>AceXML Document</Descr>\n");
|
||||
s.push_str(" <FlightPlan.FlightPlan>\n");
|
||||
s.push_str(&format!(
|
||||
" <Title>{} to {}</Title>\n",
|
||||
dep.ident, dest.ident
|
||||
));
|
||||
s.push_str(" <FPType>IFR</FPType>\n");
|
||||
s.push_str(&format!(" <CruisingAlt>{cruise_alt_ft}</CruisingAlt>\n"));
|
||||
s.push_str(&format!(" <DepartureID>{}</DepartureID>\n", dep.ident));
|
||||
s.push_str(&format!(
|
||||
" <DepartureLLA>{}</DepartureLLA>\n",
|
||||
world_position(dep.pos, 0)
|
||||
));
|
||||
s.push_str(&format!(
|
||||
" <DestinationID>{}</DestinationID>\n",
|
||||
dest.ident
|
||||
));
|
||||
s.push_str(&format!(
|
||||
" <DestinationLLA>{}</DestinationLLA>\n",
|
||||
world_position(dest.pos, 0)
|
||||
));
|
||||
s.push_str(&format!(
|
||||
" <Descr>{} to {}</Descr>\n",
|
||||
dep.ident, dest.ident
|
||||
));
|
||||
|
||||
for p in &points {
|
||||
let alt = if p.kind == PointKind::Airport {
|
||||
0
|
||||
} else {
|
||||
cruise_alt_ft
|
||||
};
|
||||
s.push_str(&format!(" <ATCWaypoint id=\"{}\">\n", p.ident));
|
||||
s.push_str(&format!(
|
||||
" <ATCWaypointType>{}</ATCWaypointType>\n",
|
||||
p.kind.pln_type()
|
||||
));
|
||||
s.push_str(&format!(
|
||||
" <WorldPosition>{}</WorldPosition>\n",
|
||||
world_position(p.pos, alt)
|
||||
));
|
||||
if !matches!(p.via.as_str(), "ADEP" | "ADES" | "DCT") {
|
||||
s.push_str(&format!(" <ATCAirway>{}</ATCAirway>\n", p.via));
|
||||
}
|
||||
s.push_str(" <ICAO>\n");
|
||||
s.push_str(&format!(" <ICAOIdent>{}</ICAOIdent>\n", p.ident));
|
||||
s.push_str(" </ICAO>\n");
|
||||
s.push_str(" </ATCWaypoint>\n");
|
||||
}
|
||||
|
||||
s.push_str(" </FlightPlan.FlightPlan>\n");
|
||||
s.push_str("</SimBase.Document>\n");
|
||||
Ok(s)
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
//! Offline, **best-effort** IFPS pre-check.
|
||||
//!
|
||||
//! This is NOT an authoritative IFPS validation — RAD restrictions, traffic-flow
|
||||
//! and FRA rules require Eurocontrol NM (see `IDEAS.md`). It only checks what our
|
||||
//! local navdata can prove about a route string:
|
||||
//!
|
||||
//! - every point exists (waypoint or navaid);
|
||||
//! - each cited airway exists and both endpoints lie on it;
|
||||
//! - the airway is traversable in the requested direction between them;
|
||||
//! - the cruise FL falls within each traversed segment's `base…top` band;
|
||||
//! - the route has no discontinuity (missing connector between two points).
|
||||
//!
|
||||
//! Airport codes at the ends and unrecognised first/last tokens (SID/STAR names)
|
||||
//! are reported as warnings, not errors.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::model::LatLon;
|
||||
use crate::rad::RadData;
|
||||
|
||||
/// DCT legs longer than this are flagged (a warning, since free-route airspace
|
||||
/// permits long directs while structured airspace/RAD usually restricts them).
|
||||
const MAX_DCT_NM: f64 = 100.0;
|
||||
|
||||
/// Result of a local IFPS pre-check.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct IfpsReport {
|
||||
pub accepted: bool,
|
||||
pub errors: Vec<String>,
|
||||
pub warnings: Vec<String>,
|
||||
/// The route expanded to its full point sequence (airway intermediates added).
|
||||
pub expanded: Vec<String>,
|
||||
}
|
||||
|
||||
enum Connector {
|
||||
Dct,
|
||||
Airway(String),
|
||||
}
|
||||
|
||||
/// Pre-validate a route string (e.g. `LFPG DCT PON UT300 ELCOB … EGLL`) at
|
||||
/// `cruise_fl` against the local navdata. When `rad` is provided, DCT legs are
|
||||
/// also checked against the Eurocontrol RAD (forbidden directs at that FL).
|
||||
pub fn prevalidate(
|
||||
conn: &Connection,
|
||||
route: &str,
|
||||
cruise_fl: i32,
|
||||
rad: Option<&RadData>,
|
||||
) -> Result<IfpsReport> {
|
||||
let tokens: Vec<String> = route.split_whitespace().map(|t| t.to_uppercase()).collect();
|
||||
let mut errors = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut expanded: Vec<String> = Vec::new();
|
||||
let mut last_point: Option<String> = None;
|
||||
let mut last_pos: Option<LatLon> = None;
|
||||
let mut pending: Option<Connector> = None;
|
||||
|
||||
for (i, t) in tokens.iter().enumerate() {
|
||||
let connector_pos = last_point.is_some() && pending.is_none();
|
||||
|
||||
if t == "DCT" {
|
||||
pending = Some(Connector::Dct);
|
||||
continue;
|
||||
}
|
||||
// Aerodrome at either end: anchor, not an enroute point.
|
||||
if (i == 0 || i == tokens.len() - 1) && airport_exists(conn, t)? {
|
||||
let cur_pos = point_pos(conn, t, last_pos)?;
|
||||
if matches!(pending, Some(Connector::Dct)) {
|
||||
if let (Some(a), Some(b), Some(prev)) = (last_pos, cur_pos, last_point.as_deref()) {
|
||||
check_dct_len(&mut warnings, prev, t, a, b);
|
||||
}
|
||||
if let Some(prev) = last_point.as_deref() {
|
||||
check_dct_rad(&mut errors, rad, prev, t, cruise_fl);
|
||||
}
|
||||
}
|
||||
expanded.push(t.clone());
|
||||
last_point = Some(t.clone());
|
||||
last_pos = cur_pos;
|
||||
pending = None;
|
||||
continue;
|
||||
}
|
||||
if connector_pos && airway_exists(conn, t)? {
|
||||
pending = Some(Connector::Airway(t.clone()));
|
||||
continue;
|
||||
}
|
||||
if point_exists(conn, t)? {
|
||||
let cur_pos = point_pos(conn, t, last_pos)?;
|
||||
match (last_point.as_ref(), pending.take()) {
|
||||
(Some(prev), Some(Connector::Airway(awy))) => {
|
||||
let (mut inter, errs) = trace_airway(conn, &awy, prev, t, cruise_fl)?;
|
||||
errors.extend(errs);
|
||||
expanded.append(&mut inter);
|
||||
expanded.push(t.clone());
|
||||
}
|
||||
(Some(prev), Some(Connector::Dct)) => {
|
||||
if let (Some(a), Some(b)) = (last_pos, cur_pos) {
|
||||
check_dct_len(&mut warnings, prev, t, a, b);
|
||||
}
|
||||
check_dct_rad(&mut errors, rad, prev, t, cruise_fl);
|
||||
expanded.push(t.clone());
|
||||
}
|
||||
(None, _) => {
|
||||
expanded.push(t.clone());
|
||||
}
|
||||
(Some(prev), None) => {
|
||||
errors.push(format!("route discontinuity: {prev} → {t} (no airway/DCT)"));
|
||||
expanded.push(t.clone());
|
||||
}
|
||||
}
|
||||
last_point = Some(t.clone());
|
||||
last_pos = cur_pos;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unrecognised token.
|
||||
if i == 0 || i == tokens.len() - 1 {
|
||||
warnings.push(format!("'{t}' not checked offline (SID/STAR/procedure?)"));
|
||||
} else if airway_exists(conn, t)? {
|
||||
errors.push(format!(
|
||||
"unexpected airway '{t}' (missing point before it?)"
|
||||
));
|
||||
} else {
|
||||
errors.push(format!("unknown point or airway: '{t}'"));
|
||||
}
|
||||
}
|
||||
|
||||
if last_point.is_none() {
|
||||
errors.push("no valid enroute point in route".to_owned());
|
||||
}
|
||||
|
||||
Ok(IfpsReport {
|
||||
accepted: errors.is_empty(),
|
||||
errors,
|
||||
warnings,
|
||||
expanded,
|
||||
})
|
||||
}
|
||||
|
||||
/// Flag a DCT leg that the RAD marks as forbidden at this flight level.
|
||||
fn check_dct_rad(errors: &mut Vec<String>, rad: Option<&RadData>, from: &str, to: &str, fl: i32) {
|
||||
if let Some(rad) = rad {
|
||||
if let Some(r) = rad.forbidden_dct(from, to, fl) {
|
||||
errors.push(format!("RAD: DCT {from} → {to} not available at FL{fl} [{}]", r.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Warn when a DCT leg is longer than [`MAX_DCT_NM`].
|
||||
fn check_dct_len(warnings: &mut Vec<String>, from: &str, to: &str, a: LatLon, b: LatLon) {
|
||||
let d = a.distance_nm(&b);
|
||||
if d > MAX_DCT_NM {
|
||||
warnings.push(format!(
|
||||
"long DCT {from} → {to}: {d:.0} nm (restricted outside free-route airspace)"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Position of `ident` (airport, then fix, then navaid). When it repeats across
|
||||
/// regions, the candidate nearest `near` is chosen.
|
||||
fn point_pos(conn: &Connection, ident: &str, near: Option<LatLon>) -> Result<Option<LatLon>> {
|
||||
if let Ok(p) = conn.query_row(
|
||||
"SELECT lat, lon FROM airports WHERE icao = ?1",
|
||||
params![ident],
|
||||
|r| Ok(LatLon::new(r.get(0)?, r.get(1)?)),
|
||||
) {
|
||||
return Ok(Some(p));
|
||||
}
|
||||
let mut cands: Vec<LatLon> = Vec::new();
|
||||
for sql in [
|
||||
"SELECT lat, lon FROM waypoints WHERE ident = ?1",
|
||||
"SELECT lat, lon FROM navaids WHERE ident = ?1",
|
||||
] {
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt.query_map(params![ident], |r| Ok(LatLon::new(r.get(0)?, r.get(1)?)))?;
|
||||
for row in rows {
|
||||
cands.push(row?);
|
||||
}
|
||||
}
|
||||
Ok(match near {
|
||||
Some(n) => cands.into_iter().min_by(|a, b| {
|
||||
a.distance_nm(&n)
|
||||
.partial_cmp(&b.distance_nm(&n))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
}),
|
||||
None => cands.into_iter().next(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Walk `airway` from `from` to `to`, honouring direction and FL band.
|
||||
/// Returns the intermediate points (excluding both endpoints) and any errors.
|
||||
fn trace_airway(
|
||||
conn: &Connection,
|
||||
airway: &str,
|
||||
from: &str,
|
||||
to: &str,
|
||||
fl: i32,
|
||||
) -> Result<(Vec<String>, Vec<String>)> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT from_ident, to_ident, direction, base_fl, top_fl \
|
||||
FROM airway_segments WHERE airway_name = ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![airway], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, i32>(3)?,
|
||||
r.get::<_, i32>(4)?,
|
||||
))
|
||||
})?;
|
||||
|
||||
// Adjacency: node -> Vec<(neighbour, base_fl, top_fl)>, respecting direction.
|
||||
let mut adj: HashMap<String, Vec<(String, i32, i32)>> = HashMap::new();
|
||||
let mut nodes: HashSet<String> = HashSet::new();
|
||||
for row in rows {
|
||||
let (f, t, dir, base, top) = row?;
|
||||
nodes.insert(f.clone());
|
||||
nodes.insert(t.clone());
|
||||
let d = dir.chars().next().unwrap_or('N');
|
||||
if d == 'N' || d == 'F' {
|
||||
adj.entry(f.clone())
|
||||
.or_default()
|
||||
.push((t.clone(), base, top));
|
||||
}
|
||||
if d == 'N' || d == 'B' {
|
||||
adj.entry(t).or_default().push((f, base, top));
|
||||
}
|
||||
}
|
||||
|
||||
let mut errors = Vec::new();
|
||||
if !nodes.contains(from) {
|
||||
errors.push(format!("{from} is not on airway {airway}"));
|
||||
}
|
||||
if !nodes.contains(to) {
|
||||
errors.push(format!("{to} is not on airway {airway}"));
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
return Ok((vec![], errors));
|
||||
}
|
||||
|
||||
// BFS, recording predecessor and the segment's FL band used to reach a node.
|
||||
let mut prev: HashMap<String, (String, i32, i32)> = HashMap::new();
|
||||
let mut visited: HashSet<String> = HashSet::from([from.to_owned()]);
|
||||
let mut queue: VecDeque<String> = VecDeque::from([from.to_owned()]);
|
||||
let mut found = false;
|
||||
while let Some(cur) = queue.pop_front() {
|
||||
if cur == to {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
if let Some(neigh) = adj.get(&cur) {
|
||||
for (n, base, top) in neigh {
|
||||
if visited.insert(n.clone()) {
|
||||
prev.insert(n.clone(), (cur.clone(), *base, *top));
|
||||
queue.push_back(n.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
errors.push(format!(
|
||||
"no {airway} connection {from} → {to} (wrong direction?)"
|
||||
));
|
||||
return Ok((vec![], errors));
|
||||
}
|
||||
|
||||
// Reconstruct path to..from, checking FL band on each edge (top 0 = unknown).
|
||||
let mut path = Vec::new();
|
||||
let mut node = to.to_owned();
|
||||
while node != *from {
|
||||
let (p, base, top) = match prev.get(&node) {
|
||||
Some(v) => v.clone(),
|
||||
None => break,
|
||||
};
|
||||
if top > 0 && (fl < base || fl > top) {
|
||||
errors.push(format!(
|
||||
"FL{fl} outside {airway} band FL{base}..FL{top} near {node}"
|
||||
));
|
||||
}
|
||||
path.push(node.clone());
|
||||
node = p;
|
||||
}
|
||||
path.reverse();
|
||||
path.pop(); // drop `to`; the caller appends it
|
||||
Ok((path, errors))
|
||||
}
|
||||
|
||||
fn airway_exists(conn: &Connection, name: &str) -> Result<bool> {
|
||||
let n: i64 = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM airways WHERE name = ?1)",
|
||||
params![name],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(n == 1)
|
||||
}
|
||||
|
||||
fn point_exists(conn: &Connection, ident: &str) -> Result<bool> {
|
||||
let n: i64 = conn.query_row(
|
||||
"SELECT (EXISTS(SELECT 1 FROM waypoints WHERE ident = ?1) \
|
||||
OR EXISTS(SELECT 1 FROM navaids WHERE ident = ?1))",
|
||||
params![ident],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(n == 1)
|
||||
}
|
||||
|
||||
fn airport_exists(conn: &Connection, icao: &str) -> Result<bool> {
|
||||
let n: i64 = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM airports WHERE icao = ?1)",
|
||||
params![icao],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(n == 1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn db_from_fixtures() -> Connection {
|
||||
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
db::import_navdata(&mut conn, &dir).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_a_valid_low_route() {
|
||||
let conn = db_from_fixtures();
|
||||
let r = prevalidate(&conn, "ABEAM T100 BEACN T100 CROSS", 200, None).unwrap();
|
||||
assert!(r.accepted, "{:?}", r.errors);
|
||||
assert_eq!(r.expanded, vec!["ABEAM", "BEACN", "CROSS"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_fl_outside_airway_band() {
|
||||
let conn = db_from_fixtures();
|
||||
// U200 band is FL245..FL460; FL100 is below it.
|
||||
let r = prevalidate(&conn, "CROSS U200 DOVER", 100, None).unwrap();
|
||||
assert!(!r.accepted);
|
||||
assert!(
|
||||
r.errors.iter().any(|e| e.contains("U200")),
|
||||
"{:?}",
|
||||
r.errors
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_point_not_on_airway() {
|
||||
let conn = db_from_fixtures();
|
||||
let r = prevalidate(&conn, "ABEAM T100 DOVER", 200, None).unwrap();
|
||||
assert!(!r.accepted);
|
||||
assert!(r.errors.iter().any(|e| e.contains("not on airway")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dct_between_known_points_is_accepted() {
|
||||
let conn = db_from_fixtures();
|
||||
let r = prevalidate(&conn, "ABEAM DCT DOVER", 200, None).unwrap();
|
||||
assert!(r.accepted, "{:?}", r.errors);
|
||||
assert_eq!(r.expanded, vec!["ABEAM", "DOVER"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_token_is_an_error() {
|
||||
let conn = db_from_fixtures();
|
||||
let r = prevalidate(&conn, "ABEAM ZZZ99 BEACN", 200, None).unwrap();
|
||||
assert!(!r.accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_rad_forbidden_dct() {
|
||||
let conn = db_from_fixtures();
|
||||
// Same DCT as `dct_between_known_points_is_accepted`, but the RAD forbids it.
|
||||
let rad = RadData {
|
||||
areas: vec![],
|
||||
dct: vec![crate::rad::DctRestriction {
|
||||
id: "TEST01".into(),
|
||||
from: "ABEAM".into(),
|
||||
to: "DOVER".into(),
|
||||
lower_fl: Some(0),
|
||||
upper_fl: Some(400),
|
||||
available: true,
|
||||
utilization: "NOT AVBL FOR TFC X".into(),
|
||||
direction: String::new(),
|
||||
}],
|
||||
fra_edges: vec![],
|
||||
level_caps: vec![],
|
||||
fra_points: vec![],
|
||||
};
|
||||
let r = prevalidate(&conn, "ABEAM DCT DOVER", 200, Some(&rad)).unwrap();
|
||||
assert!(!r.accepted, "RAD should reject the forbidden direct");
|
||||
assert!(
|
||||
r.errors
|
||||
.iter()
|
||||
.any(|e| e.contains("RAD") && e.contains("ABEAM") && e.contains("DOVER")),
|
||||
"{:?}",
|
||||
r.errors
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! `flightplanner-core` — offline flight planning building blocks.
|
||||
//!
|
||||
//! Module map (implemented incrementally, see `TODO.md`):
|
||||
//! - [`error`] : typed error enum shared across the crate.
|
||||
//! - [`model`] : shared domain types (positions, waypoints, ...).
|
||||
//! - [`navdata`] : X-Plane `.dat` parsers (step 2).
|
||||
//! - [`db`] : SQLite schema + import/query (step 2).
|
||||
//! - [`routing`] : airway graph + A* routing (step 3).
|
||||
//! - [`perf`] : aircraft profiles + fuel/time computation (step 4).
|
||||
//! - [`ifps`] : offline structural IFPS pre-check (best-effort, non-authoritative).
|
||||
//! - [`export`] : `.pln` / `.fms` / OFP writers (step 5).
|
||||
|
||||
pub mod api;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod export;
|
||||
pub mod ifps;
|
||||
pub mod model;
|
||||
pub mod navdata;
|
||||
pub mod perf;
|
||||
pub mod pfpx;
|
||||
pub mod rad;
|
||||
pub mod routes;
|
||||
pub mod routing;
|
||||
|
||||
pub use error::{CoreError, Result};
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Shared domain types produced by navdata parsing and consumed by routing,
|
||||
//! performance and export.
|
||||
|
||||
use geographiclib_rs::{Geodesic, InverseGeodesic};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A geographic position in decimal degrees (WGS84).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LatLon {
|
||||
pub lat: f64,
|
||||
pub lon: f64,
|
||||
}
|
||||
|
||||
impl LatLon {
|
||||
pub const fn new(lat: f64, lon: f64) -> Self {
|
||||
Self { lat, lon }
|
||||
}
|
||||
|
||||
/// Geodesic distance to `other` in nautical miles, on the WGS84 ellipsoid
|
||||
/// (Karney's algorithm — sub-millimetre accurate, unlike a spherical model).
|
||||
pub fn distance_nm(&self, other: &LatLon) -> f64 {
|
||||
const METERS_PER_NM: f64 = 1852.0;
|
||||
let geod = Geodesic::wgs84();
|
||||
let s12_m: f64 = geod.inverse(self.lat, self.lon, other.lat, other.lon);
|
||||
s12_m / METERS_PER_NM
|
||||
}
|
||||
}
|
||||
|
||||
/// An enroute/terminal fix from `earth_fix.dat`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Waypoint {
|
||||
pub ident: String,
|
||||
/// 2-letter ICAO region code (matches airway endpoint regions).
|
||||
pub region: String,
|
||||
pub pos: LatLon,
|
||||
}
|
||||
|
||||
/// Kinds of navaid we keep for planning (others in `earth_nav.dat` are skipped).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NavaidKind {
|
||||
Ndb,
|
||||
Vor,
|
||||
Dme,
|
||||
}
|
||||
|
||||
impl NavaidKind {
|
||||
/// Map an `earth_nav.dat` row code, or `None` for kinds we ignore
|
||||
/// (ILS/LOC/GS/markers, DME collocated with a VOR, SBAS/GBAS, ...).
|
||||
pub fn from_row_code(code: i32) -> Option<Self> {
|
||||
match code {
|
||||
2 => Some(Self::Ndb),
|
||||
3 => Some(Self::Vor),
|
||||
13 => Some(Self::Dme),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Ndb => "NDB",
|
||||
Self::Vor => "VOR",
|
||||
Self::Dme => "DME",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A navaid from `earth_nav.dat`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Navaid {
|
||||
pub ident: String,
|
||||
pub region: String,
|
||||
pub kind: NavaidKind,
|
||||
/// Frequency as stored by X-Plane (VOR: MHz×100, NDB: kHz).
|
||||
pub freq: i64,
|
||||
pub pos: LatLon,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// An airport reference point (derived from CIFP runway thresholds).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Airport {
|
||||
pub icao: String,
|
||||
pub pos: LatLon,
|
||||
}
|
||||
|
||||
/// Low- (Victor) vs high-altitude (Jet) airway layer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AirwayLayer {
|
||||
Low,
|
||||
High,
|
||||
}
|
||||
|
||||
impl AirwayLayer {
|
||||
pub fn from_code(code: i32) -> Option<Self> {
|
||||
match code {
|
||||
1 => Some(Self::Low),
|
||||
2 => Some(Self::High),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Low => "Low",
|
||||
Self::High => "High",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One airway segment between two endpoints, as parsed from `earth_awy.dat`.
|
||||
/// A physical line may belong to several named airways (`A-B`), hence `airways`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AirwaySegment {
|
||||
pub from_ident: String,
|
||||
pub from_region: String,
|
||||
pub to_ident: String,
|
||||
pub to_region: String,
|
||||
/// Direction of use: `N` (both), `F` (forward), `B` (backward).
|
||||
pub direction: char,
|
||||
pub layer: AirwayLayer,
|
||||
pub base_fl: i32,
|
||||
pub top_fl: i32,
|
||||
pub airways: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn latlon_roundtrips_through_json() {
|
||||
let p = LatLon::new(49.0097, 2.5479);
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
let back: LatLon = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(p, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn great_circle_lfpg_egll() {
|
||||
let lfpg = LatLon::new(49.0097, 2.5479);
|
||||
let egll = LatLon::new(51.4706, -0.4619);
|
||||
let d = lfpg.distance_nm(&egll);
|
||||
assert!((d - 188.0).abs() < 5.0, "distance = {d} nm");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Airport reference points from CIFP files.
|
||||
//!
|
||||
//! X-Plane `earth_*.dat` carry no airport coordinates, but each `CIFP/<ICAO>.dat`
|
||||
//! file lists runway thresholds as ARINC-packed coordinates:
|
||||
//!
|
||||
//! ```text
|
||||
//! RWY:RW08L, , ,00338, ,GLE ,3, ;N48594447,E002330988,0000;
|
||||
//! ```
|
||||
//!
|
||||
//! We take the centroid of all thresholds as the airport reference point.
|
||||
|
||||
use std::io::BufRead;
|
||||
|
||||
use crate::error::{CoreError, Result};
|
||||
use crate::model::{Airport, LatLon};
|
||||
|
||||
/// Parse an ARINC-packed coordinate such as `N48594447` (lat) or `E002330988`
|
||||
/// (lon): hemisphere letter, then `D…D MM SS ss` (seconds×100), degrees being
|
||||
/// 2 digits for latitude and 3 for longitude.
|
||||
pub fn parse_arinc_coord(tok: &str) -> std::result::Result<f64, String> {
|
||||
if tok.len() < 8 {
|
||||
return Err(format!("coordinate too short: '{tok}'"));
|
||||
}
|
||||
let (hemi, digits) = tok.split_at(1);
|
||||
let sign = match hemi {
|
||||
"N" | "E" => 1.0,
|
||||
"S" | "W" => -1.0,
|
||||
_ => return Err(format!("bad hemisphere in '{tok}'")),
|
||||
};
|
||||
// Trailing 6 digits are always MM SS ss; the rest are degrees.
|
||||
let deg_len = digits.len() - 6;
|
||||
let field = |range: std::ops::Range<usize>, what: &str| -> std::result::Result<f64, String> {
|
||||
digits
|
||||
.get(range)
|
||||
.ok_or_else(|| format!("truncated {what} in '{tok}'"))?
|
||||
.parse::<f64>()
|
||||
.map_err(|_| format!("invalid {what} in '{tok}'"))
|
||||
};
|
||||
let deg = field(0..deg_len, "degrees")?;
|
||||
let min = field(deg_len..deg_len + 2, "minutes")?;
|
||||
let sec = field(deg_len + 2..deg_len + 4, "seconds")?;
|
||||
let hund = field(deg_len + 4..deg_len + 6, "sub-seconds")?;
|
||||
Ok(sign * (deg + min / 60.0 + (sec + hund / 100.0) / 3600.0))
|
||||
}
|
||||
|
||||
/// Build an [`Airport`] from a CIFP reader, or `None` if it has no runways.
|
||||
pub fn parse_airport_from_cifp(icao: &str, reader: impl BufRead) -> Result<Option<Airport>> {
|
||||
let mut sum_lat = 0.0;
|
||||
let mut sum_lon = 0.0;
|
||||
let mut n = 0u32;
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
if !line.starts_with("RWY:") {
|
||||
continue;
|
||||
}
|
||||
// Coordinates live after the first ';': "N48594447,E002330988,0000".
|
||||
let coords = match line.split(';').nth(1) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
let mut parts = coords.split(',');
|
||||
let (lat_s, lon_s) = match (parts.next(), parts.next()) {
|
||||
(Some(a), Some(b)) => (a, b),
|
||||
_ => continue,
|
||||
};
|
||||
let to_err = |reason| CoreError::Parse {
|
||||
file: format!("CIFP/{icao}.dat"),
|
||||
line: 0,
|
||||
reason,
|
||||
};
|
||||
sum_lat += parse_arinc_coord(lat_s).map_err(to_err)?;
|
||||
sum_lon += parse_arinc_coord(lon_s).map_err(to_err)?;
|
||||
n += 1;
|
||||
}
|
||||
if n == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(Airport {
|
||||
icao: icao.to_owned(),
|
||||
pos: LatLon::new(sum_lat / n as f64, sum_lon / n as f64),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decodes_arinc_coordinates() {
|
||||
let lat = parse_arinc_coord("N48594447").unwrap();
|
||||
let lon = parse_arinc_coord("E002330988").unwrap();
|
||||
assert!((lat - (48.0 + 59.0 / 60.0 + 44.47 / 3600.0)).abs() < 1e-6);
|
||||
assert!((lon - (2.0 + 33.0 / 60.0 + 9.88 / 3600.0)).abs() < 1e-6);
|
||||
assert!(parse_arinc_coord("W000273600").unwrap() < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centroid_of_thresholds() {
|
||||
let data = "\
|
||||
RWY:RW08L, , ,00338, ,GLE ,3, ;N48594447,E002330988,0000;
|
||||
RWY:RW26R, , ,00318, ,GAU ,3, ;N48595395,E002360724,1725;
|
||||
SID:010,4,AGOP6A,RW27R,DE27R,LF,P,C,EY;
|
||||
";
|
||||
let ap = parse_airport_from_cifp("LFPG", data.as_bytes())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(ap.icao, "LFPG");
|
||||
assert!((ap.pos.lat - 48.99).abs() < 0.05);
|
||||
assert!((ap.pos.lon - 2.56).abs() < 0.05);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Parser for `earth_awy.dat` (version 1100).
|
||||
//!
|
||||
//! Data columns: `from freg ftype to treg ttype dir layer baseFL topFL name(s)`.
|
||||
//! `name(s)` may join several airways with `-` (e.g. `J121-J584`).
|
||||
|
||||
use super::{col_i32, col_str, is_meta_line, LineResult};
|
||||
use crate::model::{AirwayLayer, AirwaySegment};
|
||||
|
||||
pub fn parse_awy_line(line: &str) -> LineResult<AirwaySegment> {
|
||||
let t = line.trim();
|
||||
if is_meta_line(t) {
|
||||
return Ok(None);
|
||||
}
|
||||
let cols: Vec<&str> = t.split_whitespace().collect();
|
||||
let from_ident = col_str(&cols, 0, "from ident")?;
|
||||
let from_region = col_str(&cols, 1, "from region")?;
|
||||
let to_ident = col_str(&cols, 3, "to ident")?;
|
||||
let to_region = col_str(&cols, 4, "to region")?;
|
||||
let direction = col_str(&cols, 6, "direction")?
|
||||
.chars()
|
||||
.next()
|
||||
.ok_or_else(|| "empty direction".to_owned())?;
|
||||
let layer_code = col_i32(&cols, 7, "layer")?;
|
||||
let layer = AirwayLayer::from_code(layer_code)
|
||||
.ok_or_else(|| format!("invalid layer: '{layer_code}'"))?;
|
||||
let base_fl = col_i32(&cols, 8, "base FL")?;
|
||||
let top_fl = col_i32(&cols, 9, "top FL")?;
|
||||
let airways: Vec<String> = col_str(&cols, 10, "airway name")?
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
Ok(Some(AirwaySegment {
|
||||
from_ident: from_ident.to_owned(),
|
||||
from_region: from_region.to_owned(),
|
||||
to_ident: to_ident.to_owned(),
|
||||
to_region: to_region.to_owned(),
|
||||
direction,
|
||||
layer,
|
||||
base_fl,
|
||||
top_fl,
|
||||
airways,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_a_low_segment() {
|
||||
let s = parse_awy_line("07EBA DT 11 GILEX DT 11 N 1 95 245 G869")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(s.from_ident, "07EBA");
|
||||
assert_eq!(s.to_ident, "GILEX");
|
||||
assert_eq!(s.direction, 'N');
|
||||
assert_eq!(s.layer, AirwayLayer::Low);
|
||||
assert_eq!(s.base_fl, 95);
|
||||
assert_eq!(s.top_fl, 245);
|
||||
assert_eq!(s.airways, vec!["G869".to_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_multiple_airway_names() {
|
||||
let s = parse_awy_line("A LF 11 B LF 11 N 2 245 460 J121-J584")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(s.layer, AirwayLayer::High);
|
||||
assert_eq!(s.airways, vec!["J121".to_owned(), "J584".to_owned()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Parser for `earth_fix.dat` (version 1101).
|
||||
//!
|
||||
//! Data columns: `lat lon ident termArea region typecode`.
|
||||
|
||||
use super::{col_f64, col_str, is_meta_line, LineResult};
|
||||
use crate::model::{LatLon, Waypoint};
|
||||
|
||||
pub fn parse_fix_line(line: &str) -> LineResult<Waypoint> {
|
||||
let t = line.trim();
|
||||
if is_meta_line(t) {
|
||||
return Ok(None);
|
||||
}
|
||||
let cols: Vec<&str> = t.split_whitespace().collect();
|
||||
let lat = col_f64(&cols, 0, "latitude")?;
|
||||
let lon = col_f64(&cols, 1, "longitude")?;
|
||||
let ident = col_str(&cols, 2, "ident")?;
|
||||
// cols[3] = terminal area airport ICAO or "ENRT" (unused for now)
|
||||
let region = col_str(&cols, 4, "region")?;
|
||||
Ok(Some(Waypoint {
|
||||
ident: ident.to_owned(),
|
||||
region: region.to_owned(),
|
||||
pos: LatLon::new(lat, lon),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn skips_header_and_terminator() {
|
||||
assert!(parse_fix_line("I").unwrap().is_none());
|
||||
assert!(parse_fix_line("1101 Version - data cycle 2608")
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert!(parse_fix_line("").unwrap().is_none());
|
||||
assert!(parse_fix_line("99").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_a_fix() {
|
||||
let wp = parse_fix_line(" 33.492513889 9.217400000 07EBA ENRT DT 2118994")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(wp.ident, "07EBA");
|
||||
assert_eq!(wp.region, "DT");
|
||||
assert!((wp.pos.lat - 33.492513889).abs() < 1e-9);
|
||||
assert!((wp.pos.lon - 9.2174).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_on_bad_latitude() {
|
||||
let err = parse_fix_line("xx 9.2 07EBA ENRT DT 1").unwrap_err();
|
||||
assert!(err.contains("latitude"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! X-Plane navdata parsers (`earth_fix.dat`, `earth_nav.dat`, `earth_awy.dat`)
|
||||
//! plus airport reference points from CIFP files.
|
||||
//!
|
||||
//! Parsing is streaming (line-by-line): a multi-MB file is never buffered whole.
|
||||
//! Each line parser is a pure `&str -> LineResult<T>` function so it can be unit
|
||||
//! tested on tiny hand-made inputs; [`stream_file`] drives them over a reader and
|
||||
//! attaches file/line context to any error.
|
||||
|
||||
pub mod airport;
|
||||
pub mod airway;
|
||||
pub mod fix;
|
||||
pub mod nav;
|
||||
pub mod procedure;
|
||||
|
||||
use std::io::BufRead;
|
||||
|
||||
use crate::error::{CoreError, Result};
|
||||
|
||||
/// Outcome of parsing one physical line: `Some` = a record, `None` = skip
|
||||
/// (header / blank / terminator), `Err(reason)` = malformed data line.
|
||||
pub type LineResult<T> = std::result::Result<Option<T>, String>;
|
||||
|
||||
/// Header / blank / terminator lines shared by every X-Plane `.dat` file.
|
||||
pub(crate) fn is_meta_line(trimmed: &str) -> bool {
|
||||
trimmed.is_empty()
|
||||
|| trimmed == "I"
|
||||
|| trimmed == "A"
|
||||
|| trimmed == "99"
|
||||
|| trimmed.contains("Version")
|
||||
}
|
||||
|
||||
/// Parse a whitespace-separated column as `f64`.
|
||||
pub(crate) fn col_f64(cols: &[&str], i: usize, field: &str) -> std::result::Result<f64, String> {
|
||||
let s = cols
|
||||
.get(i)
|
||||
.ok_or_else(|| format!("missing field: {field}"))?;
|
||||
s.parse::<f64>()
|
||||
.map_err(|_| format!("invalid {field}: '{s}'"))
|
||||
}
|
||||
|
||||
/// Parse a whitespace-separated column as `i32`.
|
||||
pub(crate) fn col_i32(cols: &[&str], i: usize, field: &str) -> std::result::Result<i32, String> {
|
||||
let s = cols
|
||||
.get(i)
|
||||
.ok_or_else(|| format!("missing field: {field}"))?;
|
||||
s.parse::<i32>()
|
||||
.map_err(|_| format!("invalid {field}: '{s}'"))
|
||||
}
|
||||
|
||||
/// Borrow a whitespace-separated column as `&str`.
|
||||
pub(crate) fn col_str<'a>(
|
||||
cols: &[&'a str],
|
||||
i: usize,
|
||||
field: &str,
|
||||
) -> std::result::Result<&'a str, String> {
|
||||
cols.get(i)
|
||||
.copied()
|
||||
.ok_or_else(|| format!("missing field: {field}"))
|
||||
}
|
||||
|
||||
/// Drive a line parser over a reader, invoking `visit` for each parsed record.
|
||||
/// Returns the number of records produced. Parse errors are wrapped with the
|
||||
/// file label and 1-based line number.
|
||||
pub fn stream_file<R, T>(
|
||||
reader: R,
|
||||
file_label: &str,
|
||||
parse: impl Fn(&str) -> LineResult<T>,
|
||||
mut visit: impl FnMut(T) -> Result<()>,
|
||||
) -> Result<usize>
|
||||
where
|
||||
R: BufRead,
|
||||
{
|
||||
let mut count = 0usize;
|
||||
for (i, line) in reader.lines().enumerate() {
|
||||
let line = line?;
|
||||
match parse(&line) {
|
||||
Ok(Some(rec)) => {
|
||||
visit(rec)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(reason) => {
|
||||
return Err(CoreError::Parse {
|
||||
file: file_label.to_owned(),
|
||||
line: i + 1,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Parser for `earth_nav.dat` (version 1150).
|
||||
//!
|
||||
//! Data columns: `code lat lon elev freq range bearing ident termArea region name…`.
|
||||
//! Only NDB/VOR/DME rows are kept; other codes yield `Ok(None)`.
|
||||
|
||||
use super::{col_f64, col_i32, col_str, is_meta_line, LineResult};
|
||||
use crate::model::{LatLon, Navaid, NavaidKind};
|
||||
|
||||
pub fn parse_nav_line(line: &str) -> LineResult<Navaid> {
|
||||
let t = line.trim();
|
||||
if is_meta_line(t) {
|
||||
return Ok(None);
|
||||
}
|
||||
let cols: Vec<&str> = t.split_whitespace().collect();
|
||||
let code = col_i32(&cols, 0, "row code")?;
|
||||
let kind = match NavaidKind::from_row_code(code) {
|
||||
Some(k) => k,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let lat = col_f64(&cols, 1, "latitude")?;
|
||||
let lon = col_f64(&cols, 2, "longitude")?;
|
||||
// cols[3] elevation, cols[5] range, cols[6] bearing — unused for now.
|
||||
let freq = col_i32(&cols, 4, "frequency")? as i64;
|
||||
let ident = col_str(&cols, 7, "ident")?;
|
||||
let region = col_str(&cols, 9, "region")?;
|
||||
let name = cols.get(10..).map(|r| r.join(" ")).unwrap_or_default();
|
||||
Ok(Some(Navaid {
|
||||
ident: ident.to_owned(),
|
||||
region: region.to_owned(),
|
||||
kind,
|
||||
freq,
|
||||
pos: LatLon::new(lat, lon),
|
||||
name,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_a_vor() {
|
||||
let nv = parse_nav_line(
|
||||
" 3 9.037805556 7.285111111 1191 11630 130 -0.000 ABC ENRT DN ABUJA VOR/DME",
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(nv.ident, "ABC");
|
||||
assert_eq!(nv.region, "DN");
|
||||
assert_eq!(nv.kind, NavaidKind::Vor);
|
||||
assert_eq!(nv.freq, 11630);
|
||||
assert_eq!(nv.name, "ABUJA VOR/DME");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_ils_and_collocated_dme() {
|
||||
// code 4 = ILS/LOC, code 12 = DME component of a VOR/DME
|
||||
assert!(
|
||||
parse_nav_line(" 4 49.0 2.55 50 11000 25 0.0 ILP ENRT LF PARIS ILS")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
parse_nav_line(" 12 9.0 7.2 1191 11630 130 0.0 ABC ENRT DN ABUJA DME")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//! CIFP SID/STAR procedure parsing (ARINC 424-derived).
|
||||
//!
|
||||
//! Each airport's CIFP file holds `SID:` and `STAR:` records. A record is one
|
||||
//! leg of a procedure; legs sharing the same procedure name + transition form
|
||||
//! the ordered fix sequence. Field layout after the `SID:`/`STAR:` prefix:
|
||||
//! `seq, routeType, name, transition, fix, fixRegion, …`.
|
||||
//!
|
||||
//! v1 keeps just what routing needs: the ordered enroute **fixes** per
|
||||
//! (name, transition). A SID's last fix is where it joins the airway network
|
||||
//! ([`Procedure::exit_fix`]); a STAR's first fix is where it leaves it
|
||||
//! ([`Procedure::entry_fix`]).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ProcKind {
|
||||
Sid,
|
||||
Star,
|
||||
}
|
||||
|
||||
/// One departure/arrival procedure variant (a name + transition + fix sequence).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Procedure {
|
||||
pub kind: ProcKind,
|
||||
/// Procedure identifier, e.g. `AGOP6A`.
|
||||
pub name: String,
|
||||
/// Transition: a runway (`RW27L`), `ALL`, or an enroute transition name.
|
||||
pub transition: String,
|
||||
/// Ordered fix idents along the procedure.
|
||||
pub fixes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Procedure {
|
||||
/// Fix where a SID joins the airway network (its last fix).
|
||||
pub fn exit_fix(&self) -> Option<&str> {
|
||||
self.fixes.last().map(String::as_str)
|
||||
}
|
||||
/// Fix where a STAR leaves the airway network (its first fix).
|
||||
pub fn entry_fix(&self) -> Option<&str> {
|
||||
self.fixes.first().map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse all SID/STAR procedures from a CIFP airport file.
|
||||
pub fn parse_procedures<R: BufRead>(reader: R) -> Result<Vec<Procedure>> {
|
||||
type Key = (ProcKind, String, String);
|
||||
let mut order: Vec<Key> = Vec::new();
|
||||
let mut legs: HashMap<Key, Vec<(u32, String)>> = HashMap::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
let (kind, rest) = if let Some(r) = line.strip_prefix("SID:") {
|
||||
(ProcKind::Sid, r)
|
||||
} else if let Some(r) = line.strip_prefix("STAR:") {
|
||||
(ProcKind::Star, r)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let f: Vec<&str> = rest.split(',').collect();
|
||||
let name = field(&f, 2);
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let transition = field(&f, 3);
|
||||
let fix = field(&f, 4);
|
||||
let seq: u32 = field(&f, 0).parse().unwrap_or(0);
|
||||
|
||||
let key = (kind, name, transition);
|
||||
if !legs.contains_key(&key) {
|
||||
order.push(key.clone());
|
||||
}
|
||||
let entry = legs.entry(key).or_default();
|
||||
if !fix.is_empty() {
|
||||
entry.push((seq, fix));
|
||||
}
|
||||
}
|
||||
|
||||
let mut procs = Vec::with_capacity(order.len());
|
||||
for key in order {
|
||||
let mut group = legs.remove(&key).unwrap_or_default();
|
||||
group.sort_by_key(|(s, _)| *s);
|
||||
let mut fixes: Vec<String> = Vec::new();
|
||||
for (_, fix) in group {
|
||||
if fixes.last() != Some(&fix) {
|
||||
fixes.push(fix);
|
||||
}
|
||||
}
|
||||
if fixes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
procs.push(Procedure {
|
||||
kind: key.0,
|
||||
name: key.1,
|
||||
transition: key.2,
|
||||
fixes,
|
||||
});
|
||||
}
|
||||
Ok(procs)
|
||||
}
|
||||
|
||||
fn field(f: &[&str], i: usize) -> String {
|
||||
f.get(i).map(|s| s.trim()).unwrap_or("").to_string()
|
||||
}
|
||||
|
||||
/// Ordered fixes of the procedure named `name` (of `kind`) whose connector fix
|
||||
/// (SID exit / STAR entry) equals `connector_fix` — for drawing the full
|
||||
/// SID/STAR track on the map. Empty if not found.
|
||||
pub fn procedure_track(
|
||||
cifp_dir: &Path,
|
||||
icao: &str,
|
||||
kind: ProcKind,
|
||||
name: &str,
|
||||
connector_fix: &str,
|
||||
) -> Result<Vec<String>> {
|
||||
let path = cifp_dir.join(format!("{}.dat", icao.to_uppercase()));
|
||||
let Ok(file) = File::open(path) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let procs = parse_procedures(BufReader::new(file))?;
|
||||
let cf = connector_fix.to_uppercase();
|
||||
for p in procs
|
||||
.iter()
|
||||
.filter(|p| p.kind == kind && p.name.eq_ignore_ascii_case(name))
|
||||
{
|
||||
let matches = match kind {
|
||||
ProcKind::Sid => p.exit_fix() == Some(cf.as_str()),
|
||||
ProcKind::Star => p.entry_fix() == Some(cf.as_str()),
|
||||
};
|
||||
if matches {
|
||||
return Ok(p.fixes.clone());
|
||||
}
|
||||
}
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// Connectors for an airport read from `<cifp_dir>/<ICAO>.dat`: pairs of
|
||||
/// (procedure name, connector fix) — SID **exit** fixes for [`ProcKind::Sid`],
|
||||
/// STAR **entry** fixes for [`ProcKind::Star`], deduplicated by fix. Empty when
|
||||
/// the CIFP file is absent.
|
||||
pub fn connectors(cifp_dir: &Path, icao: &str, kind: ProcKind) -> Result<Vec<(String, String)>> {
|
||||
let path = cifp_dir.join(format!("{}.dat", icao.to_uppercase()));
|
||||
let Ok(file) = File::open(path) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let procs = parse_procedures(BufReader::new(file))?;
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
for p in procs.iter().filter(|p| p.kind == kind) {
|
||||
let fix = match kind {
|
||||
ProcKind::Sid => p.exit_fix(),
|
||||
ProcKind::Star => p.entry_fix(),
|
||||
};
|
||||
if let Some(f) = fix {
|
||||
if !out.iter().any(|(_, x)| x == f) {
|
||||
out.push((p.name.clone(), f.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::File;
|
||||
use std::io::{BufReader, Cursor};
|
||||
|
||||
#[test]
|
||||
fn groups_sid_legs_into_ordered_fixes() {
|
||||
let data = "\
|
||||
SID:010,4,TEST1,RW27L,AAA,LF,P,C\n\
|
||||
SID:030,4,TEST1,RW27L,CCC,LF\n\
|
||||
SID:020,4,TEST1,RW27L,BBB,LF\n\
|
||||
STAR:010,5,ARR1,ALL,ZZZ,LF\n";
|
||||
let procs = parse_procedures(Cursor::new(data)).unwrap();
|
||||
assert_eq!(procs.len(), 2);
|
||||
let sid = &procs[0];
|
||||
assert_eq!(sid.kind, ProcKind::Sid);
|
||||
assert_eq!(sid.name, "TEST1");
|
||||
assert_eq!(sid.transition, "RW27L");
|
||||
assert_eq!(sid.fixes, vec!["AAA", "BBB", "CCC"]); // sorted by seq
|
||||
assert_eq!(sid.exit_fix(), Some("CCC"));
|
||||
assert_eq!(procs[1].entry_fix(), Some("ZZZ"));
|
||||
}
|
||||
|
||||
/// Parses the real LFPG CIFP when present (skips otherwise).
|
||||
#[test]
|
||||
fn parses_real_lfpg_when_present() {
|
||||
let p = "../../navdata/CIFP/LFPG.dat";
|
||||
let Ok(file) = File::open(p) else {
|
||||
return;
|
||||
};
|
||||
let procs = parse_procedures(BufReader::new(file)).unwrap();
|
||||
let sids = procs.iter().filter(|p| p.kind == ProcKind::Sid).count();
|
||||
let stars = procs.iter().filter(|p| p.kind == ProcKind::Star).count();
|
||||
assert!(sids > 10, "sids = {sids}");
|
||||
assert!(stars > 5, "stars = {stars}");
|
||||
assert!(procs.iter().all(|p| !p.fixes.is_empty()));
|
||||
for s in procs.iter().filter(|p| p.kind == ProcKind::Sid).take(4) {
|
||||
eprintln!("SID {:8} rwy {:6} exits→ {:?}", s.name, s.transition, s.exit_fix());
|
||||
}
|
||||
for s in procs.iter().filter(|p| p.kind == ProcKind::Star).take(4) {
|
||||
eprintln!("STAR {:8} trans {:6} enters→ {:?}", s.name, s.transition, s.entry_fix());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
//! Aircraft performance & fuel planning over a computed [`Route`].
|
||||
//!
|
||||
//! A simple 3-phase profile (climb / cruise / descent) is laid out along the
|
||||
//! total route distance: climb and descent occupy the ground distance implied by
|
||||
//! their rate of climb/descent and TAS, cruise fills the middle. Each route leg
|
||||
//! is then split across whatever phases it overlaps, giving per-leg time and fuel
|
||||
//! with running totals, plus regulatory reserves and a block fuel figure.
|
||||
|
||||
pub mod openap;
|
||||
pub mod profile;
|
||||
|
||||
pub use openap::PerfModel;
|
||||
pub use profile::AircraftProfile;
|
||||
|
||||
use crate::routing::Route;
|
||||
|
||||
/// Time, distance and fuel for one flight phase.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct PhaseResult {
|
||||
pub dist_nm: f64,
|
||||
pub time_min: f64,
|
||||
pub fuel_kg: f64,
|
||||
}
|
||||
|
||||
/// Per-leg time/fuel with cumulative running totals.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LegFuel {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub airway: String,
|
||||
pub dist_nm: f64,
|
||||
pub time_min: f64,
|
||||
pub fuel_kg: f64,
|
||||
pub cum_time_min: f64,
|
||||
pub cum_fuel_kg: f64,
|
||||
}
|
||||
|
||||
/// Mass breakdown for the flight (only available with a physics/OpenAP profile).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Masses {
|
||||
/// Payload (pax + cargo), kg.
|
||||
pub payload_kg: f64,
|
||||
/// Zero-fuel weight = OEW + payload, kg.
|
||||
pub zfw_kg: f64,
|
||||
/// Take-off weight = ZFW + take-off fuel (block − taxi), kg.
|
||||
pub takeoff_kg: f64,
|
||||
/// Landing weight = take-off weight − trip fuel, kg.
|
||||
pub landing_kg: f64,
|
||||
/// True if take-off weight exceeds MTOW (plan is infeasible as loaded).
|
||||
pub over_mtow: bool,
|
||||
/// True if landing weight exceeds MLW.
|
||||
pub over_mlw: bool,
|
||||
}
|
||||
|
||||
/// A complete fuel plan for a route.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FuelPlan {
|
||||
pub cruise_fl: i32,
|
||||
pub climb: PhaseResult,
|
||||
pub cruise: PhaseResult,
|
||||
pub descent: PhaseResult,
|
||||
pub legs: Vec<LegFuel>,
|
||||
pub trip_fuel_kg: f64,
|
||||
pub trip_time_min: f64,
|
||||
pub taxi_kg: f64,
|
||||
pub contingency_kg: f64,
|
||||
pub alternate_kg: f64,
|
||||
pub final_reserve_kg: f64,
|
||||
pub block_fuel_kg: f64,
|
||||
/// Mass breakdown, when the profile carries OpenAP mass data.
|
||||
pub masses: Option<Masses>,
|
||||
}
|
||||
|
||||
/// Result of one fuel-accounting pass over the route at a fixed representative
|
||||
/// mass (fuel flows already resolved for that mass).
|
||||
struct Pass {
|
||||
climb: PhaseResult,
|
||||
cruise: PhaseResult,
|
||||
descent: PhaseResult,
|
||||
legs: Vec<LegFuel>,
|
||||
trip_fuel_kg: f64,
|
||||
trip_time_min: f64,
|
||||
contingency_kg: f64,
|
||||
final_reserve_kg: f64,
|
||||
alternate_kg: f64,
|
||||
block_fuel_kg: f64,
|
||||
}
|
||||
|
||||
/// Compute a fuel plan for `route` using `profile`, cruising at `cruise_fl`
|
||||
/// (×100 ft). `alternate_nm`, if given, adds a cruise-only alternate leg.
|
||||
/// `payload_kg` sets the load (pax + cargo); with an OpenAP profile the block
|
||||
/// fuel is solved by iterating mass (heavier fuel load ⇒ more burn ⇒ more fuel).
|
||||
/// Without OpenAP coefficients, fuel flow is mass-independent and `payload_kg`
|
||||
/// only affects the reported mass breakdown (which is then `None`).
|
||||
pub fn compute_fuel_plan(
|
||||
profile: &AircraftProfile,
|
||||
route: &Route,
|
||||
cruise_fl: i32,
|
||||
alternate_nm: Option<f64>,
|
||||
payload_kg: Option<f64>,
|
||||
) -> FuelPlan {
|
||||
let d_total: f64 = route.legs.iter().map(|l| l.dist_nm).sum();
|
||||
let alt_ft = cruise_fl as f64 * 100.0;
|
||||
let (c, cr, de) = (
|
||||
&profile.phases.climb,
|
||||
&profile.phases.cruise,
|
||||
&profile.phases.descent,
|
||||
);
|
||||
|
||||
// Ground distance covered by climb and descent (top-of-climb / top-of-descent).
|
||||
let mut climb_dist = c.tas_kt * (alt_ft / c.roc_fpm) / 60.0;
|
||||
let mut descent_dist = de.tas_kt * (alt_ft / de.rod_fpm) / 60.0;
|
||||
if climb_dist + descent_dist > d_total && climb_dist + descent_dist > 0.0 {
|
||||
// Short flight: cruise never reached, compress climb/descent to fit.
|
||||
let scale = d_total / (climb_dist + descent_dist);
|
||||
climb_dist *= scale;
|
||||
descent_dist *= scale;
|
||||
}
|
||||
let cruise_dist = (d_total - climb_dist - descent_dist).max(0.0);
|
||||
let taxi_kg = profile.reserves.taxi_kg;
|
||||
|
||||
// One fuel-accounting pass at a given fuel-flow evaluation mass.
|
||||
let run = |ff_mass: f64| -> Pass {
|
||||
let (climb_ff, cruise_ff, descent_ff) =
|
||||
phase_fuel_flows(profile, cruise_fl, alt_ft, ff_mass);
|
||||
// (start_nm, end_nm, tas_kt, fuel_flow_kgph) per phase, in route order.
|
||||
let phases = [
|
||||
(0.0, climb_dist, c.tas_kt, climb_ff),
|
||||
(climb_dist, climb_dist + cruise_dist, cr.tas_kt, cruise_ff),
|
||||
(climb_dist + cruise_dist, d_total, de.tas_kt, descent_ff),
|
||||
];
|
||||
|
||||
let mut totals = [PhaseResult::default(); 3];
|
||||
let mut legs = Vec::with_capacity(route.legs.len());
|
||||
let mut traveled = 0.0;
|
||||
let mut cum_time = 0.0;
|
||||
let mut cum_fuel = 0.0;
|
||||
|
||||
for leg in &route.legs {
|
||||
let (start, end) = (traveled, traveled + leg.dist_nm);
|
||||
let mut leg_time = 0.0;
|
||||
let mut leg_fuel = 0.0;
|
||||
for (i, &(ps, pe, tas, ff)) in phases.iter().enumerate() {
|
||||
let overlap = (end.min(pe) - start.max(ps)).max(0.0);
|
||||
if overlap <= 0.0 || tas <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let time = overlap / tas * 60.0;
|
||||
let fuel = ff * overlap / tas;
|
||||
leg_time += time;
|
||||
leg_fuel += fuel;
|
||||
totals[i].dist_nm += overlap;
|
||||
totals[i].time_min += time;
|
||||
totals[i].fuel_kg += fuel;
|
||||
}
|
||||
cum_time += leg_time;
|
||||
cum_fuel += leg_fuel;
|
||||
legs.push(LegFuel {
|
||||
from: leg.from.clone(),
|
||||
to: leg.to.clone(),
|
||||
airway: leg.airway.clone(),
|
||||
dist_nm: leg.dist_nm,
|
||||
time_min: leg_time,
|
||||
fuel_kg: leg_fuel,
|
||||
cum_time_min: cum_time,
|
||||
cum_fuel_kg: cum_fuel,
|
||||
});
|
||||
traveled = end;
|
||||
}
|
||||
|
||||
let [climb, cruise, descent] = totals;
|
||||
let trip_fuel_kg = climb.fuel_kg + cruise.fuel_kg + descent.fuel_kg;
|
||||
let trip_time_min = climb.time_min + cruise.time_min + descent.time_min;
|
||||
let contingency_kg = profile.reserves.contingency_pct / 100.0 * trip_fuel_kg;
|
||||
let final_reserve_kg = cruise_ff * profile.reserves.final_reserve_min / 60.0;
|
||||
let alternate_kg = alternate_nm
|
||||
.map(|d| cruise_ff * (d / cr.tas_kt))
|
||||
.unwrap_or(0.0);
|
||||
let block_fuel_kg =
|
||||
taxi_kg + trip_fuel_kg + contingency_kg + alternate_kg + final_reserve_kg;
|
||||
Pass {
|
||||
climb,
|
||||
cruise,
|
||||
descent,
|
||||
legs,
|
||||
trip_fuel_kg,
|
||||
trip_time_min,
|
||||
contingency_kg,
|
||||
final_reserve_kg,
|
||||
alternate_kg,
|
||||
block_fuel_kg,
|
||||
}
|
||||
};
|
||||
|
||||
// With an OpenAP profile, solve block fuel by fixed-point iteration on mass:
|
||||
// start empty (ZFW), then feed take-off fuel back into the evaluation mass
|
||||
// until the block figure stops moving. Without it, one mass-agnostic pass.
|
||||
let (pass, masses) = match &profile.openap {
|
||||
None => (run(0.0), None),
|
||||
Some(params) => {
|
||||
let payload = payload_kg.unwrap_or_else(|| params.default_payload_kg());
|
||||
let zfw = params.oew_kg + payload;
|
||||
let mut pass = run(zfw);
|
||||
for _ in 0..12 {
|
||||
let takeoff = zfw + (pass.block_fuel_kg - taxi_kg).max(0.0);
|
||||
// Represent the whole flight by its average all-up mass.
|
||||
let avg = takeoff - pass.trip_fuel_kg / 2.0;
|
||||
let next = run(avg);
|
||||
let converged = (next.block_fuel_kg - pass.block_fuel_kg).abs() < 0.5;
|
||||
pass = next;
|
||||
if converged {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let takeoff = zfw + (pass.block_fuel_kg - taxi_kg).max(0.0);
|
||||
let landing = takeoff - pass.trip_fuel_kg;
|
||||
let masses = Masses {
|
||||
payload_kg: payload,
|
||||
zfw_kg: zfw,
|
||||
takeoff_kg: takeoff,
|
||||
landing_kg: landing,
|
||||
over_mtow: takeoff > params.mtow_kg,
|
||||
over_mlw: landing > params.mlw_kg,
|
||||
};
|
||||
(pass, Some(masses))
|
||||
}
|
||||
};
|
||||
|
||||
FuelPlan {
|
||||
cruise_fl,
|
||||
climb: pass.climb,
|
||||
cruise: pass.cruise,
|
||||
descent: pass.descent,
|
||||
legs: pass.legs,
|
||||
trip_fuel_kg: pass.trip_fuel_kg,
|
||||
trip_time_min: pass.trip_time_min,
|
||||
taxi_kg,
|
||||
contingency_kg: pass.contingency_kg,
|
||||
alternate_kg: pass.alternate_kg,
|
||||
final_reserve_kg: pass.final_reserve_kg,
|
||||
block_fuel_kg: pass.block_fuel_kg,
|
||||
masses,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fuel flow (kg/h) for climb, cruise and descent at all-up mass `mass_kg`. Uses
|
||||
/// the physics model when the profile has OpenAP coefficients (burn depends on
|
||||
/// weight and flight level), otherwise the fixed per-phase JSON figures (and
|
||||
/// `mass_kg` is ignored).
|
||||
fn phase_fuel_flows(
|
||||
profile: &AircraftProfile,
|
||||
cruise_fl: i32,
|
||||
cruise_alt_ft: f64,
|
||||
mass_kg: f64,
|
||||
) -> (f64, f64, f64) {
|
||||
let (c, cr, de) = (
|
||||
&profile.phases.climb,
|
||||
&profile.phases.cruise,
|
||||
&profile.phases.descent,
|
||||
);
|
||||
let Some(params) = &profile.openap else {
|
||||
return (c.fuel_flow_kgph, cr.fuel_flow_kgph, de.fuel_flow_kgph);
|
||||
};
|
||||
|
||||
let model = params.model();
|
||||
// Evaluate climb/descent at a representative mid-altitude of the phase.
|
||||
let mid_ft = cruise_alt_ft / 2.0;
|
||||
|
||||
let cruise_ff = model.cruise_fuel_flow_kgph(mass_kg, cruise_fl, params.cruise_mach);
|
||||
|
||||
let climb_tas = openap::kt_to_ms(c.tas_kt);
|
||||
let climb_gamma = (c.roc_fpm * openap_fpm_to_ms() / climb_tas).asin();
|
||||
let climb_ff = model.fuel_flow_kgs(mass_kg, climb_tas, mid_ft, climb_gamma) * 3600.0;
|
||||
|
||||
let descent_tas = openap::kt_to_ms(de.tas_kt);
|
||||
let descent_gamma = -(de.rod_fpm * openap_fpm_to_ms() / descent_tas).asin();
|
||||
let descent_ff = model.fuel_flow_kgs(mass_kg, descent_tas, mid_ft, descent_gamma) * 3600.0;
|
||||
|
||||
(climb_ff, cruise_ff, descent_ff)
|
||||
}
|
||||
|
||||
/// Feet-per-minute to metres-per-second.
|
||||
fn openap_fpm_to_ms() -> f64 {
|
||||
0.3048 / 60.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::routing::{Leg, Route};
|
||||
|
||||
fn a320() -> AircraftProfile {
|
||||
AircraftProfile::from_json_str(
|
||||
r#"{
|
||||
"icao":"A320","name":"t","default_cruise_fl":360,
|
||||
"phases":{
|
||||
"climb":{"ias_kt":290,"mach":0.78,"tas_kt":380,"fuel_flow_kgph":2600,"roc_fpm":2000},
|
||||
"cruise":{"mach":0.78,"tas_kt":450,"fuel_flow_kgph":2400},
|
||||
"descent":{"ias_kt":290,"mach":0.78,"tas_kt":320,"fuel_flow_kgph":1200,"rod_fpm":1800}
|
||||
},
|
||||
"reserves":{"final_reserve_min":30,"contingency_pct":5.0,"taxi_kg":200}
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn route(total_nm: f64) -> Route {
|
||||
Route {
|
||||
legs: vec![Leg {
|
||||
from: "A".into(),
|
||||
to: "B".into(),
|
||||
airway: "DCT".into(),
|
||||
dist_nm: total_nm,
|
||||
}],
|
||||
total_nm,
|
||||
via_airways: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phases_cover_total_distance() {
|
||||
let plan = compute_fuel_plan(&a320(), &route(400.0), 360, None, None);
|
||||
let covered = plan.climb.dist_nm + plan.cruise.dist_nm + plan.descent.dist_nm;
|
||||
assert!((covered - 400.0).abs() < 1e-6, "covered = {covered}");
|
||||
assert!(plan.cruise.dist_nm > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_exceeds_trip_and_reserves_are_positive() {
|
||||
let plan = compute_fuel_plan(&a320(), &route(400.0), 360, Some(120.0), None);
|
||||
assert!(plan.trip_fuel_kg > 0.0);
|
||||
assert!(plan.contingency_kg > 0.0);
|
||||
assert!(plan.final_reserve_kg > 0.0);
|
||||
assert!(plan.alternate_kg > 0.0);
|
||||
assert!(plan.block_fuel_kg > plan.trip_fuel_kg);
|
||||
// Cumulative fuel on the last leg equals trip fuel.
|
||||
let last = plan.legs.last().unwrap();
|
||||
assert!((last.cum_fuel_kg - plan.trip_fuel_kg).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_flight_has_no_cruise() {
|
||||
let plan = compute_fuel_plan(&a320(), &route(20.0), 360, None, None);
|
||||
assert!(
|
||||
plan.cruise.dist_nm < 1e-6,
|
||||
"cruise = {}",
|
||||
plan.cruise.dist_nm
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openap_profile_makes_cruise_burn_altitude_dependent() {
|
||||
use std::path::Path;
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/aircraft");
|
||||
let p = AircraftProfile::load(&dir, "A320").unwrap();
|
||||
assert!(
|
||||
p.openap.is_some(),
|
||||
"bundled A320 should carry OpenAP coeffs"
|
||||
);
|
||||
|
||||
let (_c_lo, cruise_lo, _d_lo) = phase_fuel_flows(&p, 240, 24_000.0, 64_000.0);
|
||||
let (_c_hi, cruise_hi, _d_hi) = phase_fuel_flows(&p, 360, 36_000.0, 64_000.0);
|
||||
// Same weight, higher FL → less burn; and in a realistic A320 band.
|
||||
assert!(
|
||||
cruise_hi < cruise_lo,
|
||||
"hi {cruise_hi:.0} < lo {cruise_lo:.0}"
|
||||
);
|
||||
assert!(
|
||||
(1800.0..=2800.0).contains(&cruise_hi),
|
||||
"cruise ff {cruise_hi:.0} kg/h"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openap_and_fixed_profiles_both_produce_positive_trip_fuel() {
|
||||
use std::path::Path;
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/aircraft");
|
||||
let p = AircraftProfile::load(&dir, "A320").unwrap();
|
||||
let plan = compute_fuel_plan(&p, &route(400.0), 360, None, None);
|
||||
assert!(plan.trip_fuel_kg > 0.0);
|
||||
assert!(plan.cruise.fuel_kg > 0.0);
|
||||
}
|
||||
|
||||
fn bundled_a320() -> AircraftProfile {
|
||||
use std::path::Path;
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/aircraft");
|
||||
AircraftProfile::load(&dir, "A320").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mass_breakdown_is_consistent() {
|
||||
let p = bundled_a320();
|
||||
let plan = compute_fuel_plan(&p, &route(1500.0), 360, None, Some(16_000.0));
|
||||
let m = plan.masses.expect("OpenAP profile yields a mass breakdown");
|
||||
let params = p.openap.as_ref().unwrap();
|
||||
// ZFW = OEW + payload; TOW = ZFW + block − taxi; LDW = TOW − trip.
|
||||
assert!((m.zfw_kg - (params.oew_kg + 16_000.0)).abs() < 1e-6);
|
||||
assert!((m.takeoff_kg - (m.zfw_kg + plan.block_fuel_kg - plan.taxi_kg)).abs() < 1e-6);
|
||||
assert!((m.landing_kg - (m.takeoff_kg - plan.trip_fuel_kg)).abs() < 1e-6);
|
||||
// Take-off weight must stay within MTOW for a sane load.
|
||||
assert!(
|
||||
m.takeoff_kg <= params.mtow_kg,
|
||||
"TOW {:.0} > MTOW",
|
||||
m.takeoff_kg
|
||||
);
|
||||
assert!(!m.over_mtow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavier_payload_burns_more_trip_fuel() {
|
||||
let p = bundled_a320();
|
||||
let light = compute_fuel_plan(&p, &route(1500.0), 360, None, Some(8_000.0));
|
||||
let heavy = compute_fuel_plan(&p, &route(1500.0), 360, None, Some(20_000.0));
|
||||
assert!(
|
||||
heavy.trip_fuel_kg > light.trip_fuel_kg,
|
||||
"heavy {:.0} should exceed light {:.0}",
|
||||
heavy.trip_fuel_kg,
|
||||
light.trip_fuel_kg
|
||||
);
|
||||
assert!(heavy.masses.unwrap().takeoff_kg > light.masses.unwrap().takeoff_kg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_fuel_iteration_converges() {
|
||||
// The reported block must be self-consistent with the mass it implies:
|
||||
// re-running at the converged take-off mass reproduces the same block.
|
||||
let p = bundled_a320();
|
||||
let plan = compute_fuel_plan(&p, &route(2000.0), 360, None, Some(15_000.0));
|
||||
let m = plan.masses.unwrap();
|
||||
let avg = m.takeoff_kg - plan.trip_fuel_kg / 2.0;
|
||||
let (_c, cruise_ff, _d) = phase_fuel_flows(&p, 360, 36_000.0, avg);
|
||||
// Cruise burn recomputed at the average mass is a sane A320 figure.
|
||||
assert!(
|
||||
(1800.0..=3000.0).contains(&cruise_ff),
|
||||
"cruise ff {cruise_ff:.0}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Physics-based aircraft performance, in the spirit of OpenAP (TU Delft,
|
||||
//! Junzi Sun) — an open, first-principles alternative to EUROCONTROL BADA.
|
||||
//!
|
||||
//! Fuel flow is derived from the actual aerodynamic drag at the current mass,
|
||||
//! altitude and speed (via an ISA atmosphere and a parabolic drag polar), then
|
||||
//! turned into fuel burn through a thrust-specific fuel consumption (TSFC).
|
||||
//! Unlike a fixed `fuel_flow_kgph`, this makes burn depend on **weight** and
|
||||
//! **flight level** — the two things that dominate real trip fuel.
|
||||
//!
|
||||
//! Coefficients (`cd0`, `k`, wing area, engine thrust/TSFC/idle flow) come from
|
||||
//! OpenAP's open dataset. The cruise TSFC is calibrated to a validated installed
|
||||
//! cruise fuel flow (OpenAP's ideal `cruise_sfc` is uninstalled and runs a bit
|
||||
//! optimistic); BADA datasets can be dropped in later behind the same interface.
|
||||
|
||||
/// Standard gravity (m/s²).
|
||||
const G0: f64 = 9.80665;
|
||||
/// Specific gas constant for dry air (J/(kg·K)).
|
||||
const R_AIR: f64 = 287.05287;
|
||||
/// Ratio of specific heats for air.
|
||||
const GAMMA: f64 = 1.4;
|
||||
/// ISA sea-level temperature (K) and pressure (Pa).
|
||||
const T0: f64 = 288.15;
|
||||
const P0: f64 = 101_325.0;
|
||||
/// Tropospheric lapse rate (K/m) and tropopause height (m).
|
||||
const LAPSE: f64 = 0.0065;
|
||||
const H_TROP: f64 = 11_000.0;
|
||||
const T_TROP: f64 = 216.65;
|
||||
/// Metres per foot; metres/second per knot.
|
||||
const M_PER_FT: f64 = 0.3048;
|
||||
const MS_PER_KT: f64 = 0.514_444;
|
||||
/// Seconds per hour, for kg/s ↔ kg/h.
|
||||
const S_PER_H: f64 = 3600.0;
|
||||
|
||||
/// ISA atmospheric state at a geopotential altitude.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Atmosphere {
|
||||
/// Static temperature (K).
|
||||
pub temperature_k: f64,
|
||||
/// Static pressure (Pa).
|
||||
pub pressure_pa: f64,
|
||||
/// Air density (kg/m³).
|
||||
pub density: f64,
|
||||
/// Speed of sound (m/s).
|
||||
pub sound_speed: f64,
|
||||
}
|
||||
|
||||
impl Atmosphere {
|
||||
/// ISA state at `alt_ft` (International Standard Atmosphere, troposphere +
|
||||
/// lower stratosphere; valid to ~20 km, i.e. well above any airliner).
|
||||
pub fn isa(alt_ft: f64) -> Self {
|
||||
let h = alt_ft * M_PER_FT;
|
||||
let (t, p) = if h <= H_TROP {
|
||||
let t = T0 - LAPSE * h;
|
||||
// Barometric formula, troposphere: p = p0 (T/T0)^(g/(L·R)).
|
||||
let p = P0 * (t / T0).powf(G0 / (LAPSE * R_AIR));
|
||||
(t, p)
|
||||
} else {
|
||||
let p_trop = P0 * (T_TROP / T0).powf(G0 / (LAPSE * R_AIR));
|
||||
// Isothermal layer: p = p_trop · exp(-g (h-11000)/(R·T)).
|
||||
let p = p_trop * (-G0 * (h - H_TROP) / (R_AIR * T_TROP)).exp();
|
||||
(T_TROP, p)
|
||||
};
|
||||
let density = p / (R_AIR * t);
|
||||
let sound_speed = (GAMMA * R_AIR * t).sqrt();
|
||||
Self {
|
||||
temperature_k: t,
|
||||
pressure_pa: p,
|
||||
density,
|
||||
sound_speed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A parabolic drag polar plus the engine data needed to turn drag into fuel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PerfModel {
|
||||
/// Reference wing area (m²).
|
||||
pub wing_area_m2: f64,
|
||||
/// Zero-lift drag coefficient (clean configuration).
|
||||
pub cd0: f64,
|
||||
/// Lift-induced drag factor `k` in `CD = CD0 + k·CL²`.
|
||||
pub induced_k: f64,
|
||||
/// Number of engines.
|
||||
pub n_engines: u32,
|
||||
/// Maximum (sea-level static) thrust per engine (N).
|
||||
pub max_thrust_n: f64,
|
||||
/// Cruise thrust-specific fuel consumption at the reference altitude,
|
||||
/// in grams of fuel per newton of thrust per second — g/(N·s).
|
||||
pub tsfc_cruise_g_per_ns: f64,
|
||||
/// Idle fuel flow per engine (kg/s); the descent floor.
|
||||
pub ff_idle_kgs: f64,
|
||||
/// Altitude (ft) at which `tsfc_cruise_g_per_ns` is quoted.
|
||||
pub ref_cruise_alt_ft: f64,
|
||||
}
|
||||
|
||||
impl PerfModel {
|
||||
/// True airspeed (m/s) for a given Mach number at `alt_ft`.
|
||||
pub fn tas_ms(&self, mach: f64, alt_ft: f64) -> f64 {
|
||||
mach * Atmosphere::isa(alt_ft).sound_speed
|
||||
}
|
||||
|
||||
/// Total aerodynamic drag (N) in steady level flight at `mass_kg`, true
|
||||
/// airspeed `tas_ms`, altitude `alt_ft`.
|
||||
pub fn drag_n(&self, mass_kg: f64, tas_ms: f64, alt_ft: f64) -> f64 {
|
||||
let atmos = Atmosphere::isa(alt_ft);
|
||||
let q = 0.5 * atmos.density * tas_ms * tas_ms; // dynamic pressure (Pa)
|
||||
let cl = (mass_kg * G0) / (q * self.wing_area_m2);
|
||||
let cd = self.cd0 + self.induced_k * cl * cl;
|
||||
q * self.wing_area_m2 * cd
|
||||
}
|
||||
|
||||
/// Effective TSFC at `alt_ft`. Turbofan TSFC scales roughly with √θ (θ =
|
||||
/// local/reference temperature ratio): colder air aloft burns less per
|
||||
/// newton, which is why jets climb to cruise.
|
||||
fn tsfc_g_per_ns(&self, alt_ft: f64) -> f64 {
|
||||
let t = Atmosphere::isa(alt_ft).temperature_k;
|
||||
let t_ref = Atmosphere::isa(self.ref_cruise_alt_ft).temperature_k;
|
||||
self.tsfc_cruise_g_per_ns * (t / t_ref).sqrt()
|
||||
}
|
||||
|
||||
/// Total fuel flow (kg/s) at the given state. `path_angle_rad` is the climb
|
||||
/// (+) or descent (−) flight-path angle; the weight component along the path
|
||||
/// adds to (climb) or subtracts from (descent) the thrust required. Never
|
||||
/// falls below combined engine idle flow.
|
||||
pub fn fuel_flow_kgs(
|
||||
&self,
|
||||
mass_kg: f64,
|
||||
tas_ms: f64,
|
||||
alt_ft: f64,
|
||||
path_angle_rad: f64,
|
||||
) -> f64 {
|
||||
let drag = self.drag_n(mass_kg, tas_ms, alt_ft);
|
||||
let thrust_req = drag + mass_kg * G0 * path_angle_rad.sin();
|
||||
let ff_from_thrust = self.tsfc_g_per_ns(alt_ft) * thrust_req.max(0.0) / 1000.0;
|
||||
let idle_floor = self.n_engines as f64 * self.ff_idle_kgs;
|
||||
ff_from_thrust.max(idle_floor)
|
||||
}
|
||||
|
||||
/// Level-flight fuel flow in kg/h (convenience for cruise).
|
||||
pub fn fuel_flow_kgph(&self, mass_kg: f64, tas_ms: f64, alt_ft: f64) -> f64 {
|
||||
self.fuel_flow_kgs(mass_kg, tas_ms, alt_ft, 0.0) * S_PER_H
|
||||
}
|
||||
|
||||
/// Cruise fuel flow (kg/h) at `mass_kg` and `cruise_fl` (×100 ft), flying at
|
||||
/// `mach`.
|
||||
pub fn cruise_fuel_flow_kgph(&self, mass_kg: f64, cruise_fl: i32, mach: f64) -> f64 {
|
||||
let alt_ft = cruise_fl as f64 * 100.0;
|
||||
let tas = self.tas_ms(mach, alt_ft);
|
||||
self.fuel_flow_kgph(mass_kg, tas, alt_ft)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a true airspeed in knots to m/s.
|
||||
pub fn kt_to_ms(kt: f64) -> f64 {
|
||||
kt * MS_PER_KT
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn a320() -> PerfModel {
|
||||
PerfModel {
|
||||
wing_area_m2: 124.0,
|
||||
cd0: 0.018,
|
||||
induced_k: 0.039,
|
||||
n_engines: 2,
|
||||
max_thrust_n: 117_900.0,
|
||||
tsfc_cruise_g_per_ns: 0.0170,
|
||||
ff_idle_kgs: 0.107,
|
||||
ref_cruise_alt_ft: 35_000.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isa_matches_known_values() {
|
||||
let sl = Atmosphere::isa(0.0);
|
||||
assert!((sl.temperature_k - 288.15).abs() < 0.01);
|
||||
assert!((sl.pressure_pa - 101_325.0).abs() < 1.0);
|
||||
assert!((sl.density - 1.225).abs() < 0.001);
|
||||
assert!((sl.sound_speed - 340.3).abs() < 0.5);
|
||||
|
||||
// FL350: T ≈ 218.8 K, p ≈ 23.8 kPa, ρ ≈ 0.380 kg/m³.
|
||||
let cruise = Atmosphere::isa(35_000.0);
|
||||
assert!(
|
||||
(cruise.temperature_k - 218.8).abs() < 0.5,
|
||||
"{}",
|
||||
cruise.temperature_k
|
||||
);
|
||||
assert!(
|
||||
(cruise.pressure_pa - 23_842.0).abs() < 200.0,
|
||||
"{}",
|
||||
cruise.pressure_pa
|
||||
);
|
||||
assert!((cruise.density - 0.380).abs() < 0.005, "{}", cruise.density);
|
||||
|
||||
// Tropopause is continuous across the layer boundary.
|
||||
let below = Atmosphere::isa(36_089.0 - 1.0);
|
||||
let above = Atmosphere::isa(36_089.0 + 1.0);
|
||||
assert!((below.pressure_pa - above.pressure_pa).abs() < 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a320_cruise_fuel_flow_is_realistic() {
|
||||
// ~64 t A320 at FL350, M0.78 burns roughly 2.0–2.5 t/h (both engines).
|
||||
let ff = a320().cruise_fuel_flow_kgph(64_000.0, 350, 0.78);
|
||||
assert!((2000.0..=2600.0).contains(&ff), "cruise ff = {ff:.0} kg/h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavier_aircraft_burns_more() {
|
||||
let m = a320();
|
||||
let light = m.cruise_fuel_flow_kgph(58_000.0, 350, 0.78);
|
||||
let heavy = m.cruise_fuel_flow_kgph(72_000.0, 350, 0.78);
|
||||
assert!(
|
||||
heavy > light,
|
||||
"heavy {heavy:.0} should exceed light {light:.0}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optimum_altitude_beats_low_altitude_for_same_mass() {
|
||||
// For a given weight, cruising higher (thinner air) is more efficient
|
||||
// until induced drag takes over — FL350 should beat FL200.
|
||||
let m = a320();
|
||||
let low = m.cruise_fuel_flow_kgph(64_000.0, 200, 0.78);
|
||||
let high = m.cruise_fuel_flow_kgph(64_000.0, 350, 0.78);
|
||||
assert!(high < low, "high {high:.0} should beat low {low:.0}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descent_is_floored_at_idle() {
|
||||
let m = a320();
|
||||
let tas = m.tas_ms(0.6, 20_000.0);
|
||||
// A steep idle descent must not go below combined idle flow.
|
||||
let ff = m.fuel_flow_kgs(64_000.0, tas, 20_000.0, (-6.0_f64).to_radians());
|
||||
let idle = m.n_engines as f64 * m.ff_idle_kgs;
|
||||
assert!((ff - idle).abs() < 1e-9, "descent ff {ff} vs idle {idle}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn climb_burns_more_than_cruise() {
|
||||
let m = a320();
|
||||
let tas = m.tas_ms(0.60, 20_000.0);
|
||||
let climb = m.fuel_flow_kgs(64_000.0, tas, 20_000.0, 5.0_f64.to_radians());
|
||||
let level = m.fuel_flow_kgs(64_000.0, tas, 20_000.0, 0.0);
|
||||
assert!(climb > level, "climb {climb} should exceed level {level}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Aircraft performance profile, loaded from `data/aircraft/<icao>.json`.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{CoreError, Result};
|
||||
use crate::perf::openap::PerfModel;
|
||||
|
||||
/// Per-phase speeds and fuel flow plus regulatory reserves for one aircraft type.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AircraftProfile {
|
||||
pub icao: String,
|
||||
pub name: String,
|
||||
pub default_cruise_fl: i32,
|
||||
pub phases: Phases,
|
||||
pub reserves: Reserves,
|
||||
/// Optional physics-based (OpenAP) coefficients. When present, fuel flow is
|
||||
/// computed from drag at the actual mass/altitude instead of the fixed
|
||||
/// per-phase `fuel_flow_kgph` figures.
|
||||
#[serde(default)]
|
||||
pub openap: Option<OpenApParams>,
|
||||
}
|
||||
|
||||
/// OpenAP-derived aerodynamic and engine coefficients plus mass envelope.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OpenApParams {
|
||||
pub wing_area_m2: f64,
|
||||
pub cd0: f64,
|
||||
pub induced_k: f64,
|
||||
pub n_engines: u32,
|
||||
pub engine: String,
|
||||
pub max_thrust_n: f64,
|
||||
pub tsfc_cruise_g_per_ns: f64,
|
||||
pub ff_idle_kgs: f64,
|
||||
pub ref_cruise_alt_ft: f64,
|
||||
pub oew_kg: f64,
|
||||
pub mtow_kg: f64,
|
||||
pub mlw_kg: f64,
|
||||
pub max_fuel_kg: f64,
|
||||
pub cruise_mach: f64,
|
||||
}
|
||||
|
||||
impl OpenApParams {
|
||||
/// Build the runtime physics model from these coefficients.
|
||||
pub fn model(&self) -> PerfModel {
|
||||
PerfModel {
|
||||
wing_area_m2: self.wing_area_m2,
|
||||
cd0: self.cd0,
|
||||
induced_k: self.induced_k,
|
||||
n_engines: self.n_engines,
|
||||
max_thrust_n: self.max_thrust_n,
|
||||
tsfc_cruise_g_per_ns: self.tsfc_cruise_g_per_ns,
|
||||
ff_idle_kgs: self.ff_idle_kgs,
|
||||
ref_cruise_alt_ft: self.ref_cruise_alt_ft,
|
||||
}
|
||||
}
|
||||
|
||||
/// Default payload (kg) when the caller gives none: 70 % of a max-payload
|
||||
/// proxy (MLW − OEW). A stand-in until real max-zero-fuel-weight data is
|
||||
/// added; yields a realistic ~medium load factor.
|
||||
pub fn default_payload_kg(&self) -> f64 {
|
||||
0.70 * (self.mlw_kg - self.oew_kg)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Phases {
|
||||
pub climb: ClimbPhase,
|
||||
pub cruise: CruisePhase,
|
||||
pub descent: DescentPhase,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ClimbPhase {
|
||||
pub ias_kt: f64,
|
||||
pub mach: f64,
|
||||
pub tas_kt: f64,
|
||||
pub fuel_flow_kgph: f64,
|
||||
pub roc_fpm: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CruisePhase {
|
||||
pub mach: f64,
|
||||
pub tas_kt: f64,
|
||||
pub fuel_flow_kgph: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DescentPhase {
|
||||
pub ias_kt: f64,
|
||||
pub mach: f64,
|
||||
pub tas_kt: f64,
|
||||
pub fuel_flow_kgph: f64,
|
||||
pub rod_fpm: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Reserves {
|
||||
pub final_reserve_min: f64,
|
||||
pub contingency_pct: f64,
|
||||
pub taxi_kg: f64,
|
||||
}
|
||||
|
||||
impl AircraftProfile {
|
||||
/// Deserialize a profile from a JSON string.
|
||||
pub fn from_json_str(json: &str) -> Result<Self> {
|
||||
Ok(serde_json::from_str(json)?)
|
||||
}
|
||||
|
||||
/// Load `<dir>/<icao lowercased>.json`.
|
||||
pub fn load(dir: &Path, icao: &str) -> Result<Self> {
|
||||
let path = dir.join(format!("{}.json", icao.to_lowercase()));
|
||||
let json = std::fs::read_to_string(&path).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
CoreError::NotFound(format!("aircraft profile '{icao}' ({})", path.display()))
|
||||
} else {
|
||||
CoreError::Io(e)
|
||||
}
|
||||
})?;
|
||||
Self::from_json_str(&json)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn loads_bundled_a320_profile() {
|
||||
// data/aircraft lives at the workspace root, two levels up from this crate.
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/aircraft");
|
||||
let p = AircraftProfile::load(&dir, "A320").unwrap();
|
||||
assert_eq!(p.icao, "A320");
|
||||
assert_eq!(p.default_cruise_fl, 360);
|
||||
assert!(p.phases.cruise.tas_kt > 400.0);
|
||||
assert_eq!(p.reserves.final_reserve_min, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_profile_is_not_found() {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/aircraft");
|
||||
let err = AircraftProfile::load(&dir, "ZZZZ").unwrap_err();
|
||||
assert!(err.to_string().contains("ZZZZ"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Import PFPX `.route` files as seeds for our **own** route DB.
|
||||
//!
|
||||
//! PFPX stores one XML `.route` file per city-pair variant. We extract only the
|
||||
//! filed route string (item-15 `<ATC>`), keyed by the ADEP/ADES in the filename,
|
||||
//! and store it in our `routes` table as an **unvalidated seed** (`source="pfpx"`).
|
||||
//! These seeds are often years old, so they are re-validated / repaired against
|
||||
//! the live IFPUV oracle (and OUR RAD) before use. Nothing PFPX-specific (its RAD
|
||||
//! or navdata) is imported — only the route string, which we then make current.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
/// A filed route extracted from a PFPX `.route` file.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PfpxRoute {
|
||||
pub adep: String,
|
||||
pub ades: String,
|
||||
/// Item-15 (airways + DCT), from `<ATC>`.
|
||||
pub route: String,
|
||||
pub cycle: String,
|
||||
pub dist_nm: f64,
|
||||
}
|
||||
|
||||
/// Value of the first `<name>…</name>` element.
|
||||
fn tag<'a>(s: &'a str, name: &str) -> Option<&'a str> {
|
||||
let open = format!("<{name}>");
|
||||
let close = format!("</{name}>");
|
||||
let i = s.find(&open)? + open.len();
|
||||
let rest = &s[i..];
|
||||
let j = rest.find(&close)?;
|
||||
Some(rest[..j].trim())
|
||||
}
|
||||
|
||||
/// Parse a `.route` XML body for the given ADEP/ADES.
|
||||
pub fn parse_route_str(adep: &str, ades: &str, text: &str) -> Option<PfpxRoute> {
|
||||
let route = tag(text, "ATC")?.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if route.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let cycle = tag(text, "Cycle").unwrap_or("").to_string();
|
||||
let dist_nm = tag(text, "TotalDistance").and_then(|s| s.parse().ok()).unwrap_or(0.0);
|
||||
Some(PfpxRoute {
|
||||
adep: adep.to_uppercase(),
|
||||
ades: ades.to_uppercase(),
|
||||
route,
|
||||
cycle,
|
||||
dist_nm,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse one `.route` file. ADEP/ADES come from the 8-char filename prefix
|
||||
/// (`LFPGBIKF01.route` → LFPG, BIKF). `None` if it has no `<ATC>` route.
|
||||
pub fn parse_route_file(path: &Path) -> Option<PfpxRoute> {
|
||||
let stem = path.file_stem()?.to_str()?;
|
||||
if stem.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let (adep, ades) = (&stem[0..4], &stem[4..8]);
|
||||
if !adep.chars().chain(ades.chars()).all(|c| c.is_ascii_alphanumeric()) {
|
||||
return None;
|
||||
}
|
||||
let text = fs::read_to_string(path).ok()?;
|
||||
parse_route_str(adep, ades, &text)
|
||||
}
|
||||
|
||||
/// Import every `.route` file in `dir` as an unvalidated seed into the `routes`
|
||||
/// table (existing dep/dest/route rows are left untouched). Returns
|
||||
/// `(parsed, inserted)`.
|
||||
pub fn import_dir(conn: &mut Connection, dir: &Path) -> Result<(usize, usize)> {
|
||||
let mut parsed = 0usize;
|
||||
let mut inserted = 0usize;
|
||||
let tx = conn.transaction()?;
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let path = entry?.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("route") {
|
||||
continue;
|
||||
}
|
||||
let Some(r) = parse_route_file(&path) else { continue };
|
||||
parsed += 1;
|
||||
let source = if r.cycle.is_empty() { "pfpx".to_string() } else { format!("pfpx {}", r.cycle) };
|
||||
let n = tx.execute(
|
||||
"INSERT INTO routes \
|
||||
(dep,dest,cruise_fl,route_string,dist_nm,via_airways,ifps_ok,ifps_errors,source,generated_at) \
|
||||
VALUES (?1,?2,0,?3,?4,0,0,'', ?5, datetime('now')) \
|
||||
ON CONFLICT(dep,dest,cruise_fl,route_string) DO NOTHING",
|
||||
params![r.adep, r.ades, r.route, r.dist_nm, source],
|
||||
)?;
|
||||
inserted += n;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok((parsed, inserted))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_atc_route() {
|
||||
let xml = "<PFPXROUTE><GENERAL>\
|
||||
<ATC>OPALE UT421 BIG UT420 TNT DCT KEF</ATC>\
|
||||
<TotalDistance>1236.700000</TotalDistance>\
|
||||
<Cycle>AS1302</Cycle></GENERAL></PFPXROUTE>";
|
||||
let r = parse_route_str("lfpg", "bikf", xml).unwrap();
|
||||
assert_eq!(r.adep, "LFPG");
|
||||
assert_eq!(r.ades, "BIKF");
|
||||
assert_eq!(r.route, "OPALE UT421 BIG UT420 TNT DCT KEF");
|
||||
assert_eq!(r.cycle, "AS1302");
|
||||
assert!((r.dist_nm - 1236.7).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_without_atc() {
|
||||
assert!(parse_route_str("LFPG", "EGLL", "<PFPXROUTE></PFPXROUTE>").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! Eurocontrol RAD (Route Availability Document) model + application.
|
||||
//!
|
||||
//! The *parsing* of the RAD Excel workbook lives in the `flightplanner-rad`
|
||||
//! crate (it needs `calamine`); this module only holds the plain data model and
|
||||
//! the logic to apply restrictions during routing/validation, so `core` stays
|
||||
//! dependency-light. Callers load a [`RadData`] once and pass it in.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A named area (Annex 1): an ID mapping to a set of aerodrome ICAOs.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Area {
|
||||
pub id: String,
|
||||
pub airports: Vec<String>,
|
||||
pub region: String,
|
||||
}
|
||||
|
||||
/// Coarse classification of a DCT restriction's utilization text.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DctKind {
|
||||
/// The direct is not available (for some/all traffic).
|
||||
Forbidden,
|
||||
/// Available only for specific traffic (ARR/DEP a place, via a point, …).
|
||||
ConditionalOnly,
|
||||
/// Mandatory routing.
|
||||
Compulsory,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// A DCT (direct) restriction (Annex 3B DCT).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DctRestriction {
|
||||
pub id: String,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub lower_fl: Option<i32>,
|
||||
pub upper_fl: Option<i32>,
|
||||
/// `Y/N` column: is this direct available at all.
|
||||
pub available: bool,
|
||||
/// Free-text condition ("NOT AVBL FOR TFC …", "ONLY AVBL …", "COMPULSORY …").
|
||||
pub utilization: String,
|
||||
/// `Even` / `Odd` / empty — permitted cruising-level parity.
|
||||
pub direction: String,
|
||||
}
|
||||
|
||||
impl DctRestriction {
|
||||
pub fn kind(&self) -> DctKind {
|
||||
let u = self.utilization.to_uppercase();
|
||||
if u.contains("COMPULSORY") {
|
||||
DctKind::Compulsory
|
||||
} else if u.starts_with("NOT AVBL") || !self.available {
|
||||
DctKind::Forbidden
|
||||
} else if u.starts_with("ONLY AVBL") {
|
||||
DctKind::ConditionalOnly
|
||||
} else {
|
||||
DctKind::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this restriction's flight-level band contain `fl`?
|
||||
/// (`lower` missing ⇒ 0, `upper` missing ⇒ unlimited.)
|
||||
pub fn covers_fl(&self, fl: i32) -> bool {
|
||||
fl >= self.lower_fl.unwrap_or(0) && fl <= self.upper_fl.unwrap_or(i32::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
/// A directed "allowed FRA direct" edge, extracted from the `A DCT B` point
|
||||
/// sequences published in the RAD Annex 2 VIA-clauses. Together these form the
|
||||
/// FRA connectivity graph that IFPS accepts in Free Route Airspace.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct FraEdge {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
/// A published Free-Route-Airspace significant point (official EUROCONTROL FRA
|
||||
/// points list). Roles: en-route `E`ntry/e`X`it/`I`ntermediate (`EX` = both);
|
||||
/// arr/dep `A`/`D`. `level_lo`/`level_hi` are the point's usable FL band; `flos`
|
||||
/// is its cruising-level orientation (ODD/EVEN/ALL…).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FraPoint {
|
||||
pub name: String,
|
||||
pub lat: f64,
|
||||
pub lon: f64,
|
||||
/// FRA area(s) the point belongs to (e.g. "SECSI", "SEE").
|
||||
pub areas: Vec<String>,
|
||||
/// En-route relevance: `E`, `X`, `I`, `EX`, or empty.
|
||||
pub enroute: String,
|
||||
/// Arr/dep relevance: `A`, `D`, `AD`, or empty.
|
||||
pub arrdep: String,
|
||||
pub arr_airports: Vec<String>,
|
||||
pub dep_airports: Vec<String>,
|
||||
pub flos: String,
|
||||
pub level_lo: Option<i32>,
|
||||
pub level_hi: Option<i32>,
|
||||
/// FIR/ACC location indicators the point belongs to.
|
||||
pub loc_ind: Vec<String>,
|
||||
}
|
||||
|
||||
impl FraPoint {
|
||||
/// Usable as an en-route waypoint (not withdrawn / not purely arr-dep).
|
||||
pub fn is_enroute(&self) -> bool {
|
||||
matches!(self.enroute.as_str(), "E" | "X" | "I" | "EX")
|
||||
}
|
||||
/// Is `fl` within the point's published level band?
|
||||
pub fn covers_fl(&self, fl: i32) -> bool {
|
||||
fl >= self.level_lo.unwrap_or(0) && fl <= self.level_hi.unwrap_or(i32::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
/// A city-pair flight-level cap (Annex 2A): traffic from `from` to `to` may not
|
||||
/// file above `cap_fl`. `from`/`to` may name Annex-1 groups (expanded via areas).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LevelCap {
|
||||
pub id: String,
|
||||
pub from: Vec<String>,
|
||||
pub to: Vec<String>,
|
||||
/// The routing condition ("VIA …" / "EXC VIA …"); kept verbatim, ignored in v1.
|
||||
pub condition: String,
|
||||
/// Ceiling flight level (lowest FL mentioned in the capping cell).
|
||||
pub cap_fl: Option<i32>,
|
||||
}
|
||||
|
||||
/// The parsed RAD (the parts we currently model).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct RadData {
|
||||
pub areas: Vec<Area>,
|
||||
pub dct: Vec<DctRestriction>,
|
||||
/// FRA connectivity edges (Annex 2 VIA-clauses); empty if not parsed.
|
||||
#[serde(default)]
|
||||
pub fra_edges: Vec<FraEdge>,
|
||||
/// City-pair flight-level caps (Annex 2A); empty if not parsed.
|
||||
#[serde(default)]
|
||||
pub level_caps: Vec<LevelCap>,
|
||||
/// Official EUROCONTROL FRA significant points (separate file); empty if not loaded.
|
||||
#[serde(default)]
|
||||
pub fra_points: Vec<FraPoint>,
|
||||
}
|
||||
|
||||
impl RadData {
|
||||
/// A **forbidden** DCT restriction matching `from`→`to` at `fl`, if any —
|
||||
/// i.e. flying this direct would violate the RAD.
|
||||
pub fn forbidden_dct(&self, from: &str, to: &str, fl: i32) -> Option<&DctRestriction> {
|
||||
self.dct.iter().find(|d| {
|
||||
d.kind() == DctKind::Forbidden
|
||||
&& d.from.eq_ignore_ascii_case(from)
|
||||
&& d.to.eq_ignore_ascii_case(to)
|
||||
&& d.covers_fl(fl)
|
||||
})
|
||||
}
|
||||
|
||||
/// Undirected adjacency map of the FRA connectivity graph (point → neighbours).
|
||||
pub fn fra_adjacency(&self) -> std::collections::HashMap<String, Vec<String>> {
|
||||
let mut m: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
|
||||
for e in &self.fra_edges {
|
||||
m.entry(e.from.clone()).or_default().push(e.to.clone());
|
||||
m.entry(e.to.clone()).or_default().push(e.from.clone());
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// The lowest RAD flight-level cap (Annex 2A) applying to `from`→`to`, or
|
||||
/// `None` if uncapped. Group names in the From/To sets are expanded to their
|
||||
/// Annex-1 airports. v1 applies matches unconditionally (ignores conditions).
|
||||
pub fn max_cruise_fl(&self, from: &str, to: &str) -> Option<i32> {
|
||||
let icao_in = |icao: &str, set: &[String]| -> bool {
|
||||
set.iter().any(|s| {
|
||||
s.eq_ignore_ascii_case(icao)
|
||||
|| self.areas.iter().any(|a| {
|
||||
a.id.eq_ignore_ascii_case(s)
|
||||
&& a.airports.iter().any(|ap| ap.eq_ignore_ascii_case(icao))
|
||||
})
|
||||
})
|
||||
};
|
||||
self.level_caps
|
||||
.iter()
|
||||
.filter(|c| icao_in(from, &c.from) && icao_in(to, &c.to))
|
||||
.filter_map(|c| c.cap_fl)
|
||||
.min()
|
||||
}
|
||||
|
||||
/// Count of restrictions by kind — for summaries/status.
|
||||
pub fn dct_counts(&self) -> (usize, usize, usize) {
|
||||
let mut forbidden = 0;
|
||||
let mut only = 0;
|
||||
let mut comp = 0;
|
||||
for d in &self.dct {
|
||||
match d.kind() {
|
||||
DctKind::Forbidden => forbidden += 1,
|
||||
DctKind::ConditionalOnly => only += 1,
|
||||
DctKind::Compulsory => comp += 1,
|
||||
DctKind::Other => {}
|
||||
}
|
||||
}
|
||||
(forbidden, only, comp)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn restr(from: &str, to: &str, lo: i32, hi: i32, util: &str) -> DctRestriction {
|
||||
DctRestriction {
|
||||
id: "X".into(),
|
||||
from: from.into(),
|
||||
to: to.into(),
|
||||
lower_fl: Some(lo),
|
||||
upper_fl: Some(hi),
|
||||
available: true,
|
||||
utilization: util.into(),
|
||||
direction: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_forbidden_dct_in_band() {
|
||||
let rad = RadData {
|
||||
areas: vec![],
|
||||
dct: vec![restr("ABC", "DEF", 100, 300, "NOT AVBL FOR TFC ARR EDDF")],
|
||||
fra_edges: vec![],
|
||||
level_caps: vec![],
|
||||
fra_points: vec![],
|
||||
};
|
||||
assert!(rad.forbidden_dct("ABC", "DEF", 200).is_some());
|
||||
assert!(rad.forbidden_dct("abc", "def", 200).is_some()); // case-insensitive
|
||||
assert!(rad.forbidden_dct("ABC", "DEF", 350).is_none()); // above band
|
||||
assert!(rad.forbidden_dct("DEF", "ABC", 200).is_none()); // directional
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_classification() {
|
||||
assert_eq!(restr("A", "B", 0, 999, "NOT AVBL FOR TFC X").kind(), DctKind::Forbidden);
|
||||
assert_eq!(restr("A", "B", 0, 999, "ONLY AVBL FOR TFC ARR X").kind(), DctKind::ConditionalOnly);
|
||||
assert_eq!(restr("A", "B", 0, 999, "ONLY AVBL AND COMPULSORY FOR X").kind(), DctKind::Compulsory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_cruise_fl_matches_and_expands_groups() {
|
||||
let rad = RadData {
|
||||
areas: vec![Area {
|
||||
id: "GENEVA_AREA".into(),
|
||||
airports: vec!["LSGG".into(), "LSGE".into()],
|
||||
region: "LS".into(),
|
||||
}],
|
||||
dct: vec![],
|
||||
fra_edges: vec![],
|
||||
level_caps: vec![
|
||||
LevelCap { id: "R1".into(), from: vec!["LFPG".into()], to: vec!["GENEVA_AREA".into()], condition: String::new(), cap_fl: Some(345) },
|
||||
LevelCap { id: "R2".into(), from: vec!["LFPG".into()], to: vec!["LSGG".into()], condition: String::new(), cap_fl: Some(295) },
|
||||
LevelCap { id: "R3".into(), from: vec!["EGLL".into()], to: vec!["LSGG".into()], condition: String::new(), cap_fl: Some(200) },
|
||||
],
|
||||
fra_points: vec![],
|
||||
};
|
||||
// LFPG→LSGG matches R1 (via group) and R2 → min cap 295.
|
||||
assert_eq!(rad.max_cruise_fl("LFPG", "LSGG"), Some(295));
|
||||
// LFPG→LSGE only matches R1 (group) → 345.
|
||||
assert_eq!(rad.max_cruise_fl("LFPG", "LSGE"), Some(345));
|
||||
// No matching pair.
|
||||
assert_eq!(rad.max_cruise_fl("EDDF", "LEMD"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Our own database of flight plans — routes we've generated and IFPS-pre-checked,
|
||||
//! cached per city pair in the `routes` table (see `db::schema`).
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
const SEP: &str = " ||| ";
|
||||
|
||||
/// A stored (generated + pre-checked) flight plan.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CachedRoute {
|
||||
pub dep: String,
|
||||
pub dest: String,
|
||||
pub cruise_fl: i32,
|
||||
pub route_string: String,
|
||||
pub dist_nm: f64,
|
||||
pub via_airways: bool,
|
||||
pub ifps_ok: bool,
|
||||
pub ifps_errors: Vec<String>,
|
||||
pub source: String,
|
||||
pub generated_at: String,
|
||||
}
|
||||
|
||||
/// Insert or refresh a route (unique on dep/dest/FL/route_string).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn record(
|
||||
conn: &Connection,
|
||||
dep: &str,
|
||||
dest: &str,
|
||||
cruise_fl: i32,
|
||||
route_string: &str,
|
||||
dist_nm: f64,
|
||||
via_airways: bool,
|
||||
ifps_ok: bool,
|
||||
ifps_errors: &[String],
|
||||
source: &str,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO routes \
|
||||
(dep,dest,cruise_fl,route_string,dist_nm,via_airways,ifps_ok,ifps_errors,source,generated_at) \
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9, datetime('now')) \
|
||||
ON CONFLICT(dep,dest,cruise_fl,route_string) DO UPDATE SET \
|
||||
dist_nm=excluded.dist_nm, via_airways=excluded.via_airways, ifps_ok=excluded.ifps_ok, \
|
||||
ifps_errors=excluded.ifps_errors, source=excluded.source, generated_at=excluded.generated_at",
|
||||
params![
|
||||
dep.to_uppercase(),
|
||||
dest.to_uppercase(),
|
||||
cruise_fl,
|
||||
route_string,
|
||||
dist_nm,
|
||||
via_airways as i32,
|
||||
ifps_ok as i32,
|
||||
ifps_errors.join(SEP),
|
||||
source,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stored routes for a city pair, most recent first.
|
||||
pub fn recent(conn: &Connection, dep: &str, dest: &str, limit: usize) -> Result<Vec<CachedRoute>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT dep,dest,cruise_fl,route_string,dist_nm,via_airways,ifps_ok,ifps_errors,source,generated_at \
|
||||
FROM routes WHERE dep=?1 AND dest=?2 ORDER BY generated_at DESC LIMIT ?3",
|
||||
)?;
|
||||
let rows = stmt.query_map(
|
||||
params![dep.to_uppercase(), dest.to_uppercase(), limit as i64],
|
||||
|r| {
|
||||
let errs: String = r.get(7)?;
|
||||
Ok(CachedRoute {
|
||||
dep: r.get(0)?,
|
||||
dest: r.get(1)?,
|
||||
cruise_fl: r.get(2)?,
|
||||
route_string: r.get(3)?,
|
||||
dist_nm: r.get(4)?,
|
||||
via_airways: r.get::<_, i32>(5)? != 0,
|
||||
ifps_ok: r.get::<_, i32>(6)? != 0,
|
||||
ifps_errors: if errs.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
errs.split(SEP).map(str::to_string).collect()
|
||||
},
|
||||
source: r.get(8)?,
|
||||
generated_at: r.get(9)?,
|
||||
})
|
||||
},
|
||||
)?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Total number of stored routes.
|
||||
pub fn count(conn: &Connection) -> Result<i64> {
|
||||
Ok(conn.query_row("SELECT COUNT(*) FROM routes", [], |r| r.get(0))?)
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
//! Oracle-in-the-loop route discovery.
|
||||
//!
|
||||
//! Offline synthesis can't guarantee IFPS validity (the RAD is thousands of
|
||||
//! conditional rules). Instead we generate a candidate, submit it to an
|
||||
//! authoritative [`IfpsValidator`] (the public Eurocontrol IFPUV), read its
|
||||
//! precise feedback, mechanically repair the route, and re-validate — until it
|
||||
//! is ACCEPTED. The loop is **monotonic**: a repair is kept only if it strictly
|
||||
//! reduces the error count, so the result is never worse than the first draft.
|
||||
//!
|
||||
//! v1 repairs the mechanical, high-impact error classes:
|
||||
//! * `ROUTE165` (DCT too long in a TMA/area) → reroute that segment via airways
|
||||
//! * `PROF204/205` flight-level caps → move the cruise level into the window
|
||||
//! * `ROUTE130` unknown designator → drop the token
|
||||
//! Mandatory-routing / FRA-border errors (`PROF205 mandatory`, `ROUTE52`) are
|
||||
//! reported, not auto-fixed, in v1.
|
||||
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
use super::{airway_path, plan_preferred, Leg, Route};
|
||||
|
||||
/// One IFPS message (code + text).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct IfpsErr {
|
||||
pub code: String,
|
||||
pub msg: String,
|
||||
}
|
||||
|
||||
/// Verdict from an authoritative IFPS validator.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct IfpsVerdict {
|
||||
pub accepted: bool,
|
||||
pub errors: Vec<IfpsErr>,
|
||||
}
|
||||
|
||||
/// Something that can validate an ICAO route against IFPS. Implemented in the app
|
||||
/// layer by spawning the IFPUV scraper; mocked in tests.
|
||||
pub trait IfpsValidator {
|
||||
/// Validate item-15 `route` from `adep` to `ades` at flight level `fl`.
|
||||
fn validate(&self, adep: &str, ades: &str, route: &str, fl: i32) -> Result<IfpsVerdict>;
|
||||
}
|
||||
|
||||
/// Outcome of a discovery run.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiscoverResult {
|
||||
pub accepted: bool,
|
||||
pub route_string: String,
|
||||
/// Item-15 (enroute) portion that was validated.
|
||||
pub item15: String,
|
||||
pub fl: i32,
|
||||
pub total_nm: f64,
|
||||
pub errors: Vec<IfpsErr>,
|
||||
pub iterations: usize,
|
||||
pub log: Vec<String>,
|
||||
}
|
||||
|
||||
/// ICAO item-15 (enroute) string for a route: start at the SID exit fix, then
|
||||
/// `airway to` for each enroute leg, ending at the STAR entry fix. The leading
|
||||
/// SID and trailing STAR (first/last leg) are omitted — IFPS derives them.
|
||||
pub fn route_item15(route: &Route) -> String {
|
||||
let legs = &route.legs;
|
||||
match legs.len() {
|
||||
0 => String::new(),
|
||||
1 => "DCT".to_owned(),
|
||||
n => {
|
||||
let mut s = legs[0].to.clone();
|
||||
for leg in &legs[1..n - 1] {
|
||||
s.push(' ');
|
||||
s.push_str(&leg.airway);
|
||||
s.push(' ');
|
||||
s.push_str(&leg.to);
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fl3(fl: i32) -> String {
|
||||
format!("F{:03}", fl)
|
||||
}
|
||||
|
||||
// ── error parsing ───────────────────────────────────────────────────────────
|
||||
|
||||
/// The `A..B` fix pair from a `ROUTE165` "DCT SEGMENT A..B (n NM) IS TOO LONG".
|
||||
fn parse_too_long(msg: &str) -> Option<(String, String)> {
|
||||
let up = msg.to_uppercase();
|
||||
let seg = up.split("SEGMENT ").nth(1)?;
|
||||
let pair = seg.split(" (").next()?.trim(); // "A..B"
|
||||
let (a, b) = pair.split_once("..")?;
|
||||
let (a, b) = (a.trim(), b.trim());
|
||||
// Only simple single-token idents (skip the verbose ROUTE52 form with spaces).
|
||||
if a.is_empty() || b.is_empty() || a.contains(' ') || b.contains(' ') {
|
||||
return None;
|
||||
}
|
||||
Some((a.to_owned(), b.to_owned()))
|
||||
}
|
||||
|
||||
/// The `(A, B)` fix pair from a `PROF195` "A AWY B DOES NOT EXIST IN FL RANGE …"
|
||||
/// (an airway that isn't valid at the current flight level).
|
||||
fn parse_fl_gap(msg: &str) -> Option<(String, String)> {
|
||||
let up = msg.to_uppercase();
|
||||
let head = up.split(" DOES NOT EXIST").next()?; // "DIK T856 ADUSU"
|
||||
let toks: Vec<&str> = head.split_whitespace().collect();
|
||||
if toks.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = (toks[0], toks[toks.len() - 1]);
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((a.to_owned(), b.to_owned()))
|
||||
}
|
||||
|
||||
/// A forbidden flight-level band expressed by a PROF204/205 message.
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct Band {
|
||||
ceiling: Option<i32>, // forbidden at/above this FL
|
||||
floor: Option<i32>, // forbidden at/below this FL
|
||||
}
|
||||
|
||||
fn parse_band(msg: &str) -> Option<Band> {
|
||||
let up = msg.to_uppercase();
|
||||
let mut band = Band::default();
|
||||
// `F<lo>..F<hi>`
|
||||
if let Some(i) = up.find("..F") {
|
||||
let before = &up[..i];
|
||||
if let Some(fpos) = before.rfind('F') {
|
||||
let lo: String = before[fpos + 1..].chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
let hi: String = up[i + 3..].chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if let (Ok(lo), Ok(hi)) = (lo.parse::<i32>(), hi.parse::<i32>()) {
|
||||
if lo == 0 {
|
||||
band.floor = Some(hi); // forbidden below hi → fly above
|
||||
} else {
|
||||
// `Fa..F999` (ceiling) OR a narrow hole `Fa..Fb`: in both
|
||||
// cases flying below `a` avoids it → treat as a ceiling at a.
|
||||
band.ceiling = Some(lo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// `NOT ABV FL<x>` (also covers `RFL NOT ABV FL335/FL355` → the lower cap)
|
||||
if let Some(i) = up.find("NOT ABV FL") {
|
||||
let x: String = up[i + "NOT ABV FL".len()..].chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if let Ok(x) = x.parse::<i32>() {
|
||||
band.ceiling = Some(band.ceiling.map_or(x, |c| c.min(x)));
|
||||
}
|
||||
}
|
||||
(band.ceiling.is_some() || band.floor.is_some()).then_some(band)
|
||||
}
|
||||
|
||||
/// The `X` from `ROUTE130 UNKNOWN DESIGNATOR X`.
|
||||
fn parse_unknown(msg: &str) -> Option<String> {
|
||||
let up = msg.to_uppercase();
|
||||
let rest = up.split("UNKNOWN DESIGNATOR ").nth(1)?;
|
||||
rest.split_whitespace().next().map(str::to_owned).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
// ── repairs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn total_nm(legs: &[Leg]) -> f64 {
|
||||
legs.iter().map(|l| l.dist_nm).sum()
|
||||
}
|
||||
|
||||
/// Try to repair `route`/`fl` from `errors`. Returns the new route/fl and a note,
|
||||
/// or `None` when no repair applies. One class of repair per call. `from`/`to`
|
||||
/// and the connector fixes let the FL repair re-plan the route at the new level.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn apply_repairs(
|
||||
conn: &Connection,
|
||||
from: &str,
|
||||
to: &str,
|
||||
dep_fixes: &[String],
|
||||
dest_fixes: &[String],
|
||||
route: &Route,
|
||||
fl: i32,
|
||||
errors: &[IfpsErr],
|
||||
) -> Result<Option<(Route, i32, String)>> {
|
||||
// 1) Re-route a broken segment through the FL-filtered airway graph:
|
||||
// ROUTE165 (DCT too long in a TMA) and PROF195 (airway not valid at this
|
||||
// FL — e.g. we lowered FL for a RAD cap and broke a high-level airway).
|
||||
let mut segs: Vec<(String, String)> = errors
|
||||
.iter()
|
||||
.filter_map(|e| match e.code.as_str() {
|
||||
"ROUTE165" => parse_too_long(&e.msg),
|
||||
"PROF195" => parse_fl_gap(&e.msg),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
segs.sort();
|
||||
segs.dedup();
|
||||
if !segs.is_empty() {
|
||||
let mut legs: Vec<Leg> = Vec::with_capacity(route.legs.len());
|
||||
let mut spliced = Vec::new();
|
||||
for leg in &route.legs {
|
||||
// Any leg matching a broken segment (DCT or airway) is re-routed.
|
||||
let hit = segs.iter().any(|(a, b)| a == &leg.from && b == &leg.to);
|
||||
if hit {
|
||||
if let Some(path) = airway_path(conn, &leg.from, &leg.to, Some(fl))? {
|
||||
spliced.push(format!("{}..{}", leg.from, leg.to));
|
||||
legs.extend(path);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
legs.push(leg.clone());
|
||||
}
|
||||
if !spliced.is_empty() {
|
||||
let nm = total_nm(&legs);
|
||||
return Ok(Some((
|
||||
Route { legs, total_nm: nm, via_airways: true },
|
||||
fl,
|
||||
format!("airway-splice {}", spliced.join(", ")),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// 2) FL caps → move into the allowed window.
|
||||
let mut ceiling = i32::MAX;
|
||||
let mut floor = 0;
|
||||
let mut saw = false;
|
||||
for e in errors {
|
||||
if e.code != "PROF204" && e.code != "PROF205" {
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = parse_band(&e.msg) {
|
||||
saw = true;
|
||||
if let Some(c) = b.ceiling {
|
||||
ceiling = ceiling.min(c);
|
||||
}
|
||||
if let Some(f) = b.floor {
|
||||
floor = floor.max(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Move the cruise level into the allowed window `(floor, ceiling)`. Handles
|
||||
// both a ceiling (fly lower) and a floor (fly higher — e.g. after we over-
|
||||
// lowered). Empty window (floor ≥ ceiling) ⇒ genuine conflict, no fix.
|
||||
if saw {
|
||||
let ceil = if ceiling == i32::MAX { 600 } else { ceiling };
|
||||
let hi_bound = ((ceil - 5) / 10) * 10; // highest 10s FL below the ceiling
|
||||
let lo_bound = (floor / 10 + 1) * 10; // lowest 10s FL above the floor
|
||||
let outside = fl >= ceil || fl <= floor;
|
||||
if outside && lo_bound <= hi_bound {
|
||||
// prefer a standard cruise level inside the window
|
||||
let target = 360.clamp(lo_bound, hi_bound);
|
||||
if target != fl && target >= 60 {
|
||||
// Re-plan at the new level so airways are valid there (a route
|
||||
// planned at F360 may use airways that don't exist at F190).
|
||||
let replanned = super::plan_route_best(conn, from, to, Some(target), dep_fixes, dest_fixes)
|
||||
.ok()
|
||||
.filter(|r| r.legs.len() >= 2);
|
||||
let (route, how) = match replanned {
|
||||
Some(r) => (r, "re-planned"),
|
||||
None => (route.clone(), "same route"),
|
||||
};
|
||||
return Ok(Some((route, target, format!("RAD level window → {} ({how})", fl3(target)))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) unknown designators → drop the token.
|
||||
let drop: Vec<String> = errors
|
||||
.iter()
|
||||
.filter(|e| e.code == "ROUTE130")
|
||||
.filter_map(|e| parse_unknown(&e.msg))
|
||||
.collect();
|
||||
if !drop.is_empty() {
|
||||
let legs: Vec<Leg> = route
|
||||
.legs
|
||||
.iter()
|
||||
.filter(|l| !drop.iter().any(|d| d == &l.to || d == &l.airway))
|
||||
.cloned()
|
||||
.collect();
|
||||
if legs.len() != route.legs.len() && legs.len() >= 1 {
|
||||
let nm = total_nm(&legs);
|
||||
return Ok(Some((
|
||||
Route { legs, total_nm: nm, via_airways: route.via_airways },
|
||||
fl,
|
||||
format!("drop {}", drop.join(", ")),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
// ── the loop ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Maximum validate/repair iterations (each is one live IFPUV round-trip).
|
||||
const MAX_ITERS: usize = 8;
|
||||
|
||||
/// Max attempts with different SID/STAR gateways (each is a full inner run).
|
||||
const MAX_GATEWAY_ATTEMPTS: usize = 3;
|
||||
|
||||
/// Discover an IFPS-valid route from `from` to `to`. Runs the candidate/repair
|
||||
/// loop; if it stalls on a SID/STAR **gateway limit** (`ROUTE135`/`ROUTE134`), it
|
||||
/// excludes that gateway fix and retries via another, up to
|
||||
/// [`MAX_GATEWAY_ATTEMPTS`]. Returns the best (fewest-error) result seen;
|
||||
/// `accepted` means IFPS no-error.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn find_valid_route(
|
||||
conn: &Connection,
|
||||
from: &str,
|
||||
to: &str,
|
||||
dep_sid: &[(String, String)],
|
||||
dest_star: &[(String, String)],
|
||||
start_fl: i32,
|
||||
rad: Option<&crate::rad::RadData>,
|
||||
validator: &dyn IfpsValidator,
|
||||
) -> Result<DiscoverResult> {
|
||||
let mut excl_dep: HashSet<String> = HashSet::new();
|
||||
let mut excl_dest: HashSet<String> = HashSet::new();
|
||||
let mut full_log: Vec<String> = Vec::new();
|
||||
let mut best: Option<DiscoverResult> = None;
|
||||
|
||||
for attempt in 1..=MAX_GATEWAY_ATTEMPTS {
|
||||
let dsid: Vec<(String, String)> =
|
||||
dep_sid.iter().filter(|(_, f)| !excl_dep.contains(&f.to_uppercase())).cloned().collect();
|
||||
let dstar: Vec<(String, String)> =
|
||||
dest_star.iter().filter(|(_, f)| !excl_dest.contains(&f.to_uppercase())).cloned().collect();
|
||||
if attempt > 1 {
|
||||
full_log.push(format!("— gateway attempt {attempt} —"));
|
||||
}
|
||||
|
||||
let res = attempt_once(conn, from, to, &dsid, &dstar, start_fl, rad, validator)?;
|
||||
full_log.extend(res.log.iter().cloned());
|
||||
if res.accepted {
|
||||
let mut r = res;
|
||||
r.log = full_log;
|
||||
return Ok(r);
|
||||
}
|
||||
let swap = res.errors.iter().find_map(gateway_fix);
|
||||
if best.as_ref().map(|b| res.errors.len() < b.errors.len()).unwrap_or(true) {
|
||||
best = Some(res);
|
||||
}
|
||||
match swap {
|
||||
Some((is_dep, fix)) if attempt < MAX_GATEWAY_ATTEMPTS => {
|
||||
full_log.push(format!("gateway limit on {fix} → excluding and retrying"));
|
||||
if is_dep {
|
||||
excl_dep.insert(fix);
|
||||
} else {
|
||||
excl_dest.insert(fix);
|
||||
}
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
let mut r = best.expect("at least one attempt ran");
|
||||
r.log = full_log;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// One discovery attempt with a fixed set of allowed gateways: generate the seed
|
||||
/// / FRA / airway candidates, start the repair loop from whichever the oracle
|
||||
/// rates best.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn attempt_once(
|
||||
conn: &Connection,
|
||||
from: &str,
|
||||
to: &str,
|
||||
dep_sid: &[(String, String)],
|
||||
dest_star: &[(String, String)],
|
||||
start_fl: i32,
|
||||
rad: Option<&crate::rad::RadData>,
|
||||
validator: &dyn IfpsValidator,
|
||||
) -> Result<DiscoverResult> {
|
||||
let dep_fixes: Vec<String> = dep_sid.iter().map(|(_, f)| f.clone()).collect();
|
||||
let dest_fixes: Vec<String> = dest_star.iter().map(|(_, f)| f.clone()).collect();
|
||||
let mut candidates: Vec<(&str, Route)> = Vec::new();
|
||||
// Official FRA-points corridor first — routes the *right* points (dep/arr/
|
||||
// intermediate roles + level availability) from the EUROCONTROL FRA list.
|
||||
if let Some(r) = rad.filter(|r| !r.fra_points.is_empty()).and_then(|r| {
|
||||
super::fra_points::plan_fra_points(conn, from, to, start_fl, &r.fra_points, Some(r))
|
||||
.ok()
|
||||
.flatten()
|
||||
}) {
|
||||
if r.legs.len() >= 2 {
|
||||
candidates.push(("fra-points", r));
|
||||
}
|
||||
}
|
||||
// Real filed seeds next (e.g. imported PFPX routes) — a real route, even if
|
||||
// stale, is a far better starting point for the oracle than pure synthesis.
|
||||
if let Ok(stored) = crate::routes::recent(conn, from, to, 6) {
|
||||
if let Some(seed) = stored.iter().find(|s| s.source.starts_with("pfpx")) {
|
||||
if let Ok(Some(r)) = super::parse_route_string(conn, from, to, &seed.route_string) {
|
||||
if r.legs.len() >= 2 {
|
||||
candidates.push(("seed", r));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(r) = plan_preferred(conn, from, to, dep_sid, dest_star, Some(start_fl), rad) {
|
||||
candidates.push(("fra", r));
|
||||
}
|
||||
if let Ok(r) = super::plan_route_best(conn, from, to, Some(start_fl), &dep_fixes, &dest_fixes) {
|
||||
candidates.push(("airway", r));
|
||||
}
|
||||
|
||||
let mut pre_log: Vec<String> = Vec::new();
|
||||
let mut chosen: Option<(Route, IfpsVerdict)> = None;
|
||||
for (tag, cand) in candidates {
|
||||
let v = validator.validate(from, to, &route_item15(&cand), start_fl)?;
|
||||
pre_log.push(format!("candidate {tag}: {} err{}", v.errors.len(), if v.accepted { " (ACCEPTED)" } else { "" }));
|
||||
let better = chosen.as_ref().map(|(_, cv)| v.errors.len() < cv.errors.len()).unwrap_or(true);
|
||||
let accepted = v.accepted;
|
||||
if better {
|
||||
chosen = Some((cand, v));
|
||||
}
|
||||
if accepted {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (initial, verdict) =
|
||||
chosen.ok_or_else(|| crate::error::CoreError::Routing("no route candidate".into()))?;
|
||||
|
||||
let mut res = run_loop(conn, from, to, &dep_fixes, &dest_fixes, initial, verdict, start_fl, validator)?;
|
||||
pre_log.append(&mut res.log);
|
||||
res.log = pre_log;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// A SID/STAR gateway fix to exclude, parsed from a `ROUTE135` (SID) / `ROUTE134`
|
||||
/// (STAR) "…LIMIT IS EXCEEDED … CONNECTING TO <fix>." Returns `(is_dep_sid, fix)`.
|
||||
fn gateway_fix(e: &IfpsErr) -> Option<(bool, String)> {
|
||||
let is_dep = match e.code.as_str() {
|
||||
"ROUTE135" => true, // SID limit
|
||||
"ROUTE134" => false, // STAR limit
|
||||
_ => return None,
|
||||
};
|
||||
let up = e.msg.to_uppercase();
|
||||
let fix = up
|
||||
.split("CONNECTING TO ")
|
||||
.nth(1)?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.trim_end_matches('.')
|
||||
.to_string();
|
||||
(!fix.is_empty()).then_some((is_dep, fix))
|
||||
}
|
||||
|
||||
/// The validate/repair loop over a chosen `initial` candidate and its already
|
||||
/// obtained `initial_verdict`. Separated so it can be unit-tested with a mock
|
||||
/// validator and injected verdicts.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run_loop(
|
||||
conn: &Connection,
|
||||
from: &str,
|
||||
to: &str,
|
||||
dep_fixes: &[String],
|
||||
dest_fixes: &[String],
|
||||
initial: Route,
|
||||
initial_verdict: IfpsVerdict,
|
||||
start_fl: i32,
|
||||
validator: &dyn IfpsValidator,
|
||||
) -> Result<DiscoverResult> {
|
||||
let mut cur = initial;
|
||||
let mut fl = start_fl;
|
||||
let mut verdict = initial_verdict;
|
||||
let mut log: Vec<String> = Vec::new();
|
||||
let mut best: Option<(Route, i32, Vec<IfpsErr>)> = None;
|
||||
let mut iterations = 0;
|
||||
|
||||
for i in 1..=MAX_ITERS {
|
||||
iterations = i;
|
||||
if verdict.accepted {
|
||||
log.push(format!("iter {i}: {} → ACCEPTED", fl3(fl)));
|
||||
best = Some((cur, fl, Vec::new()));
|
||||
break;
|
||||
}
|
||||
if let Some((_, _, berr)) = &best {
|
||||
if verdict.errors.len() >= berr.len() {
|
||||
log.push(format!(
|
||||
"iter {i}: {} → {} err (not better than {}) — reverting",
|
||||
fl3(fl),
|
||||
verdict.errors.len(),
|
||||
berr.len()
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let codes: Vec<&str> = verdict.errors.iter().map(|e| e.code.as_str()).collect();
|
||||
best = Some((cur.clone(), fl, verdict.errors.clone()));
|
||||
match apply_repairs(conn, from, to, dep_fixes, dest_fixes, &cur, fl, &verdict.errors)? {
|
||||
Some((r, nfl, note)) => {
|
||||
log.push(format!("iter {i}: {} → {} err [{}] | fix: {note}", fl3(fl), verdict.errors.len(), codes.join(",")));
|
||||
cur = r;
|
||||
fl = nfl;
|
||||
verdict = validator.validate(from, to, &route_item15(&cur), fl)?;
|
||||
}
|
||||
None => {
|
||||
log.push(format!("iter {i}: {} → {} err [{}] | no auto-fix", fl3(fl), verdict.errors.len(), codes.join(",")));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (route, vfl, errors) = best.expect("loop runs at least once");
|
||||
Ok(DiscoverResult {
|
||||
accepted: errors.is_empty(),
|
||||
route_string: route.route_string(),
|
||||
item15: route_item15(&route),
|
||||
fl: vfl,
|
||||
total_nm: route.total_nm,
|
||||
errors,
|
||||
iterations,
|
||||
log,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
fn leg(from: &str, awy: &str, to: &str, nm: f64) -> Leg {
|
||||
Leg { from: from.into(), airway: awy.into(), to: to.into(), dist_nm: nm }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item15_skips_sid_star_and_keeps_airways() {
|
||||
let route = Route {
|
||||
legs: vec![
|
||||
leg("LFPG", "OPAL6A", "OPALE", 20.0),
|
||||
leg("OPALE", "DCT", "KESAX", 15.0),
|
||||
leg("KESAX", "UN491", "DIMAL", 30.0),
|
||||
leg("DIMAL", "ALES1H", "EGLL", 40.0),
|
||||
],
|
||||
total_nm: 105.0,
|
||||
via_airways: false,
|
||||
};
|
||||
assert_eq!(route_item15(&route), "OPALE DCT KESAX UN491 DIMAL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_error_messages() {
|
||||
assert_eq!(
|
||||
parse_too_long("THE DCT SEGMENT KOGAS..VEVAR (66 NM) IS TOO LONG FOR LFMMDCTX. MAXIMUM IS 0 NM"),
|
||||
Some(("KOGAS".into(), "VEVAR".into()))
|
||||
);
|
||||
assert_eq!(parse_unknown("UNKNOWN DESIGNATOR AGOP6A").as_deref(), Some("AGOP6A"));
|
||||
assert_eq!(parse_band("VIA EG LF:F245..F999 IS ON FORBIDDEN ROUTE").unwrap().ceiling, Some(245));
|
||||
assert_eq!(parse_band("RFL NOT ABV FL335/FL355").unwrap().ceiling, Some(335));
|
||||
// narrow forbidden band F355..F365 → fly below 355 (ceiling), not a floor
|
||||
let hole = parse_band("VIA BOMBI GIGET:F355..F365 IS ON FORBIDDEN ROUTE").unwrap();
|
||||
assert_eq!((hole.ceiling, hole.floor), (Some(355), None));
|
||||
assert_eq!(
|
||||
parse_fl_gap("DIK T856 ADUSU DOES NOT EXIST IN FL RANGE F000..F245"),
|
||||
Some(("DIK".into(), "ADUSU".into()))
|
||||
);
|
||||
}
|
||||
|
||||
/// A mock validator that replays a scripted sequence of verdicts, so we can
|
||||
/// test the loop's monotonicity + repair ordering without any network.
|
||||
struct Mock {
|
||||
script: RefCell<Vec<IfpsVerdict>>,
|
||||
}
|
||||
impl IfpsValidator for Mock {
|
||||
fn validate(&self, _a: &str, _b: &str, _r: &str, _fl: i32) -> Result<IfpsVerdict> {
|
||||
let mut s = self.script.borrow_mut();
|
||||
Ok(if s.is_empty() { IfpsVerdict { accepted: true, errors: vec![] } } else { s.remove(0) })
|
||||
}
|
||||
}
|
||||
|
||||
fn err(code: &str, msg: &str) -> IfpsErr {
|
||||
IfpsErr { code: code.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
fn route3() -> Route {
|
||||
// A short enroute route so item-15 has a fix; no airways to splice.
|
||||
Route {
|
||||
legs: vec![leg("LFPG", "SID1A", "AAAAA", 20.0), leg("AAAAA", "DCT", "BBBBB", 40.0), leg("BBBBB", "STAR1", "LFMN", 30.0)],
|
||||
total_nm: 90.0,
|
||||
via_airways: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_reverts_when_a_fix_makes_it_worse() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
// iter1: one FL-cap error (fixable) → lower FL; iter2: 3 errors (worse)
|
||||
// → revert and report the F360 / 1-error state.
|
||||
let initial = IfpsVerdict { accepted: false, errors: vec![err("PROF204", "EG LF:F245..F999 IS ON FORBIDDEN ROUTE")] };
|
||||
let mock = Mock {
|
||||
script: RefCell::new(vec![IfpsVerdict {
|
||||
accepted: false,
|
||||
errors: vec![err("X1", "a"), err("X2", "b"), err("X3", "c")],
|
||||
}]),
|
||||
};
|
||||
let r = run_loop(&conn, "LFPG", "LFMN", &[], &[], route3(), initial, 360, &mock).unwrap();
|
||||
assert!(!r.accepted);
|
||||
assert_eq!(r.fl, 360, "reverted to the pre-fix level");
|
||||
assert_eq!(r.errors.len(), 1, "best (fewest-error) state is kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_accepts_after_level_fix() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
let initial = IfpsVerdict { accepted: false, errors: vec![err("PROF204", "LF:F245..F999 IS ON FORBIDDEN ROUTE")] };
|
||||
let mock = Mock { script: RefCell::new(vec![IfpsVerdict { accepted: true, errors: vec![] }]) };
|
||||
let r = run_loop(&conn, "LFPG", "LFMN", &[], &[], route3(), initial, 360, &mock).unwrap();
|
||||
assert!(r.accepted, "log: {:?}", r.log);
|
||||
assert_eq!(r.fl, 240, "lowered below the 245 cap");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Free Route Airspace (FRA) routing.
|
||||
//!
|
||||
//! Modern European enroute airspace is *free route*: flight plans are filed as a
|
||||
//! chain of **DCT** (directs) between published points, not along airways. This
|
||||
//! router builds such a chain from the SID exit fix to the STAR entry fix,
|
||||
//! anchoring on real waypoints roughly along the great circle, and skipping
|
||||
//! directs the RAD marks as forbidden.
|
||||
//!
|
||||
//! It is a heuristic (it can't know the exact FRA horizontal entry/exit points),
|
||||
//! so it produces FRA-*style* routes — closer to reality than airway routing —
|
||||
//! but, like any offline engine, it can't *guarantee* IFPS acceptance.
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::model::LatLon;
|
||||
use crate::rad::RadData;
|
||||
|
||||
use super::{airport_pos, Leg, Route};
|
||||
|
||||
/// Spacing between DCT anchor points along the route.
|
||||
const ANCHOR_STEP_NM: f64 = 170.0;
|
||||
/// Max cross-track distance to accept a waypoint as an anchor.
|
||||
const ANCHOR_MAX_XTK_NM: f64 = 55.0;
|
||||
|
||||
/// Plan a FRA route from `from_icao` to `to_icao`. `dep_exits` are the SID exit
|
||||
/// fixes, `star_entries` the STAR entry fixes; the enroute portion is a DCT chain
|
||||
/// between them. `rad`, when given, removes RAD-forbidden directs.
|
||||
pub fn plan_fra(
|
||||
conn: &Connection,
|
||||
from_icao: &str,
|
||||
to_icao: &str,
|
||||
dep_sids: &[(String, String)],
|
||||
star_entries: &[(String, String)],
|
||||
cruise_fl: i32,
|
||||
rad: Option<&RadData>,
|
||||
) -> Result<Route> {
|
||||
let dep = airport_pos(conn, from_icao)?;
|
||||
let dst = airport_pos(conn, to_icao)?;
|
||||
|
||||
// Pick the SID exit / STAR entry that lies most *on the way* (minimises the
|
||||
// dep→fix→dest detour), not just the first that resolves — otherwise a SID
|
||||
// heading the wrong way (e.g. LFPG AGOP6A→RBT, south) gets chosen for a
|
||||
// northbound flight.
|
||||
let start = resolve_fix(conn, dep_sids, dep, dep, dst); // (sid_name, fix, pos)
|
||||
let end = resolve_fix(conn, star_entries, dst, dep, dst); // (star_name, fix, pos)
|
||||
let start_pos = start.as_ref().map(|(_, _, p)| *p).unwrap_or(dep);
|
||||
let end_pos = end.as_ref().map(|(_, _, p)| *p).unwrap_or(dst);
|
||||
|
||||
let wps = load_box(conn, dep, dst, 2.5)?;
|
||||
|
||||
// Ordered points: DEP → [SID exit] → anchors → [STAR entry] → DEST.
|
||||
let mut points: Vec<(String, LatLon)> = vec![(from_icao.to_uppercase(), dep)];
|
||||
if let Some((_, fix, pos)) = &start {
|
||||
points.push((fix.clone(), *pos));
|
||||
}
|
||||
|
||||
let span = start_pos.distance_nm(&end_pos);
|
||||
let n = (span / ANCHOR_STEP_NM).floor() as usize;
|
||||
for i in 1..=n {
|
||||
let frac = i as f64 / (n + 1) as f64;
|
||||
let target = interpolate(start_pos, end_pos, frac);
|
||||
if let Some((id, pos)) = nearest_wp(&wps, target, ANCHOR_MAX_XTK_NM) {
|
||||
let prev = &points.last().unwrap().0;
|
||||
if prev == &id {
|
||||
continue;
|
||||
}
|
||||
if let Some(rad) = rad {
|
||||
if rad.forbidden_dct(prev, &id, cruise_fl).is_some() {
|
||||
continue; // RAD forbids this direct — skip the anchor
|
||||
}
|
||||
}
|
||||
points.push((id, pos));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((_, fix, pos)) = &end {
|
||||
if points.last().map(|(id, _)| id != fix).unwrap_or(true) {
|
||||
points.push((fix.clone(), *pos));
|
||||
}
|
||||
}
|
||||
points.push((to_icao.to_uppercase(), dst));
|
||||
|
||||
// First leg = SID (dep → its exit fix), last leg = STAR (entry fix → dest),
|
||||
// middle legs are DCT.
|
||||
let sid = start.as_ref().map(|(n, _, _)| n.clone());
|
||||
let star = end.as_ref().map(|(n, _, _)| n.clone());
|
||||
let last = points.len().saturating_sub(2);
|
||||
let mut legs = Vec::with_capacity(last + 1);
|
||||
let mut total_nm = 0.0;
|
||||
for (i, w) in points.windows(2).enumerate() {
|
||||
let d = w[0].1.distance_nm(&w[1].1);
|
||||
total_nm += d;
|
||||
let airway = if i == 0 {
|
||||
sid.clone().unwrap_or_else(|| "DCT".to_owned())
|
||||
} else if i == last {
|
||||
star.clone().unwrap_or_else(|| "DCT".to_owned())
|
||||
} else {
|
||||
"DCT".to_owned()
|
||||
};
|
||||
legs.push(Leg {
|
||||
from: w[0].0.clone(),
|
||||
to: w[1].0.clone(),
|
||||
airway,
|
||||
dist_nm: d,
|
||||
});
|
||||
}
|
||||
Ok(Route {
|
||||
legs,
|
||||
total_nm,
|
||||
via_airways: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The (procedure, fix) best aligned with the flight: among connectors whose fix
|
||||
/// resolves to a position (disambiguated nearest `near`), the one minimising the
|
||||
/// `dep → fix → dst` detour. Returns `(procedure_name, fix_ident, position)`.
|
||||
fn resolve_fix(
|
||||
conn: &Connection,
|
||||
procs: &[(String, String)],
|
||||
near: LatLon,
|
||||
dep: LatLon,
|
||||
dst: LatLon,
|
||||
) -> Option<(String, String, LatLon)> {
|
||||
procs
|
||||
.iter()
|
||||
.filter_map(|(name, fix)| {
|
||||
point_pos(conn, fix, near).map(|p| (name.clone(), fix.to_uppercase(), p))
|
||||
})
|
||||
.min_by(|a, b| {
|
||||
let da = dep.distance_nm(&a.2) + a.2.distance_nm(&dst);
|
||||
let db = dep.distance_nm(&b.2) + b.2.distance_nm(&dst);
|
||||
da.total_cmp(&db)
|
||||
})
|
||||
}
|
||||
|
||||
/// Position of `ident` (waypoint or navaid), nearest to `near` when ambiguous.
|
||||
fn point_pos(conn: &Connection, ident: &str, near: LatLon) -> Option<LatLon> {
|
||||
let mut cands: Vec<LatLon> = Vec::new();
|
||||
for sql in [
|
||||
"SELECT lat, lon FROM waypoints WHERE ident = ?1",
|
||||
"SELECT lat, lon FROM navaids WHERE ident = ?1",
|
||||
] {
|
||||
if let Ok(mut stmt) = conn.prepare(sql) {
|
||||
if let Ok(rows) =
|
||||
stmt.query_map(params![ident], |r| Ok(LatLon::new(r.get(0)?, r.get(1)?)))
|
||||
{
|
||||
cands.extend(rows.flatten());
|
||||
}
|
||||
}
|
||||
}
|
||||
cands
|
||||
.into_iter()
|
||||
.min_by(|a, b| a.distance_nm(&near).total_cmp(&b.distance_nm(&near)))
|
||||
}
|
||||
|
||||
/// Load waypoints + navaids inside the dep/dest bounding box (+`margin` degrees).
|
||||
fn load_box(conn: &Connection, a: LatLon, b: LatLon, margin: f64) -> Result<Vec<(String, LatLon)>> {
|
||||
let (min_lat, max_lat) = (a.lat.min(b.lat) - margin, a.lat.max(b.lat) + margin);
|
||||
let (min_lon, max_lon) = (a.lon.min(b.lon) - margin, a.lon.max(b.lon) + margin);
|
||||
let mut out = Vec::new();
|
||||
for table in ["waypoints", "navaids"] {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT ident, lat, lon FROM {table} \
|
||||
WHERE lat BETWEEN ?1 AND ?2 AND lon BETWEEN ?3 AND ?4"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![min_lat, max_lat, min_lon, max_lon], |r| {
|
||||
Ok((r.get::<_, String>(0)?, LatLon::new(r.get(1)?, r.get(2)?)))
|
||||
})?;
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Nearest waypoint to `target` within `max_xtk_nm`.
|
||||
fn nearest_wp(wps: &[(String, LatLon)], target: LatLon, max_xtk_nm: f64) -> Option<(String, LatLon)> {
|
||||
wps.iter()
|
||||
.map(|(id, p)| (p.distance_nm(&target), id, p))
|
||||
.filter(|(d, _, _)| *d <= max_xtk_nm)
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.map(|(_, id, p)| (id.clone(), *p))
|
||||
}
|
||||
|
||||
/// Spherical (great-circle) interpolation between two positions at fraction `f`.
|
||||
fn interpolate(a: LatLon, b: LatLon, f: f64) -> LatLon {
|
||||
let (lat1, lon1) = (a.lat.to_radians(), a.lon.to_radians());
|
||||
let (lat2, lon2) = (b.lat.to_radians(), b.lon.to_radians());
|
||||
let dlat = (lat2 - lat1) / 2.0;
|
||||
let dlon = (lon2 - lon1) / 2.0;
|
||||
let hav = dlat.sin().powi(2) + lat1.cos() * lat2.cos() * dlon.sin().powi(2);
|
||||
let d = 2.0 * hav.sqrt().asin();
|
||||
if d.abs() < 1e-9 {
|
||||
return a;
|
||||
}
|
||||
let ca = ((1.0 - f) * d).sin() / d.sin();
|
||||
let cb = (f * d).sin() / d.sin();
|
||||
let x = ca * lat1.cos() * lon1.cos() + cb * lat2.cos() * lon2.cos();
|
||||
let y = ca * lat1.cos() * lon1.sin() + cb * lat2.cos() * lon2.sin();
|
||||
let z = ca * lat1.sin() + cb * lat2.sin();
|
||||
let lat = z.atan2((x * x + y * y).sqrt());
|
||||
let lon = y.atan2(x);
|
||||
LatLon::new(lat.to_degrees(), lon.to_degrees())
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! FRA routing over the **published** connectivity graph.
|
||||
//!
|
||||
//! [`crate::rad`] extracts the `A DCT B` point sequences from the RAD Annex 2
|
||||
//! VIA-clauses into a graph of allowed Free-Route directs. This router finds the
|
||||
//! shortest great-circle path through that graph from a SID exit fix to a STAR
|
||||
//! entry fix — so the enroute portion uses *real* published FRA points and
|
||||
//! directs (what IFPS accepts), instead of invented anchors.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BinaryHeap, HashMap};
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::model::LatLon;
|
||||
|
||||
use super::{airport_pos, Leg, Route};
|
||||
|
||||
/// Min-heap state for Dijkstra (ordered by ascending cost).
|
||||
struct State {
|
||||
cost: f64,
|
||||
node: String,
|
||||
}
|
||||
impl PartialEq for State {
|
||||
fn eq(&self, o: &Self) -> bool {
|
||||
self.cost == o.cost
|
||||
}
|
||||
}
|
||||
impl Eq for State {}
|
||||
impl PartialOrd for State {
|
||||
fn partial_cmp(&self, o: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(o))
|
||||
}
|
||||
}
|
||||
impl Ord for State {
|
||||
fn cmp(&self, o: &Self) -> Ordering {
|
||||
// reversed: smaller cost = higher priority
|
||||
o.cost.total_cmp(&self.cost)
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan a route through the FRA graph `adj` (point → neighbours). Chooses the SID
|
||||
/// exit / STAR entry and the intermediate directs that minimise total distance.
|
||||
/// Returns `None` when no SID exit and STAR entry are connected in the graph.
|
||||
pub fn plan_fra_graph(
|
||||
conn: &Connection,
|
||||
from_icao: &str,
|
||||
to_icao: &str,
|
||||
dep_sids: &[(String, String)],
|
||||
star_entries: &[(String, String)],
|
||||
adj: &HashMap<String, Vec<String>>,
|
||||
) -> Result<Option<Route>> {
|
||||
if adj.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let dep = airport_pos(conn, from_icao)?;
|
||||
let dst = airport_pos(conn, to_icao)?;
|
||||
let mid = LatLon::new((dep.lat + dst.lat) / 2.0, (dep.lon + dst.lon) / 2.0);
|
||||
|
||||
// Load every navdata point inside the dep→dest corridor (2 queries), keeping
|
||||
// the one nearest the corridor mid per ident. FRA nodes outside the corridor
|
||||
// are dropped — they can't be on a sensible path anyway. This bounds both the
|
||||
// query count and the graph size (fast enough for interactive planning).
|
||||
let pos = load_corridor(conn, dep, dst, mid, 3.0)?;
|
||||
|
||||
// SID exit / STAR entry candidates that are present (and placeable) in the graph.
|
||||
let name_of = |list: &[(String, String)]| -> HashMap<String, String> {
|
||||
let mut m = HashMap::new();
|
||||
for (name, fix) in list {
|
||||
m.entry(fix.to_uppercase()).or_insert_with(|| name.clone());
|
||||
}
|
||||
m
|
||||
};
|
||||
let sid_name = name_of(dep_sids);
|
||||
let star_name = name_of(star_entries);
|
||||
let starts: Vec<String> = sid_name.keys().filter(|f| pos.contains_key(*f)).cloned().collect();
|
||||
let goals: std::collections::HashSet<String> =
|
||||
star_name.keys().filter(|f| pos.contains_key(*f)).cloned().collect();
|
||||
if starts.is_empty() || goals.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Dijkstra from all starts (seeded by the DEP→exit leg distance) to any goal;
|
||||
// goal cost includes the goal→DEST leg so we pick the best overall gateway.
|
||||
let mut dist: HashMap<String, f64> = HashMap::new();
|
||||
let mut prev: HashMap<String, String> = HashMap::new();
|
||||
let mut heap = BinaryHeap::new();
|
||||
for s in &starts {
|
||||
let d0 = dep.distance_nm(&pos[s]);
|
||||
if dist.get(s).map(|&d| d0 < d).unwrap_or(true) {
|
||||
dist.insert(s.clone(), d0);
|
||||
heap.push(State { cost: d0, node: s.clone() });
|
||||
}
|
||||
}
|
||||
|
||||
let mut best_goal: Option<(String, f64)> = None;
|
||||
while let Some(State { cost, node }) = heap.pop() {
|
||||
if cost > *dist.get(&node).unwrap_or(&f64::INFINITY) {
|
||||
continue;
|
||||
}
|
||||
if goals.contains(&node) {
|
||||
let total = cost + pos[&node].distance_nm(&dst);
|
||||
if best_goal.as_ref().map(|(_, c)| total < *c).unwrap_or(true) {
|
||||
best_goal = Some((node.clone(), total));
|
||||
}
|
||||
}
|
||||
let pu = pos[&node];
|
||||
for v in adj.get(&node).into_iter().flatten() {
|
||||
let Some(pv) = pos.get(v) else { continue };
|
||||
let nd = cost + pu.distance_nm(pv);
|
||||
if nd < *dist.get(v).unwrap_or(&f64::INFINITY) {
|
||||
dist.insert(v.clone(), nd);
|
||||
prev.insert(v.clone(), node.clone());
|
||||
heap.push(State { cost: nd, node: v.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some((goal, _)) = best_goal else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Reconstruct the point path start → … → goal.
|
||||
let mut path = vec![goal.clone()];
|
||||
let mut cur = goal.clone();
|
||||
while let Some(p) = prev.get(&cur) {
|
||||
path.push(p.clone());
|
||||
cur = p.clone();
|
||||
}
|
||||
path.reverse();
|
||||
|
||||
// Assemble the route: DEP →(SID) path[0] → DCT … → path[last] →(STAR) DEST.
|
||||
let mut pts: Vec<(String, LatLon)> = vec![(from_icao.to_uppercase(), dep)];
|
||||
for id in &path {
|
||||
pts.push((id.clone(), pos[id]));
|
||||
}
|
||||
pts.push((to_icao.to_uppercase(), dst));
|
||||
|
||||
let sid = sid_name.get(&path[0]).cloned().unwrap_or_else(|| "DCT".into());
|
||||
let star = star_name.get(&goal).cloned().unwrap_or_else(|| "DCT".into());
|
||||
let last = pts.len().saturating_sub(2);
|
||||
let mut legs = Vec::with_capacity(last + 1);
|
||||
let mut total_nm = 0.0;
|
||||
for (i, w) in pts.windows(2).enumerate() {
|
||||
let d = w[0].1.distance_nm(&w[1].1);
|
||||
total_nm += d;
|
||||
let airway = if i == 0 {
|
||||
sid.clone()
|
||||
} else if i == last {
|
||||
star.clone()
|
||||
} else {
|
||||
"DCT".to_owned()
|
||||
};
|
||||
legs.push(Leg { from: w[0].0.clone(), to: w[1].0.clone(), airway, dist_nm: d });
|
||||
}
|
||||
Ok(Some(Route { legs, total_nm, via_airways: false }))
|
||||
}
|
||||
|
||||
/// All navdata points (waypoints + navaids) inside the dep/dest bounding box
|
||||
/// (+`margin` degrees), as ident → position, keeping the one nearest `near` when
|
||||
/// an ident occurs more than once.
|
||||
fn load_corridor(
|
||||
conn: &Connection,
|
||||
a: LatLon,
|
||||
b: LatLon,
|
||||
near: LatLon,
|
||||
margin: f64,
|
||||
) -> Result<HashMap<String, LatLon>> {
|
||||
let (min_lat, max_lat) = (a.lat.min(b.lat) - margin, a.lat.max(b.lat) + margin);
|
||||
let (min_lon, max_lon) = (a.lon.min(b.lon) - margin, a.lon.max(b.lon) + margin);
|
||||
let mut out: HashMap<String, LatLon> = HashMap::new();
|
||||
for table in ["waypoints", "navaids"] {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT ident, lat, lon FROM {table} \
|
||||
WHERE lat BETWEEN ?1 AND ?2 AND lon BETWEEN ?3 AND ?4"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![min_lat, max_lat, min_lon, max_lon], |r| {
|
||||
Ok((r.get::<_, String>(0)?, LatLon::new(r.get(1)?, r.get(2)?)))
|
||||
})?;
|
||||
for row in rows.flatten() {
|
||||
let (ident, p) = row;
|
||||
out.entry(ident)
|
||||
.and_modify(|cur| {
|
||||
if p.distance_nm(&near) < cur.distance_nm(&near) {
|
||||
*cur = p;
|
||||
}
|
||||
})
|
||||
.or_insert(p);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Routing over the **official EUROCONTROL FRA points** with a connectivity graph.
|
||||
//!
|
||||
//! The FRA points list gives the point catalog + roles (E/X/I, A/D) + level
|
||||
//! bands, but not which points connect. In Free Route Airspace you may DCT
|
||||
//! between two points **only within the same FRA area** (a point in several areas
|
||||
//! bridges them); crossing longer legs or wrong-area pairs is what IFPS rejects
|
||||
//! (`ROUTE52`, `ROUTE165`). So this router builds a graph whose edges join points
|
||||
//! that **share an FRA area** (within a DCT-length cap, both level-valid, not
|
||||
//! RAD-forbidden) and A*-searches from a real **departure** point (`D`/`E`) to a
|
||||
//! real **arrival** point (`A`/`X`). The IFPUV oracle + repair loop handle the
|
||||
//! residual (TMA airway splices, mandatory routings).
|
||||
|
||||
use petgraph::algo::astar;
|
||||
use petgraph::graph::{DiGraph, NodeIndex};
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::model::LatLon;
|
||||
use crate::rad::{FraPoint, RadData};
|
||||
|
||||
use super::{airport_pos, Leg, Route};
|
||||
|
||||
/// How far from the airport a departure/arrival FRA point may sit.
|
||||
const TERMINAL_RADIUS_NM: f64 = 500.0;
|
||||
/// Corridor bounding-box margin (degrees) around the dep→dest line.
|
||||
const CORRIDOR_MARGIN: f64 = 3.0;
|
||||
/// Max length of a single FRA DCT edge (nm). Nearby FRA points (incl. across an
|
||||
/// area boundary — the border crossing) connect; longer TMA legs are caught by
|
||||
/// the oracle (`ROUTE165`) and repaired with airways.
|
||||
const MAX_FRA_DCT_NM: f64 = 150.0;
|
||||
/// Per-hop penalty (nm) added to each edge so A* prefers fewer, longer DCTs
|
||||
/// (real routes use ~8 points, not one per nearby FRA point).
|
||||
const HOP_PENALTY_NM: f64 = 25.0;
|
||||
|
||||
fn pos(p: &FraPoint) -> LatLon {
|
||||
LatLon::new(p.lat, p.lon)
|
||||
}
|
||||
|
||||
/// Plan a Free-Route path through the official FRA points from `from` to `to` at
|
||||
/// `cruise_fl`, routing only along same-area DCT edges. `None` if no connected
|
||||
/// path between a departure and an arrival FRA point exists.
|
||||
pub fn plan_fra_points(
|
||||
conn: &Connection,
|
||||
from: &str,
|
||||
to: &str,
|
||||
cruise_fl: i32,
|
||||
points: &[FraPoint],
|
||||
rad: Option<&RadData>,
|
||||
) -> Result<Option<Route>> {
|
||||
if points.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let dep = airport_pos(conn, from)?;
|
||||
let dst = airport_pos(conn, to)?;
|
||||
let fl = cruise_fl;
|
||||
let d0 = dep.distance_nm(&dst);
|
||||
|
||||
// Corridor: level-valid FRA points inside the dep→dest bounding box.
|
||||
let (min_lat, max_lat) = (dep.lat.min(dst.lat) - CORRIDOR_MARGIN, dep.lat.max(dst.lat) + CORRIDOR_MARGIN);
|
||||
let (min_lon, max_lon) = (dep.lon.min(dst.lon) - CORRIDOR_MARGIN, dep.lon.max(dst.lon) + CORRIDOR_MARGIN);
|
||||
let corridor: Vec<&FraPoint> = points
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.covers_fl(fl)
|
||||
&& p.lat >= min_lat && p.lat <= max_lat
|
||||
&& p.lon >= min_lon && p.lon <= max_lon
|
||||
})
|
||||
.collect();
|
||||
if corridor.len() < 2 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Departure / arrival points (indices into `corridor`).
|
||||
let dep_i = corridor
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| (p.arrdep.contains('D') || p.enroute == "E" || p.enroute == "EX")
|
||||
&& dep.distance_nm(&pos(p)) <= TERMINAL_RADIUS_NM
|
||||
&& pos(p).distance_nm(&dst) < d0)
|
||||
.min_by(|(_, a), (_, b)| dep.distance_nm(&pos(a)).total_cmp(&dep.distance_nm(&pos(b))))
|
||||
.map(|(i, _)| i);
|
||||
let arr_i = corridor
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| (p.arrdep.contains('A') || p.enroute == "X" || p.enroute == "EX")
|
||||
&& dst.distance_nm(&pos(p)) <= TERMINAL_RADIUS_NM
|
||||
&& pos(p).distance_nm(&dep) < d0)
|
||||
.min_by(|(_, a), (_, b)| dst.distance_nm(&pos(a)).total_cmp(&dst.distance_nm(&pos(b))))
|
||||
.map(|(i, _)| i);
|
||||
let (Some(dep_i), Some(arr_i)) = (dep_i, arr_i) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if dep_i == arr_i {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Graph: each corridor FRA point → its K nearest within the DCT cap (not
|
||||
// RAD-forbidden at this FL). Node weight = corridor index.
|
||||
let mut g: DiGraph<usize, f64> = DiGraph::new();
|
||||
let node: Vec<NodeIndex> = (0..corridor.len()).map(|i| g.add_node(i)).collect();
|
||||
// (RAD-forbidden directs are left to the IFPUV oracle — checking every one of
|
||||
// the thousands of DCT restrictions inside this O(N²) loop is too slow.)
|
||||
let _ = rad;
|
||||
for i in 0..corridor.len() {
|
||||
let pi = pos(corridor[i]);
|
||||
for j in 0..corridor.len() {
|
||||
if i == j {
|
||||
continue;
|
||||
}
|
||||
let d = pi.distance_nm(&pos(corridor[j]));
|
||||
if d > MAX_FRA_DCT_NM {
|
||||
continue;
|
||||
}
|
||||
// Per-hop penalty ⇒ A* prefers fewer, longer DCTs.
|
||||
g.add_edge(node[i], node[j], d + HOP_PENALTY_NM);
|
||||
}
|
||||
}
|
||||
|
||||
let target = pos(corridor[arr_i]);
|
||||
let result = astar(
|
||||
&g,
|
||||
node[dep_i],
|
||||
|n| n == node[arr_i],
|
||||
|e| *e.weight(),
|
||||
|n| pos(corridor[g[n]]).distance_nm(&target),
|
||||
);
|
||||
let Some((_, path)) = result else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Route: DEP → dep_pt → … → arr_pt → DEST (all DCT; IFPS derives SID/STAR).
|
||||
let mut pts: Vec<(String, LatLon)> = vec![(from.to_uppercase(), dep)];
|
||||
for n in &path {
|
||||
let p = corridor[g[*n]];
|
||||
pts.push((p.name.clone(), pos(p)));
|
||||
}
|
||||
pts.push((to.to_uppercase(), dst));
|
||||
|
||||
let mut legs = Vec::with_capacity(pts.len() - 1);
|
||||
let mut total_nm = 0.0;
|
||||
for w in pts.windows(2) {
|
||||
let d = w[0].1.distance_nm(&w[1].1);
|
||||
total_nm += d;
|
||||
legs.push(Leg { from: w[0].0.clone(), to: w[1].0.clone(), airway: "DCT".to_owned(), dist_nm: d });
|
||||
}
|
||||
Ok(Some(Route { legs, total_nm, via_airways: false }))
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! In-memory airway graph built from SQLite, used by A* routing.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use petgraph::graph::{DiGraph, NodeIndex};
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::model::LatLon;
|
||||
|
||||
/// A routable point (airway endpoint) in the graph.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NodeData {
|
||||
pub ident: String,
|
||||
pub region: String,
|
||||
pub pos: LatLon,
|
||||
}
|
||||
|
||||
/// An airway (or DCT connector) edge between two points.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EdgeData {
|
||||
pub airway: String,
|
||||
pub dist_nm: f64,
|
||||
pub base_fl: i32,
|
||||
pub top_fl: i32,
|
||||
}
|
||||
|
||||
/// Whether a flight level lies within an airway segment's `base…top` band
|
||||
/// (`top <= 0` means unlimited/unknown).
|
||||
pub fn fl_in_band(fl: i32, base_fl: i32, top_fl: i32) -> bool {
|
||||
fl >= base_fl && (top_fl <= 0 || fl <= top_fl)
|
||||
}
|
||||
|
||||
/// The directed airway network.
|
||||
pub struct RouteGraph {
|
||||
pub g: DiGraph<NodeData, EdgeData>,
|
||||
}
|
||||
|
||||
impl RouteGraph {
|
||||
/// Build the graph from `waypoints`/`navaids` (positions) and
|
||||
/// `airway_segments` (edges). Segments whose endpoints have no known
|
||||
/// position are skipped. When `cruise_fl` is `Some`, only airways valid at
|
||||
/// that flight level (within their `base…top` band) are included.
|
||||
pub fn build(conn: &Connection, cruise_fl: Option<i32>) -> Result<Self> {
|
||||
let positions = load_positions(conn)?;
|
||||
let mut g = DiGraph::new();
|
||||
let mut index: HashMap<(String, String), NodeIndex> = HashMap::new();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT from_ident, from_region, to_ident, to_region, direction, airway_name, base_fl, top_fl \
|
||||
FROM airway_segments",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, String>(4)?,
|
||||
r.get::<_, String>(5)?,
|
||||
r.get::<_, i32>(6)?,
|
||||
r.get::<_, i32>(7)?,
|
||||
))
|
||||
})?;
|
||||
|
||||
for row in rows {
|
||||
let (fi, fr, ti, tr, dir, awy, base_fl, top_fl) = row?;
|
||||
if let Some(fl) = cruise_fl {
|
||||
if !fl_in_band(fl, base_fl, top_fl) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let (fp, tp) = match (
|
||||
positions.get(&(fi.clone(), fr.clone())),
|
||||
positions.get(&(ti.clone(), tr.clone())),
|
||||
) {
|
||||
(Some(fp), Some(tp)) => (*fp, *tp),
|
||||
_ => continue,
|
||||
};
|
||||
let a = node_or_insert(&mut g, &mut index, fi, fr, fp);
|
||||
let b = node_or_insert(&mut g, &mut index, ti, tr, tp);
|
||||
let dist_nm = fp.distance_nm(&tp);
|
||||
let forward = matches!(dir.chars().next(), Some('N') | Some('F'));
|
||||
let backward = matches!(dir.chars().next(), Some('N') | Some('B'));
|
||||
if forward {
|
||||
g.add_edge(
|
||||
a,
|
||||
b,
|
||||
EdgeData {
|
||||
airway: awy.clone(),
|
||||
dist_nm,
|
||||
base_fl,
|
||||
top_fl,
|
||||
},
|
||||
);
|
||||
}
|
||||
if backward {
|
||||
g.add_edge(
|
||||
b,
|
||||
a,
|
||||
EdgeData {
|
||||
airway: awy,
|
||||
dist_nm,
|
||||
base_fl,
|
||||
top_fl,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(Self { g })
|
||||
}
|
||||
|
||||
/// Number of graph nodes (airway endpoints).
|
||||
pub fn node_count(&self) -> usize {
|
||||
self.g.node_count()
|
||||
}
|
||||
|
||||
/// Any graph node with `ident` (first match). Used when a rough position
|
||||
/// hint isn't available (e.g. splicing an airway sub-path between two fixes).
|
||||
pub fn find_ident(&self, ident: &str) -> Option<NodeIndex> {
|
||||
self.g
|
||||
.node_indices()
|
||||
.find(|&ix| self.g[ix].ident.eq_ignore_ascii_case(ident))
|
||||
}
|
||||
|
||||
/// The graph node with `ident` (nearest to `near` when it repeats across
|
||||
/// regions). Used to connect an airport to a named SID/STAR fix.
|
||||
pub fn node_by_ident(&self, ident: &str, near: LatLon) -> Option<NodeIndex> {
|
||||
self.g
|
||||
.node_indices()
|
||||
.filter(|&ix| self.g[ix].ident.eq_ignore_ascii_case(ident))
|
||||
.min_by(|&a, &b| {
|
||||
self.g[a]
|
||||
.pos
|
||||
.distance_nm(&near)
|
||||
.total_cmp(&self.g[b].pos.distance_nm(&near))
|
||||
})
|
||||
}
|
||||
|
||||
/// The `k` graph nodes closest to `from`, within `max_nm`, nearest first.
|
||||
pub fn nearest_nodes(&self, from: LatLon, k: usize, max_nm: f64) -> Vec<NodeIndex> {
|
||||
let mut candidates: Vec<(f64, NodeIndex)> = self
|
||||
.g
|
||||
.node_indices()
|
||||
.filter_map(|ix| {
|
||||
let d = self.g[ix].pos.distance_nm(&from);
|
||||
(d <= max_nm).then_some((d, ix))
|
||||
})
|
||||
.collect();
|
||||
candidates.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
candidates.truncate(k);
|
||||
candidates.into_iter().map(|(_, ix)| ix).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn node_or_insert(
|
||||
g: &mut DiGraph<NodeData, EdgeData>,
|
||||
index: &mut HashMap<(String, String), NodeIndex>,
|
||||
ident: String,
|
||||
region: String,
|
||||
pos: LatLon,
|
||||
) -> NodeIndex {
|
||||
if let Some(ix) = index.get(&(ident.clone(), region.clone())) {
|
||||
return *ix;
|
||||
}
|
||||
let ix = g.add_node(NodeData {
|
||||
ident: ident.clone(),
|
||||
region: region.clone(),
|
||||
pos,
|
||||
});
|
||||
index.insert((ident, region), ix);
|
||||
ix
|
||||
}
|
||||
|
||||
fn load_positions(conn: &Connection) -> Result<HashMap<(String, String), LatLon>> {
|
||||
let mut map: HashMap<(String, String), LatLon> = HashMap::new();
|
||||
for table in ["waypoints", "navaids"] {
|
||||
let mut stmt = conn.prepare(&format!("SELECT ident, region, lat, lon FROM {table}"))?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
(r.get::<_, String>(0)?, r.get::<_, String>(1)?),
|
||||
LatLon::new(r.get(2)?, r.get(3)?),
|
||||
))
|
||||
})?;
|
||||
for row in rows {
|
||||
let (key, pos) = row?;
|
||||
map.entry(key).or_insert(pos);
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Dense free-route (DCT) routing over the navdata point cloud.
|
||||
//!
|
||||
//! Modern European enroute is Free Route Airspace: you may fly DCT between
|
||||
//! (almost) any two published points, subject to length limits. Our RAD-derived
|
||||
//! FRA graph ([`super::fra_graph`]) only has the *explicitly published* VIA pairs,
|
||||
//! so it's far too sparse to find real routes (e.g. Paris–Nice `LATRA DCT LAMUT
|
||||
//! … NISAR`). This router instead builds a **k-nearest-neighbour DCT graph** over
|
||||
//! every waypoint/navaid in the dep→dest corridor and runs A* — yielding the
|
||||
//! near-great-circle path through real points (the optimal free-route shape).
|
||||
//! RAD-forbidden directs are skipped; the IFPUV oracle repairs residual issues
|
||||
//! (e.g. TMA segments where DCT is banned → airway splice) in [`super::discover`].
|
||||
|
||||
use petgraph::algo::astar;
|
||||
use petgraph::graph::{DiGraph, NodeIndex};
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::model::LatLon;
|
||||
use crate::rad::RadData;
|
||||
|
||||
use super::{airport_pos, Leg, Route};
|
||||
|
||||
/// Max length of a single DCT edge (nm). Most FRA areas allow long directs; TMAs
|
||||
/// don't, but the oracle repairs those. Keeps the kNN graph sparse.
|
||||
const MAX_DCT_NM: f64 = 220.0;
|
||||
/// Neighbours per node in the DCT graph.
|
||||
const K: usize = 10;
|
||||
/// How many nearest corridor points an airport connects to (when no SID/STAR).
|
||||
const CONNECT_K: usize = 12;
|
||||
|
||||
/// Plan a dense free-route (DCT) path from `from` to `to`. `dep_conn`/`dest_conn`
|
||||
/// are SID exit / STAR entry fix idents used to anchor the terminal connection.
|
||||
pub fn plan_hybrid(
|
||||
conn: &Connection,
|
||||
from: &str,
|
||||
to: &str,
|
||||
dep_conn: &[String],
|
||||
dest_conn: &[String],
|
||||
cruise_fl: i32,
|
||||
rad: Option<&RadData>,
|
||||
) -> Result<Option<Route>> {
|
||||
let dep = airport_pos(conn, from)?;
|
||||
let dst = airport_pos(conn, to)?;
|
||||
let pts = load_corridor(conn, dep, dst, 2.5)?;
|
||||
if pts.len() < 2 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut g: DiGraph<(String, LatLon), f64> = DiGraph::new();
|
||||
let node: Vec<NodeIndex> = pts.iter().map(|(id, p)| g.add_node((id.clone(), *p))).collect();
|
||||
|
||||
// kNN DCT edges (bidirectional), skipping RAD-forbidden directs at this FL.
|
||||
for i in 0..pts.len() {
|
||||
let mut nbrs: Vec<(f64, usize)> = (0..pts.len())
|
||||
.filter(|&j| j != i)
|
||||
.map(|j| (pts[i].1.distance_nm(&pts[j].1), j))
|
||||
.filter(|(d, _)| *d <= MAX_DCT_NM)
|
||||
.collect();
|
||||
nbrs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
for (d, j) in nbrs.into_iter().take(K) {
|
||||
if let Some(rad) = rad {
|
||||
if rad.forbidden_dct(&pts[i].0, &pts[j].0, cruise_fl).is_some() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
g.add_edge(node[i], node[j], d);
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal connection: airport → SID exit fixes (or nearest corridor points).
|
||||
let dep_ix = g.add_node((from.to_uppercase(), dep));
|
||||
let dst_ix = g.add_node((to.to_uppercase(), dst));
|
||||
connect(&mut g, &pts, &node, dep_ix, dep, dep_conn, true);
|
||||
connect(&mut g, &pts, &node, dst_ix, dst, dest_conn, false);
|
||||
|
||||
let result = astar(
|
||||
&g,
|
||||
dep_ix,
|
||||
|n| n == dst_ix,
|
||||
|e| *e.weight(),
|
||||
|n| g[n].1.distance_nm(&dst),
|
||||
);
|
||||
let Some((total_nm, path)) = result else {
|
||||
return Ok(None);
|
||||
};
|
||||
if path.len() < 2 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut legs = Vec::with_capacity(path.len() - 1);
|
||||
for w in path.windows(2) {
|
||||
let (a, b) = (&g[w[0]], &g[w[1]]);
|
||||
legs.push(Leg {
|
||||
from: a.0.clone(),
|
||||
to: b.0.clone(),
|
||||
airway: "DCT".to_owned(),
|
||||
dist_nm: a.1.distance_nm(&b.1),
|
||||
});
|
||||
}
|
||||
Ok(Some(Route { legs, total_nm, via_airways: false }))
|
||||
}
|
||||
|
||||
/// Wire an airport node to its SID/STAR connector fixes (matched by ident), or to
|
||||
/// the nearest corridor points when none resolve. `outbound` = airport→fix.
|
||||
fn connect(
|
||||
g: &mut DiGraph<(String, LatLon), f64>,
|
||||
pts: &[(String, LatLon)],
|
||||
node: &[NodeIndex],
|
||||
apt_ix: NodeIndex,
|
||||
apt: LatLon,
|
||||
conn_fixes: &[String],
|
||||
outbound: bool,
|
||||
) {
|
||||
let mut targets: Vec<usize> = conn_fixes
|
||||
.iter()
|
||||
.filter_map(|f| pts.iter().position(|(id, _)| id.eq_ignore_ascii_case(f)))
|
||||
.collect();
|
||||
if targets.is_empty() {
|
||||
let mut near: Vec<(f64, usize)> =
|
||||
pts.iter().enumerate().map(|(i, (_, p))| (apt.distance_nm(p), i)).collect();
|
||||
near.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
targets = near.into_iter().take(CONNECT_K).map(|(_, i)| i).collect();
|
||||
}
|
||||
for i in targets {
|
||||
let d = apt.distance_nm(&pts[i].1);
|
||||
if outbound {
|
||||
g.add_edge(apt_ix, node[i], d);
|
||||
} else {
|
||||
g.add_edge(node[i], apt_ix, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A filable **enroute** waypoint ident: a 5-letter name code (5LNC), all
|
||||
/// alphabetic. Terminal/procedure fixes (e.g. `AT410`, `MA13`, `DE27R`, `PG271`)
|
||||
/// contain digits and are NOT valid enroute points — IFPS rejects them.
|
||||
fn is_enroute_wpt(ident: &str) -> bool {
|
||||
ident.len() == 5 && ident.chars().all(|c| c.is_ascii_uppercase())
|
||||
}
|
||||
|
||||
/// Navdata points inside the dep/dest bounding box (+`margin`°) usable for
|
||||
/// enroute DCT: 5LNC waypoints + all navaids. One per ident (nearest the corridor
|
||||
/// mid when repeated).
|
||||
fn load_corridor(conn: &Connection, a: LatLon, b: LatLon, margin: f64) -> Result<Vec<(String, LatLon)>> {
|
||||
let mid = LatLon::new((a.lat + b.lat) / 2.0, (a.lon + b.lon) / 2.0);
|
||||
let (min_lat, max_lat) = (a.lat.min(b.lat) - margin, a.lat.max(b.lat) + margin);
|
||||
let (min_lon, max_lon) = (a.lon.min(b.lon) - margin, a.lon.max(b.lon) + margin);
|
||||
let mut best: std::collections::HashMap<String, LatLon> = std::collections::HashMap::new();
|
||||
for table in ["waypoints", "navaids"] {
|
||||
let enroute_only = table == "waypoints";
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT ident, lat, lon FROM {table} WHERE lat BETWEEN ?1 AND ?2 AND lon BETWEEN ?3 AND ?4"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![min_lat, max_lat, min_lon, max_lon], |r| {
|
||||
Ok((r.get::<_, String>(0)?, LatLon::new(r.get(1)?, r.get(2)?)))
|
||||
})?;
|
||||
for row in rows.flatten() {
|
||||
let (ident, p) = row;
|
||||
if enroute_only && !is_enroute_wpt(&ident) {
|
||||
continue;
|
||||
}
|
||||
best.entry(ident)
|
||||
.and_modify(|cur| {
|
||||
if p.distance_nm(&mid) < cur.distance_nm(&mid) {
|
||||
*cur = p;
|
||||
}
|
||||
})
|
||||
.or_insert(p);
|
||||
}
|
||||
}
|
||||
Ok(best.into_iter().collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn enroute_ident_filter() {
|
||||
assert!(is_enroute_wpt("KESAX") && is_enroute_wpt("LATRA") && is_enroute_wpt("NISAR"));
|
||||
assert!(!is_enroute_wpt("AT410") && !is_enroute_wpt("MA13") && !is_enroute_wpt("DE27R"));
|
||||
assert!(!is_enroute_wpt("RBT")); // navaids come from the navaids table
|
||||
}
|
||||
|
||||
/// Prints the offline hybrid route for a few pairs (needs real.db). Ignored.
|
||||
/// `cargo test -p flightplanner-core prints_hybrid -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore = "needs real.db"]
|
||||
fn prints_hybrid_routes() {
|
||||
let Ok(conn) = Connection::open("../../real.db") else { return };
|
||||
for (f, t) in [("LFPG", "EGLL"), ("LFPG", "LFMN"), ("LFPG", "EDDF")] {
|
||||
match plan_hybrid(&conn, f, t, &[], &[], 360, None) {
|
||||
Ok(Some(r)) => {
|
||||
let n = r.legs.len();
|
||||
let item15: Vec<String> =
|
||||
r.legs.iter().take(n.saturating_sub(1)).map(|l| l.to.clone()).collect();
|
||||
eprintln!("{f}->{t} ({:.0} nm, {n} legs): {}", r.total_nm, item15.join(" DCT "));
|
||||
}
|
||||
other => eprintln!("{f}->{t}: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
//! Airway routing: build a graph from the airway network and run A* between two
|
||||
//! airports, connecting each airport to nearby airway points with DCT legs, and
|
||||
//! falling back to a single direct great-circle leg when no path is found.
|
||||
|
||||
pub mod discover;
|
||||
pub mod fra;
|
||||
pub mod fra_graph;
|
||||
pub mod fra_points;
|
||||
pub mod graph;
|
||||
pub mod hybrid;
|
||||
|
||||
use petgraph::algo::astar;
|
||||
use petgraph::graph::NodeIndex;
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::error::{CoreError, Result};
|
||||
use crate::model::LatLon;
|
||||
use crate::rad::RadData;
|
||||
use graph::{EdgeData, NodeData, RouteGraph};
|
||||
|
||||
/// Radius within which an airport is connected to airway points (DCT), and how
|
||||
/// many such entry/exit points to consider.
|
||||
const CONNECT_MAX_NM: f64 = 100.0;
|
||||
const CONNECT_K: usize = 30;
|
||||
|
||||
/// One leg of a computed route.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Leg {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
/// Airway name, or `"DCT"` for a direct leg.
|
||||
pub airway: String,
|
||||
pub dist_nm: f64,
|
||||
}
|
||||
|
||||
/// A computed route from departure to destination.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Route {
|
||||
pub legs: Vec<Leg>,
|
||||
pub total_nm: f64,
|
||||
/// `true` if routed through the airway graph, `false` for the direct fallback.
|
||||
pub via_airways: bool,
|
||||
}
|
||||
|
||||
impl Route {
|
||||
/// Flight-plan style string: `LFPG DCT ABEAM T100 BEACN … EGLL`.
|
||||
pub fn route_string(&self) -> String {
|
||||
let mut s = self
|
||||
.legs
|
||||
.first()
|
||||
.map(|l| l.from.clone())
|
||||
.unwrap_or_default();
|
||||
for leg in &self.legs {
|
||||
s.push_str(&format!(" {} {}", leg.airway, leg.to));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan a route between two airport ICAO codes. When `cruise_fl` is `Some`, only
|
||||
/// airways valid at that flight level are used.
|
||||
pub fn plan_route(
|
||||
conn: &Connection,
|
||||
from_icao: &str,
|
||||
to_icao: &str,
|
||||
cruise_fl: Option<i32>,
|
||||
) -> Result<Route> {
|
||||
plan_route_conn(conn, from_icao, to_icao, cruise_fl, &[], &[])
|
||||
}
|
||||
|
||||
/// Like [`plan_route`], but the departure/destination connect to the network via
|
||||
/// the given SID **exit** fixes / STAR **entry** fixes when any resolve to graph
|
||||
/// nodes; otherwise it falls back to the nearest airway points (DCT).
|
||||
pub fn plan_route_conn(
|
||||
conn: &Connection,
|
||||
from_icao: &str,
|
||||
to_icao: &str,
|
||||
cruise_fl: Option<i32>,
|
||||
dep_conn: &[String],
|
||||
dest_conn: &[String],
|
||||
) -> Result<Route> {
|
||||
let dep = airport_pos(conn, from_icao)?;
|
||||
let dst = airport_pos(conn, to_icao)?;
|
||||
|
||||
let mut rg = RouteGraph::build(conn, cruise_fl)?;
|
||||
let entries = resolve_conn(&rg, dep_conn, dep)
|
||||
.unwrap_or_else(|| rg.nearest_nodes(dep, CONNECT_K, CONNECT_MAX_NM));
|
||||
let exits = resolve_conn(&rg, dest_conn, dst)
|
||||
.unwrap_or_else(|| rg.nearest_nodes(dst, CONNECT_K, CONNECT_MAX_NM));
|
||||
|
||||
if entries.is_empty() || exits.is_empty() {
|
||||
return Ok(direct_route(from_icao, to_icao, dep, dst));
|
||||
}
|
||||
|
||||
// Add departure/destination as temporary nodes wired to nearby airway points.
|
||||
let dep_ix = rg.g.add_node(NodeData {
|
||||
ident: from_icao.to_owned(),
|
||||
region: String::new(),
|
||||
pos: dep,
|
||||
});
|
||||
for e in entries {
|
||||
let dist_nm = dep.distance_nm(&rg.g[e].pos);
|
||||
rg.g.add_edge(
|
||||
dep_ix,
|
||||
e,
|
||||
EdgeData {
|
||||
airway: "DCT".to_owned(),
|
||||
dist_nm,
|
||||
base_fl: 0,
|
||||
top_fl: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
let dst_ix = rg.g.add_node(NodeData {
|
||||
ident: to_icao.to_owned(),
|
||||
region: String::new(),
|
||||
pos: dst,
|
||||
});
|
||||
for e in exits {
|
||||
let dist_nm = rg.g[e].pos.distance_nm(&dst);
|
||||
rg.g.add_edge(
|
||||
e,
|
||||
dst_ix,
|
||||
EdgeData {
|
||||
airway: "DCT".to_owned(),
|
||||
dist_nm,
|
||||
base_fl: 0,
|
||||
top_fl: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let result = astar(
|
||||
&rg.g,
|
||||
dep_ix,
|
||||
|n| n == dst_ix,
|
||||
|e| e.weight().dist_nm,
|
||||
|n| rg.g[n].pos.distance_nm(&dst),
|
||||
);
|
||||
|
||||
match result {
|
||||
Some((total_nm, path)) => {
|
||||
let mut legs = Vec::with_capacity(path.len().saturating_sub(1));
|
||||
for pair in path.windows(2) {
|
||||
let (a, b) = (pair[0], pair[1]);
|
||||
let airway =
|
||||
rg.g.find_edge(a, b)
|
||||
.and_then(|e| rg.g.edge_weight(e))
|
||||
.map(|ed| ed.airway.clone())
|
||||
.unwrap_or_else(|| "DCT".to_owned());
|
||||
legs.push(Leg {
|
||||
from: rg.g[a].ident.clone(),
|
||||
to: rg.g[b].ident.clone(),
|
||||
airway,
|
||||
dist_nm: rg.g[a].pos.distance_nm(&rg.g[b].pos),
|
||||
});
|
||||
}
|
||||
Ok(Route {
|
||||
legs,
|
||||
total_nm,
|
||||
via_airways: true,
|
||||
})
|
||||
}
|
||||
None => Ok(direct_route(from_icao, to_icao, dep, dst)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve connector fix idents (SID exits / STAR entries) to graph nodes.
|
||||
/// `None` when the list is empty or none resolve, so the caller falls back to
|
||||
/// nearest-airway-point connection.
|
||||
fn resolve_conn(rg: &RouteGraph, idents: &[String], near: LatLon) -> Option<Vec<NodeIndex>> {
|
||||
if idents.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let nodes: Vec<NodeIndex> = idents
|
||||
.iter()
|
||||
.filter_map(|id| rg.node_by_ident(id, near))
|
||||
.collect();
|
||||
(!nodes.is_empty()).then_some(nodes)
|
||||
}
|
||||
|
||||
/// Filtering airways to the cruise FL yields an IFPS-coherent route, but on some
|
||||
/// pairs the upper network is fragmented and forces an absurd detour; the full
|
||||
/// network gives the short route but may cite airways invalid at the FL. So we
|
||||
/// compute both and keep the FL-valid one unless it detours by >60 %.
|
||||
const FL_ROUTE_MAX_RATIO: f64 = 1.6;
|
||||
|
||||
/// Best airway route: FL-filtered when it doesn't detour too much, else full.
|
||||
pub fn plan_route_best(
|
||||
conn: &Connection,
|
||||
from: &str,
|
||||
to: &str,
|
||||
cruise_fl: Option<i32>,
|
||||
dep_conn: &[String],
|
||||
dest_conn: &[String],
|
||||
) -> Result<Route> {
|
||||
let full = plan_route_conn(conn, from, to, None, dep_conn, dest_conn)?;
|
||||
match cruise_fl {
|
||||
Some(fl) => {
|
||||
let fl_route = plan_route_conn(conn, from, to, Some(fl), dep_conn, dest_conn)?;
|
||||
if fl_route.via_airways && fl_route.total_nm <= FL_ROUTE_MAX_RATIO * full.total_nm.max(1.0) {
|
||||
Ok(fl_route)
|
||||
} else {
|
||||
Ok(full)
|
||||
}
|
||||
}
|
||||
None => Ok(full),
|
||||
}
|
||||
}
|
||||
|
||||
/// The route to file for modern European airspace, in preference order:
|
||||
/// 1. FRA **graph** (published Annex-2 directs), 2. FRA heuristic (great-circle
|
||||
/// anchors), 3. airway routing. `dep_sid`/`dest_star` are (procedure, fix) pairs.
|
||||
pub fn plan_preferred(
|
||||
conn: &Connection,
|
||||
from: &str,
|
||||
to: &str,
|
||||
dep_sid: &[(String, String)],
|
||||
dest_star: &[(String, String)],
|
||||
cruise_fl: Option<i32>,
|
||||
rad: Option<&RadData>,
|
||||
) -> Result<Route> {
|
||||
// Official FRA-points corridor first (real dep/arr/intermediate points).
|
||||
if let Some(r) = rad.filter(|r| !r.fra_points.is_empty()).and_then(|r| {
|
||||
fra_points::plan_fra_points(conn, from, to, cruise_fl.unwrap_or(350), &r.fra_points, Some(r))
|
||||
.ok()
|
||||
.flatten()
|
||||
}) {
|
||||
if r.legs.len() >= 2 {
|
||||
return Ok(r);
|
||||
}
|
||||
}
|
||||
let fra_adj = rad.map(|r| r.fra_adjacency()).unwrap_or_default();
|
||||
if let Some(r) = fra_graph::plan_fra_graph(conn, from, to, dep_sid, dest_star, &fra_adj)
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|r| r.legs.len() >= 2)
|
||||
{
|
||||
return Ok(r);
|
||||
}
|
||||
let dep_fixes: Vec<String> = dep_sid.iter().map(|(_, f)| f.clone()).collect();
|
||||
let dest_fixes: Vec<String> = dest_star.iter().map(|(_, f)| f.clone()).collect();
|
||||
match fra::plan_fra(conn, from, to, dep_sid, dest_star, cruise_fl.unwrap_or(350), rad) {
|
||||
Ok(r) if r.legs.len() >= 2 => Ok(r),
|
||||
_ => plan_route_best(conn, from, to, cruise_fl, &dep_fixes, &dest_fixes),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `t` looks like an airway designator (`UT421`, `UN570`, `L613`, `Y8`)
|
||||
/// rather than a fix: 1–3 leading letters, ≥1 digit, optional trailing letters.
|
||||
fn is_airway(t: &str) -> bool {
|
||||
let b = t.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() && b[i].is_ascii_uppercase() {
|
||||
i += 1;
|
||||
}
|
||||
if i == 0 || i > 3 {
|
||||
return false;
|
||||
}
|
||||
let after_letters = i;
|
||||
while i < b.len() && b[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
if i == after_letters {
|
||||
return false; // needs at least one digit
|
||||
}
|
||||
while i < b.len() && b[i].is_ascii_uppercase() {
|
||||
i += 1;
|
||||
}
|
||||
i == b.len()
|
||||
}
|
||||
|
||||
/// Position of `ident` (waypoint or navaid), nearest `near` when ambiguous.
|
||||
fn ident_pos(conn: &Connection, ident: &str, near: LatLon) -> Option<LatLon> {
|
||||
let mut cands: Vec<LatLon> = Vec::new();
|
||||
for sql in [
|
||||
"SELECT lat, lon FROM waypoints WHERE ident = ?1",
|
||||
"SELECT lat, lon FROM navaids WHERE ident = ?1",
|
||||
] {
|
||||
if let Ok(mut stmt) = conn.prepare(sql) {
|
||||
if let Ok(rows) = stmt.query_map(params![ident], |r| Ok(LatLon::new(r.get(0)?, r.get(1)?))) {
|
||||
cands.extend(rows.flatten());
|
||||
}
|
||||
}
|
||||
}
|
||||
cands.into_iter().min_by(|a, b| a.distance_nm(&near).total_cmp(&b.distance_nm(&near)))
|
||||
}
|
||||
|
||||
/// Parse a filed item-15 string (`OPALE UT421 BIG DCT KEF`) into a [`Route`]
|
||||
/// DEP → … → DEST, resolving fix positions from the DB. Airway tokens attach to
|
||||
/// the following leg; unresolvable tokens (e.g. lat/lon shorthand) are skipped.
|
||||
/// `None` when fewer than one usable enroute fix resolves. Used to turn a stored
|
||||
/// (e.g. imported PFPX) route seed into a repairable candidate.
|
||||
pub fn parse_route_string(conn: &Connection, from: &str, to: &str, item15: &str) -> Result<Option<Route>> {
|
||||
let dep = airport_pos(conn, from)?;
|
||||
let dst = airport_pos(conn, to)?;
|
||||
let mid = LatLon::new((dep.lat + dst.lat) / 2.0, (dep.lon + dst.lon) / 2.0);
|
||||
let mut points: Vec<(String, LatLon)> = vec![(from.to_uppercase(), dep)];
|
||||
let mut awys: Vec<String> = Vec::new();
|
||||
let mut pending = "DCT".to_string();
|
||||
for tok in item15.split_whitespace() {
|
||||
let t = tok.to_uppercase();
|
||||
if t == "DCT" || is_airway(&t) {
|
||||
pending = t;
|
||||
continue;
|
||||
}
|
||||
if let Some(p) = ident_pos(conn, &t, mid) {
|
||||
awys.push(std::mem::replace(&mut pending, "DCT".to_owned()));
|
||||
points.push((t, p));
|
||||
}
|
||||
}
|
||||
awys.push(pending); // DEP-last-fix … DEST hop
|
||||
points.push((to.to_uppercase(), dst));
|
||||
if points.len() < 3 {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut legs = Vec::with_capacity(points.len() - 1);
|
||||
let mut total_nm = 0.0;
|
||||
for i in 0..points.len() - 1 {
|
||||
let d = points[i].1.distance_nm(&points[i + 1].1);
|
||||
total_nm += d;
|
||||
legs.push(Leg {
|
||||
from: points[i].0.clone(),
|
||||
to: points[i + 1].0.clone(),
|
||||
airway: awys[i].clone(),
|
||||
dist_nm: d,
|
||||
});
|
||||
}
|
||||
Ok(Some(Route { legs, total_nm, via_airways: true }))
|
||||
}
|
||||
|
||||
/// Route the sub-segment between two enroute fixes through the airway network,
|
||||
/// returning the ordered legs (with airway names) — used to repair a DCT that
|
||||
/// IFPS rejects as "too long" (DCT not allowed in that TMA/area). `None` when
|
||||
/// either fix isn't an airway node or no path exists at `cruise_fl`.
|
||||
pub fn airway_path(
|
||||
conn: &Connection,
|
||||
from_ident: &str,
|
||||
to_ident: &str,
|
||||
cruise_fl: Option<i32>,
|
||||
) -> Result<Option<Vec<Leg>>> {
|
||||
let rg = RouteGraph::build(conn, cruise_fl)?;
|
||||
let (Some(a), Some(b)) = (rg.find_ident(from_ident), rg.find_ident(to_ident)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let target = rg.g[b].pos;
|
||||
let result = astar(
|
||||
&rg.g,
|
||||
a,
|
||||
|n| n == b,
|
||||
|e| e.weight().dist_nm,
|
||||
|n| rg.g[n].pos.distance_nm(&target),
|
||||
);
|
||||
let Some((_, path)) = result else {
|
||||
return Ok(None);
|
||||
};
|
||||
if path.len() < 2 {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut legs = Vec::with_capacity(path.len() - 1);
|
||||
for pair in path.windows(2) {
|
||||
let (x, y) = (pair[0], pair[1]);
|
||||
let airway = rg
|
||||
.g
|
||||
.find_edge(x, y)
|
||||
.and_then(|e| rg.g.edge_weight(e))
|
||||
.map(|ed| ed.airway.clone())
|
||||
.unwrap_or_else(|| "DCT".to_owned());
|
||||
legs.push(Leg {
|
||||
from: rg.g[x].ident.clone(),
|
||||
to: rg.g[y].ident.clone(),
|
||||
airway,
|
||||
dist_nm: rg.g[x].pos.distance_nm(&rg.g[y].pos),
|
||||
});
|
||||
}
|
||||
Ok(Some(legs))
|
||||
}
|
||||
|
||||
fn direct_route(from: &str, to: &str, dep: LatLon, dst: LatLon) -> Route {
|
||||
let dist_nm = dep.distance_nm(&dst);
|
||||
Route {
|
||||
legs: vec![Leg {
|
||||
from: from.to_owned(),
|
||||
to: to.to_owned(),
|
||||
airway: "DCT".to_owned(),
|
||||
dist_nm,
|
||||
}],
|
||||
total_nm: dist_nm,
|
||||
via_airways: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn airport_pos(conn: &Connection, icao: &str) -> Result<LatLon> {
|
||||
conn.query_row(
|
||||
"SELECT lat, lon FROM airports WHERE icao = ?1",
|
||||
params![icao],
|
||||
|r| Ok(LatLon::new(r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.map_err(|e| match e {
|
||||
rusqlite::Error::QueryReturnedNoRows => {
|
||||
CoreError::NotFound(format!("airport {icao} (import navdata first?)"))
|
||||
}
|
||||
other => other.into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the ordered waypoints of a computed `route` to geographic positions
|
||||
/// (for drawing on a map). Endpoints match airports first, then en-route
|
||||
/// fixes/navaids; when an ident repeats across regions the candidate nearest the
|
||||
/// previous point is chosen. Idents that can't be resolved are skipped.
|
||||
pub fn resolve_geometry(conn: &Connection, route: &Route) -> Result<Vec<(String, LatLon)>> {
|
||||
let mut idents: Vec<&str> = Vec::new();
|
||||
if let Some(first) = route.legs.first() {
|
||||
idents.push(first.from.as_str());
|
||||
}
|
||||
for l in &route.legs {
|
||||
idents.push(l.to.as_str());
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(idents.len());
|
||||
let mut prev: Option<LatLon> = None;
|
||||
for id in idents {
|
||||
if let Some(pos) = lookup_pos(conn, id, prev)? {
|
||||
out.push((id.to_owned(), pos));
|
||||
prev = Some(pos);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn lookup_pos(conn: &Connection, ident: &str, near: Option<LatLon>) -> Result<Option<LatLon>> {
|
||||
// Airports resolve uniquely by ICAO.
|
||||
if let Ok(pos) = conn.query_row(
|
||||
"SELECT lat, lon FROM airports WHERE icao = ?1",
|
||||
params![ident],
|
||||
|r| Ok(LatLon::new(r.get(0)?, r.get(1)?)),
|
||||
) {
|
||||
return Ok(Some(pos));
|
||||
}
|
||||
|
||||
// En-route fixes and navaids may repeat across regions; collect candidates.
|
||||
let mut cands: Vec<LatLon> = Vec::new();
|
||||
for sql in [
|
||||
"SELECT lat, lon FROM waypoints WHERE ident = ?1",
|
||||
"SELECT lat, lon FROM navaids WHERE ident = ?1",
|
||||
] {
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt.query_map(params![ident], |r| Ok(LatLon::new(r.get(0)?, r.get(1)?)))?;
|
||||
for row in rows {
|
||||
cands.push(row?);
|
||||
}
|
||||
}
|
||||
let pick = match near {
|
||||
Some(p) => cands.into_iter().min_by(|a, b| {
|
||||
a.distance_nm(&p)
|
||||
.partial_cmp(&b.distance_nm(&p))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
}),
|
||||
None => cands.into_iter().next(),
|
||||
};
|
||||
Ok(pick)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Step-5 test: export a fixture route to `.pln`, `.fms` and OFP text.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use flightplanner_core::{db, export, routing};
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn fixtures_db() -> Connection {
|
||||
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
db::import_navdata(&mut conn, &dir).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_pln_fms_and_ofp() {
|
||||
let conn = fixtures_db();
|
||||
let route = routing::plan_route(&conn, "LFPG", "EGLL", None).unwrap();
|
||||
|
||||
let pln = export::pln::to_pln(&conn, &route, 34000).unwrap();
|
||||
assert!(pln.contains("<SimBase.Document"));
|
||||
assert!(pln.contains("<DepartureID>LFPG</DepartureID>"));
|
||||
assert!(pln.contains("<DestinationID>EGLL</DestinationID>"));
|
||||
assert!(pln.contains("<ATCWaypointType>Airport</ATCWaypointType>"));
|
||||
|
||||
let fms = export::fms::to_fms(&conn, &route, 34000).unwrap();
|
||||
assert!(fms.starts_with("I\n1100 Version"));
|
||||
assert!(fms.contains("ADEP LFPG"));
|
||||
assert!(fms.contains("ADES EGLL"));
|
||||
assert!(fms.contains("NUMENR "));
|
||||
|
||||
let ofp = export::ofp::to_ofp(&route, None, Some("A320"), Some(340));
|
||||
assert!(ofp.contains("OPERATIONAL FLIGHT PLAN"));
|
||||
assert!(ofp.contains("LFPG -> EGLL"));
|
||||
assert!(ofp.contains("FL340"));
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
RWY:RW09L, , ,00079, , ,3, ;N51285200,W000273600,0000;
|
||||
RWY:RW27R, , ,00078, , ,3, ;N51284900,W000260900,0000;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
RWY:RW08L, , ,00338, ,GLE ,3, ;N48594447,E002330988,0000;
|
||||
RWY:RW26R, , ,00318, ,GAU ,3, ;N48595395,E002360724,1725;
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
I
|
||||
1100 Version - test fixture, not real navdata
|
||||
|
||||
ABEAM LF 11 BEACN LF 11 N 1 35 245 T100
|
||||
BEACN LF 11 CROSS LF 11 N 1 35 245 T100
|
||||
CROSS LF 11 DOVER EG 11 N 2 245 460 U200
|
||||
DOVER EG 11 ENTRY EG 11 N 2 245 460 U200
|
||||
99
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
I
|
||||
1101 Version - test fixture, not real navdata
|
||||
|
||||
49.000000000 2.500000000 ABEAM ENRT LF 1000001
|
||||
49.500000000 2.000000000 BEACN ENRT LF 1000002
|
||||
50.000000000 1.000000000 CROSS ENRT LF 1000003
|
||||
50.500000000 0.000000000 DOVER ENRT EG 1000004
|
||||
51.000000000 -0.500000000 ENTRY ENRT EG 1000005
|
||||
99
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
I
|
||||
1150 Version - test fixture, not real navdata
|
||||
|
||||
3 49.010000000 2.600000000 300 11400 130 1.000 PGV ENRT LF PARIS VOR
|
||||
2 50.900000000 -0.400000000 100 3700 50 0.000 EGN ENRT EG ENTRY NDB
|
||||
4 49.000000000 2.550000000 50 11000 25 0.000 ILP ENRT LF PARIS ILS
|
||||
99
|
||||
@@ -0,0 +1,57 @@
|
||||
//! End-to-end step-2 test: parse the hand-made fixtures and import them into an
|
||||
//! in-memory SQLite database, then assert row counts and a couple of lookups.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use flightplanner_core::db;
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn fixtures_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
|
||||
}
|
||||
|
||||
fn count(conn: &Connection, table: &str) -> i64 {
|
||||
conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_fixture_navdata() {
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
let stats = db::import_navdata(&mut conn, &fixtures_dir()).unwrap();
|
||||
|
||||
// Fixtures: 5 fixes, 1 VOR + 1 NDB (ILS skipped), 4 airway segments, 2 airports.
|
||||
assert_eq!(stats.waypoints, 5);
|
||||
assert_eq!(stats.navaids, 2);
|
||||
assert_eq!(stats.airway_segments, 4);
|
||||
assert_eq!(stats.airports, 2);
|
||||
|
||||
assert_eq!(count(&conn, "waypoints"), 5);
|
||||
assert_eq!(count(&conn, "navaids"), 2);
|
||||
assert_eq!(count(&conn, "airway_segments"), 4);
|
||||
assert_eq!(count(&conn, "airports"), 2);
|
||||
|
||||
// A specific fix carries the right region.
|
||||
let region: String = conn
|
||||
.query_row(
|
||||
"SELECT region FROM waypoints WHERE ident = 'DOVER'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(region, "EG");
|
||||
|
||||
// LFPG reference point is the centroid of its two runway thresholds.
|
||||
let (lat, lon): (f64, f64) = conn
|
||||
.query_row(
|
||||
"SELECT lat, lon FROM airports WHERE icao = 'LFPG'",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!((lat - 48.99).abs() < 0.05, "lat = {lat}");
|
||||
assert!((lon - 2.56).abs() < 0.05, "lon = {lon}");
|
||||
|
||||
// Distinct airway names were recorded.
|
||||
assert_eq!(count(&conn, "airways"), 2);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! End-to-end step-3 test: import the fixtures, then route LFPG→EGLL through the
|
||||
//! hand-made airway network and check the result is sensible.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use flightplanner_core::routing::graph::RouteGraph;
|
||||
use flightplanner_core::{db, routing};
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn fixtures_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
|
||||
}
|
||||
|
||||
fn fixtures_db() -> Connection {
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
db::import_navdata(&mut conn, &fixtures_dir()).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routes_lfpg_to_egll_via_airways() {
|
||||
let conn = fixtures_db();
|
||||
|
||||
let route = routing::plan_route(&conn, "LFPG", "EGLL", None).unwrap();
|
||||
|
||||
assert!(route.via_airways, "expected an airway route, got {route:?}");
|
||||
assert_eq!(route.legs.first().unwrap().from, "LFPG");
|
||||
assert_eq!(route.legs.last().unwrap().to, "EGLL");
|
||||
|
||||
// The fixture network forces travel over the T100/U200 airways.
|
||||
let airways: Vec<&str> = route.legs.iter().map(|l| l.airway.as_str()).collect();
|
||||
assert!(
|
||||
airways.iter().any(|a| *a != "DCT"),
|
||||
"route used no airway: {airways:?}"
|
||||
);
|
||||
|
||||
// Sanity on total distance (direct LFPG-EGLL ≈ 188 nm; via fixtures a bit more).
|
||||
assert!(
|
||||
route.total_nm > 150.0 && route.total_nm < 400.0,
|
||||
"total = {} nm",
|
||||
route.total_nm
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_airport_is_reported() {
|
||||
let conn = fixtures_db();
|
||||
let err = routing::plan_route(&conn, "LFPG", "ZZZZ", None).unwrap_err();
|
||||
assert!(err.to_string().contains("ZZZZ"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_filters_airways_by_flight_level() {
|
||||
let conn = fixtures_db();
|
||||
// Fixtures: T100 (low, FL35..245) links ABEAM/BEACN/CROSS,
|
||||
// U200 (high, FL245..460) links CROSS/DOVER/ENTRY.
|
||||
assert_eq!(RouteGraph::build(&conn, None).unwrap().node_count(), 5);
|
||||
assert_eq!(RouteGraph::build(&conn, Some(200)).unwrap().node_count(), 3); // T100 only
|
||||
assert_eq!(RouteGraph::build(&conn, Some(300)).unwrap().node_count(), 3); // U200 only
|
||||
assert_eq!(RouteGraph::build(&conn, Some(500)).unwrap().node_count(), 0); // above both
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fra_produces_a_dct_chain() {
|
||||
let conn = fixtures_db();
|
||||
let route = routing::fra::plan_fra(&conn, "LFPG", "EGLL", &[], &[], 360, None).unwrap();
|
||||
assert!(!route.via_airways, "FRA is DCT-based: {route:?}");
|
||||
assert!(route.legs.iter().all(|l| l.airway == "DCT"), "{route:?}");
|
||||
assert_eq!(route.legs.first().unwrap().from, "LFPG");
|
||||
assert_eq!(route.legs.last().unwrap().to, "EGLL");
|
||||
assert!(
|
||||
route.total_nm > 150.0 && route.total_nm < 400.0,
|
||||
"total = {} nm",
|
||||
route.total_nm
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connects_departure_via_given_fix() {
|
||||
let conn = fixtures_db();
|
||||
// Force LFPG to join the network at CROSS (a SID exit fix, say) rather than
|
||||
// the nearest airway point.
|
||||
let route =
|
||||
routing::plan_route_conn(&conn, "LFPG", "EGLL", None, &["CROSS".to_string()], &[]).unwrap();
|
||||
assert!(route.via_airways, "{route:?}");
|
||||
let first = route.legs.first().unwrap();
|
||||
assert_eq!(first.from, "LFPG");
|
||||
assert_eq!(first.to, "CROSS", "should connect via the given fix: {route:?}");
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "flightplanner-rad"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "flightplanner_rad"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "rad-tool"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
flightplanner-core = { workspace = true }
|
||||
calamine = "0.26"
|
||||
serde = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
@@ -0,0 +1,355 @@
|
||||
//! Eurocontrol RAD (Route Availability Document) parsing.
|
||||
//!
|
||||
//! The RAD is a public per-AIRAC Excel workbook (see the `rad-data-source`
|
||||
//! reference). This crate reads it with `calamine` and turns the annexes into a
|
||||
//! restriction model the routing/validation engine can apply.
|
||||
|
||||
use anyhow::Result;
|
||||
use calamine::{open_workbook, Data, Reader, Xlsx};
|
||||
|
||||
// The RAD data model lives in `core` (so routing/validation can use it without
|
||||
// pulling in `calamine`); this crate produces those types.
|
||||
pub use flightplanner_core::rad::{Area, DctKind, DctRestriction, FraEdge, FraPoint, LevelCap, RadData};
|
||||
|
||||
/// Parse the official EUROCONTROL "FRA Points" list (a separate `.xlsx` — see the
|
||||
/// `fra-points-official` note). Sheet `"FRA Points"`, one row per point.
|
||||
pub fn parse_fra_points(path: &str) -> Result<Vec<FraPoint>> {
|
||||
let rows = rows(path, "FRA Points")?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.skip(1) // header
|
||||
.filter_map(|r| {
|
||||
let name = cell(r, 2);
|
||||
let lat = parse_lat(&cell(r, 3));
|
||||
let lon = parse_lon(&cell(r, 4));
|
||||
let (name, lat, lon) = match (name.is_empty(), lat, lon) {
|
||||
(false, Some(la), Some(lo)) => (name, la, lo),
|
||||
_ => return None,
|
||||
};
|
||||
let (level_lo, level_hi) = parse_levels(&cell(r, 11));
|
||||
Some(FraPoint {
|
||||
name,
|
||||
lat,
|
||||
lon,
|
||||
areas: split_amp(&cell(r, 5)),
|
||||
enroute: cell(r, 6).trim_matches('-').trim().to_string(),
|
||||
arrdep: cell(r, 7).trim_matches('-').trim().to_string(),
|
||||
arr_airports: split_ws(&cell(r, 8)),
|
||||
dep_airports: split_ws(&cell(r, 9)),
|
||||
flos: cell(r, 10).trim_matches('-').trim().to_string(),
|
||||
level_lo,
|
||||
level_hi,
|
||||
loc_ind: split_ws(&cell(r, 13)),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Latitude `XDDMMSS` (X = N/S) → signed decimal degrees.
|
||||
fn parse_lat(s: &str) -> Option<f64> {
|
||||
let s = s.trim();
|
||||
let b = s.as_bytes();
|
||||
if b.len() < 7 {
|
||||
return None;
|
||||
}
|
||||
let sign = match b[0] {
|
||||
b'N' => 1.0,
|
||||
b'S' => -1.0,
|
||||
_ => return None,
|
||||
};
|
||||
let d: f64 = s[1..3].parse().ok()?;
|
||||
let m: f64 = s[3..5].parse().ok()?;
|
||||
let sec: f64 = s[5..7].parse().ok()?;
|
||||
Some(sign * (d + m / 60.0 + sec / 3600.0))
|
||||
}
|
||||
|
||||
/// Longitude `XDDDMMSS` (X = E/W) → signed decimal degrees.
|
||||
fn parse_lon(s: &str) -> Option<f64> {
|
||||
let s = s.trim();
|
||||
let b = s.as_bytes();
|
||||
if b.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let sign = match b[0] {
|
||||
b'E' => 1.0,
|
||||
b'W' => -1.0,
|
||||
_ => return None,
|
||||
};
|
||||
let d: f64 = s[1..4].parse().ok()?;
|
||||
let m: f64 = s[4..6].parse().ok()?;
|
||||
let sec: f64 = s[6..8].parse().ok()?;
|
||||
Some(sign * (d + m / 60.0 + sec / 3600.0))
|
||||
}
|
||||
|
||||
/// "FL195 / FL660" → (Some(195), Some(660)); "GND / FL245" → (Some(0), Some(245)).
|
||||
fn parse_levels(s: &str) -> (Option<i32>, Option<i32>) {
|
||||
let up = s.to_uppercase();
|
||||
let mut fls: Vec<i32> = Vec::new();
|
||||
let mut rest = up.as_str();
|
||||
while let Some(p) = rest.find("FL") {
|
||||
rest = &rest[p + 2..];
|
||||
let num: String = rest.chars().take_while(char::is_ascii_digit).collect();
|
||||
rest = &rest[num.len()..];
|
||||
if let Ok(n) = num.parse::<i32>() {
|
||||
fls.push(n);
|
||||
}
|
||||
}
|
||||
let lo = fls.first().copied().or_else(|| up.contains("GND").then_some(0));
|
||||
let hi = fls.get(1).copied().or_else(|| if fls.len() == 1 { None } else { fls.first().copied() });
|
||||
(lo, hi)
|
||||
}
|
||||
|
||||
fn split_amp(s: &str) -> Vec<String> {
|
||||
s.split('&').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect()
|
||||
}
|
||||
|
||||
fn split_ws(s: &str) -> Vec<String> {
|
||||
s.split([' ', ',', '\n']).map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// Parse the annexes we currently model from the workbook at `path`.
|
||||
pub fn parse(path: &str) -> Result<RadData> {
|
||||
Ok(RadData {
|
||||
areas: parse_areas(path)?,
|
||||
dct: parse_dct(path)?,
|
||||
fra_edges: parse_fra_edges(path)?,
|
||||
level_caps: parse_level_caps(path)?,
|
||||
fra_points: Vec::new(), // loaded separately via parse_fra_points
|
||||
})
|
||||
}
|
||||
|
||||
/// Annex 2A — city-pair flight-level caps.
|
||||
pub fn parse_level_caps(path: &str) -> Result<Vec<LevelCap>> {
|
||||
let rows = rows(path, "Annex 2A")?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.skip(1) // header
|
||||
.filter_map(|r| {
|
||||
let from = parse_idents(&cell(r, 4));
|
||||
let to = parse_idents(&cell(r, 6));
|
||||
let cap_fl = min_fl(&cell(r, 8));
|
||||
if from.is_empty() || to.is_empty() || cap_fl.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(LevelCap {
|
||||
id: cell(r, 3),
|
||||
from,
|
||||
to,
|
||||
condition: cell(r, 7),
|
||||
cap_fl,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Lowest flight level mentioned in a capping cell like `FL345` or `FL355FL375`.
|
||||
fn min_fl(s: &str) -> Option<i32> {
|
||||
let up = s.to_uppercase();
|
||||
let mut out: Option<i32> = None;
|
||||
let mut rest = up.as_str();
|
||||
while let Some(pos) = rest.find("FL") {
|
||||
rest = &rest[pos + 2..];
|
||||
let num: String = rest.chars().take_while(char::is_ascii_digit).collect();
|
||||
rest = &rest[num.len()..];
|
||||
if let Ok(fl) = num.parse::<i32>() {
|
||||
out = Some(out.map_or(fl, |m| m.min(fl)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Annex 1 — area definitions.
|
||||
pub fn parse_areas(path: &str) -> Result<Vec<Area>> {
|
||||
let rows = rows(path, "Annex 1")?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.skip(1) // header
|
||||
.filter_map(|r| {
|
||||
let id = cell(r, 3);
|
||||
let def = cell(r, 4);
|
||||
if id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Area {
|
||||
id,
|
||||
airports: parse_idents(&def),
|
||||
region: cell(r, 6),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Annex 3B — DCT restrictions.
|
||||
pub fn parse_dct(path: &str) -> Result<Vec<DctRestriction>> {
|
||||
let rows = rows(path, "Annex 3B DCT")?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.skip(1) // header
|
||||
.filter_map(|r| {
|
||||
let from = cell(r, 4);
|
||||
let to = cell(r, 5);
|
||||
if from.is_empty() || to.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let avail = cell(r, 8).to_uppercase();
|
||||
Some(DctRestriction {
|
||||
id: cell(r, 3),
|
||||
from,
|
||||
to,
|
||||
lower_fl: parse_fl(&cell(r, 6)),
|
||||
upper_fl: parse_fl(&cell(r, 7)),
|
||||
available: avail.starts_with('Y'),
|
||||
utilization: cell(r, 9),
|
||||
direction: cell(r, 13),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Scan Annex 2A/2B/2C for `A DCT B` fix pairs and return the de-duplicated set
|
||||
/// of allowed FRA direct edges. This is our FRA connectivity catalog: routing
|
||||
/// through these points/edges is what IFPS accepts in Free Route Airspace.
|
||||
pub fn parse_fra_edges(path: &str) -> Result<Vec<FraEdge>> {
|
||||
use std::collections::HashSet;
|
||||
let mut seen: HashSet<FraEdge> = HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
// Annex 2A/2B/2C hold the enroute FRA routings; Annex 3A DEP/ARR hold the
|
||||
// compulsory departure/arrival routings (e.g. LF7352: LFPG deps via
|
||||
// `OPALE DCT KESAX DCT DIMAL DCT ALESO`) — both are needed for the graph.
|
||||
for sheet in ["Annex 2A", "Annex 2B", "Annex 2C", "Annex 3A DEP", "Annex 3A ARR"] {
|
||||
let Ok(rows) = rows(path, sheet) else { continue };
|
||||
for row in rows {
|
||||
// Normalise separators so `DCT` always stands alone as a token.
|
||||
let text = row.join(" ").replace(['(', ')', ',', '\n'], " ");
|
||||
let toks: Vec<&str> = text.split_whitespace().collect();
|
||||
for w in toks.windows(3) {
|
||||
if w[1] == "DCT" && is_fix(w[0]) && is_fix(w[2]) {
|
||||
let e = FraEdge { from: w[0].to_string(), to: w[2].to_string() };
|
||||
if seen.insert(e.clone()) {
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A plausible navaid/waypoint ident: 2–6 chars, letters+digits, ≥1 letter, not
|
||||
/// a RAD keyword. Excludes airway designators would be nice but they rarely sit
|
||||
/// on both sides of a literal `DCT`, so the DCT-pair test already filters them.
|
||||
fn is_fix(s: &str) -> bool {
|
||||
let s = s.trim();
|
||||
let len = s.len();
|
||||
if !(2..=6).contains(&len) {
|
||||
return false;
|
||||
}
|
||||
if !s.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) {
|
||||
return false;
|
||||
}
|
||||
if !s.chars().any(|c| c.is_ascii_uppercase()) {
|
||||
return false;
|
||||
}
|
||||
!matches!(
|
||||
s,
|
||||
"DCT" | "VIA" | "AND" | "THEN" | "ARR" | "DEP" | "EXC" | "RFL" | "BLW"
|
||||
| "ABV" | "BTN" | "IAW" | "LOA" | "TFC" | "AVBL" | "NOT" | "ONLY"
|
||||
| "H24" | "UFN" | "FL" | "AT" | "IN" | "OR" | "TO" | "VItoken"
|
||||
)
|
||||
}
|
||||
|
||||
// ── low-level helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// List the sheet (annex) names in the workbook.
|
||||
pub fn sheets(path: &str) -> Result<Vec<String>> {
|
||||
let wb: Xlsx<_> = open_workbook(path)?;
|
||||
Ok(wb.sheet_names().to_vec())
|
||||
}
|
||||
|
||||
/// Dimensions (rows, cols) of a sheet.
|
||||
pub fn dims(path: &str, sheet: &str) -> Result<(usize, usize)> {
|
||||
let mut wb: Xlsx<_> = open_workbook(path)?;
|
||||
Ok(wb.worksheet_range(sheet)?.get_size())
|
||||
}
|
||||
|
||||
/// All rows of `sheet` as trimmed strings.
|
||||
pub fn rows(path: &str, sheet: &str) -> Result<Vec<Vec<String>>> {
|
||||
let mut wb: Xlsx<_> = open_workbook(path)?;
|
||||
Ok(wb
|
||||
.worksheet_range(sheet)?
|
||||
.rows()
|
||||
.map(|r| r.iter().map(cell_str).collect())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// First `n` rows of `sheet` (for exploration).
|
||||
pub fn dump(path: &str, sheet: &str, n: usize) -> Result<Vec<Vec<String>>> {
|
||||
Ok(rows(path, sheet)?.into_iter().take(n).collect())
|
||||
}
|
||||
|
||||
fn cell(row: &[String], i: usize) -> String {
|
||||
row.get(i).cloned().unwrap_or_default().trim().to_string()
|
||||
}
|
||||
|
||||
fn cell_str(c: &Data) -> String {
|
||||
match c {
|
||||
Data::Empty => String::new(),
|
||||
Data::String(s) => s.trim().to_string(),
|
||||
Data::Float(f) => f.to_string(),
|
||||
Data::Int(i) => i.to_string(),
|
||||
Data::Bool(b) => b.to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// First flight level found in a cell like `FL245`, `MEAFL025`, `FL195FL315`.
|
||||
fn parse_fl(s: &str) -> Option<i32> {
|
||||
let up = s.to_uppercase();
|
||||
let pos = up.find("FL")?;
|
||||
let num: String = up[pos + 2..].chars().take_while(char::is_ascii_digit).collect();
|
||||
num.parse().ok()
|
||||
}
|
||||
|
||||
/// Split an Annex-1 definition like `(EGBB, EGBE, EGNX)` into idents.
|
||||
fn parse_idents(def: &str) -> Vec<String> {
|
||||
def.trim()
|
||||
.trim_matches(|c| c == '(' || c == ')')
|
||||
.split([',', '\n'])
|
||||
.map(|s| s.trim().trim_matches(|c| c == '(' || c == ')').to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Parses the real RAD workbook when it's present (CWD = crate dir in tests).
|
||||
/// Skips (passes) otherwise so the test stays portable.
|
||||
#[test]
|
||||
fn parses_real_rad_when_present() {
|
||||
let f = "../../rad/RAD_current.xlsx";
|
||||
if !std::path::Path::new(f).exists() {
|
||||
return;
|
||||
}
|
||||
let rad = parse(f).unwrap();
|
||||
assert!(rad.areas.len() > 50, "areas = {}", rad.areas.len());
|
||||
assert!(rad.dct.len() > 1000, "dct = {}", rad.dct.len());
|
||||
assert!(rad.dct.iter().any(|d| d.kind() == DctKind::Forbidden));
|
||||
assert!(rad.dct.iter().any(|d| d.kind() == DctKind::Compulsory));
|
||||
// FL bands parse to plausible values.
|
||||
assert!(rad
|
||||
.dct
|
||||
.iter()
|
||||
.filter_map(|d| d.upper_fl)
|
||||
.all(|fl| (0..=700).contains(&fl)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_fl_variants() {
|
||||
assert_eq!(parse_fl("FL245"), Some(245));
|
||||
assert_eq!(parse_fl("MEAFL025"), Some(25));
|
||||
assert_eq!(parse_fl("FL195FL315"), Some(195));
|
||||
assert_eq!(parse_fl(""), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! `rad-tool` — explore/parse the Eurocontrol RAD workbook.
|
||||
//!
|
||||
//! Usage:
|
||||
//! rad-tool sheets
|
||||
//! rad-tool dump "<sheet>" [rows]
|
||||
//! RAD file path from $RAD_FILE, default `rad/RAD_current.xlsx`.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let path = std::env::var("RAD_FILE").unwrap_or_else(|_| "rad/RAD_current.xlsx".into());
|
||||
|
||||
match args.get(1).map(String::as_str) {
|
||||
Some("sheets") => {
|
||||
for s in flightplanner_rad::sheets(&path)? {
|
||||
let (r, c) = flightplanner_rad::dims(&path, &s).unwrap_or((0, 0));
|
||||
println!("{s} ({r} rows x {c} cols)");
|
||||
}
|
||||
}
|
||||
Some("dump") => {
|
||||
let sheet = args.get(2).cloned().unwrap_or_default();
|
||||
let n: usize = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(15);
|
||||
for (i, row) in flightplanner_rad::dump(&path, &sheet, n)?.into_iter().enumerate() {
|
||||
println!("[{i:>3}] {}", row.join(" | "));
|
||||
}
|
||||
}
|
||||
Some("find") => {
|
||||
// find "<sheet>" <substr> [max] — print rows containing <substr>.
|
||||
let sheet = args.get(2).cloned().unwrap_or_default();
|
||||
let needle = args.get(3).cloned().unwrap_or_default().to_uppercase();
|
||||
let max: usize = args.get(4).and_then(|s| s.parse().ok()).unwrap_or(20);
|
||||
let mut hits = 0;
|
||||
for (i, row) in flightplanner_rad::rows(&path, &sheet)?.into_iter().enumerate() {
|
||||
let joined = row.join(" | ");
|
||||
if joined.to_uppercase().contains(&needle) {
|
||||
println!("[{i:>4}] {joined}");
|
||||
hits += 1;
|
||||
if hits >= max {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("-- {hits} row(s) matched '{needle}' in {sheet}");
|
||||
}
|
||||
Some("summary") => {
|
||||
use flightplanner_rad::DctKind;
|
||||
let rad = flightplanner_rad::parse(&path)?;
|
||||
println!("Parsed RAD: {}", path);
|
||||
println!(" Areas (Annex 1) : {}", rad.areas.len());
|
||||
println!(" DCT restrictions (3B) : {}", rad.dct.len());
|
||||
let forbidden = rad.dct.iter().filter(|d| d.kind() == DctKind::Forbidden).count();
|
||||
let only = rad.dct.iter().filter(|d| d.kind() == DctKind::ConditionalOnly).count();
|
||||
let comp = rad.dct.iter().filter(|d| d.kind() == DctKind::Compulsory).count();
|
||||
println!(" forbidden={forbidden} conditional-only={only} compulsory={comp}");
|
||||
println!(" sample areas:");
|
||||
for a in rad.areas.iter().take(3) {
|
||||
println!(" {} [{}] = {:?}", a.id, a.region, a.airports);
|
||||
}
|
||||
println!(" sample DCT restrictions:");
|
||||
for d in rad.dct.iter().take(4) {
|
||||
println!(
|
||||
" {} {}->{} FL{:?}-{:?} avail={} [{:?}] {}",
|
||||
d.id, d.from, d.to, d.lower_fl, d.upper_fl, d.available, d.kind(),
|
||||
d.utilization.replace('\n', " ").chars().take(60).collect::<String>()
|
||||
);
|
||||
}
|
||||
}
|
||||
Some("fra") => {
|
||||
// fra [point] — extract allowed FRA DCT edges; if a point is given,
|
||||
// list its neighbours.
|
||||
let edges = flightplanner_rad::parse_fra_edges(&path)?;
|
||||
use std::collections::BTreeSet;
|
||||
let points: BTreeSet<&str> =
|
||||
edges.iter().flat_map(|e| [e.from.as_str(), e.to.as_str()]).collect();
|
||||
println!("FRA edges: {} | distinct points: {}", edges.len(), points.len());
|
||||
match args.get(2) {
|
||||
Some(p) => {
|
||||
let p = p.to_uppercase();
|
||||
let nbrs: BTreeSet<&str> = edges
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if e.from == p {
|
||||
Some(e.to.as_str())
|
||||
} else if e.to == p {
|
||||
Some(e.from.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
println!("{p} present: {} neighbours ({}): {:?}", points.contains(p.as_str()), nbrs.len(), nbrs);
|
||||
}
|
||||
None => {
|
||||
for e in edges.iter().take(25) {
|
||||
println!(" {} -> {}", e.from, e.to);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("caps") => {
|
||||
// caps [FROM TO] — total level caps, or the cap for a city pair.
|
||||
let rad = flightplanner_rad::parse(&path)?;
|
||||
println!("Level caps (Annex 2A): {}", rad.level_caps.len());
|
||||
match (args.get(2), args.get(3)) {
|
||||
(Some(f), Some(t)) => {
|
||||
let f = f.to_uppercase();
|
||||
let t = t.to_uppercase();
|
||||
match rad.max_cruise_fl(&f, &t) {
|
||||
Some(cap) => println!("{f}->{t}: max cruise FL{cap:03}"),
|
||||
None => println!("{f}->{t}: no cap"),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for c in rad.level_caps.iter().take(6) {
|
||||
println!(" {} {:?}->{:?} FL{:?} [{}]", c.id, c.from, c.to, c.cap_fl, c.condition.replace('\n', " ").chars().take(30).collect::<String>());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("frapts") => {
|
||||
// frapts [file] [POINT] — parse the official FRA points list.
|
||||
let file = args.get(2).cloned().unwrap_or_else(|| "rad/fra-points.xlsx".into());
|
||||
let pts = flightplanner_rad::parse_fra_points(&file)?;
|
||||
println!("FRA points: {}", pts.len());
|
||||
let roles = |r: &str| pts.iter().filter(|p| p.enroute == r).count();
|
||||
println!(" roles: E={} X={} EX={} I={}", roles("E"), roles("X"), roles("EX"), roles("I"));
|
||||
match args.get(3) {
|
||||
Some(name) => {
|
||||
let name = name.to_uppercase();
|
||||
for p in pts.iter().filter(|p| p.name.eq_ignore_ascii_case(&name)) {
|
||||
println!(
|
||||
" {} @{:.4},{:.4} area={:?} enroute={} arrdep={} FL{:?}-{:?} flos={} loc={:?}",
|
||||
p.name, p.lat, p.lon, p.areas, p.enroute, p.arrdep, p.level_lo, p.level_hi, p.flos, p.loc_ind
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
for p in pts.iter().take(4) {
|
||||
println!(" {} @{:.3},{:.3} {:?} {} FL{:?}-{:?}", p.name, p.lat, p.lon, p.areas, p.enroute, p.level_lo, p.level_hi);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => println!("usage: rad-tool sheets | dump | find | summary | fra [point] | caps [FROM TO] | frapts [file] [POINT]"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "flightplanner-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "flightplanner-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
flightplanner-core = { workspace = true }
|
||||
flightplanner-rad = { path = "../rad" }
|
||||
axum = "0.7"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] }
|
||||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Custom HTTP backend for Flight Planner — the "server" mode of the app.
|
||||
//!
|
||||
//! Exposes the same `core::api::plan` engine over HTTP so several clients can
|
||||
//! share one navdata DB / route database. The GUI can point at this instead of
|
||||
//! computing locally (chosen by the end user in Settings).
|
||||
//!
|
||||
//! Config via env: `FP_DB` (navdata SQLite), `FP_AIRCRAFT_DIR`, `FP_BIND`.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
use flightplanner_core::api::{self, CachedRouteDto, PlanRequest, PlanResult};
|
||||
use flightplanner_core::rad::RadData;
|
||||
|
||||
/// Server-side data locations (the client never dictates server file paths).
|
||||
#[derive(Clone)]
|
||||
struct Cfg {
|
||||
db: String,
|
||||
aircraft_dir: String,
|
||||
cifp: String,
|
||||
rad: Option<Arc<RadData>>,
|
||||
}
|
||||
|
||||
async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RoutesQuery {
|
||||
dep: String,
|
||||
dest: String,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// Our own database of stored flight plans for a city pair.
|
||||
async fn routes(
|
||||
State(cfg): State<Cfg>,
|
||||
Query(q): Query<RoutesQuery>,
|
||||
) -> Result<Json<Vec<CachedRouteDto>>, (StatusCode, String)> {
|
||||
let db = cfg.db.clone();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
api::recent_routes(&db, &q.dep, &q.dest, q.limit.unwrap_or(10))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
res.map(Json)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))
|
||||
}
|
||||
|
||||
async fn stats(State(cfg): State<Cfg>) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let db = cfg.db.clone();
|
||||
let n = tokio::task::spawn_blocking(move || api::route_db_count(&db))
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
|
||||
Ok(Json(serde_json::json!({ "routes": n })))
|
||||
}
|
||||
|
||||
async fn plan(
|
||||
State(cfg): State<Cfg>,
|
||||
Json(mut req): Json<PlanRequest>,
|
||||
) -> Result<Json<PlanResult>, (StatusCode, String)> {
|
||||
// Force the server's own data paths, ignoring whatever the client sent.
|
||||
req.db_path = cfg.db.clone();
|
||||
req.aircraft_dir = cfg.aircraft_dir.clone();
|
||||
req.cifp_dir = Some(cfg.cifp.clone());
|
||||
let rad = cfg.rad.clone();
|
||||
// `api::plan` is blocking (SQLite + compute) — keep it off the async pool.
|
||||
let res = tokio::task::spawn_blocking(move || api::plan(&req, rad.as_deref()))
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
res.map(Json)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))
|
||||
}
|
||||
|
||||
/// Load the RAD workbook once (env `FP_RAD`, default `rad/RAD_current.xlsx`).
|
||||
fn load_rad() -> Option<RadData> {
|
||||
let path = std::env::var("FP_RAD").unwrap_or_else(|_| "rad/RAD_current.xlsx".into());
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
eprintln!("RAD: file not found ({path}) — RAD checks disabled");
|
||||
return None;
|
||||
}
|
||||
match flightplanner_rad::parse(&path) {
|
||||
Ok(rad) => {
|
||||
let (f, o, c) = rad.dct_counts();
|
||||
println!(
|
||||
"RAD loaded: {} areas, {} DCT ({f} forbidden / {o} conditional / {c} compulsory)",
|
||||
rad.areas.len(),
|
||||
rad.dct.len()
|
||||
);
|
||||
Some(rad)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("RAD: parse failed ({e}) — RAD checks disabled");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let rad = load_rad();
|
||||
let cfg = Cfg {
|
||||
db: std::env::var("FP_DB").unwrap_or_else(|_| "real.db".into()),
|
||||
aircraft_dir: std::env::var("FP_AIRCRAFT_DIR").unwrap_or_else(|_| "data/aircraft".into()),
|
||||
cifp: std::env::var("FP_CIFP").unwrap_or_else(|_| "navdata/CIFP".into()),
|
||||
rad: rad.map(Arc::new),
|
||||
};
|
||||
let bind = std::env::var("FP_BIND").unwrap_or_else(|_| "0.0.0.0:8787".into());
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/plan", post(plan))
|
||||
.route("/routes", get(routes))
|
||||
.route("/stats", get(stats))
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(cfg.clone());
|
||||
|
||||
let addr: SocketAddr = bind.parse().expect("invalid FP_BIND");
|
||||
println!("Flight Planner server → http://{addr}");
|
||||
println!(" navdata DB : {}", cfg.db);
|
||||
println!(" aircraft dir : {}", cfg.aircraft_dir);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.expect("bind failed");
|
||||
axum::serve(listener, app).await.expect("server crashed");
|
||||
}
|
||||
Reference in New Issue
Block a user