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