use crate::InternalBallisticInputs;
use std::f64;
const FEET_TO_METERS: f64 = 0.3048;
const FPS_TO_MPS: f64 = FEET_TO_METERS;
const YARDS_TO_METERS: f64 = 0.9144;
const DEGREES_TO_RADIANS: f64 = std::f64::consts::PI / 180.0;
const RADIANS_TO_DEGREES: f64 = 180.0 / std::f64::consts::PI;
const ZERO_FINDING_MAX_ITER: usize = 100;
#[derive(Debug, Clone)]
pub struct AngleResult {
pub angle_rad: f64,
pub iterations_used: usize,
pub final_error: f64,
pub success: bool,
}
pub fn brent_root_find<F>(
f: F,
mut a: f64,
mut b: f64,
x_tolerance: f64,
max_iterations: usize,
) -> Result<AngleResult, String>
where
F: Fn(f64) -> f64,
{
let mut fa = f(a);
let mut fb = f(b);
let mut iterations = 0;
if fa * fb > 0.0 {
return Err(format!("Root not bracketed: f({a}) = {fa}, f({b}) = {fb}"));
}
if fa.abs() < fb.abs() {
std::mem::swap(&mut a, &mut b);
std::mem::swap(&mut fa, &mut fb);
}
let mut c = a;
let mut fc = fa;
let mut d = b - a;
let mut e = d;
while iterations < max_iterations {
iterations += 1;
if fb == 0.0 {
return Ok(AngleResult {
angle_rad: b,
iterations_used: iterations,
final_error: fb.abs(),
success: true,
});
}
if fc.abs() < fb.abs() {
a = b;
b = c;
c = a;
fa = fb;
fb = fc;
fc = fa;
}
let tolerance_scaled = 2.0 * f64::EPSILON * b.abs() + 0.5 * x_tolerance;
let m = 0.5 * (c - b);
if m.abs() <= tolerance_scaled {
return Ok(AngleResult {
angle_rad: b,
iterations_used: iterations,
final_error: fb.abs(),
success: true,
});
}
if e.abs() >= tolerance_scaled && fa.abs() > fb.abs() {
if fc.abs() < f64::EPSILON || fa.abs() < f64::EPSILON {
d = m;
e = m;
} else {
let s = fb / fa;
let mut p;
let mut q;
if (a - c).abs() < f64::EPSILON {
p = 2.0 * m * s;
q = 1.0 - s;
} else {
q = fa / fc;
let r = fb / fc;
p = s * (2.0 * m * q * (q - r) - (b - a) * (r - 1.0));
q = (q - 1.0) * (r - 1.0) * (s - 1.0);
}
if p > 0.0 {
q = -q;
} else {
p = -p;
}
let s = e;
e = d;
if q.abs() > f64::EPSILON
&& 2.0 * p < 3.0 * m * q - (tolerance_scaled * q).abs()
&& p < (0.5 * s * q).abs()
{
d = p / q;
} else {
d = m;
e = d;
}
}
} else {
d = m;
e = d;
}
a = b;
fa = fb;
if d.abs() > tolerance_scaled {
b += d;
} else if m > 0.0 {
b += tolerance_scaled;
} else {
b -= tolerance_scaled;
}
fb = f(b);
if (fc * fb) > 0.0 {
c = a;
fc = fa;
e = b - a;
d = e;
}
}
Ok(AngleResult {
angle_rad: b,
iterations_used: iterations,
final_error: fb.abs(),
success: false,
})
}
pub fn adjusted_muzzle_velocity(inputs: &InternalBallisticInputs) -> f64 {
let mut mv = inputs.muzzle_velocity;
if inputs.use_powder_sensitivity {
mv += inputs.powder_temp_sensitivity * (inputs.temperature - inputs.powder_temp);
}
mv
}
pub fn zero_angle(
inputs: &InternalBallisticInputs,
trajectory_func: impl Fn(&InternalBallisticInputs, f64) -> Result<f64, String> + Copy,
) -> Result<AngleResult, String> {
let vert = if inputs.shooting_angle.abs() > 1e-6 {
inputs.target_distance * inputs.shooting_angle.tan()
} else {
0.0
};
let height_diff = |look_angle_rad: f64| -> f64 {
match trajectory_func(inputs, look_angle_rad) {
Ok(bullet_height) => bullet_height - vert,
Err(_) => f64::NAN, }
};
let lower_bound = -10.0 * DEGREES_TO_RADIANS;
let upper_bound = 10.0 * DEGREES_TO_RADIANS;
match brent_root_find(height_diff, lower_bound, upper_bound, 1e-6, 100) {
Ok(result) if result.success => Ok(result),
_ => {
let wider_lower = -45.0 * DEGREES_TO_RADIANS;
let wider_upper = 45.0 * DEGREES_TO_RADIANS;
match brent_root_find(height_diff, wider_lower, wider_upper, 1e-5, 150) {
Ok(result) if result.success => Ok(result),
Ok(result) => {
Ok(AngleResult {
angle_rad: result.angle_rad,
iterations_used: result.iterations_used,
final_error: result.final_error,
success: false,
})
}
Err(_) => {
Ok(AngleResult {
angle_rad: 0.0,
iterations_used: 0,
final_error: f64::INFINITY,
success: false,
})
}
}
}
}
}
pub fn solve_muzzle_angle(
inputs: &InternalBallisticInputs,
zero_distance_los_m: f64,
trajectory_func: impl Fn(&InternalBallisticInputs) -> Result<f64, String> + Copy, angle_lower_deg: f64,
angle_upper_deg: f64,
rtol: f64,
) -> Result<AngleResult, String> {
if angle_lower_deg >= angle_upper_deg {
return Err("angle_lower_deg must be less than angle_upper_deg".to_string());
}
let lower = angle_lower_deg * DEGREES_TO_RADIANS;
let mut upper = angle_upper_deg * DEGREES_TO_RADIANS;
let vertical_error = |angle_rad: f64| -> f64 {
let mut candidate = inputs.clone();
candidate.muzzle_angle = angle_rad * RADIANS_TO_DEGREES;
candidate.target_distance = zero_distance_los_m / YARDS_TO_METERS;
trajectory_func(&candidate).unwrap_or(f64::NAN)
};
let f_lower = vertical_error(lower);
if f_lower.abs() < 1e-9 {
return Ok(AngleResult {
angle_rad: lower,
iterations_used: 1,
final_error: f_lower.abs(),
success: true,
});
}
let f_upper = vertical_error(upper);
if f_upper.abs() < 1e-9 {
return Ok(AngleResult {
angle_rad: upper,
iterations_used: 1,
final_error: f_upper.abs(),
success: true,
});
}
if f_lower * f_upper > 0.0 {
let step = 5.0 * DEGREES_TO_RADIANS;
let max_angle = 45.0 * DEGREES_TO_RADIANS;
let mut current = upper;
let mut f_current = f_upper;
while current < max_angle && f_lower * f_current > 0.0 {
current += step;
f_current = vertical_error(current);
}
if f_lower * f_current > 0.0 {
return Err("Unable to bracket zero; widen angle bounds or check inputs".to_string());
}
upper = current;
}
let range = (upper - lower).abs();
let tolerance = if range > f64::EPSILON {
rtol * range
} else {
rtol * 1e-12 };
brent_root_find(
vertical_error,
lower,
upper,
tolerance,
ZERO_FINDING_MAX_ITER,
)
}
pub fn quick_drop_estimate(
muzzle_velocity_fps: f64,
distance_yards: f64,
_bullet_mass_grains: f64,
bc: f64,
) -> f64 {
if muzzle_velocity_fps <= 0.0 || distance_yards <= 0.0 {
return 0.0; }
let bc_safe = bc.max(0.1);
let distance_ft = distance_yards * 3.0;
let step_count = ((distance_yards / 5.0).ceil() as usize).clamp(32, 4096);
let step_ft = distance_ft / step_count as f64;
let gravity_ft_s2 = crate::constants::G_ACCEL_MPS2 / FEET_TO_METERS;
let speed_of_sound_fps = crate::constants::SPEED_OF_SOUND_MPS / FPS_TO_MPS;
let derivatives = |state: &[f64; 3]| -> Option<[f64; 3]> {
let [_, vx, vy] = *state;
if !(vx.is_finite() && vy.is_finite() && vx > 1e-9) {
return None;
}
let speed = vx.hypot(vy);
let mach = speed / speed_of_sound_fps;
let cd = crate::drag::get_drag_coefficient(mach, &crate::DragModel::G1);
let drag_accel = speed.powi(2) * cd * crate::constants::CD_TO_RETARD / bc_safe;
Some([
vy / vx,
-drag_accel / speed,
(-gravity_ft_s2 - drag_accel * vy / speed) / vx,
])
};
let mut state = [0.0, muzzle_velocity_fps, 0.0];
for _ in 0..step_count {
let Some(k1) = derivatives(&state) else {
return f64::NAN;
};
let midpoint: [f64; 3] = std::array::from_fn(|i| state[i] + 0.5 * step_ft * k1[i]);
let Some(k2) = derivatives(&midpoint) else {
return f64::NAN;
};
state = std::array::from_fn(|i| state[i] + step_ft * k2[i]);
}
-state[0] * FEET_TO_METERS
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_inputs() -> InternalBallisticInputs {
InternalBallisticInputs {
muzzle_velocity: 823.0, bc_value: 0.5,
bullet_mass: 0.0109, bullet_diameter: 0.00782, target_distance: 500.0,
temperature: 21.1, powder_temp: 21.1, ..Default::default()
}
}
#[test]
fn test_brent_root_find_quadratic() {
let f = |x: f64| x * x - 4.0;
let result = brent_root_find(f, 1.0, 3.0, 1e-6, 100).unwrap();
assert!(result.success);
assert!((result.angle_rad - 2.0).abs() < 1e-6);
assert!(result.iterations_used > 0);
assert!(result.final_error < 1e-6);
}
#[test]
fn brent_uses_inverse_quadratic_interpolation() {
let f = |x: f64| x * x - 2.0;
let result = brent_root_find(f, 1.0, 2.0, 1e-12, 100).unwrap();
assert!(result.success);
assert!((result.angle_rad - 2.0_f64.sqrt()).abs() < 1e-12);
assert!(
result.iterations_used <= 10,
"smooth quadratic should converge superlinearly, took {} iterations",
result.iterations_used
);
}
#[test]
fn test_brent_root_find_linear() {
let f = |x: f64| 2.0 * x - 6.0;
let result = brent_root_find(f, 0.0, 5.0, 1e-6, 100).unwrap();
assert!(result.success);
assert!((result.angle_rad - 3.0).abs() < 1e-6);
}
#[test]
fn brent_angle_tolerance_is_invariant_to_residual_units() {
let expected_root = 2.0_f64.sqrt();
let x_tolerance = 1e-8;
for scale in [1.0, 1e-9] {
let result = brent_root_find(
|angle_rad| scale * (angle_rad * angle_rad - 2.0),
1.0,
2.0,
x_tolerance,
100,
)
.unwrap();
assert!(result.success);
assert!(
(result.angle_rad - expected_root).abs() <= x_tolerance,
"residual scale {scale} loosened the angular tolerance: {result:?}"
);
assert!(result.iterations_used > 1);
}
let exhausted = brent_root_find(|x| 1e-9 * (x * x - 2.0), 1.0, 2.0, 1e-6, 0).unwrap();
assert!(!exhausted.success);
}
#[test]
fn test_brent_root_find_no_bracket() {
let f = |x: f64| x * x + 1.0; let result = brent_root_find(f, 1.0, 3.0, 1e-6, 100);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Root not bracketed"));
}
#[test]
fn test_adjusted_muzzle_velocity_no_sensitivity() {
let inputs = create_test_inputs();
let result = adjusted_muzzle_velocity(&inputs);
assert_eq!(result, 823.0); }
#[test]
fn test_adjusted_muzzle_velocity_with_sensitivity() {
let mut inputs = create_test_inputs();
inputs.use_powder_sensitivity = true;
inputs.powder_temp_sensitivity = 0.6; inputs.temperature = 31.1;
inputs.powder_temp = 21.1;
let result = adjusted_muzzle_velocity(&inputs);
assert!((result - 829.0).abs() < 1e-12);
inputs.temperature = 11.1;
let colder_result = adjusted_muzzle_velocity(&inputs);
assert!((colder_result - 817.0).abs() < 1e-12);
}
#[test]
fn test_quick_drop_estimate() {
let drop = quick_drop_estimate(2700.0, 500.0, 168.0, 0.5);
assert!(drop > 0.0);
assert!(drop < 50.0);
let drop_high_bc = quick_drop_estimate(2700.0, 500.0, 168.0, 0.8);
assert!(drop_high_bc < drop);
}
#[test]
fn quick_drop_tracks_g1_point_mass_reference() {
let distance_yards = 500.0;
let distance_m = distance_yards * YARDS_TO_METERS;
let inputs = InternalBallisticInputs {
muzzle_velocity: 1200.0 * FPS_TO_MPS,
bc_value: 0.5,
bc_type: crate::DragModel::G1,
bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
bullet_diameter: 0.308 * 0.0254,
muzzle_height: 0.0,
ground_threshold: f64::NEG_INFINITY,
..Default::default()
};
let mut solver =
crate::TrajectorySolver::new(inputs, Default::default(), Default::default());
solver.set_max_range(distance_m);
let reference = solver.solve().unwrap();
let position = reference.position_at_range(distance_m).unwrap();
let reference_drop_m = -position.y;
assert!(
(9.2..=9.6).contains(&reference_drop_m),
"unexpected canonical G1 fixture: {reference_drop_m} m"
);
let estimated_drop_m = quick_drop_estimate(1200.0, distance_yards, 168.0, 0.5);
let relative_error = (estimated_drop_m - reference_drop_m).abs() / reference_drop_m;
assert!(
relative_error < 0.1,
"quick G1 drop {estimated_drop_m} m differs from reference {reference_drop_m} m by {:.1}%",
relative_error * 100.0
);
}
#[test]
fn test_zero_angle_uses_si_distance_and_radians() {
let mut inputs = create_test_inputs();
inputs.target_distance = 800.0;
inputs.shooting_angle = 1.0_f64.to_radians();
let result = zero_angle(&inputs, |trajectory_inputs, look_angle_rad| {
Ok(trajectory_inputs.target_distance * look_angle_rad)
})
.unwrap();
assert!(result.success);
assert!(
(result.angle_rad - inputs.shooting_angle).abs() < 1e-4,
"SI zero target should solve near {} rad, got {}",
inputs.shooting_angle,
result.angle_rad
);
}
#[test]
fn zero_angle_uses_horizontal_range_for_incline_geometry() {
let mut inputs = create_test_inputs();
inputs.target_distance = 800.0;
inputs.shooting_angle = 30.0_f64.to_radians();
let result = zero_angle(&inputs, |trajectory_inputs, look_angle_rad| {
Ok(trajectory_inputs.target_distance * look_angle_rad.tan())
})
.unwrap();
assert!(result.success);
assert!(
(result.angle_rad - inputs.shooting_angle).abs() < 1e-4,
"horizontal-range zero should solve at {} rad, got {}",
inputs.shooting_angle,
result.angle_rad
);
}
#[test]
fn test_zero_angle_bounds() {
let lower = -10.0 * DEGREES_TO_RADIANS;
let upper = 10.0 * DEGREES_TO_RADIANS;
assert!(lower < 0.0);
assert!(upper > 0.0);
assert!((upper - lower).abs() > 0.1); }
#[test]
fn test_brent_root_find_near_zero_function_values() {
let f = |x: f64| (x - 1.0) * 1e-10; let result = brent_root_find(f, 0.0, 2.0, 1e-12, 100).unwrap();
assert!(result.success || result.iterations_used > 0);
assert!((result.angle_rad - 1.0).abs() < 0.1);
}
#[test]
fn test_brent_root_find_steep_function() {
let f = |x: f64| (x - 0.5).powi(3) * 1e6;
let result = brent_root_find(f, 0.0, 1.0, 1e-9, 100).unwrap();
assert!(result.success);
assert!((result.angle_rad - 0.5).abs() < 1e-6);
}
#[test]
fn test_brent_root_find_oscillating_convergence() {
let f = |x: f64| x.sin() - 0.5;
let result = brent_root_find(f, 0.0, 1.0, 1e-10, 100).unwrap();
assert!(result.success);
assert!((result.angle_rad - std::f64::consts::FRAC_PI_6).abs() < 1e-6);
}
#[test]
fn test_brent_root_find_flat_region() {
let f = |x: f64| (x - 2.0).powi(5);
let result = brent_root_find(f, 1.0, 3.0, 1e-8, 100).unwrap();
assert!(result.success);
assert!((result.angle_rad - 2.0).abs() < 1e-4);
}
}