Skip to main content

ballistics_engine/
angle_calculations.rs

1use crate::InternalBallisticInputs;
2use std::f64;
3
4// Constants for unit conversions
5const FEET_TO_METERS: f64 = 0.3048;
6const FPS_TO_MPS: f64 = FEET_TO_METERS;
7const YARDS_TO_METERS: f64 = 0.9144;
8const DEGREES_TO_RADIANS: f64 = std::f64::consts::PI / 180.0;
9const RADIANS_TO_DEGREES: f64 = 180.0 / std::f64::consts::PI;
10
11// Zero finding constants
12const ZERO_FINDING_MAX_ITER: usize = 100;
13
14/// Result of angle calculation
15#[derive(Debug, Clone)]
16pub struct AngleResult {
17    pub angle_rad: f64,
18    pub iterations_used: usize,
19    pub final_error: f64,
20    pub success: bool,
21}
22
23/// Brent's method for root finding - optimized implementation.
24///
25/// `x_tolerance` is an absolute tolerance in the same units as `a` and `b`.
26/// [`AngleResult::final_error`] reports `|f(x)|` in the function's output units.
27pub fn brent_root_find<F>(
28    f: F,
29    mut a: f64,
30    mut b: f64,
31    x_tolerance: f64,
32    max_iterations: usize,
33) -> Result<AngleResult, String>
34where
35    F: Fn(f64) -> f64,
36{
37    let mut fa = f(a);
38    let mut fb = f(b);
39    let mut iterations = 0;
40
41    // Ensure the root is bracketed
42    if fa * fb > 0.0 {
43        return Err(format!("Root not bracketed: f({a}) = {fa}, f({b}) = {fb}"));
44    }
45
46    // Ensure |f(a)| >= |f(b)|
47    if fa.abs() < fb.abs() {
48        std::mem::swap(&mut a, &mut b);
49        std::mem::swap(&mut fa, &mut fb);
50    }
51
52    let mut c = a;
53    let mut fc = fa;
54    let mut d = b - a;
55    let mut e = d;
56
57    while iterations < max_iterations {
58        iterations += 1;
59
60        if fb == 0.0 {
61            return Ok(AngleResult {
62                angle_rad: b,
63                iterations_used: iterations,
64                final_error: fb.abs(),
65                success: true,
66            });
67        }
68
69        if fc.abs() < fb.abs() {
70            a = b;
71            b = c;
72            c = a;
73            fa = fb;
74            fb = fc;
75            fc = fa;
76        }
77
78        let tolerance_scaled = 2.0 * f64::EPSILON * b.abs() + 0.5 * x_tolerance;
79        let m = 0.5 * (c - b);
80
81        if m.abs() <= tolerance_scaled {
82            return Ok(AngleResult {
83                angle_rad: b,
84                iterations_used: iterations,
85                final_error: fb.abs(),
86                success: true,
87            });
88        }
89
90        if e.abs() >= tolerance_scaled && fa.abs() > fb.abs() {
91            // Check for safe division before interpolation
92            if fc.abs() < f64::EPSILON || fa.abs() < f64::EPSILON {
93                // Fallback to bisection if denominators are too small
94                d = m;
95                e = m;
96            } else {
97                let s = fb / fa;
98                let mut p;
99                let mut q;
100
101                if (a - c).abs() < f64::EPSILON {
102                    // Linear interpolation
103                    p = 2.0 * m * s;
104                    q = 1.0 - s;
105                } else {
106                    // Inverse quadratic interpolation
107                    q = fa / fc;
108                    let r = fb / fc;
109                    p = s * (2.0 * m * q * (q - r) - (b - a) * (r - 1.0));
110                    q = (q - 1.0) * (r - 1.0) * (s - 1.0);
111                }
112
113                if p > 0.0 {
114                    q = -q;
115                } else {
116                    p = -p;
117                }
118
119                let s = e;
120                e = d;
121
122                // Check for safe division in the acceptance test
123                if q.abs() > f64::EPSILON
124                    && 2.0 * p < 3.0 * m * q - (tolerance_scaled * q).abs()
125                    && p < (0.5 * s * q).abs()
126                {
127                    d = p / q;
128                } else {
129                    d = m;
130                    e = d;
131                }
132            }
133        } else {
134            d = m;
135            e = d;
136        }
137
138        a = b;
139        fa = fb;
140
141        if d.abs() > tolerance_scaled {
142            b += d;
143        } else if m > 0.0 {
144            b += tolerance_scaled;
145        } else {
146            b -= tolerance_scaled;
147        }
148
149        fb = f(b);
150
151        if (fc * fb) > 0.0 {
152            c = a;
153            fc = fa;
154            e = b - a;
155            d = e;
156        }
157    }
158
159    Ok(AngleResult {
160        angle_rad: b,
161        iterations_used: iterations,
162        final_error: fb.abs(),
163        success: false,
164    })
165}
166
167/// Calculate adjusted muzzle velocity for powder temperature sensitivity.
168///
169/// `powder_temp_sensitivity` is an additive velocity slope in m/s per degree Celsius; both
170/// temperature fields are Celsius.
171pub fn adjusted_muzzle_velocity(inputs: &InternalBallisticInputs) -> f64 {
172    let mut mv = inputs.muzzle_velocity;
173
174    if inputs.use_powder_sensitivity {
175        mv += inputs.powder_temp_sensitivity * (inputs.temperature - inputs.powder_temp);
176    }
177
178    mv
179}
180
181/// Calculate zero angle using Brent's method and Rust trajectory integration.
182///
183/// `inputs.target_distance` is meters and `inputs.shooting_angle` is radians.
184/// `trajectory_func` must return the projectile's fixed-frame vertical height
185/// at the horizontal downrange distance `inputs.target_distance`.
186pub fn zero_angle(
187    inputs: &InternalBallisticInputs,
188    trajectory_func: impl Fn(&InternalBallisticInputs, f64) -> Result<f64, String> + Copy,
189) -> Result<AngleResult, String> {
190    // Set up the target vertical position based on shooting angle
191    let vert = if inputs.shooting_angle.abs() > 1e-6 {
192        inputs.target_distance * inputs.shooting_angle.tan()
193    } else {
194        0.0
195    };
196
197    // Define the height difference function
198    // MBA-192: Use NaN instead of -999.0 on trajectory failure to prevent false roots
199    let height_diff = |look_angle_rad: f64| -> f64 {
200        // Calculate bullet height at target distance minus target height
201        match trajectory_func(inputs, look_angle_rad) {
202            Ok(bullet_height) => bullet_height - vert,
203            Err(_) => f64::NAN, // NaN causes Brent's method to fail gracefully
204        }
205    };
206
207    // Reasonable bounds for the zero angle in radians
208    // Most rifle zeroing will be within +/- 10 degrees
209    let lower_bound = -10.0 * DEGREES_TO_RADIANS;
210    let upper_bound = 10.0 * DEGREES_TO_RADIANS;
211
212    // Try primary bounds first
213    match brent_root_find(height_diff, lower_bound, upper_bound, 1e-6, 100) {
214        Ok(result) if result.success => Ok(result),
215        _ => {
216            // Fallback to wider search range
217            let wider_lower = -45.0 * DEGREES_TO_RADIANS;
218            let wider_upper = 45.0 * DEGREES_TO_RADIANS;
219
220            match brent_root_find(height_diff, wider_lower, wider_upper, 1e-5, 150) {
221                Ok(result) if result.success => Ok(result),
222                Ok(result) => {
223                    // Return best attempt even if not fully successful
224                    Ok(AngleResult {
225                        angle_rad: result.angle_rad,
226                        iterations_used: result.iterations_used,
227                        final_error: result.final_error,
228                        success: false,
229                    })
230                }
231                Err(_) => {
232                    // If all else fails, return 0 as a safe default
233                    Ok(AngleResult {
234                        angle_rad: 0.0,
235                        iterations_used: 0,
236                        final_error: f64::INFINITY,
237                        success: false,
238                    })
239                }
240            }
241        }
242    }
243}
244
245/// Solve muzzle angle using Brent's method optimization
246pub fn solve_muzzle_angle(
247    inputs: &InternalBallisticInputs,
248    zero_distance_los_m: f64,
249    trajectory_func: impl Fn(&InternalBallisticInputs) -> Result<f64, String> + Copy, // Returns drop_m
250    angle_lower_deg: f64,
251    angle_upper_deg: f64,
252    rtol: f64,
253) -> Result<AngleResult, String> {
254    if angle_lower_deg >= angle_upper_deg {
255        return Err("angle_lower_deg must be less than angle_upper_deg".to_string());
256    }
257
258    let lower = angle_lower_deg * DEGREES_TO_RADIANS;
259    let mut upper = angle_upper_deg * DEGREES_TO_RADIANS;
260
261    // Define the vertical error function
262    let vertical_error = |angle_rad: f64| -> f64 {
263        // Create modified inputs with new angle and target distance
264        let mut candidate = inputs.clone();
265        candidate.muzzle_angle = angle_rad * RADIANS_TO_DEGREES;
266        candidate.target_distance = zero_distance_los_m / YARDS_TO_METERS; // Convert back to yards
267
268        // NaN (not a finite sentinel) on trajectory failure: a large finite value like 1e6
269        // can fake a sign change and make the bracket/Brent solver lock onto the
270        // discontinuity and report a spurious root as success. NaN makes those comparisons
271        // false, so the solver fails gracefully (mirrors the zero_angle MBA-192 fix).
272        trajectory_func(&candidate).unwrap_or(f64::NAN)
273    };
274
275    // Check bounds
276    let f_lower = vertical_error(lower);
277    if f_lower.abs() < 1e-9 {
278        return Ok(AngleResult {
279            angle_rad: lower,
280            iterations_used: 1,
281            final_error: f_lower.abs(),
282            success: true,
283        });
284    }
285
286    let f_upper = vertical_error(upper);
287    if f_upper.abs() < 1e-9 {
288        return Ok(AngleResult {
289            angle_rad: upper,
290            iterations_used: 1,
291            final_error: f_upper.abs(),
292            success: true,
293        });
294    }
295
296    // Expand upper bound if needed to get a sign change
297    if f_lower * f_upper > 0.0 {
298        let step = 5.0 * DEGREES_TO_RADIANS;
299        let max_angle = 45.0 * DEGREES_TO_RADIANS;
300        let mut current = upper;
301        let mut f_current = f_upper;
302
303        while current < max_angle && f_lower * f_current > 0.0 {
304            current += step;
305            f_current = vertical_error(current);
306        }
307
308        if f_lower * f_current > 0.0 {
309            return Err("Unable to bracket zero; widen angle bounds or check inputs".to_string());
310        }
311
312        upper = current;
313    }
314
315    // Use Brent's method to find the root with safe tolerance calculation
316    let range = (upper - lower).abs();
317    let tolerance = if range > f64::EPSILON {
318        rtol * range
319    } else {
320        rtol * 1e-12 // Minimum tolerance for very small ranges
321    };
322    brent_root_find(
323        vertical_error,
324        lower,
325        upper,
326        tolerance,
327        ZERO_FINDING_MAX_ITER,
328    )
329}
330
331/// Estimate bore-line drop for a horizontal shot in ICAO sea-level conditions.
332///
333/// Inputs are muzzle velocity in feet per second, distance in yards, bullet mass in grains, and
334/// a G1 ballistic coefficient; the returned drop is meters. Bullet mass is retained for API
335/// compatibility but does not enter the retardation calculation because G1 BC already includes
336/// the projectile's sectional density and form factor.
337pub fn quick_drop_estimate(
338    muzzle_velocity_fps: f64,
339    distance_yards: f64,
340    _bullet_mass_grains: f64,
341    bc: f64,
342) -> f64 {
343    if muzzle_velocity_fps <= 0.0 || distance_yards <= 0.0 {
344        return 0.0; // No drop if no velocity or distance
345    }
346
347    let bc_safe = bc.max(0.1);
348    let distance_ft = distance_yards * 3.0;
349    let step_count = ((distance_yards / 5.0).ceil() as usize).clamp(32, 4096);
350    let step_ft = distance_ft / step_count as f64;
351    let gravity_ft_s2 = crate::constants::G_ACCEL_MPS2 / FEET_TO_METERS;
352    let speed_of_sound_fps = crate::constants::SPEED_OF_SOUND_MPS / FPS_TO_MPS;
353
354    // State is vertical position, downrange velocity, and vertical velocity, all in imperial
355    // units. Integrating over downrange distance keeps the work bounded and still captures the
356    // Mach-dependent G1 retardation and vertical drag that control flight time and drop.
357    let derivatives = |state: &[f64; 3]| -> Option<[f64; 3]> {
358        let [_, vx, vy] = *state;
359        if !(vx.is_finite() && vy.is_finite() && vx > 1e-9) {
360            return None;
361        }
362        let speed = vx.hypot(vy);
363        let mach = speed / speed_of_sound_fps;
364        let cd = crate::drag::get_drag_coefficient(mach, &crate::DragModel::G1);
365        let drag_accel = speed.powi(2) * cd * crate::constants::CD_TO_RETARD / bc_safe;
366
367        Some([
368            vy / vx,
369            -drag_accel / speed,
370            (-gravity_ft_s2 - drag_accel * vy / speed) / vx,
371        ])
372    };
373
374    let mut state = [0.0, muzzle_velocity_fps, 0.0];
375    for _ in 0..step_count {
376        let Some(k1) = derivatives(&state) else {
377            return f64::NAN;
378        };
379        let midpoint: [f64; 3] = std::array::from_fn(|i| state[i] + 0.5 * step_ft * k1[i]);
380        let Some(k2) = derivatives(&midpoint) else {
381            return f64::NAN;
382        };
383        state = std::array::from_fn(|i| state[i] + step_ft * k2[i]);
384    }
385
386    -state[0] * FEET_TO_METERS
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    fn create_test_inputs() -> InternalBallisticInputs {
394        InternalBallisticInputs {
395            muzzle_velocity: 823.0, // 2700 fps in m/s
396            bc_value: 0.5,
397            bullet_mass: 0.0109,      // 168 grains in kg
398            bullet_diameter: 0.00782, // 0.308 inches in meters
399            target_distance: 500.0,
400            temperature: 21.1, // 70°F in Celsius
401            powder_temp: 21.1, // Match temperature for default case (70°F)
402            ..Default::default()
403        }
404    }
405
406    #[test]
407    fn test_brent_root_find_quadratic() {
408        // Test with simple quadratic: x^2 - 4 = 0, root at x = 2
409        let f = |x: f64| x * x - 4.0;
410        let result = brent_root_find(f, 1.0, 3.0, 1e-6, 100).unwrap();
411
412        assert!(result.success);
413        assert!((result.angle_rad - 2.0).abs() < 1e-6);
414        assert!(result.iterations_used > 0);
415        assert!(result.final_error < 1e-6);
416    }
417
418    #[test]
419    fn brent_uses_inverse_quadratic_interpolation() {
420        let f = |x: f64| x * x - 2.0;
421        let result = brent_root_find(f, 1.0, 2.0, 1e-12, 100).unwrap();
422
423        assert!(result.success);
424        assert!((result.angle_rad - 2.0_f64.sqrt()).abs() < 1e-12);
425        assert!(
426            result.iterations_used <= 10,
427            "smooth quadratic should converge superlinearly, took {} iterations",
428            result.iterations_used
429        );
430    }
431
432    #[test]
433    fn test_brent_root_find_linear() {
434        // Test with linear function: 2x - 6 = 0, root at x = 3
435        let f = |x: f64| 2.0 * x - 6.0;
436        let result = brent_root_find(f, 0.0, 5.0, 1e-6, 100).unwrap();
437
438        assert!(result.success);
439        assert!((result.angle_rad - 3.0).abs() < 1e-6);
440    }
441
442    #[test]
443    fn brent_angle_tolerance_is_invariant_to_residual_units() {
444        let expected_root = 2.0_f64.sqrt();
445        let x_tolerance = 1e-8;
446
447        for scale in [1.0, 1e-9] {
448            let result = brent_root_find(
449                |angle_rad| scale * (angle_rad * angle_rad - 2.0),
450                1.0,
451                2.0,
452                x_tolerance,
453                100,
454            )
455            .unwrap();
456
457            assert!(result.success);
458            assert!(
459                (result.angle_rad - expected_root).abs() <= x_tolerance,
460                "residual scale {scale} loosened the angular tolerance: {result:?}"
461            );
462            assert!(result.iterations_used > 1);
463        }
464
465        let exhausted = brent_root_find(|x| 1e-9 * (x * x - 2.0), 1.0, 2.0, 1e-6, 0).unwrap();
466        assert!(!exhausted.success);
467    }
468
469    #[test]
470    fn test_brent_root_find_no_bracket() {
471        // Test with function that doesn't change sign in the interval
472        let f = |x: f64| x * x + 1.0; // Always positive
473        let result = brent_root_find(f, 1.0, 3.0, 1e-6, 100);
474
475        assert!(result.is_err());
476        assert!(result.unwrap_err().contains("Root not bracketed"));
477    }
478
479    #[test]
480    fn test_adjusted_muzzle_velocity_no_sensitivity() {
481        let inputs = create_test_inputs();
482
483        let result = adjusted_muzzle_velocity(&inputs);
484        assert_eq!(result, 823.0); // muzzle_velocity in m/s
485    }
486
487    #[test]
488    fn test_adjusted_muzzle_velocity_with_sensitivity() {
489        let mut inputs = create_test_inputs();
490        inputs.use_powder_sensitivity = true;
491        inputs.powder_temp_sensitivity = 0.6; // m/s per degree Celsius
492        inputs.temperature = 31.1;
493        inputs.powder_temp = 21.1;
494
495        let result = adjusted_muzzle_velocity(&inputs);
496        assert!((result - 829.0).abs() < 1e-12);
497
498        inputs.temperature = 11.1;
499        let colder_result = adjusted_muzzle_velocity(&inputs);
500        assert!((colder_result - 817.0).abs() < 1e-12);
501    }
502
503    #[test]
504    fn test_quick_drop_estimate() {
505        let drop = quick_drop_estimate(2700.0, 500.0, 168.0, 0.5);
506
507        // Should be a reasonable drop value (a few meters for 500 yards)
508        assert!(drop > 0.0);
509        assert!(drop < 50.0); // Sanity check - shouldn't be more than 50m drop
510
511        // Test that higher BC gives less drop
512        let drop_high_bc = quick_drop_estimate(2700.0, 500.0, 168.0, 0.8);
513        assert!(drop_high_bc < drop);
514    }
515
516    #[test]
517    fn quick_drop_tracks_g1_point_mass_reference() {
518        let distance_yards = 500.0;
519        let distance_m = distance_yards * YARDS_TO_METERS;
520        let inputs = InternalBallisticInputs {
521            muzzle_velocity: 1200.0 * FPS_TO_MPS,
522            bc_value: 0.5,
523            bc_type: crate::DragModel::G1,
524            bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
525            bullet_diameter: 0.308 * 0.0254,
526            muzzle_height: 0.0,
527            ground_threshold: f64::NEG_INFINITY,
528            ..Default::default()
529        };
530
531        let mut solver =
532            crate::TrajectorySolver::new(inputs, Default::default(), Default::default());
533        solver.set_max_range(distance_m);
534        let reference = solver.solve().unwrap();
535        let position = reference.position_at_range(distance_m).unwrap();
536        let reference_drop_m = -position.y;
537        assert!(
538            (9.2..=9.6).contains(&reference_drop_m),
539            "unexpected canonical G1 fixture: {reference_drop_m} m"
540        );
541
542        let estimated_drop_m = quick_drop_estimate(1200.0, distance_yards, 168.0, 0.5);
543        let relative_error = (estimated_drop_m - reference_drop_m).abs() / reference_drop_m;
544        assert!(
545            relative_error < 0.1,
546            "quick G1 drop {estimated_drop_m} m differs from reference {reference_drop_m} m by {:.1}%",
547            relative_error * 100.0
548        );
549    }
550
551    #[test]
552    fn test_zero_angle_uses_si_distance_and_radians() {
553        let mut inputs = create_test_inputs();
554        inputs.target_distance = 800.0;
555        inputs.shooting_angle = 1.0_f64.to_radians();
556
557        let result = zero_angle(&inputs, |trajectory_inputs, look_angle_rad| {
558            Ok(trajectory_inputs.target_distance * look_angle_rad)
559        })
560        .unwrap();
561
562        assert!(result.success);
563        assert!(
564            (result.angle_rad - inputs.shooting_angle).abs() < 1e-4,
565            "SI zero target should solve near {} rad, got {}",
566            inputs.shooting_angle,
567            result.angle_rad
568        );
569    }
570
571    #[test]
572    fn zero_angle_uses_horizontal_range_for_incline_geometry() {
573        let mut inputs = create_test_inputs();
574        inputs.target_distance = 800.0;
575        inputs.shooting_angle = 30.0_f64.to_radians();
576
577        let result = zero_angle(&inputs, |trajectory_inputs, look_angle_rad| {
578            Ok(trajectory_inputs.target_distance * look_angle_rad.tan())
579        })
580        .unwrap();
581
582        assert!(result.success);
583        assert!(
584            (result.angle_rad - inputs.shooting_angle).abs() < 1e-4,
585            "horizontal-range zero should solve at {} rad, got {}",
586            inputs.shooting_angle,
587            result.angle_rad
588        );
589    }
590
591    #[test]
592    fn test_zero_angle_bounds() {
593        // Test that angle bounds are reasonable
594        let lower = -10.0 * DEGREES_TO_RADIANS;
595        let upper = 10.0 * DEGREES_TO_RADIANS;
596
597        assert!(lower < 0.0);
598        assert!(upper > 0.0);
599        assert!((upper - lower).abs() > 0.1); // Reasonable search range
600    }
601
602    #[test]
603    fn test_brent_root_find_near_zero_function_values() {
604        // Test with function that has very small values near the root
605        // This exercises the EPSILON guards against division by zero
606        // The algorithm should not panic and should converge to some result
607        let f = |x: f64| (x - 1.0) * 1e-10; // Small function values
608        let result = brent_root_find(f, 0.0, 2.0, 1e-12, 100).unwrap();
609
610        // Main goal: no panic from division by zero
611        // With very small function values, the algorithm should still work
612        assert!(result.success || result.iterations_used > 0);
613        // The result should be reasonably close to the root
614        assert!((result.angle_rad - 1.0).abs() < 0.1);
615    }
616
617    #[test]
618    fn test_brent_root_find_steep_function() {
619        // Test with steep function that could cause large intermediate values
620        let f = |x: f64| (x - 0.5).powi(3) * 1e6;
621        let result = brent_root_find(f, 0.0, 1.0, 1e-9, 100).unwrap();
622
623        assert!(result.success);
624        assert!((result.angle_rad - 0.5).abs() < 1e-6);
625    }
626
627    #[test]
628    fn test_brent_root_find_oscillating_convergence() {
629        // Test function that might cause the interpolation to struggle
630        // forcing fallback to bisection (tests the safety guards)
631        let f = |x: f64| x.sin() - 0.5;
632        let result = brent_root_find(f, 0.0, 1.0, 1e-10, 100).unwrap();
633
634        assert!(result.success);
635        // Root is at arcsin(0.5) ≈ 0.5236
636        assert!((result.angle_rad - std::f64::consts::FRAC_PI_6).abs() < 1e-6);
637    }
638
639    #[test]
640    fn test_brent_root_find_flat_region() {
641        // Test with function that has flat regions (derivative near zero)
642        // which could cause issues in interpolation
643        let f = |x: f64| (x - 2.0).powi(5);
644        let result = brent_root_find(f, 1.0, 3.0, 1e-8, 100).unwrap();
645
646        assert!(result.success);
647        assert!((result.angle_rad - 2.0).abs() < 1e-4);
648    }
649}