use serde::{Deserialize, Serialize};
use crate::atmosphere;
use crate::error::{PavanError, Result};
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LongitudinalStability {
pub neutral_point: f64,
pub static_margin: f64,
pub cm_alpha: f64,
pub cl_alpha: f64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ControlSurface {
pub chord_fraction: f64,
pub span_start: f64,
pub span_end: f64,
pub max_deflection_rad: f64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[non_exhaustive]
pub struct TrimSolution {
pub alpha_rad: f64,
pub elevator_rad: f64,
pub cl: f64,
pub cd: f64,
pub cm_residual: f64,
}
#[must_use]
#[inline]
pub fn neutral_point(cl_alpha: f64, cm_alpha: f64, x_ac: f64) -> f64 {
if cl_alpha.abs() < f64::EPSILON {
return x_ac;
}
x_ac - cm_alpha / cl_alpha
}
#[must_use]
#[inline]
pub fn static_margin(x_np: f64, x_cg: f64) -> f64 {
x_np - x_cg
}
#[must_use]
#[inline]
pub fn cm_alpha(
cl_alpha_wing: f64,
x_ac_wing: f64,
x_cg: f64,
tail_volume: f64,
cl_alpha_tail: f64,
tail_efficiency: f64,
downwash_gradient: f64,
) -> f64 {
cl_alpha_wing * (x_cg - x_ac_wing)
- tail_efficiency * tail_volume * cl_alpha_tail * (1.0 - downwash_gradient)
}
#[must_use]
#[inline]
pub fn flap_effectiveness(chord_fraction: f64) -> f64 {
let cf = chord_fraction.clamp(0.0, 1.0);
if cf < f64::EPSILON {
return 0.0;
}
if (cf - 1.0).abs() < f64::EPSILON {
return 1.0;
}
let theta_f = (2.0 * cf - 1.0).acos();
1.0 - (theta_f - theta_f.sin()) / std::f64::consts::PI
}
#[must_use]
#[inline]
pub fn elevator_effectiveness(
cl_alpha_tail: f64,
elevator_chord_fraction: f64,
tail_volume: f64,
tail_efficiency: f64,
) -> f64 {
let tau = flap_effectiveness(elevator_chord_fraction);
-tail_efficiency * tail_volume * cl_alpha_tail * tau
}
pub fn trim(
cl_required: f64,
cm_0: f64,
cm_alpha_val: f64,
cm_delta_e: f64,
cl_alpha_val: f64,
) -> Result<TrimSolution> {
if cl_alpha_val.abs() < f64::EPSILON {
return Err(PavanError::ComputationError(
"zero lift curve slope: cannot trim".into(),
));
}
if cm_delta_e.abs() < f64::EPSILON {
return Err(PavanError::ComputationError(
"zero elevator effectiveness: cannot trim".into(),
));
}
let alpha = cl_required / cl_alpha_val;
let delta_e = -(cm_0 + cm_alpha_val * alpha) / cm_delta_e;
let cm_residual = cm_0 + cm_alpha_val * alpha + cm_delta_e * delta_e;
Ok(TrimSolution {
alpha_rad: alpha,
elevator_rad: delta_e,
cl: cl_required,
cd: 0.0,
cm_residual,
})
}
#[allow(clippy::too_many_arguments)]
pub fn trim_at_speed(
weight_n: f64,
velocity: f64,
altitude: f64,
wing_area: f64,
cm_0: f64,
cm_alpha_val: f64,
cm_delta_e: f64,
cl_alpha_val: f64,
) -> Result<TrimSolution> {
if velocity <= 0.0 {
return Err(PavanError::InvalidVelocity(
"velocity must be positive".into(),
));
}
if wing_area <= 0.0 {
return Err(PavanError::InvalidGeometry(
"wing area must be positive".into(),
));
}
let rho = atmosphere::standard_density(altitude);
let q = atmosphere::dynamic_pressure(rho, velocity);
if q <= 0.0 {
return Err(PavanError::ComputationError("zero dynamic pressure".into()));
}
let cl_required = weight_n / (q * wing_area);
trim(cl_required, cm_0, cm_alpha_val, cm_delta_e, cl_alpha_val)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn neutral_point_basic() {
let xnp = neutral_point(5.0, -0.5, 0.25);
assert!((xnp - 0.35).abs() < 0.001, "x_np should be 0.35, got {xnp}");
}
#[test]
fn neutral_point_zero_cl_alpha() {
let xnp = neutral_point(0.0, -0.5, 0.25);
assert!((xnp - 0.25).abs() < f64::EPSILON);
}
#[test]
fn static_margin_stable() {
let sm = static_margin(0.35, 0.25);
assert!(sm > 0.0, "CG ahead of NP should be stable");
assert!((sm - 0.10).abs() < 0.001);
}
#[test]
fn static_margin_unstable() {
let sm = static_margin(0.25, 0.35);
assert!(sm < 0.0, "CG behind NP should be unstable");
}
#[test]
fn static_margin_neutral() {
let sm = static_margin(0.25, 0.25);
assert!(sm.abs() < f64::EPSILON);
}
#[test]
fn cm_alpha_with_tail() {
let cma = cm_alpha(5.0, 0.25, 0.30, 0.7, 4.0, 0.9, 0.4);
assert!(
(cma - (-1.262)).abs() < 0.01,
"Cm_α should be ~-1.262, got {cma}"
);
assert!(cma < 0.0, "with tail, Cm_α should be negative (stable)");
}
#[test]
fn cm_alpha_wing_only() {
let cma = cm_alpha(5.0, 0.25, 0.30, 0.0, 0.0, 0.0, 0.0);
assert!(cma > 0.0, "wing only with CG behind AC should be unstable");
}
#[test]
fn flap_effectiveness_25_percent() {
let tau = flap_effectiveness(0.25);
assert!(
(tau - 0.609).abs() < 0.02,
"τ at cf/c=0.25 should be ~0.609, got {tau}"
);
}
#[test]
fn flap_effectiveness_50_percent() {
let tau = flap_effectiveness(0.50);
assert!(
(tau - 0.818).abs() < 0.02,
"τ at cf/c=0.50 should be ~0.818, got {tau}"
);
}
#[test]
fn flap_effectiveness_zero() {
assert_eq!(flap_effectiveness(0.0), 0.0);
}
#[test]
fn flap_effectiveness_full() {
assert!((flap_effectiveness(1.0) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn flap_effectiveness_monotonic() {
let t1 = flap_effectiveness(0.1);
let t2 = flap_effectiveness(0.3);
let t3 = flap_effectiveness(0.5);
assert!(t2 > t1);
assert!(t3 > t2);
}
#[test]
fn elevator_effectiveness_basic() {
let cm_de = elevator_effectiveness(4.0, 0.25, 0.7, 0.9);
assert!(cm_de < 0.0, "elevator should produce negative dCm/dδe");
}
#[test]
fn trim_basic() {
let sol = trim(0.5, 0.05, -1.0, -1.5, 5.0).expect("trim");
assert!(
(sol.cl - 0.5).abs() < f64::EPSILON,
"trimmed CL should match required"
);
assert!(
sol.cm_residual.abs() < 1e-10,
"moment should be zero at trim"
);
assert!((sol.alpha_rad - 0.1).abs() < 1e-10);
}
#[test]
fn trim_zero_cl_alpha_errors() {
assert!(trim(0.5, 0.0, -1.0, -1.5, 0.0).is_err());
}
#[test]
fn trim_zero_elevator_errors() {
assert!(trim(0.5, 0.0, -1.0, 0.0, 5.0).is_err());
}
#[test]
fn trim_at_speed_cessna_class() {
let sol = trim_at_speed(10000.0, 60.0, 0.0, 16.2, 0.05, -1.0, -1.5, 5.0).expect("trim");
assert!(
sol.alpha_rad > 0.0 && sol.alpha_rad < 0.3,
"α should be reasonable"
);
assert!(sol.cm_residual.abs() < 1e-10);
}
#[test]
fn trim_at_speed_zero_velocity_errors() {
assert!(trim_at_speed(10000.0, 0.0, 0.0, 16.2, 0.0, -1.0, -1.5, 5.0).is_err());
}
#[test]
fn trim_at_speed_zero_area_errors() {
assert!(trim_at_speed(10000.0, 60.0, 0.0, 0.0, 0.0, -1.0, -1.5, 5.0).is_err());
}
#[test]
fn serde_trim_solution() {
let sol = trim(0.5, 0.05, -1.0, -1.5, 5.0).expect("trim");
let json = serde_json::to_string(&sol).expect("serialize");
let back: TrimSolution = serde_json::from_str(&json).expect("deserialize");
assert!((back.alpha_rad - sol.alpha_rad).abs() < f64::EPSILON);
}
#[test]
fn serde_longitudinal_stability() {
let ls = LongitudinalStability {
neutral_point: 0.35,
static_margin: 0.10,
cm_alpha: -1.0,
cl_alpha: 5.0,
};
let json = serde_json::to_string(&ls).expect("serialize");
let back: LongitudinalStability = serde_json::from_str(&json).expect("deserialize");
assert!((back.neutral_point - ls.neutral_point).abs() < f64::EPSILON);
}
#[test]
fn serde_control_surface() {
let cs = ControlSurface {
chord_fraction: 0.25,
span_start: 0.3,
span_end: 0.9,
max_deflection_rad: 25.0_f64.to_radians(),
};
let json = serde_json::to_string(&cs).expect("serialize");
let back: ControlSurface = serde_json::from_str(&json).expect("deserialize");
assert!((back.chord_fraction - cs.chord_fraction).abs() < f64::EPSILON);
}
}