Initial commit: offline flight planner (Rust, PFPX-class)

Clean-room reproduction of PFPX: route generation + IFPS validation + OFP.
Independent design — official ICAO/EUROCONTROL data only (RAD, FRA points,
IFPUV oracle); no community FPL sources.

Workspace crates: core (routing/discover/rad/navdata/perf/export),
cli, server, rad (Annex parser), gui (Tauri v2 + React + MapLibre).
Route discovery: oracle-in-the-loop repair against Eurocontrol IFPUV.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 11:10:35 +02:00
commit 2cb633e99d
108 changed files with 19460 additions and 0 deletions
+147
View File
@@ -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}");
}
}