Skip to main content

ballistics_engine/
angle_calculations.rs

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