2cb633e99d
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>
148 lines
4.4 KiB
Rust
148 lines
4.4 KiB
Rust
//! 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}");
|
||
}
|
||
}
|