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    let height_diff = |look_angle_rad: f64| -> f64 {
188        // Calculate bullet height at target distance minus target height
189        match trajectory_func(inputs, look_angle_rad) {
190            Ok(bullet_height) => bullet_height - vert,
191            Err(_) => -999.0, // Return large negative on failure
192        }
193    };
194    
195    // Reasonable bounds for the zero angle in radians
196    // Most rifle zeroing will be within +/- 10 degrees
197    let lower_bound = -10.0 * DEGREES_TO_RADIANS;
198    let upper_bound = 10.0 * DEGREES_TO_RADIANS;
199    
200    // Try primary bounds first
201    match brent_root_find(height_diff, lower_bound, upper_bound, 1e-6, 100) {
202        Ok(result) if result.success => Ok(result),
203        _ => {
204            // Fallback to wider search range
205            let wider_lower = -45.0 * DEGREES_TO_RADIANS;
206            let wider_upper = 45.0 * DEGREES_TO_RADIANS;
207            
208            match brent_root_find(height_diff, wider_lower, wider_upper, 1e-5, 150) {
209                Ok(result) if result.success => Ok(result),
210                Ok(result) => {
211                    // Return best attempt even if not fully successful
212                    Ok(AngleResult {
213                        angle_rad: result.angle_rad,
214                        iterations_used: result.iterations_used,
215                        final_error: result.final_error,
216                        success: false,
217                    })
218                },
219                Err(_) => {
220                    // If all else fails, return 0 as a safe default
221                    Ok(AngleResult {
222                        angle_rad: 0.0,
223                        iterations_used: 0,
224                        final_error: f64::INFINITY,
225                        success: false,
226                    })
227                }
228            }
229        }
230    }
231}
232
233/// Solve muzzle angle using Brent's method optimization
234pub fn solve_muzzle_angle(
235    inputs: &InternalBallisticInputs,
236    zero_distance_los_m: f64,
237    trajectory_func: impl Fn(&InternalBallisticInputs) -> Result<f64, String> + Copy, // Returns drop_m
238    angle_lower_deg: f64,
239    angle_upper_deg: f64,
240    rtol: f64,
241) -> Result<AngleResult, String> {
242    if angle_lower_deg >= angle_upper_deg {
243        return Err("angle_lower_deg must be less than angle_upper_deg".to_string());
244    }
245    
246    let lower = angle_lower_deg * DEGREES_TO_RADIANS;
247    let mut upper = angle_upper_deg * DEGREES_TO_RADIANS;
248    
249    // Define the vertical error function
250    let vertical_error = |angle_rad: f64| -> f64 {
251        // Create modified inputs with new angle and target distance
252        let mut candidate = inputs.clone();
253        candidate.muzzle_angle = angle_rad * RADIANS_TO_DEGREES;
254        candidate.target_distance = zero_distance_los_m / YARDS_TO_METERS; // Convert back to yards
255        
256        trajectory_func(&candidate).unwrap_or(1e6)
257    };
258    
259    // Check bounds
260    let f_lower = vertical_error(lower);
261    if f_lower.abs() < 1e-9 {
262        return Ok(AngleResult {
263            angle_rad: lower,
264            iterations_used: 1,
265            final_error: f_lower.abs(),
266            success: true,
267        });
268    }
269    
270    let f_upper = vertical_error(upper);
271    if f_upper.abs() < 1e-9 {
272        return Ok(AngleResult {
273            angle_rad: upper,
274            iterations_used: 1,
275            final_error: f_upper.abs(),
276            success: true,
277        });
278    }
279    
280    // Expand upper bound if needed to get a sign change
281    if f_lower * f_upper > 0.0 {
282        let step = 5.0 * DEGREES_TO_RADIANS;
283        let max_angle = 45.0 * DEGREES_TO_RADIANS;
284        let mut current = upper;
285        let mut f_current = f_upper;
286        
287        while current < max_angle && f_lower * f_current > 0.0 {
288            current += step;
289            f_current = vertical_error(current);
290        }
291        
292        if f_lower * f_current > 0.0 {
293            return Err("Unable to bracket zero; widen angle bounds or check inputs".to_string());
294        }
295        
296        upper = current;
297    }
298    
299    // Use Brent's method to find the root with safe tolerance calculation
300    let range = (upper - lower).abs();
301    let tolerance = if range > f64::EPSILON {
302        rtol * range
303    } else {
304        rtol * 1e-12 // Minimum tolerance for very small ranges
305    };
306    brent_root_find(vertical_error, lower, upper, tolerance, ZERO_FINDING_MAX_ITER)
307}
308
309/// Calculate simple ballistic drop approximation for quick estimates
310pub fn quick_drop_estimate(
311    muzzle_velocity_fps: f64,
312    distance_yards: f64,
313    _bullet_mass_grains: f64,
314    bc: f64,
315) -> f64 {
316    let mv_mps = muzzle_velocity_fps * FPS_TO_MPS;
317    let distance_m = distance_yards * YARDS_TO_METERS;
318    
319    // Simple ballistic approximation with safe divisions
320    if mv_mps <= 0.0 || distance_m <= 0.0 {
321        return 0.0; // No drop if no velocity or distance
322    }
323    
324    let time_of_flight = distance_m / mv_mps;
325    let gravity = 9.80665;
326    
327    // Basic drop calculation with BC approximation
328    let bc_safe = bc.max(0.1);
329    let drag_factor = 1.0 / bc_safe;
330    let velocity_loss = drag_factor * time_of_flight * 0.1;
331    let effective_velocity = mv_mps * (1.0 - velocity_loss).max(0.1); // Ensure positive
332    let adjusted_time = distance_m / effective_velocity;
333    
334    0.5 * gravity * adjusted_time * adjusted_time
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    
341    
342    fn create_test_inputs() -> InternalBallisticInputs {
343        InternalBallisticInputs {
344            muzzle_velocity: 823.0,  // 2700 fps in m/s
345            bc_value: 0.5,
346            bullet_mass: 0.0109,  // 168 grains in kg
347            bullet_diameter: 0.00782,  // 0.308 inches in meters
348            target_distance: 500.0,
349            temperature: 21.1,  // 70°F in Celsius
350            powder_temp: 21.1,  // Match temperature for default case (70°F)
351            ..Default::default()
352        }
353    }
354    
355    #[test]
356    fn test_brent_root_find_quadratic() {
357        // Test with simple quadratic: x^2 - 4 = 0, root at x = 2
358        let f = |x: f64| x * x - 4.0;
359        let result = brent_root_find(f, 1.0, 3.0, 1e-6, 100).unwrap();
360        
361        assert!(result.success);
362        assert!((result.angle_rad - 2.0).abs() < 1e-6);
363        assert!(result.iterations_used > 0);
364        assert!(result.final_error < 1e-6);
365    }
366    
367    #[test]
368    fn test_brent_root_find_linear() {
369        // Test with linear function: 2x - 6 = 0, root at x = 3
370        let f = |x: f64| 2.0 * x - 6.0;
371        let result = brent_root_find(f, 0.0, 5.0, 1e-6, 100).unwrap();
372        
373        assert!(result.success);
374        assert!((result.angle_rad - 3.0).abs() < 1e-6);
375    }
376    
377    #[test]
378    fn test_brent_root_find_no_bracket() {
379        // Test with function that doesn't change sign in the interval
380        let f = |x: f64| x * x + 1.0; // Always positive
381        let result = brent_root_find(f, 1.0, 3.0, 1e-6, 100);
382        
383        assert!(result.is_err());
384        assert!(result.unwrap_err().contains("Root not bracketed"));
385    }
386    
387    #[test]
388    fn test_adjusted_muzzle_velocity_no_sensitivity() {
389        let inputs = create_test_inputs();
390        
391        let result = adjusted_muzzle_velocity(&inputs);
392        assert_eq!(result, 823.0);  // muzzle_velocity in m/s
393    }
394    
395    #[test]
396    fn test_adjusted_muzzle_velocity_with_sensitivity() {
397        let mut inputs = create_test_inputs();
398        inputs.use_powder_sensitivity = true;
399        inputs.powder_temp_sensitivity = 1.0; // 1 fps per degree F
400        inputs.temperature = 85.0; // 15 degrees above powder temp
401        inputs.powder_temp = 70.0; // Base powder temp
402
403        let result = adjusted_muzzle_velocity(&inputs);
404        // Should be 823 * (1 + 1.0 * (85-70) / 15) = 823 * (1 + 1) = 1646
405        assert!((result - 1646.0).abs() < 1e-6);
406    }
407    
408    #[test]
409    fn test_quick_drop_estimate() {
410        let drop = quick_drop_estimate(2700.0, 500.0, 168.0, 0.5);
411        
412        // Should be a reasonable drop value (a few meters for 500 yards)
413        assert!(drop > 0.0);
414        assert!(drop < 50.0); // Sanity check - shouldn't be more than 50m drop
415        
416        // Test that higher BC gives less drop
417        let drop_high_bc = quick_drop_estimate(2700.0, 500.0, 168.0, 0.8);
418        assert!(drop_high_bc < drop);
419    }
420    
421    #[test]
422    fn test_zero_angle_bounds() {
423        // Test that angle bounds are reasonable
424        let lower = -10.0 * DEGREES_TO_RADIANS;
425        let upper = 10.0 * DEGREES_TO_RADIANS;
426        
427        assert!(lower < 0.0);
428        assert!(upper > 0.0);
429        assert!((upper - lower).abs() > 0.1); // Reasonable search range
430    }
431}