Skip to main content

ballistics_engine/
angle_calculations.rs

1use crate::InternalBallisticInputs;
2use std::f64;
3
4// Constants for unit conversions
5const FPS_TO_MPS: f64 = 0.3048;
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
23pub fn brent_root_find<F>(
24    f: F,
25    mut a: f64,
26    mut b: f64,
27    tolerance: f64,
28    max_iterations: usize,
29) -> Result<AngleResult, String>
30where
31    F: Fn(f64) -> f64,
32{
33    let mut fa = f(a);
34    let mut fb = f(b);
35    let mut iterations = 0;
36
37    // Ensure the root is bracketed
38    if fa * fb > 0.0 {
39        return Err(format!("Root not bracketed: f({a}) = {fa}, f({b}) = {fb}"));
40    }
41
42    // Ensure |f(a)| >= |f(b)|
43    if fa.abs() < fb.abs() {
44        std::mem::swap(&mut a, &mut b);
45        std::mem::swap(&mut fa, &mut fb);
46    }
47
48    let mut c = a;
49    let mut fc = fa;
50    let mut d = b - a;
51    let mut e = d;
52
53    while iterations < max_iterations {
54        iterations += 1;
55
56        if fb.abs() < tolerance {
57            return Ok(AngleResult {
58                angle_rad: b,
59                iterations_used: iterations,
60                final_error: fb.abs(),
61                success: true,
62            });
63        }
64
65        if fa.abs() < fb.abs() {
66            a = b;
67            b = c;
68            c = a;
69            fa = fb;
70            fb = fc;
71            fc = fa;
72        }
73
74        let tolerance_scaled = 2.0 * f64::EPSILON * b.abs() + 0.5 * tolerance;
75        let m = 0.5 * (c - b);
76
77        if m.abs() <= tolerance_scaled {
78            return Ok(AngleResult {
79                angle_rad: b,
80                iterations_used: iterations,
81                final_error: fb.abs(),
82                success: true,
83            });
84        }
85
86        if e.abs() >= tolerance_scaled && fc.abs() > fb.abs() {
87            // Check for safe division before interpolation
88            if fc.abs() < f64::EPSILON || fa.abs() < f64::EPSILON {
89                // Fallback to bisection if denominators are too small
90                d = m;
91                e = m;
92            } else {
93                let s = fb / fc;
94                let mut p;
95                let mut q;
96
97                if (a - c).abs() < f64::EPSILON {
98                    // Linear interpolation
99                    p = 2.0 * m * s;
100                    q = 1.0 - s;
101                } else {
102                    // Inverse quadratic interpolation
103                    q = fc / fa;
104                    let r = fb / fa;
105                    p = s * (2.0 * m * q * (q - r) - (b - a) * (r - 1.0));
106                    q = (q - 1.0) * (r - 1.0) * (s - 1.0);
107                }
108
109                if p > 0.0 {
110                    q = -q;
111                } else {
112                    p = -p;
113                }
114
115                let s = e;
116                e = d;
117
118                // Check for safe division in the acceptance test
119                if q.abs() > f64::EPSILON
120                    && 2.0 * p < 3.0 * m * q - (tolerance_scaled * q).abs()
121                    && p < (0.5 * s * q).abs()
122                {
123                    d = p / q;
124                } else {
125                    d = m;
126                    e = d;
127                }
128            }
129        } else {
130            d = m;
131            e = d;
132        }
133
134        a = b;
135        fa = fb;
136
137        if d.abs() > tolerance_scaled {
138            b += d;
139        } else if m > 0.0 {
140            b += tolerance_scaled;
141        } else {
142            b -= tolerance_scaled;
143        }
144
145        fb = f(b);
146
147        if (fc * fb) > 0.0 {
148            c = a;
149            fc = fa;
150            e = b - a;
151            d = e;
152        }
153    }
154
155    Ok(AngleResult {
156        angle_rad: b,
157        iterations_used: iterations,
158        final_error: fb.abs(),
159        success: fb.abs() < tolerance * 10.0, // Relaxed success criteria
160    })
161}
162
163/// Calculate adjusted muzzle velocity for powder temperature sensitivity
164pub fn adjusted_muzzle_velocity(inputs: &InternalBallisticInputs) -> f64 {
165    let mut mv = inputs.muzzle_velocity;
166
167    if inputs.use_powder_sensitivity {
168        mv *=
169            1.0 + inputs.powder_temp_sensitivity * (inputs.temperature - inputs.powder_temp) / 15.0;
170    }
171
172    mv
173}
174
175/// Calculate zero angle using Brent's method and Rust trajectory integration
176pub fn zero_angle(
177    inputs: &InternalBallisticInputs,
178    trajectory_func: impl Fn(&InternalBallisticInputs, f64) -> Result<f64, String> + Copy,
179) -> Result<AngleResult, String> {
180    // Set up the target vertical position based on shooting angle
181    let vert = if inputs.shooting_angle.abs() > 1e-6 {
182        let angle_rad = inputs.shooting_angle * DEGREES_TO_RADIANS;
183        (inputs.target_distance * YARDS_TO_METERS) * angle_rad.sin()
184    } else {
185        0.0
186    };
187
188    // Define the height difference function
189    // MBA-192: Use NaN instead of -999.0 on trajectory failure to prevent false roots
190    let height_diff = |look_angle_rad: f64| -> f64 {
191        // Calculate bullet height at target distance minus target height
192        match trajectory_func(inputs, look_angle_rad) {
193            Ok(bullet_height) => bullet_height - vert,
194            Err(_) => f64::NAN, // NaN causes Brent's method to fail gracefully
195        }
196    };
197
198    // Reasonable bounds for the zero angle in radians
199    // Most rifle zeroing will be within +/- 10 degrees
200    let lower_bound = -10.0 * DEGREES_TO_RADIANS;
201    let upper_bound = 10.0 * DEGREES_TO_RADIANS;
202
203    // Try primary bounds first
204    match brent_root_find(height_diff, lower_bound, upper_bound, 1e-6, 100) {
205        Ok(result) if result.success => Ok(result),
206        _ => {
207            // Fallback to wider search range
208            let wider_lower = -45.0 * DEGREES_TO_RADIANS;
209            let wider_upper = 45.0 * DEGREES_TO_RADIANS;
210
211            match brent_root_find(height_diff, wider_lower, wider_upper, 1e-5, 150) {
212                Ok(result) if result.success => Ok(result),
213                Ok(result) => {
214                    // Return best attempt even if not fully successful
215                    Ok(AngleResult {
216                        angle_rad: result.angle_rad,
217                        iterations_used: result.iterations_used,
218                        final_error: result.final_error,
219                        success: false,
220                    })
221                }
222                Err(_) => {
223                    // If all else fails, return 0 as a safe default
224                    Ok(AngleResult {
225                        angle_rad: 0.0,
226                        iterations_used: 0,
227                        final_error: f64::INFINITY,
228                        success: false,
229                    })
230                }
231            }
232        }
233    }
234}
235
236/// Solve muzzle angle using Brent's method optimization
237pub fn solve_muzzle_angle(
238    inputs: &InternalBallisticInputs,
239    zero_distance_los_m: f64,
240    trajectory_func: impl Fn(&InternalBallisticInputs) -> Result<f64, String> + Copy, // Returns drop_m
241    angle_lower_deg: f64,
242    angle_upper_deg: f64,
243    rtol: f64,
244) -> Result<AngleResult, String> {
245    if angle_lower_deg >= angle_upper_deg {
246        return Err("angle_lower_deg must be less than angle_upper_deg".to_string());
247    }
248
249    let lower = angle_lower_deg * DEGREES_TO_RADIANS;
250    let mut upper = angle_upper_deg * DEGREES_TO_RADIANS;
251
252    // Define the vertical error function
253    let vertical_error = |angle_rad: f64| -> f64 {
254        // Create modified inputs with new angle and target distance
255        let mut candidate = inputs.clone();
256        candidate.muzzle_angle = angle_rad * RADIANS_TO_DEGREES;
257        candidate.target_distance = zero_distance_los_m / YARDS_TO_METERS; // Convert back to yards
258
259        // NaN (not a finite sentinel) on trajectory failure: a large finite value like 1e6
260        // can fake a sign change and make the bracket/Brent solver lock onto the
261        // discontinuity and report a spurious root as success. NaN makes those comparisons
262        // false, so the solver fails gracefully (mirrors the zero_angle MBA-192 fix).
263        trajectory_func(&candidate).unwrap_or(f64::NAN)
264    };
265
266    // Check bounds
267    let f_lower = vertical_error(lower);
268    if f_lower.abs() < 1e-9 {
269        return Ok(AngleResult {
270            angle_rad: lower,
271            iterations_used: 1,
272            final_error: f_lower.abs(),
273            success: true,
274        });
275    }
276
277    let f_upper = vertical_error(upper);
278    if f_upper.abs() < 1e-9 {
279        return Ok(AngleResult {
280            angle_rad: upper,
281            iterations_used: 1,
282            final_error: f_upper.abs(),
283            success: true,
284        });
285    }
286
287    // Expand upper bound if needed to get a sign change
288    if f_lower * f_upper > 0.0 {
289        let step = 5.0 * DEGREES_TO_RADIANS;
290        let max_angle = 45.0 * DEGREES_TO_RADIANS;
291        let mut current = upper;
292        let mut f_current = f_upper;
293
294        while current < max_angle && f_lower * f_current > 0.0 {
295            current += step;
296            f_current = vertical_error(current);
297        }
298
299        if f_lower * f_current > 0.0 {
300            return Err("Unable to bracket zero; widen angle bounds or check inputs".to_string());
301        }
302
303        upper = current;
304    }
305
306    // Use Brent's method to find the root with safe tolerance calculation
307    let range = (upper - lower).abs();
308    let tolerance = if range > f64::EPSILON {
309        rtol * range
310    } else {
311        rtol * 1e-12 // Minimum tolerance for very small ranges
312    };
313    brent_root_find(
314        vertical_error,
315        lower,
316        upper,
317        tolerance,
318        ZERO_FINDING_MAX_ITER,
319    )
320}
321
322/// Calculate simple ballistic drop approximation for quick estimates
323pub fn quick_drop_estimate(
324    muzzle_velocity_fps: f64,
325    distance_yards: f64,
326    _bullet_mass_grains: f64,
327    bc: f64,
328) -> f64 {
329    let mv_mps = muzzle_velocity_fps * FPS_TO_MPS;
330    let distance_m = distance_yards * YARDS_TO_METERS;
331
332    // Simple ballistic approximation with safe divisions
333    if mv_mps <= 0.0 || distance_m <= 0.0 {
334        return 0.0; // No drop if no velocity or distance
335    }
336
337    let time_of_flight = distance_m / mv_mps;
338    let gravity = 9.80665;
339
340    // Basic drop calculation with BC approximation
341    let bc_safe = bc.max(0.1);
342    let drag_factor = 1.0 / bc_safe;
343    let velocity_loss = drag_factor * time_of_flight * 0.1;
344    let effective_velocity = mv_mps * (1.0 - velocity_loss).max(0.1); // Ensure positive
345    let adjusted_time = distance_m / effective_velocity;
346
347    0.5 * gravity * adjusted_time * adjusted_time
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    fn create_test_inputs() -> InternalBallisticInputs {
355        InternalBallisticInputs {
356            muzzle_velocity: 823.0, // 2700 fps in m/s
357            bc_value: 0.5,
358            bullet_mass: 0.0109,      // 168 grains in kg
359            bullet_diameter: 0.00782, // 0.308 inches in meters
360            target_distance: 500.0,
361            temperature: 21.1, // 70°F in Celsius
362            powder_temp: 21.1, // Match temperature for default case (70°F)
363            ..Default::default()
364        }
365    }
366
367    #[test]
368    fn test_brent_root_find_quadratic() {
369        // Test with simple quadratic: x^2 - 4 = 0, root at x = 2
370        let f = |x: f64| x * x - 4.0;
371        let result = brent_root_find(f, 1.0, 3.0, 1e-6, 100).unwrap();
372
373        assert!(result.success);
374        assert!((result.angle_rad - 2.0).abs() < 1e-6);
375        assert!(result.iterations_used > 0);
376        assert!(result.final_error < 1e-6);
377    }
378
379    #[test]
380    fn test_brent_root_find_linear() {
381        // Test with linear function: 2x - 6 = 0, root at x = 3
382        let f = |x: f64| 2.0 * x - 6.0;
383        let result = brent_root_find(f, 0.0, 5.0, 1e-6, 100).unwrap();
384
385        assert!(result.success);
386        assert!((result.angle_rad - 3.0).abs() < 1e-6);
387    }
388
389    #[test]
390    fn test_brent_root_find_no_bracket() {
391        // Test with function that doesn't change sign in the interval
392        let f = |x: f64| x * x + 1.0; // Always positive
393        let result = brent_root_find(f, 1.0, 3.0, 1e-6, 100);
394
395        assert!(result.is_err());
396        assert!(result.unwrap_err().contains("Root not bracketed"));
397    }
398
399    #[test]
400    fn test_adjusted_muzzle_velocity_no_sensitivity() {
401        let inputs = create_test_inputs();
402
403        let result = adjusted_muzzle_velocity(&inputs);
404        assert_eq!(result, 823.0); // muzzle_velocity in m/s
405    }
406
407    #[test]
408    fn test_adjusted_muzzle_velocity_with_sensitivity() {
409        let mut inputs = create_test_inputs();
410        inputs.use_powder_sensitivity = true;
411        inputs.powder_temp_sensitivity = 1.0; // 1 fps per degree F
412        inputs.temperature = 85.0; // 15 degrees above powder temp
413        inputs.powder_temp = 70.0; // Base powder temp
414
415        let result = adjusted_muzzle_velocity(&inputs);
416        // Should be 823 * (1 + 1.0 * (85-70) / 15) = 823 * (1 + 1) = 1646
417        assert!((result - 1646.0).abs() < 1e-6);
418    }
419
420    #[test]
421    fn test_quick_drop_estimate() {
422        let drop = quick_drop_estimate(2700.0, 500.0, 168.0, 0.5);
423
424        // Should be a reasonable drop value (a few meters for 500 yards)
425        assert!(drop > 0.0);
426        assert!(drop < 50.0); // Sanity check - shouldn't be more than 50m drop
427
428        // Test that higher BC gives less drop
429        let drop_high_bc = quick_drop_estimate(2700.0, 500.0, 168.0, 0.8);
430        assert!(drop_high_bc < drop);
431    }
432
433    #[test]
434    fn test_zero_angle_bounds() {
435        // Test that angle bounds are reasonable
436        let lower = -10.0 * DEGREES_TO_RADIANS;
437        let upper = 10.0 * DEGREES_TO_RADIANS;
438
439        assert!(lower < 0.0);
440        assert!(upper > 0.0);
441        assert!((upper - lower).abs() > 0.1); // Reasonable search range
442    }
443
444    #[test]
445    fn test_brent_root_find_near_zero_function_values() {
446        // Test with function that has very small values near the root
447        // This exercises the EPSILON guards against division by zero
448        // The algorithm should not panic and should converge to some result
449        let f = |x: f64| (x - 1.0) * 1e-10; // Small function values
450        let result = brent_root_find(f, 0.0, 2.0, 1e-12, 100).unwrap();
451
452        // Main goal: no panic from division by zero
453        // With very small function values, the algorithm should still work
454        assert!(result.success || result.iterations_used > 0);
455        // The result should be reasonably close to the root
456        assert!((result.angle_rad - 1.0).abs() < 0.1);
457    }
458
459    #[test]
460    fn test_brent_root_find_steep_function() {
461        // Test with steep function that could cause large intermediate values
462        let f = |x: f64| (x - 0.5).powi(3) * 1e6;
463        let result = brent_root_find(f, 0.0, 1.0, 1e-9, 100).unwrap();
464
465        assert!(result.success);
466        assert!((result.angle_rad - 0.5).abs() < 1e-6);
467    }
468
469    #[test]
470    fn test_brent_root_find_oscillating_convergence() {
471        // Test function that might cause the interpolation to struggle
472        // forcing fallback to bisection (tests the safety guards)
473        let f = |x: f64| x.sin() - 0.5;
474        let result = brent_root_find(f, 0.0, 1.0, 1e-10, 100).unwrap();
475
476        assert!(result.success);
477        // Root is at arcsin(0.5) ≈ 0.5236
478        assert!((result.angle_rad - 0.5235987755982989).abs() < 1e-6);
479    }
480
481    #[test]
482    fn test_brent_root_find_flat_region() {
483        // Test with function that has flat regions (derivative near zero)
484        // which could cause issues in interpolation
485        let f = |x: f64| (x - 2.0).powi(5);
486        let result = brent_root_find(f, 1.0, 3.0, 1e-8, 100).unwrap();
487
488        assert!(result.success);
489        assert!((result.angle_rad - 2.0).abs() < 1e-4);
490    }
491}