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