use avian3d::math::Scalar;
#[allow(clippy::too_many_arguments)]
pub fn zone_local_angles(
alpha: Scalar,
beta: Scalar,
p: Scalar,
q: Scalar,
r: Scalar,
x: Scalar,
y: Scalar,
v: Scalar,
) -> (Scalar, Scalar) {
let alpha_local = alpha + (p * y - q * x) / v;
let beta_local = beta + (r * x) / v;
(alpha_local, beta_local)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aerodynamics::coefficients::evaluate_zone_coefficients;
use crate::aerodynamics::world_forces::zone_force_world;
use crate::components::aero_coeff::AeroCoeff;
use crate::components::{AeroZone, ControlInputs};
use avian3d::math::{Quaternion, Vector};
fn neutral_controls() -> ControlInputs {
ControlInputs {
elevator: 0.0,
aileron: 0.0,
rudder: 0.0,
throttle: 0.0,
}
}
#[cfg(feature = "f32")]
const EPS: Scalar = 1e-5;
#[cfg(not(feature = "f32"))]
const EPS: Scalar = 1e-12;
#[test]
fn roll_rate_increases_alpha_at_positive_y() {
let (alpha_l, beta_l) = zone_local_angles(0.1, 0.0, 1.0, 0.0, 0.0, 0.0, 4.0, 50.0);
let expected = 0.1 + 4.0 / 50.0;
assert!(
(alpha_l - expected).abs() < EPS,
"Δα_roll should be p·y/V, got {alpha_l}"
);
assert!((beta_l - 0.0).abs() < EPS, "roll rate should not affect β");
}
#[test]
fn roll_rate_decreases_alpha_at_negative_y() {
let (alpha_l, _) = zone_local_angles(0.1, 0.0, 1.0, 0.0, 0.0, 0.0, -4.0, 50.0);
let expected = 0.1 - 4.0 / 50.0;
assert!(
(alpha_l - expected).abs() < EPS,
"port tip should see reduced α under positive roll"
);
}
#[test]
fn pitch_rate_increases_alpha_at_tail() {
let (alpha_l, _) = zone_local_angles(0.1, 0.0, 0.0, 1.0, 0.0, -4.0, 0.0, 50.0);
let expected = 0.1 + 4.0 / 50.0;
assert!(
(alpha_l - expected).abs() < EPS,
"tail should see increased α during pull, got {alpha_l}"
);
}
#[test]
fn pitch_rate_decreases_alpha_at_nose() {
let (alpha_l, _) = zone_local_angles(0.1, 0.0, 0.0, 1.0, 0.0, 4.0, 0.0, 50.0);
let expected = 0.1 - 4.0 / 50.0;
assert!(
(alpha_l - expected).abs() < EPS,
"nose should see reduced α during pull, got {alpha_l}"
);
}
#[test]
fn yaw_rate_shifts_beta_at_longitudinal_station() {
let (alpha_l, beta_l) = zone_local_angles(0.0, 0.05, 0.0, 0.0, 1.0, -3.0, 0.0, 50.0);
let expected_beta = 0.05 + (-3.0) / 50.0;
assert!(
(beta_l - expected_beta).abs() < EPS,
"Δβ_yaw should be r·x/V, got {beta_l}"
);
assert!((alpha_l - 0.0).abs() < EPS, "yaw rate should not affect α");
}
#[test]
fn yaw_rate_does_not_shift_beta_at_spanwise_zone() {
let (_, beta_l) = zone_local_angles(0.0, 0.05, 0.0, 0.0, 1.0, 0.0, 3.0, 50.0);
assert!(
(beta_l - 0.05).abs() < EPS,
"purely spanwise zone should see no β change from yaw rate, got {beta_l}"
);
}
#[test]
fn zero_rates_leave_angles_unchanged() {
let (a, b) = zone_local_angles(0.2, 0.1, 0.0, 0.0, 0.0, -2.0, 3.0, 30.0);
assert!((a - 0.2).abs() < EPS, "zero rates should not alter α");
assert!((b - 0.1).abs() < EPS, "zero rates should not alter β");
}
#[test]
fn all_layers_combine_additively() {
let (a, b) = zone_local_angles(0.1, 0.05, 1.0, 1.0, 1.0, 2.0, 3.0, 10.0);
assert!((a - 0.2).abs() < EPS, "combined α layers, got {a}");
assert!((b - 0.25).abs() < EPS, "combined β layers, got {b}");
}
#[test]
fn zone_rotation_dihedral_gives_correct_alpha_asymmetry() {
use avian3d::math::{Quaternion, Vector};
let gamma: Scalar = 0.0698; let alpha_body: Scalar = 0.05;
let beta: Scalar = 0.2;
let vel_body = Vector::new(
alpha_body.cos() * beta.cos(),
beta.sin(),
alpha_body.sin() * beta.cos(),
);
let zone_q_stbd = Quaternion::from_rotation_x(-gamma);
let vel_stbd = zone_q_stbd.inverse() * vel_body;
let alpha_stbd = vel_stbd.z.atan2(vel_stbd.x);
let zone_q_port = Quaternion::from_rotation_x(gamma);
let vel_port = zone_q_port.inverse() * vel_body;
let alpha_port = vel_port.z.atan2(vel_port.x);
assert!(
alpha_stbd > alpha_body,
"starboard dihedral zone sees MORE alpha at positive beta: {alpha_stbd:.5} vs body {alpha_body:.5}"
);
assert!(
alpha_port < alpha_body,
"port dihedral zone sees LESS alpha at positive beta: {alpha_port:.5} vs body {alpha_body:.5}"
);
let expected_delta = gamma * beta.sin();
assert!(
(alpha_stbd - alpha_body - expected_delta).abs() < 5e-4,
"asymmetry should be near Γ·sin(β) ≈ {expected_delta:.5}, got {:.5}",
alpha_stbd - alpha_body
);
}
#[test]
fn emergent_roll_damping_from_symmetric_zones() {
let zone = AeroZone {
cl: AeroCoeff::Table1D {
breakpoints: vec![-0.5, 0.5],
values: vec![-2.5, 2.5],
},
cd: AeroCoeff::Scalar(0.0),
..Default::default()
};
let ctrl = neutral_controls();
let alpha: Scalar = 0.1;
let v: Scalar = 50.0;
let p: Scalar = 1.0;
let re = 1e6;
let qbar = 1500.0;
let (al_r, bl_r) = zone_local_angles(alpha, 0.0, p, 0.0, 0.0, 0.0, 4.0, v);
let cr = evaluate_zone_coefficients(&zone, &ctrl, al_r, bl_r, re, qbar, 1.0);
let (al_l, bl_l) = zone_local_angles(alpha, 0.0, p, 0.0, 0.0, 0.0, -4.0, v);
let cl_coeffs = evaluate_zone_coefficients(&zone, &ctrl, al_l, bl_l, re, qbar, 1.0);
assert!(
cr.cl > cl_coeffs.cl,
"starboard tip should have higher CL: {:.3} vs {:.3}",
cr.cl,
cl_coeffs.cl
);
let b2w = Quaternion::IDENTITY;
let vel_r = Vector::new(al_r.cos() * bl_r.cos(), bl_r.sin(), al_r.sin() * bl_r.cos());
let vel_l = Vector::new(al_l.cos() * bl_l.cos(), bl_l.sin(), al_l.sin() * bl_l.cos());
let wf_r = zone_force_world(&cr, qbar, 16.0, 10.0, 1.6, al_r, vel_r, b2w);
let wf_l = zone_force_world(&cl_coeffs, qbar, 16.0, 10.0, 1.6, al_l, vel_l, b2w);
let arm_r = Vector::new(0.0, 4.0, 0.0);
let arm_l = Vector::new(0.0, -4.0, 0.0);
let net_roll = arm_r.cross(wf_r.force).x + arm_l.cross(wf_l.force).x;
assert!(
net_roll < 0.0,
"emergent roll damping should oppose p>0, net = {net_roll:.2} N·m"
);
}
}