1use crate::InternalBallisticInputs;
2use std::f64;
3
4const FEET_TO_METERS: f64 = 0.3048;
6const FPS_TO_MPS: f64 = FEET_TO_METERS;
7const YARDS_TO_METERS: f64 = 0.9144;
8const DEGREES_TO_RADIANS: f64 = std::f64::consts::PI / 180.0;
9const RADIANS_TO_DEGREES: f64 = 180.0 / std::f64::consts::PI;
10
11const ZERO_FINDING_MAX_ITER: usize = 100;
13
14#[derive(Debug, Clone)]
16pub struct AngleResult {
17 pub angle_rad: f64,
18 pub iterations_used: usize,
19 pub final_error: f64,
20 pub success: bool,
21}
22
23pub fn brent_root_find<F>(
28 f: F,
29 mut a: f64,
30 mut b: f64,
31 x_tolerance: f64,
32 max_iterations: usize,
33) -> Result<AngleResult, String>
34where
35 F: Fn(f64) -> f64,
36{
37 let mut fa = f(a);
38 let mut fb = f(b);
39 let mut iterations = 0;
40
41 if fa * fb > 0.0 {
43 return Err(format!("Root not bracketed: f({a}) = {fa}, f({b}) = {fb}"));
44 }
45
46 if fa.abs() < fb.abs() {
48 std::mem::swap(&mut a, &mut b);
49 std::mem::swap(&mut fa, &mut fb);
50 }
51
52 let mut c = a;
53 let mut fc = fa;
54 let mut d = b - a;
55 let mut e = d;
56
57 while iterations < max_iterations {
58 iterations += 1;
59
60 if fb == 0.0 {
61 return Ok(AngleResult {
62 angle_rad: b,
63 iterations_used: iterations,
64 final_error: fb.abs(),
65 success: true,
66 });
67 }
68
69 if fc.abs() < fb.abs() {
70 a = b;
71 b = c;
72 c = a;
73 fa = fb;
74 fb = fc;
75 fc = fa;
76 }
77
78 let tolerance_scaled = 2.0 * f64::EPSILON * b.abs() + 0.5 * x_tolerance;
79 let m = 0.5 * (c - b);
80
81 if m.abs() <= tolerance_scaled {
82 return Ok(AngleResult {
83 angle_rad: b,
84 iterations_used: iterations,
85 final_error: fb.abs(),
86 success: true,
87 });
88 }
89
90 if e.abs() >= tolerance_scaled && fa.abs() > fb.abs() {
91 if fc.abs() < f64::EPSILON || fa.abs() < f64::EPSILON {
93 d = m;
95 e = m;
96 } else {
97 let s = fb / fa;
98 let mut p;
99 let mut q;
100
101 if (a - c).abs() < f64::EPSILON {
102 p = 2.0 * m * s;
104 q = 1.0 - s;
105 } else {
106 q = fa / fc;
108 let r = fb / fc;
109 p = s * (2.0 * m * q * (q - r) - (b - a) * (r - 1.0));
110 q = (q - 1.0) * (r - 1.0) * (s - 1.0);
111 }
112
113 if p > 0.0 {
114 q = -q;
115 } else {
116 p = -p;
117 }
118
119 let s = e;
120 e = d;
121
122 if q.abs() > f64::EPSILON
124 && 2.0 * p < 3.0 * m * q - (tolerance_scaled * q).abs()
125 && p < (0.5 * s * q).abs()
126 {
127 d = p / q;
128 } else {
129 d = m;
130 e = d;
131 }
132 }
133 } else {
134 d = m;
135 e = d;
136 }
137
138 a = b;
139 fa = fb;
140
141 if d.abs() > tolerance_scaled {
142 b += d;
143 } else if m > 0.0 {
144 b += tolerance_scaled;
145 } else {
146 b -= tolerance_scaled;
147 }
148
149 fb = f(b);
150
151 if (fc * fb) > 0.0 {
152 c = a;
153 fc = fa;
154 e = b - a;
155 d = e;
156 }
157 }
158
159 Ok(AngleResult {
160 angle_rad: b,
161 iterations_used: iterations,
162 final_error: fb.abs(),
163 success: false,
164 })
165}
166
167pub fn adjusted_muzzle_velocity(inputs: &InternalBallisticInputs) -> f64 {
172 let mut mv = inputs.muzzle_velocity;
173
174 if inputs.use_powder_sensitivity {
175 mv += inputs.powder_temp_sensitivity * (inputs.temperature - inputs.powder_temp);
176 }
177
178 mv
179}
180
181pub fn zero_angle(
187 inputs: &InternalBallisticInputs,
188 trajectory_func: impl Fn(&InternalBallisticInputs, f64) -> Result<f64, String> + Copy,
189) -> Result<AngleResult, String> {
190 let vert = if inputs.shooting_angle.abs() > 1e-6 {
192 inputs.target_distance * inputs.shooting_angle.tan()
193 } else {
194 0.0
195 };
196
197 let height_diff = |look_angle_rad: f64| -> f64 {
200 match trajectory_func(inputs, look_angle_rad) {
202 Ok(bullet_height) => bullet_height - vert,
203 Err(_) => f64::NAN, }
205 };
206
207 let lower_bound = -10.0 * DEGREES_TO_RADIANS;
210 let upper_bound = 10.0 * DEGREES_TO_RADIANS;
211
212 match brent_root_find(height_diff, lower_bound, upper_bound, 1e-6, 100) {
214 Ok(result) if result.success => Ok(result),
215 _ => {
216 let wider_lower = -45.0 * DEGREES_TO_RADIANS;
218 let wider_upper = 45.0 * DEGREES_TO_RADIANS;
219
220 match brent_root_find(height_diff, wider_lower, wider_upper, 1e-5, 150) {
221 Ok(result) if result.success => Ok(result),
222 Ok(result) => {
223 Ok(AngleResult {
225 angle_rad: result.angle_rad,
226 iterations_used: result.iterations_used,
227 final_error: result.final_error,
228 success: false,
229 })
230 }
231 Err(_) => {
232 Ok(AngleResult {
234 angle_rad: 0.0,
235 iterations_used: 0,
236 final_error: f64::INFINITY,
237 success: false,
238 })
239 }
240 }
241 }
242 }
243}
244
245pub fn solve_muzzle_angle(
247 inputs: &InternalBallisticInputs,
248 zero_distance_los_m: f64,
249 trajectory_func: impl Fn(&InternalBallisticInputs) -> Result<f64, String> + Copy, angle_lower_deg: f64,
251 angle_upper_deg: f64,
252 rtol: f64,
253) -> Result<AngleResult, String> {
254 if angle_lower_deg >= angle_upper_deg {
255 return Err("angle_lower_deg must be less than angle_upper_deg".to_string());
256 }
257
258 let lower = angle_lower_deg * DEGREES_TO_RADIANS;
259 let mut upper = angle_upper_deg * DEGREES_TO_RADIANS;
260
261 let vertical_error = |angle_rad: f64| -> f64 {
263 let mut candidate = inputs.clone();
265 candidate.muzzle_angle = angle_rad * RADIANS_TO_DEGREES;
266 candidate.target_distance = zero_distance_los_m / YARDS_TO_METERS; trajectory_func(&candidate).unwrap_or(f64::NAN)
273 };
274
275 let f_lower = vertical_error(lower);
277 if f_lower.abs() < 1e-9 {
278 return Ok(AngleResult {
279 angle_rad: lower,
280 iterations_used: 1,
281 final_error: f_lower.abs(),
282 success: true,
283 });
284 }
285
286 let f_upper = vertical_error(upper);
287 if f_upper.abs() < 1e-9 {
288 return Ok(AngleResult {
289 angle_rad: upper,
290 iterations_used: 1,
291 final_error: f_upper.abs(),
292 success: true,
293 });
294 }
295
296 if f_lower * f_upper > 0.0 {
298 let step = 5.0 * DEGREES_TO_RADIANS;
299 let max_angle = 45.0 * DEGREES_TO_RADIANS;
300 let mut current = upper;
301 let mut f_current = f_upper;
302
303 while current < max_angle && f_lower * f_current > 0.0 {
304 current += step;
305 f_current = vertical_error(current);
306 }
307
308 if f_lower * f_current > 0.0 {
309 return Err("Unable to bracket zero; widen angle bounds or check inputs".to_string());
310 }
311
312 upper = current;
313 }
314
315 let range = (upper - lower).abs();
317 let tolerance = if range > f64::EPSILON {
318 rtol * range
319 } else {
320 rtol * 1e-12 };
322 brent_root_find(
323 vertical_error,
324 lower,
325 upper,
326 tolerance,
327 ZERO_FINDING_MAX_ITER,
328 )
329}
330
331pub fn quick_drop_estimate(
338 muzzle_velocity_fps: f64,
339 distance_yards: f64,
340 _bullet_mass_grains: f64,
341 bc: f64,
342) -> f64 {
343 if muzzle_velocity_fps <= 0.0 || distance_yards <= 0.0 {
344 return 0.0; }
346
347 let bc_safe = bc.max(0.1);
348 let distance_ft = distance_yards * 3.0;
349 let step_count = ((distance_yards / 5.0).ceil() as usize).clamp(32, 4096);
350 let step_ft = distance_ft / step_count as f64;
351 let gravity_ft_s2 = crate::constants::G_ACCEL_MPS2 / FEET_TO_METERS;
352 let speed_of_sound_fps = crate::constants::SPEED_OF_SOUND_MPS / FPS_TO_MPS;
353
354 let derivatives = |state: &[f64; 3]| -> Option<[f64; 3]> {
358 let [_, vx, vy] = *state;
359 if !(vx.is_finite() && vy.is_finite() && vx > 1e-9) {
360 return None;
361 }
362 let speed = vx.hypot(vy);
363 let mach = speed / speed_of_sound_fps;
364 let cd = crate::drag::get_drag_coefficient(mach, &crate::DragModel::G1);
365 let drag_accel = speed.powi(2) * cd * crate::constants::CD_TO_RETARD / bc_safe;
366
367 Some([
368 vy / vx,
369 -drag_accel / speed,
370 (-gravity_ft_s2 - drag_accel * vy / speed) / vx,
371 ])
372 };
373
374 let mut state = [0.0, muzzle_velocity_fps, 0.0];
375 for _ in 0..step_count {
376 let Some(k1) = derivatives(&state) else {
377 return f64::NAN;
378 };
379 let midpoint: [f64; 3] = std::array::from_fn(|i| state[i] + 0.5 * step_ft * k1[i]);
380 let Some(k2) = derivatives(&midpoint) else {
381 return f64::NAN;
382 };
383 state = std::array::from_fn(|i| state[i] + step_ft * k2[i]);
384 }
385
386 -state[0] * FEET_TO_METERS
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 fn create_test_inputs() -> InternalBallisticInputs {
394 InternalBallisticInputs {
395 muzzle_velocity: 823.0, bc_value: 0.5,
397 bullet_mass: 0.0109, bullet_diameter: 0.00782, target_distance: 500.0,
400 temperature: 21.1, powder_temp: 21.1, ..Default::default()
403 }
404 }
405
406 #[test]
407 fn test_brent_root_find_quadratic() {
408 let f = |x: f64| x * x - 4.0;
410 let result = brent_root_find(f, 1.0, 3.0, 1e-6, 100).unwrap();
411
412 assert!(result.success);
413 assert!((result.angle_rad - 2.0).abs() < 1e-6);
414 assert!(result.iterations_used > 0);
415 assert!(result.final_error < 1e-6);
416 }
417
418 #[test]
419 fn brent_uses_inverse_quadratic_interpolation() {
420 let f = |x: f64| x * x - 2.0;
421 let result = brent_root_find(f, 1.0, 2.0, 1e-12, 100).unwrap();
422
423 assert!(result.success);
424 assert!((result.angle_rad - 2.0_f64.sqrt()).abs() < 1e-12);
425 assert!(
426 result.iterations_used <= 10,
427 "smooth quadratic should converge superlinearly, took {} iterations",
428 result.iterations_used
429 );
430 }
431
432 #[test]
433 fn test_brent_root_find_linear() {
434 let f = |x: f64| 2.0 * x - 6.0;
436 let result = brent_root_find(f, 0.0, 5.0, 1e-6, 100).unwrap();
437
438 assert!(result.success);
439 assert!((result.angle_rad - 3.0).abs() < 1e-6);
440 }
441
442 #[test]
443 fn brent_angle_tolerance_is_invariant_to_residual_units() {
444 let expected_root = 2.0_f64.sqrt();
445 let x_tolerance = 1e-8;
446
447 for scale in [1.0, 1e-9] {
448 let result = brent_root_find(
449 |angle_rad| scale * (angle_rad * angle_rad - 2.0),
450 1.0,
451 2.0,
452 x_tolerance,
453 100,
454 )
455 .unwrap();
456
457 assert!(result.success);
458 assert!(
459 (result.angle_rad - expected_root).abs() <= x_tolerance,
460 "residual scale {scale} loosened the angular tolerance: {result:?}"
461 );
462 assert!(result.iterations_used > 1);
463 }
464
465 let exhausted = brent_root_find(|x| 1e-9 * (x * x - 2.0), 1.0, 2.0, 1e-6, 0).unwrap();
466 assert!(!exhausted.success);
467 }
468
469 #[test]
470 fn test_brent_root_find_no_bracket() {
471 let f = |x: f64| x * x + 1.0; let result = brent_root_find(f, 1.0, 3.0, 1e-6, 100);
474
475 assert!(result.is_err());
476 assert!(result.unwrap_err().contains("Root not bracketed"));
477 }
478
479 #[test]
480 fn test_adjusted_muzzle_velocity_no_sensitivity() {
481 let inputs = create_test_inputs();
482
483 let result = adjusted_muzzle_velocity(&inputs);
484 assert_eq!(result, 823.0); }
486
487 #[test]
488 fn test_adjusted_muzzle_velocity_with_sensitivity() {
489 let mut inputs = create_test_inputs();
490 inputs.use_powder_sensitivity = true;
491 inputs.powder_temp_sensitivity = 0.6; inputs.temperature = 31.1;
493 inputs.powder_temp = 21.1;
494
495 let result = adjusted_muzzle_velocity(&inputs);
496 assert!((result - 829.0).abs() < 1e-12);
497
498 inputs.temperature = 11.1;
499 let colder_result = adjusted_muzzle_velocity(&inputs);
500 assert!((colder_result - 817.0).abs() < 1e-12);
501 }
502
503 #[test]
504 fn test_quick_drop_estimate() {
505 let drop = quick_drop_estimate(2700.0, 500.0, 168.0, 0.5);
506
507 assert!(drop > 0.0);
509 assert!(drop < 50.0); let drop_high_bc = quick_drop_estimate(2700.0, 500.0, 168.0, 0.8);
513 assert!(drop_high_bc < drop);
514 }
515
516 #[test]
517 fn quick_drop_tracks_g1_point_mass_reference() {
518 let distance_yards = 500.0;
519 let distance_m = distance_yards * YARDS_TO_METERS;
520 let inputs = InternalBallisticInputs {
521 muzzle_velocity: 1200.0 * FPS_TO_MPS,
522 bc_value: 0.5,
523 bc_type: crate::DragModel::G1,
524 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
525 bullet_diameter: 0.308 * 0.0254,
526 muzzle_height: 0.0,
527 ground_threshold: f64::NEG_INFINITY,
528 ..Default::default()
529 };
530
531 let mut solver =
532 crate::TrajectorySolver::new(inputs, Default::default(), Default::default());
533 solver.set_max_range(distance_m);
534 let reference = solver.solve().unwrap();
535 let position = reference.position_at_range(distance_m).unwrap();
536 let reference_drop_m = -position.y;
537 assert!(
538 (9.2..=9.6).contains(&reference_drop_m),
539 "unexpected canonical G1 fixture: {reference_drop_m} m"
540 );
541
542 let estimated_drop_m = quick_drop_estimate(1200.0, distance_yards, 168.0, 0.5);
543 let relative_error = (estimated_drop_m - reference_drop_m).abs() / reference_drop_m;
544 assert!(
545 relative_error < 0.1,
546 "quick G1 drop {estimated_drop_m} m differs from reference {reference_drop_m} m by {:.1}%",
547 relative_error * 100.0
548 );
549 }
550
551 #[test]
552 fn test_zero_angle_uses_si_distance_and_radians() {
553 let mut inputs = create_test_inputs();
554 inputs.target_distance = 800.0;
555 inputs.shooting_angle = 1.0_f64.to_radians();
556
557 let result = zero_angle(&inputs, |trajectory_inputs, look_angle_rad| {
558 Ok(trajectory_inputs.target_distance * look_angle_rad)
559 })
560 .unwrap();
561
562 assert!(result.success);
563 assert!(
564 (result.angle_rad - inputs.shooting_angle).abs() < 1e-4,
565 "SI zero target should solve near {} rad, got {}",
566 inputs.shooting_angle,
567 result.angle_rad
568 );
569 }
570
571 #[test]
572 fn zero_angle_uses_horizontal_range_for_incline_geometry() {
573 let mut inputs = create_test_inputs();
574 inputs.target_distance = 800.0;
575 inputs.shooting_angle = 30.0_f64.to_radians();
576
577 let result = zero_angle(&inputs, |trajectory_inputs, look_angle_rad| {
578 Ok(trajectory_inputs.target_distance * look_angle_rad.tan())
579 })
580 .unwrap();
581
582 assert!(result.success);
583 assert!(
584 (result.angle_rad - inputs.shooting_angle).abs() < 1e-4,
585 "horizontal-range zero should solve at {} rad, got {}",
586 inputs.shooting_angle,
587 result.angle_rad
588 );
589 }
590
591 #[test]
592 fn test_zero_angle_bounds() {
593 let lower = -10.0 * DEGREES_TO_RADIANS;
595 let upper = 10.0 * DEGREES_TO_RADIANS;
596
597 assert!(lower < 0.0);
598 assert!(upper > 0.0);
599 assert!((upper - lower).abs() > 0.1); }
601
602 #[test]
603 fn test_brent_root_find_near_zero_function_values() {
604 let f = |x: f64| (x - 1.0) * 1e-10; let result = brent_root_find(f, 0.0, 2.0, 1e-12, 100).unwrap();
609
610 assert!(result.success || result.iterations_used > 0);
613 assert!((result.angle_rad - 1.0).abs() < 0.1);
615 }
616
617 #[test]
618 fn test_brent_root_find_steep_function() {
619 let f = |x: f64| (x - 0.5).powi(3) * 1e6;
621 let result = brent_root_find(f, 0.0, 1.0, 1e-9, 100).unwrap();
622
623 assert!(result.success);
624 assert!((result.angle_rad - 0.5).abs() < 1e-6);
625 }
626
627 #[test]
628 fn test_brent_root_find_oscillating_convergence() {
629 let f = |x: f64| x.sin() - 0.5;
632 let result = brent_root_find(f, 0.0, 1.0, 1e-10, 100).unwrap();
633
634 assert!(result.success);
635 assert!((result.angle_rad - std::f64::consts::FRAC_PI_6).abs() < 1e-6);
637 }
638
639 #[test]
640 fn test_brent_root_find_flat_region() {
641 let f = |x: f64| (x - 2.0).powi(5);
644 let result = brent_root_find(f, 1.0, 3.0, 1e-8, 100).unwrap();
645
646 assert!(result.success);
647 assert!((result.angle_rad - 2.0).abs() < 1e-4);
648 }
649}