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        trajectory_func(&candidate).unwrap_or(1e6)
260    };
261
262    // Check bounds
263    let f_lower = vertical_error(lower);
264    if f_lower.abs() < 1e-9 {
265        return Ok(AngleResult {
266            angle_rad: lower,
267            iterations_used: 1,
268            final_error: f_lower.abs(),
269            success: true,
270        });
271    }
272
273    let f_upper = vertical_error(upper);
274    if f_upper.abs() < 1e-9 {
275        return Ok(AngleResult {
276            angle_rad: upper,
277            iterations_used: 1,
278            final_error: f_upper.abs(),
279            success: true,
280        });
281    }
282
283    // Expand upper bound if needed to get a sign change
284    if f_lower * f_upper > 0.0 {
285        let step = 5.0 * DEGREES_TO_RADIANS;
286        let max_angle = 45.0 * DEGREES_TO_RADIANS;
287        let mut current = upper;
288        let mut f_current = f_upper;
289
290        while current < max_angle && f_lower * f_current > 0.0 {
291            current += step;
292            f_current = vertical_error(current);
293        }
294
295        if f_lower * f_current > 0.0 {
296            return Err("Unable to bracket zero; widen angle bounds or check inputs".to_string());
297        }
298
299        upper = current;
300    }
301
302    // Use Brent's method to find the root with safe tolerance calculation
303    let range = (upper - lower).abs();
304    let tolerance = if range > f64::EPSILON {
305        rtol * range
306    } else {
307        rtol * 1e-12 // Minimum tolerance for very small ranges
308    };
309    brent_root_find(
310        vertical_error,
311        lower,
312        upper,
313        tolerance,
314        ZERO_FINDING_MAX_ITER,
315    )
316}
317
318/// Calculate simple ballistic drop approximation for quick estimates
319pub fn quick_drop_estimate(
320    muzzle_velocity_fps: f64,
321    distance_yards: f64,
322    _bullet_mass_grains: f64,
323    bc: f64,
324) -> f64 {
325    let mv_mps = muzzle_velocity_fps * FPS_TO_MPS;
326    let distance_m = distance_yards * YARDS_TO_METERS;
327
328    // Simple ballistic approximation with safe divisions
329    if mv_mps <= 0.0 || distance_m <= 0.0 {
330        return 0.0; // No drop if no velocity or distance
331    }
332
333    let time_of_flight = distance_m / mv_mps;
334    let gravity = 9.80665;
335
336    // Basic drop calculation with BC approximation
337    let bc_safe = bc.max(0.1);
338    let drag_factor = 1.0 / bc_safe;
339    let velocity_loss = drag_factor * time_of_flight * 0.1;
340    let effective_velocity = mv_mps * (1.0 - velocity_loss).max(0.1); // Ensure positive
341    let adjusted_time = distance_m / effective_velocity;
342
343    0.5 * gravity * adjusted_time * adjusted_time
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    fn create_test_inputs() -> InternalBallisticInputs {
351        InternalBallisticInputs {
352            muzzle_velocity: 823.0, // 2700 fps in m/s
353            bc_value: 0.5,
354            bullet_mass: 0.0109,      // 168 grains in kg
355            bullet_diameter: 0.00782, // 0.308 inches in meters
356            target_distance: 500.0,
357            temperature: 21.1, // 70°F in Celsius
358            powder_temp: 21.1, // Match temperature for default case (70°F)
359            ..Default::default()
360        }
361    }
362
363    #[test]
364    fn test_brent_root_find_quadratic() {
365        // Test with simple quadratic: x^2 - 4 = 0, root at x = 2
366        let f = |x: f64| x * x - 4.0;
367        let result = brent_root_find(f, 1.0, 3.0, 1e-6, 100).unwrap();
368
369        assert!(result.success);
370        assert!((result.angle_rad - 2.0).abs() < 1e-6);
371        assert!(result.iterations_used > 0);
372        assert!(result.final_error < 1e-6);
373    }
374
375    #[test]
376    fn test_brent_root_find_linear() {
377        // Test with linear function: 2x - 6 = 0, root at x = 3
378        let f = |x: f64| 2.0 * x - 6.0;
379        let result = brent_root_find(f, 0.0, 5.0, 1e-6, 100).unwrap();
380
381        assert!(result.success);
382        assert!((result.angle_rad - 3.0).abs() < 1e-6);
383    }
384
385    #[test]
386    fn test_brent_root_find_no_bracket() {
387        // Test with function that doesn't change sign in the interval
388        let f = |x: f64| x * x + 1.0; // Always positive
389        let result = brent_root_find(f, 1.0, 3.0, 1e-6, 100);
390
391        assert!(result.is_err());
392        assert!(result.unwrap_err().contains("Root not bracketed"));
393    }
394
395    #[test]
396    fn test_adjusted_muzzle_velocity_no_sensitivity() {
397        let inputs = create_test_inputs();
398
399        let result = adjusted_muzzle_velocity(&inputs);
400        assert_eq!(result, 823.0); // muzzle_velocity in m/s
401    }
402
403    #[test]
404    fn test_adjusted_muzzle_velocity_with_sensitivity() {
405        let mut inputs = create_test_inputs();
406        inputs.use_powder_sensitivity = true;
407        inputs.powder_temp_sensitivity = 1.0; // 1 fps per degree F
408        inputs.temperature = 85.0; // 15 degrees above powder temp
409        inputs.powder_temp = 70.0; // Base powder temp
410
411        let result = adjusted_muzzle_velocity(&inputs);
412        // Should be 823 * (1 + 1.0 * (85-70) / 15) = 823 * (1 + 1) = 1646
413        assert!((result - 1646.0).abs() < 1e-6);
414    }
415
416    #[test]
417    fn test_quick_drop_estimate() {
418        let drop = quick_drop_estimate(2700.0, 500.0, 168.0, 0.5);
419
420        // Should be a reasonable drop value (a few meters for 500 yards)
421        assert!(drop > 0.0);
422        assert!(drop < 50.0); // Sanity check - shouldn't be more than 50m drop
423
424        // Test that higher BC gives less drop
425        let drop_high_bc = quick_drop_estimate(2700.0, 500.0, 168.0, 0.8);
426        assert!(drop_high_bc < drop);
427    }
428
429    #[test]
430    fn test_zero_angle_bounds() {
431        // Test that angle bounds are reasonable
432        let lower = -10.0 * DEGREES_TO_RADIANS;
433        let upper = 10.0 * DEGREES_TO_RADIANS;
434
435        assert!(lower < 0.0);
436        assert!(upper > 0.0);
437        assert!((upper - lower).abs() > 0.1); // Reasonable search range
438    }
439
440    #[test]
441    fn test_brent_root_find_near_zero_function_values() {
442        // Test with function that has very small values near the root
443        // This exercises the EPSILON guards against division by zero
444        // The algorithm should not panic and should converge to some result
445        let f = |x: f64| (x - 1.0) * 1e-10; // Small function values
446        let result = brent_root_find(f, 0.0, 2.0, 1e-12, 100).unwrap();
447
448        // Main goal: no panic from division by zero
449        // With very small function values, the algorithm should still work
450        assert!(result.success || result.iterations_used > 0);
451        // The result should be reasonably close to the root
452        assert!((result.angle_rad - 1.0).abs() < 0.1);
453    }
454
455    #[test]
456    fn test_brent_root_find_steep_function() {
457        // Test with steep function that could cause large intermediate values
458        let f = |x: f64| (x - 0.5).powi(3) * 1e6;
459        let result = brent_root_find(f, 0.0, 1.0, 1e-9, 100).unwrap();
460
461        assert!(result.success);
462        assert!((result.angle_rad - 0.5).abs() < 1e-6);
463    }
464
465    #[test]
466    fn test_brent_root_find_oscillating_convergence() {
467        // Test function that might cause the interpolation to struggle
468        // forcing fallback to bisection (tests the safety guards)
469        let f = |x: f64| x.sin() - 0.5;
470        let result = brent_root_find(f, 0.0, 1.0, 1e-10, 100).unwrap();
471
472        assert!(result.success);
473        // Root is at arcsin(0.5) ≈ 0.5236
474        assert!((result.angle_rad - 0.5235987755982989).abs() < 1e-6);
475    }
476
477    #[test]
478    fn test_brent_root_find_flat_region() {
479        // Test with function that has flat regions (derivative near zero)
480        // which could cause issues in interpolation
481        let f = |x: f64| (x - 2.0).powi(5);
482        let result = brent_root_find(f, 1.0, 3.0, 1e-8, 100).unwrap();
483
484        assert!(result.success);
485        assert!((result.angle_rad - 2.0).abs() < 1e-4);
486    }
487}