//! Aircraft performance profile, loaded from `data/aircraft/.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, } /// 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 { Ok(serde_json::from_str(json)?) } /// Load `/.json`. pub fn load(dir: &Path, icao: &str) -> Result { 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}"); } }