use crate::atmosphere::{
calculate_air_density_cimp, get_direct_atmosphere, get_local_atmosphere_humid, AtmoSock,
};
use crate::bc_estimation::{velocity_segment_bc, BCSegmentEstimator};
use crate::constants::*;
use crate::drag::get_drag_coefficient_full;
use crate::InternalBallisticInputs as BallisticInputs;
use nalgebra::Vector3;
const MAGNUS_COEFF_SUBSONIC: f64 = 0.030;
const MAGNUS_COEFF_TRANSONIC_REDUCTION: f64 = 0.015;
const MAGNUS_COEFF_SUPERSONIC_BASE: f64 = 0.015;
const MAGNUS_COEFF_SUPERSONIC_SCALE: f64 = 0.0044;
const MAGNUS_TRANSONIC_LOWER: f64 = 0.8; const MAGNUS_TRANSONIC_UPPER: f64 = 1.2; const MAGNUS_TRANSONIC_RANGE: f64 = 0.4; const MAGNUS_SUPERSONIC_RANGE: f64 = 1.8;
const MAX_REALISTIC_DENSITY: f64 = 2.0; const MIN_REALISTIC_SPEED_OF_SOUND: f64 = 200.0;
fn dry_air_temperature_c_from_sound_speed(speed_of_sound_mps: f64) -> f64 {
speed_of_sound_mps * speed_of_sound_mps / (1.4 * 287.05) - 273.15
}
pub(crate) fn calculate_magnus_moment_coefficient(mach: f64) -> f64 {
if mach < MAGNUS_TRANSONIC_LOWER {
MAGNUS_COEFF_SUBSONIC
} else if mach < MAGNUS_TRANSONIC_UPPER {
MAGNUS_COEFF_SUBSONIC
- MAGNUS_COEFF_TRANSONIC_REDUCTION * (mach - MAGNUS_TRANSONIC_LOWER)
/ MAGNUS_TRANSONIC_RANGE
} else {
MAGNUS_COEFF_SUPERSONIC_BASE
+ MAGNUS_COEFF_SUPERSONIC_SCALE
* ((mach - MAGNUS_TRANSONIC_UPPER) / MAGNUS_SUPERSONIC_RANGE).min(1.0)
}
}
#[inline]
pub(crate) fn level_vector_to_shot_frame(
vector: Vector3<f64>,
shooting_angle: f64,
) -> Vector3<f64> {
if shooting_angle == 0.0 {
return vector;
}
let (sin_angle, cos_angle) = shooting_angle.sin_cos();
Vector3::new(
vector.x * cos_angle + vector.y * sin_angle,
-vector.x * sin_angle + vector.y * cos_angle,
vector.z,
)
}
pub(crate) fn yaw_of_repose_magnus_direction(
air_velocity: Vector3<f64>,
gravity_acceleration: Vector3<f64>,
is_twist_right: bool,
) -> Option<Vector3<f64>> {
let speed = air_velocity.norm();
if !speed.is_finite() || speed <= 1e-12 {
return None;
}
let velocity_unit = air_velocity / speed;
let gravity_normal =
gravity_acceleration - velocity_unit * gravity_acceleration.dot(&velocity_unit);
let normal_magnitude = gravity_normal.norm();
if !normal_magnitude.is_finite() || normal_magnitude <= 1e-12 {
return None;
}
let right_twist_direction = gravity_normal / normal_magnitude;
Some(if is_twist_right {
right_twist_direction
} else {
-right_twist_direction
})
}
#[allow(clippy::too_many_arguments)]
pub fn compute_derivatives(
pos: Vector3<f64>,
vel: Vector3<f64>,
inputs: &BallisticInputs,
wind_vector: Vector3<f64>,
atmos_params: (f64, f64, f64, f64),
bc_used: f64,
omega_vector: Option<Vector3<f64>>,
_time: f64,
atmo_sock: Option<&AtmoSock>,
) -> [f64; 6] {
let theta = inputs.shooting_angle;
let accel_gravity = Vector3::new(
-G_ACCEL_MPS2 * theta.sin(),
-G_ACCEL_MPS2 * theta.cos(),
0.0,
);
let wind_vector = level_vector_to_shot_frame(wind_vector, theta);
let velocity_adjusted = vel - wind_vector;
let speed_air = velocity_adjusted.norm();
let mut accel_drag = Vector3::zeros();
let mut accel_magnus = Vector3::zeros();
if speed_air > crate::constants::MIN_VELOCITY_THRESHOLD {
let v_rel_fps = speed_air * MPS_TO_FPS;
let altitude_at_pos = crate::atmosphere::shot_frame_altitude(
inputs.altitude,
pos[0],
pos[1],
inputs.shooting_angle,
);
let (air_density, speed_of_sound, temperature_c) = if atmos_params.0 < MAX_REALISTIC_DENSITY
&& atmos_params.1 > MIN_REALISTIC_SPEED_OF_SOUND
&& atmos_params.2 == 0.0
&& atmos_params.3 == 0.0
{
let (rho, sound) = get_direct_atmosphere(atmos_params.0, atmos_params.1);
(rho, sound, dry_air_temperature_c_from_sound_speed(sound))
} else {
let (base_temp_c, base_press_hpa, base_ratio) = match atmo_sock {
Some(sock) => {
let (zt, zp, zh) = sock.atmo_for_range(pos[0]);
(zt, zp, calculate_air_density_cimp(zt, zp, zh) / 1.225)
}
None => {
let base_ratio = if atmos_params.3 > 0.0 {
atmos_params.3
} else {
1.0
};
(atmos_params.1, atmos_params.2, base_ratio)
}
};
let (rho, sound) = get_local_atmosphere_humid(
altitude_at_pos,
atmos_params.0, base_temp_c, base_press_hpa, base_ratio, 0.0, );
(rho, sound, dry_air_temperature_c_from_sound_speed(sound))
};
let mach = if speed_of_sound > 1e-9 {
speed_air / speed_of_sound
} else {
0.0 };
let drag_factor = get_drag_coefficient_full(
mach,
&inputs.bc_type,
false, false, None, if inputs.caliber_inches > 0.0 {
Some(inputs.caliber_inches)
} else {
Some(inputs.bullet_diameter / 0.0254) },
if inputs.weight_grains > 0.0 {
Some(inputs.weight_grains)
} else {
Some(inputs.bullet_mass / 0.00006479891) },
Some(speed_air),
Some(air_density),
Some(temperature_c),
);
let mut bc_val = bc_used;
if inputs.use_bc_segments {
if inputs.bc_segments_data.is_some() {
bc_val = get_bc_for_velocity(v_rel_fps, inputs, bc_used);
} else {
match inputs.bc_segments.as_deref() {
Some(segments) if !segments.is_empty() => {
bc_val = interpolated_bc(mach, segments, Some(inputs));
}
Some(_) => {}
None => bc_val = get_bc_for_velocity(v_rel_fps, inputs, bc_used),
}
}
} else if let Some(segments) = inputs
.bc_segments
.as_deref()
.filter(|segments| !segments.is_empty())
{
bc_val = interpolated_bc(mach, segments, Some(inputs));
}
let bc_val = bc_val.max(1e-6);
let yaw_rad = if inputs.tipoff_decay_distance.abs() > 1e-9 {
inputs.tipoff_yaw * (-pos[0] / inputs.tipoff_decay_distance).exp()
} else {
inputs.tipoff_yaw };
let yaw_multiplier = 1.0 + yaw_rad.powi(2);
let density_scale = air_density / STANDARD_AIR_DENSITY;
let caliber_in = if inputs.caliber_inches > 0.0 {
inputs.caliber_inches
} else {
inputs.bullet_diameter / 0.0254 };
let weight_gr = if inputs.weight_grains > 0.0 {
inputs.weight_grains
} else {
inputs.bullet_mass / 0.00006479891 };
let shape = crate::transonic_drag::resolve_projectile_shape(
inputs.bullet_model.as_deref(),
caliber_in,
weight_gr,
&inputs.bc_type.to_string(),
);
let drag_factor =
crate::transonic_drag::transonic_correction(mach, drag_factor, shape, false);
let (drag_factor, retard_denom) = match inputs.custom_drag_table {
Some(ref table) => (
table.interpolate(mach),
inputs.custom_drag_denominator(bc_val),
),
None => (drag_factor, bc_val),
};
let standard_factor = drag_factor * CD_TO_RETARD;
let a_drag_ft_s2 =
(v_rel_fps.powi(2) * standard_factor * yaw_multiplier * density_scale) / retard_denom;
let a_drag_m_s2 = a_drag_ft_s2 * FPS_TO_MPS;
accel_drag = -a_drag_m_s2 * (velocity_adjusted / speed_air);
if inputs.enable_magnus
&& !(inputs.use_enhanced_spin_drift && inputs.enable_advanced_effects)
&& inputs.bullet_diameter > 0.0
&& inputs.twist_rate > 0.0
{
let diameter_m = inputs.bullet_diameter;
let (spin_rate_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
inputs.muzzle_velocity,
speed_air,
inputs.twist_rate,
diameter_m,
);
let c_np = calculate_magnus_moment_coefficient(mach);
let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
let d_in = inputs.caliber_inches;
let m_gr = inputs.weight_grains;
let l_in = if inputs.bullet_length > 0.0 {
inputs.bullet_length / 0.0254 } else {
let est_m = crate::stability::estimate_bullet_length_m(
inputs.bullet_diameter,
inputs.bullet_mass,
);
if est_m > 0.0 {
est_m / 0.0254
} else {
4.5 * d_in.max(1e-9)
}
};
let sg = crate::spin_drift::calculate_dynamic_stability(
m_gr,
speed_air,
spin_rate_rad_s,
d_in,
l_in,
air_density,
);
let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
sg,
speed_air,
spin_rate_rad_s,
0.0,
0.0,
air_density,
d_in,
l_in,
m_gr,
mach,
"match",
false,
);
let magnus_force_magnitude =
0.5 * air_density * speed_air.powi(2) * area * c_np * spin_param * yaw_rad.sin();
if magnus_force_magnitude > 1e-12 {
if let Some(magnus_direction) = yaw_of_repose_magnus_direction(
velocity_adjusted,
accel_gravity,
inputs.is_twist_right,
) {
let bullet_mass_kg = inputs.bullet_mass; accel_magnus = (magnus_force_magnitude / bullet_mass_kg) * magnus_direction;
}
}
}
}
let mut accel = accel_gravity + accel_drag + accel_magnus;
if let Some(omega) = omega_vector {
let omega = level_vector_to_shot_frame(omega, theta);
let accel_coriolis = -2.0 * omega.cross(&vel);
accel += accel_coriolis;
}
[vel[0], vel[1], vel[2], accel[0], accel[1], accel[2]]
}
pub fn interpolated_bc(
mach: f64,
segments: &[(f64, f64)],
inputs: Option<&BallisticInputs>,
) -> f64 {
if segments.is_empty() {
if let Some(inputs) = inputs {
return inputs.bc_value;
}
return crate::constants::BC_FALLBACK_CONSERVATIVE;
}
if segments.len() == 1 {
return segments[0].1;
}
let sorted_segments: std::borrow::Cow<[(f64, f64)]> =
if segments.windows(2).all(|w| w[0].0 <= w[1].0) {
std::borrow::Cow::Borrowed(segments)
} else {
let mut v = segments.to_vec();
v.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
std::borrow::Cow::Owned(v)
};
if mach <= sorted_segments[0].0 {
return sorted_segments[0].1;
}
if mach >= sorted_segments[sorted_segments.len() - 1].0 {
return sorted_segments[sorted_segments.len() - 1].1;
}
let idx = sorted_segments.partition_point(|(m, _)| *m <= mach);
if idx == 0 || idx >= sorted_segments.len() {
return sorted_segments[0].1;
}
let (mach1, bc1) = sorted_segments[idx - 1];
let (mach2, bc2) = sorted_segments[idx];
let denominator = mach2 - mach1;
if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
return bc1; }
let t = (mach - mach1) / denominator;
bc1 + t * (bc2 - bc1)
}
fn get_bc_for_velocity(velocity_fps: f64, inputs: &BallisticInputs, bc_used: f64) -> f64 {
if !inputs.use_bc_segments {
return bc_used;
}
if let Some(bc_segments_data) = inputs
.bc_segments_data
.as_ref()
.filter(|segments| !segments.is_empty())
{
return velocity_segment_bc(velocity_fps, bc_segments_data, bc_used);
}
if let Some(segments) = estimate_bc_segments_for(inputs, bc_used) {
return velocity_segment_bc(velocity_fps, &segments, bc_used);
}
bc_used
}
pub(crate) fn estimate_bc_segments_for(
inputs: &BallisticInputs,
bc_used: f64,
) -> Option<Vec<crate::BCSegmentData>> {
if !(inputs.bullet_diameter > 0.0 && inputs.bullet_mass > 0.0 && bc_used > 0.0) {
return None;
}
let model = if let Some(ref bullet_id) = inputs.bullet_id {
bullet_id.clone()
} else {
format!("{}gr bullet", inputs.weight_grains as i32)
};
let bc_type_str = inputs.bc_type_str.as_deref().unwrap_or(match inputs.bc_type {
crate::DragModel::G7 => "G7",
_ => "G1",
});
Some(BCSegmentEstimator::estimate_bc_segments(
bc_used,
inputs.caliber_inches,
inputs.weight_grains,
&model,
bc_type_str,
))
}
#[cfg(test)]
mod tests {
use super::*;
fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
let (sin_angle, cos_angle) = angle.sin_cos();
Vector3::new(
level.x * cos_angle + level.y * sin_angle,
-level.x * sin_angle + level.y * cos_angle,
level.z,
)
}
#[test]
fn level_vector_projection_is_bit_exact_at_zero_incline() {
let level = Vector3::new(12.5, -0.0, -7.25);
let projected = level_vector_to_shot_frame(level, 0.0);
for component in 0..3 {
assert_eq!(projected[component].to_bits(), level[component].to_bits());
}
}
#[test]
fn dry_air_sound_speed_round_trips_to_local_celsius() {
for expected_temp_c in [-40.0_f64, 15.0, 40.0] {
let sound_speed = (1.4 * 287.05 * (expected_temp_c + 273.15)).sqrt();
let actual_temp_c = dry_air_temperature_c_from_sound_speed(sound_speed);
assert!(
(actual_temp_c - expected_temp_c).abs() < 1e-10,
"sound speed {sound_speed} m/s recovered {actual_temp_c} C, expected {expected_temp_c} C"
);
}
let direct_mode_temp_c = dry_air_temperature_c_from_sound_speed(340.0);
assert!(
direct_mode_temp_c > 10.0 && direct_mode_temp_c < 20.0,
"direct-mode 340 m/s must be interpreted as sound speed, not 340 C"
);
}
#[test]
fn tipoff_yaw_uses_documented_radians_for_drag_and_decay() {
let mut baseline_inputs = create_test_inputs();
baseline_inputs.tipoff_yaw = 0.0;
baseline_inputs.tipoff_decay_distance = 50.0;
let mut yawed_inputs = baseline_inputs.clone();
yawed_inputs.tipoff_yaw = 0.1;
let drag_x = |inputs: &BallisticInputs, downrange_m: f64| {
compute_derivatives(
Vector3::new(downrange_m, 0.0, 0.0),
Vector3::new(300.0, 0.0, 0.0),
inputs,
Vector3::zeros(),
(1.225, 340.0, 0.0, 0.0),
0.5,
None,
0.0,
None,
)[3]
};
for (downrange_m, expected_ratio) in
[(0.0_f64, 1.01), (50.0 * std::f64::consts::LN_2, 1.0025)]
{
let baseline_drag = drag_x(&baseline_inputs, downrange_m);
let yawed_drag = drag_x(&yawed_inputs, downrange_m);
let actual_ratio = yawed_drag / baseline_drag;
assert!(baseline_drag < 0.0, "baseline must be downrange drag");
assert!(
(actual_ratio - expected_ratio).abs() < 1e-12,
"tip-off yaw at x={downrange_m} m used the wrong angular unit or decay: \
ratio={actual_ratio}, expected={expected_ratio}"
);
}
}
fn create_test_inputs() -> BallisticInputs {
BallisticInputs {
muzzle_velocity: 800.0, bc_value: 0.5,
bullet_mass: 168.0 * 0.00006479891, bullet_diameter: 0.308 * 0.0254, bullet_length: 1.215 * 0.0254, caliber_inches: 0.308,
weight_grains: 168.0,
altitude: 1000.0,
..Default::default()
}
}
#[test]
fn measured_bc_drag_ignores_name_based_form_factor_flag() {
let derivatives_with_flag = |use_form_factor| {
let inputs = BallisticInputs {
bc_value: 0.462,
bc_type: crate::DragModel::G1,
bullet_model: Some("168gr SMK Match".to_string()),
use_form_factor,
..create_test_inputs()
};
compute_derivatives(
Vector3::zeros(),
Vector3::new(600.0, 0.0, 0.0),
&inputs,
Vector3::zeros(),
(1.225, 340.0, 0.0, 0.0),
inputs.bc_value,
None,
0.0,
None,
)
};
let baseline = derivatives_with_flag(false);
let flagged = derivatives_with_flag(true);
for component in 3..6 {
assert_eq!(
flagged[component].to_bits(),
baseline[component].to_bits(),
"published BC already encodes form factor: component {component}, baseline={} flagged={}",
baseline[component],
flagged[component]
);
}
}
#[test]
fn inclined_positions_at_same_world_altitude_have_same_atmospheric_acceleration() {
let angle = std::f64::consts::FRAC_PI_6;
let mut inputs = create_test_inputs();
inputs.altitude = 100.0;
inputs.shooting_angle = angle;
let velocity = Vector3::new(600.0, 0.0, 0.0);
let along_slant = Vector3::new(1_000.0, 0.0, 0.0);
let across_slant = Vector3::new(0.0, 500.0 / angle.cos(), 0.0);
let atmo = (inputs.altitude, 15.0, 1013.25, 1.0);
let a = compute_derivatives(
along_slant,
velocity,
&inputs,
Vector3::zeros(),
atmo,
inputs.bc_value,
None,
0.0,
None,
);
let b = compute_derivatives(
across_slant,
velocity,
&inputs,
Vector3::zeros(),
atmo,
inputs.bc_value,
None,
0.0,
None,
);
for component in 3..6 {
assert!(
(a[component] - b[component]).abs() < 1e-10,
"derivative component {component} differs at equal world altitude: {} vs {}",
a[component],
b[component]
);
}
}
#[test]
fn inclined_headwind_is_rotated_into_solver_frame() {
let angle = std::f64::consts::FRAC_PI_6;
let mut inputs = create_test_inputs();
inputs.shooting_angle = angle;
let level_headwind = Vector3::new(-100.0, 0.0, 0.0);
let velocity = expected_shot_frame_vector(level_headwind, angle);
let actual = compute_derivatives(
Vector3::zeros(),
velocity,
&inputs,
level_headwind,
(1.225, 340.0, 0.0, 0.0),
inputs.bc_value,
None,
0.0,
None,
);
let expected = Vector3::new(
-G_ACCEL_MPS2 * angle.sin(),
-G_ACCEL_MPS2 * angle.cos(),
0.0,
);
assert!(
(Vector3::new(actual[3], actual[4], actual[5]) - expected).norm() < 1e-12,
"co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
);
}
#[test]
fn inclined_coriolis_is_rotated_into_solver_frame() {
let angle = std::f64::consts::FRAC_PI_6;
let mut inputs = create_test_inputs();
inputs.shooting_angle = angle;
let velocity = Vector3::new(600.0, 20.0, 5.0);
let level_omega = Vector3::new(3.0e-5, 6.0e-5, -2.0e-5);
let run = |omega| {
compute_derivatives(
Vector3::zeros(),
velocity,
&inputs,
Vector3::zeros(),
(1.225, 340.0, 0.0, 0.0),
inputs.bc_value,
omega,
0.0,
None,
)
};
let baseline = run(None);
let with_coriolis = run(Some(level_omega));
let actual = Vector3::new(
with_coriolis[3] - baseline[3],
with_coriolis[4] - baseline[4],
with_coriolis[5] - baseline[5],
);
let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
assert!(
(actual - expected).norm() < 1e-12,
"inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
);
}
#[test]
fn explicit_velocity_segment_gap_uses_scalar_bc_without_auto_estimation() {
let mut inputs = create_test_inputs();
inputs.bc_value = 0.91;
inputs.use_bc_segments = true;
inputs.bc_segments_data = Some(vec![
crate::BCSegmentData {
velocity_min: 0.0,
velocity_max: 999.0,
bc_value: 0.2,
},
crate::BCSegmentData {
velocity_min: 1000.0,
velocity_max: 2000.0,
bc_value: 0.3,
},
]);
assert_eq!(
get_bc_for_velocity(999.5, &inputs, inputs.bc_value),
inputs.bc_value,
"an explicit table gap must not be replaced by an auto-estimated segment"
);
}
#[test]
fn test_mba955_bc_segments_prepopulate_byte_identical() {
let mut slow = create_test_inputs();
slow.use_bc_segments = true;
slow.bc_segments_data = None;
slow.bc_segments = None;
let bc_used = slow.bc_value;
let mut fast = slow.clone();
fast.bc_segments_data = estimate_bc_segments_for(&fast, bc_used);
assert!(
fast.bc_segments_data.is_some(),
"estimation should yield segments for a valid bullet"
);
for v in (200..=3500).step_by(50) {
let vf = v as f64;
let a = get_bc_for_velocity(vf, &slow, bc_used);
let b = get_bc_for_velocity(vf, &fast, bc_used);
assert_eq!(
a.to_bits(),
b.to_bits(),
"BC differs at {vf} fps: slow={a} fast={b}"
);
}
}
#[test]
fn estimated_segments_inherit_typed_g7_drag_model() {
let mut inputs = create_test_inputs();
inputs.bc_value = 0.243;
inputs.bc_type = crate::DragModel::G7;
inputs.bc_type_str = None;
inputs.bullet_mass = 175.0 * 0.00006479891;
inputs.weight_grains = 175.0;
let actual = estimate_bc_segments_for(&inputs, inputs.bc_value).unwrap();
let expected = BCSegmentEstimator::estimate_bc_segments(
inputs.bc_value,
inputs.caliber_inches,
inputs.weight_grains,
"175gr bullet",
"G7",
);
assert_eq!(actual.len(), expected.len());
for (actual, expected) in actual.iter().zip(&expected) {
assert_eq!(actual.velocity_min.to_bits(), expected.velocity_min.to_bits());
assert_eq!(actual.velocity_max.to_bits(), expected.velocity_max.to_bits());
assert_eq!(actual.bc_value.to_bits(), expected.bc_value.to_bits());
}
inputs.bc_type_str = Some("G1".to_string());
let legacy = estimate_bc_segments_for(&inputs, inputs.bc_value).unwrap();
let expected_g1 = BCSegmentEstimator::estimate_bc_segments(
inputs.bc_value,
inputs.caliber_inches,
inputs.weight_grains,
"175gr bullet",
"G1",
);
assert_eq!(legacy.len(), expected_g1.len());
for (legacy, expected) in legacy.iter().zip(&expected_g1) {
assert_eq!(legacy.bc_value.to_bits(), expected.bc_value.to_bits());
}
}
#[test]
fn test_compute_derivatives_basic() {
let pos = Vector3::new(0.0, 0.0, 0.0);
let vel = Vector3::new(800.0, 0.0, 0.0);
let inputs = create_test_inputs();
let wind_vector = Vector3::zeros();
let atmos_params = (1.225, 340.0, 0.0, 0.0); let bc_used = 0.5;
let result = compute_derivatives(
pos,
vel,
&inputs,
wind_vector,
atmos_params,
bc_used,
None,
0.0,
None,
);
assert_eq!(result.len(), 6);
assert!((result[0] - vel[0]).abs() < 1e-10);
assert!((result[1] - vel[1]).abs() < 1e-10);
assert!((result[2] - vel[2]).abs() < 1e-10);
assert!(result[4] < 0.0);
assert!(result[3] < 0.0); }
#[test]
fn standard_atmosphere_zero_ratio_uses_sea_level_fallback() {
let pos = Vector3::new(0.0, 0.0, 0.0);
let vel = Vector3::new(800.0, 0.0, 0.0);
let inputs = create_test_inputs();
let run = |base_ratio| {
compute_derivatives(
pos,
vel,
&inputs,
Vector3::zeros(),
(inputs.altitude, 15.0, 1013.25, base_ratio),
inputs.bc_value,
None,
0.0,
None,
)
};
let missing_ratio = run(0.0);
let explicit_sea_level = run(1.0);
assert!(
missing_ratio[3] < 0.0,
"missing standard density ratio must not produce vacuum drag"
);
assert!((missing_ratio[3] - explicit_sea_level[3]).abs() < 1e-12);
}
#[test]
fn test_compute_derivatives_with_wind() {
let pos = Vector3::new(0.0, 0.0, 0.0);
let vel = Vector3::new(800.0, 0.0, 0.0);
let inputs = create_test_inputs();
let wind_vector = Vector3::new(10.0, 0.0, 0.0); let atmos_params = (1.225, 340.0, 0.0, 0.0); let bc_used = 0.5;
let result = compute_derivatives(
pos,
vel,
&inputs,
wind_vector,
atmos_params,
bc_used,
None,
0.0,
None,
);
assert!(result[3] < 0.0); }
#[test]
fn test_compute_derivatives_with_coriolis() {
let pos = Vector3::new(0.0, 0.0, 0.0);
let vel = Vector3::new(800.0, 0.0, 0.0);
let inputs = create_test_inputs();
let wind_vector = Vector3::zeros();
let atmos_params = (1.225, 340.0, 0.0, 0.0); let bc_used = 0.5;
let omega = Vector3::new(0.0, 0.0, 7.2921e-5);
let result = compute_derivatives(
pos,
vel,
&inputs,
wind_vector,
atmos_params,
bc_used,
Some(omega),
0.0,
None,
);
assert!(result[4].abs() > 1e-3); }
#[test]
fn test_interpolated_bc() {
let segments = vec![(0.5, 0.4), (1.0, 0.5), (1.5, 0.6), (2.0, 0.5)];
assert!((interpolated_bc(1.0, &segments, None) - 0.5).abs() < 1e-10);
let bc_075 = interpolated_bc(0.75, &segments, None);
assert!(bc_075 > 0.4 && bc_075 < 0.5);
assert!((interpolated_bc(0.1, &segments, None) - 0.4).abs() < 1e-10);
assert!((interpolated_bc(3.0, &segments, None) - 0.5).abs() < 1e-10);
}
#[test]
fn test_interpolated_bc_edge_cases() {
assert!(
(interpolated_bc(1.0, &[], None) - crate::constants::BC_FALLBACK_CONSERVATIVE).abs()
< 1e-10
);
let mut inputs = create_test_inputs();
inputs.bc_type = crate::DragModel::G7;
inputs.bc_value = 0.487;
assert_eq!(
interpolated_bc(1.0, &[], Some(&inputs)).to_bits(),
inputs.bc_value.to_bits(),
"an empty optional table must preserve the caller's scalar BC"
);
let single = vec![(1.0, 0.7)];
assert!((interpolated_bc(1.5, &single, None) - 0.7).abs() < 1e-10);
}
#[test]
fn empty_mach_segments_preserve_active_bc_used() {
let mut inputs = create_test_inputs();
inputs.bc_type = crate::DragModel::G7;
inputs.bc_value = 0.123;
let drag_acceleration = |inputs: &BallisticInputs| {
compute_derivatives(
Vector3::zeros(),
Vector3::new(700.0, 0.0, 0.0),
inputs,
Vector3::zeros(),
(1.225, 340.0, 0.0, 0.0),
0.487,
None,
0.0,
None,
)[3]
};
inputs.use_bc_segments = false;
inputs.bc_segments = None;
let no_table = drag_acceleration(&inputs);
for use_bc_segments in [false, true] {
inputs.use_bc_segments = use_bc_segments;
inputs.bc_segments = Some(Vec::new());
let empty_table = drag_acceleration(&inputs);
assert_eq!(
empty_table.to_bits(),
no_table.to_bits(),
"Some(empty) must preserve bc_used when use_bc_segments={use_bc_segments}"
);
}
}
#[test]
fn test_magnus_effect() {
let pos = Vector3::new(0.0, 0.0, 0.0);
let vel = Vector3::new(822.96, 0.0, 0.0); let wind_vector = Vector3::zeros();
let atmos_params = (1.225, 340.0, 0.0, 0.0); let bc_used = 0.5;
let acceleration = |enable_magnus, is_twist_right| {
let mut inputs = create_test_inputs();
inputs.twist_rate = 10.0; inputs.is_twist_right = is_twist_right;
inputs.enable_magnus = enable_magnus;
let result = compute_derivatives(
pos,
vel,
&inputs,
wind_vector,
atmos_params,
bc_used,
None,
0.0,
None,
);
Vector3::new(result[3], result[4], result[5])
};
let baseline = acceleration(false, true);
let right_twist = acceleration(true, true) - baseline;
let left_twist = acceleration(true, false) - baseline;
assert!(
right_twist.y < 0.0,
"right-hand Magnus must point down, got {right_twist:?}"
);
assert!(
left_twist.y > 0.0,
"left-hand Magnus must point up, got {left_twist:?}"
);
assert!((right_twist.y + left_twist.y).abs() < 1e-12);
assert!(right_twist.x.abs() < 1e-12 && right_twist.z.abs() < 1e-12);
assert!(left_twist.x.abs() < 1e-12 && left_twist.z.abs() < 1e-12);
assert!(
right_twist.y.abs() < 0.05,
"Magnus must remain a small force"
);
}
#[test]
fn magnus_uses_velocity_corrected_muzzle_stability_gate() {
let muzzle_velocity = 1_400.0 / MPS_TO_FPS;
let mut inputs = create_test_inputs();
inputs.muzzle_velocity = muzzle_velocity;
inputs.twist_rate = 15.0;
inputs.enable_magnus = true;
let bare_sg = crate::spin_drift::miller_stability(0.308, 168.0, 15.0, 1.215);
let canonical_sg = crate::spin_drift::effective_sg_from_inputs(&inputs, 15.0, 1013.25);
assert!(bare_sg > 1.0, "test requires bare Sg above the Magnus gate");
assert!(
canonical_sg < 1.0,
"velocity-corrected Sg must be below the gate, got {canonical_sg}"
);
let acceleration = |inputs: &BallisticInputs| {
let result = compute_derivatives(
Vector3::zeros(),
Vector3::new(muzzle_velocity, 0.0, 0.0),
inputs,
Vector3::zeros(),
(1.225, 340.0, 0.0, 0.0),
0.5,
None,
0.0,
None,
);
Vector3::new(result[3], result[4], result[5])
};
let enabled = acceleration(&inputs);
inputs.enable_magnus = false;
let disabled = acceleration(&inputs);
assert_eq!(
enabled, disabled,
"canonical Sg below 1 must suppress every Magnus acceleration component"
);
}
#[test]
fn magnus_force_grows_as_fixed_spin_projectile_slows() {
let mut inputs = create_test_inputs();
inputs.muzzle_velocity = 800.0;
inputs.twist_rate = 12.0;
inputs.enable_magnus = true;
let magnus_acceleration = |speed_mps| {
let evaluate = |enable_magnus| {
let mut run_inputs = inputs.clone();
run_inputs.enable_magnus = enable_magnus;
compute_derivatives(
Vector3::zeros(),
Vector3::new(speed_mps, 0.0, 0.0),
&run_inputs,
Vector3::zeros(),
(1.225, 340.0, 0.0, 0.0),
0.5,
None,
0.0,
None,
)[4]
};
(evaluate(true) - evaluate(false)).abs()
};
let fast = magnus_acceleration(200.0);
let slow = magnus_acceleration(100.0);
let ratio = slow / fast;
let expected_ratio = 2.0_f64.powf(5.0 / 3.0);
assert!(fast > 0.0 && slow > 0.0, "fast={fast}, slow={slow}");
assert!(
(ratio - expected_ratio).abs() < 1e-3,
"fixed-spin Magnus acceleration must grow downrange; slow/fast={ratio}, \
expected={expected_ratio}"
);
}
#[test]
fn test_magnus_moment_coefficient() {
assert!((calculate_magnus_moment_coefficient(0.5) - 0.030).abs() < 0.001); assert!((calculate_magnus_moment_coefficient(0.8) - 0.030).abs() < 0.001); assert!((calculate_magnus_moment_coefficient(1.0) - 0.0225).abs() < 0.001); assert!((calculate_magnus_moment_coefficient(1.2) - 0.015).abs() < 0.001); assert!((calculate_magnus_moment_coefficient(2.0) - 0.01653).abs() < 0.001);
}
}