1use crate::{
7 atmosphere::get_local_atmosphere_humid,
8 constants::{G_ACCEL_MPS2, MPS_TO_FPS},
9 drag::get_drag_coefficient,
10 wind::WindSock,
11 BCSegmentData, DragModel, InternalBallisticInputs as BallisticInputs,
12};
13use nalgebra::Vector3;
14
15#[derive(Debug, Clone)]
17pub struct FastSolution {
18 pub t: Vec<f64>,
20 pub y: Vec<Vec<f64>>,
22 pub t_events: [Vec<f64>; 3],
24 pub success: bool,
26}
27
28impl FastSolution {
29 pub fn sol(&self, t_query: &[f64]) -> Vec<Vec<f64>> {
31 let mut result = vec![vec![0.0; t_query.len()]; 6];
32
33 for (i, &tq) in t_query.iter().enumerate() {
34 let idx = match self
37 .t
38 .binary_search_by(|&t| t.partial_cmp(&tq).unwrap_or(std::cmp::Ordering::Greater))
39 {
40 Ok(idx) => idx,
41 Err(idx) => idx,
42 };
43
44 if idx == 0 {
45 for j in 0..6 {
47 result[j][i] = self.y[j][0];
48 }
49 } else if idx >= self.t.len() {
50 for j in 0..6 {
52 result[j][i] = self.y[j][self.t.len() - 1];
53 }
54 } else {
55 let t0 = self.t[idx - 1];
57 let t1 = self.t[idx];
58 let span = t1 - t0;
59
60 for j in 0..6 {
61 let y0 = self.y[j][idx - 1];
62 let y1 = self.y[j][idx];
63 result[j][i] = if span.abs() < f64::EPSILON {
64 y1
65 } else {
66 let frac = (tq - t0) / span;
67 y0 + frac * (y1 - y0)
68 };
69 }
70 }
71 }
72
73 result
74 }
75
76 pub fn from_trajectory_data(
78 times: Vec<f64>,
79 states: Vec<[f64; 6]>,
80 t_events: [Vec<f64>; 3],
81 ) -> Self {
82 let n_points = times.len();
83 let mut y = vec![vec![0.0; n_points]; 6];
84
85 for (i, state) in states.iter().enumerate() {
86 for j in 0..6 {
87 y[j][i] = state[j];
88 }
89 }
90
91 FastSolution {
92 t: times,
93 y,
94 t_events,
95 success: true,
96 }
97 }
98
99 fn degenerate(initial_state: &[f64; 6]) -> Self {
103 let mut y = vec![Vec::new(); 6];
104 for (j, slot) in y.iter_mut().enumerate() {
105 slot.push(initial_state[j]);
106 }
107 FastSolution {
108 t: vec![0.0],
109 y,
110 t_events: [Vec::new(), Vec::new(), Vec::new()],
111 success: false,
112 }
113 }
114}
115
116fn atmo_is_physical(atmo_params: (f64, f64, f64, f64)) -> bool {
129 let (a, b, c, d) = atmo_params;
130 if !(a.is_finite() && b.is_finite() && c.is_finite() && d.is_finite()) {
131 return false;
132 }
133 let direct_mode = c == 0.0 && d == 0.0 && a > 0.0 && a < 2.0 && b > 200.0;
136 direct_mode || c > 0.0
138}
139
140pub struct FastIntegrationParams {
142 pub horiz: f64,
143 pub vert: f64,
144 pub initial_state: [f64; 6],
145 pub t_span: (f64, f64),
146 pub atmo_params: (f64, f64, f64, f64),
150}
151
152pub fn aerodynamic_jump_launch_offset_rad(
160 inputs: &BallisticInputs,
161 atmo_params: (f64, f64, f64, f64),
162) -> f64 {
163 if !inputs.enable_aerodynamic_jump {
164 return 0.0;
165 }
166 let diameter = inputs.bullet_diameter;
167 if !(inputs.twist_rate.is_finite() && inputs.twist_rate != 0.0)
168 || !(diameter.is_finite() && diameter > 0.0)
169 || !(inputs.bullet_length.is_finite() && inputs.bullet_length > 0.0)
170 || !inputs.muzzle_velocity.is_finite()
171 {
172 return 0.0;
173 }
174 let sg = crate::stability::compute_stability_coefficient(inputs, atmo_params);
175 if !(sg.is_finite() && sg > 0.0) {
176 return 0.0;
177 }
178 let length_cal = inputs.bullet_length / diameter;
179 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
180 let crosswind_from_right_mph = inputs.wind_speed * inputs.wind_angle.sin() * MS_TO_MPH;
181 let vertical_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
182 sg,
183 length_cal,
184 crosswind_from_right_mph,
185 inputs.is_twist_right,
186 );
187 if !vertical_moa.is_finite() {
188 return 0.0;
189 }
190 const MOA_PER_RAD: f64 = 3437.7467707849;
191 vertical_moa / MOA_PER_RAD
192}
193
194fn rotate_launch_velocity(state: &mut [f64; 6], theta_rad: f64) {
197 let (vx, vy, vz) = (state[3], state[4], state[5]);
198 let speed = (vx * vx + vy * vy + vz * vz).sqrt();
199 if speed <= 0.0 {
200 return;
201 }
202 let h = (vx * vx + vz * vz).sqrt(); let new_elev = vy.atan2(h) + theta_rad;
204 state[4] = speed * new_elev.sin();
205 let new_h = speed * new_elev.cos();
206 let scale = if h > 1e-12 { new_h / h } else { 0.0 };
207 state[3] = vx * scale;
208 state[5] = vz * scale;
209}
210
211pub fn fast_integrate(
213 inputs: &BallisticInputs,
214 wind_sock: &WindSock,
215 params: FastIntegrationParams,
216) -> FastSolution {
217 if !atmo_is_physical(params.atmo_params) {
219 return FastSolution::degenerate(¶ms.initial_state);
220 }
221 let mut effective_inputs = inputs.clone();
222 if params.atmo_params.2 > 0.0 {
223 effective_inputs.altitude = params.atmo_params.0;
224 effective_inputs.temperature = params.atmo_params.1;
225 effective_inputs.pressure = params.atmo_params.2;
226 effective_inputs.humidity = params.atmo_params.3;
227 }
228 let inputs = &effective_inputs;
229 let _mass_kg = inputs.bullet_mass; let bc = inputs.bc_value;
232 let drag_model = &inputs.bc_type;
233
234 let has_bc_segments =
236 inputs.bc_segments.is_some() && !inputs.bc_segments.as_ref().unwrap().is_empty();
237 let has_bc_segments_data =
238 inputs.bc_segments_data.is_some() && !inputs.bc_segments_data.as_ref().unwrap().is_empty();
239
240 let dt = if params.horiz > 200.0 {
242 0.001
243 } else if params.horiz > 100.0 {
244 0.0005
245 } else {
246 0.0001
247 };
248
249 let mut initial_state = params.initial_state;
252 let aj_offset = aerodynamic_jump_launch_offset_rad(inputs, params.atmo_params);
253 if aj_offset != 0.0 {
254 rotate_launch_velocity(&mut initial_state, aj_offset);
255 }
256 let vx = initial_state[3]; let n_steps = ((params.t_span.1 / dt) as usize) + 1;
270 let est_steps = if vx > 1e-6 && params.horiz > 0.0 {
271 (((4.0 * params.horiz / vx) / dt) as usize) + 1
272 } else {
273 n_steps
274 };
275 let cap = est_steps.min(n_steps);
276 let mut times = Vec::with_capacity(cap);
277 let mut states = Vec::with_capacity(cap);
278
279 times.push(0.0);
281 states.push(initial_state);
282
283 let base_density = if params.atmo_params.3 > 0.0 {
293 params.atmo_params.3 * 1.225
294 } else {
295 1.225 };
297
298 let drag_model_str: &str = match drag_model {
306 DragModel::G1 => "G1",
307 DragModel::G2 => "G2",
308 DragModel::G5 => "G5",
309 DragModel::G6 => "G6",
310 DragModel::G7 => "G7",
311 DragModel::G8 => "G8",
312 DragModel::GI => "GI",
313 DragModel::GS => "GS",
314 };
315
316 let caliber_in = if inputs.caliber_inches > 0.0 {
318 inputs.caliber_inches
319 } else {
320 inputs.bullet_diameter / 0.0254
321 };
322 let weight_gr = if inputs.weight_grains > 0.0 {
323 inputs.weight_grains
324 } else {
325 inputs.bullet_mass / 0.00006479891
326 };
327
328 let projectile_shape = crate::transonic_drag::resolve_projectile_shape(
331 inputs.bullet_model.as_deref(),
332 caliber_in,
333 weight_gr,
334 drag_model_str,
335 );
336
337 let omega_vector = if inputs.enable_coriolis && inputs.latitude.is_some() {
343 let omega_earth = 7.2921159e-5_f64; let lat = inputs.latitude.unwrap().to_radians();
345 let az = inputs.shot_azimuth; Some(Vector3::new(
347 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), ))
351 } else {
352 None
353 };
354
355 let mut hit_target = false;
357 let mut hit_ground = false;
358 let mut max_ord_time = None;
359 let mut max_ord_y = 0.0;
360 let ground_threshold = inputs.ground_threshold;
361
362 for i in 0..n_steps - 1 {
364 let t = i as f64 * dt;
365 let state = states[i];
366
367 let pos = Vector3::new(state[0], state[1], state[2]);
368 let _vel = Vector3::new(state[3], state[4], state[5]);
369
370 if pos.x >= params.horiz {
372 hit_target = true;
373 break;
374 }
375
376 if pos.y <= ground_threshold {
377 hit_ground = true;
378 break;
379 }
380
381 if pos.y > max_ord_y {
383 max_ord_y = pos.y;
384 max_ord_time = Some(t);
385 }
386
387 let k1 = compute_derivatives(
389 &state,
390 inputs,
391 wind_sock,
392 base_density,
393 drag_model,
394 projectile_shape,
395 bc,
396 has_bc_segments,
397 has_bc_segments_data,
398 omega_vector,
399 );
400
401 let mut state2 = state;
402 for j in 0..6 {
403 state2[j] = state[j] + 0.5 * dt * k1[j];
404 }
405 let k2 = compute_derivatives(
406 &state2,
407 inputs,
408 wind_sock,
409 base_density,
410 drag_model,
411 projectile_shape,
412 bc,
413 has_bc_segments,
414 has_bc_segments_data,
415 omega_vector,
416 );
417
418 let mut state3 = state;
419 for j in 0..6 {
420 state3[j] = state[j] + 0.5 * dt * k2[j];
421 }
422 let k3 = compute_derivatives(
423 &state3,
424 inputs,
425 wind_sock,
426 base_density,
427 drag_model,
428 projectile_shape,
429 bc,
430 has_bc_segments,
431 has_bc_segments_data,
432 omega_vector,
433 );
434
435 let mut state4 = state;
436 for j in 0..6 {
437 state4[j] = state[j] + dt * k3[j];
438 }
439 let k4 = compute_derivatives(
440 &state4,
441 inputs,
442 wind_sock,
443 base_density,
444 drag_model,
445 projectile_shape,
446 bc,
447 has_bc_segments,
448 has_bc_segments_data,
449 omega_vector,
450 );
451
452 let mut new_state = state;
454 for j in 0..6 {
455 new_state[j] = state[j] + dt * (k1[j] + 2.0 * k2[j] + 2.0 * k3[j] + k4[j]) / 6.0;
456 }
457
458 times.push(t + dt);
459 states.push(new_state);
460 }
461
462 let t_events = [
464 if hit_target {
465 vec![*times.last().unwrap()]
466 } else {
467 vec![]
468 },
469 if let Some(t) = max_ord_time {
470 vec![t]
471 } else {
472 vec![]
473 },
474 if hit_ground {
475 vec![*times.last().unwrap()]
476 } else {
477 vec![]
478 },
479 ];
480
481 FastSolution::from_trajectory_data(times, states, t_events)
482}
483
484fn compute_derivatives(
486 state: &[f64; 6],
487 inputs: &BallisticInputs,
488 wind_sock: &WindSock,
489 base_density: f64,
490 drag_model: &DragModel,
491 projectile_shape: crate::transonic_drag::ProjectileShape,
492 bc: f64,
493 has_bc_segments: bool,
494 has_bc_segments_data: bool,
495 omega: Option<Vector3<f64>>,
496) -> [f64; 6] {
497 let pos = Vector3::new(state[0], state[1], state[2]);
498 let vel = Vector3::new(state[3], state[4], state[5]);
499
500 let wind_vector = wind_sock.vector_for_range_stateless(pos.x);
502
503 let vel_adjusted = vel - wind_vector;
505 let v_mag = vel_adjusted.norm();
506
507 let theta = inputs.shooting_angle;
510 let accel_gravity = Vector3::new(
511 -G_ACCEL_MPS2 * theta.sin(),
512 -G_ACCEL_MPS2 * theta.cos(),
513 0.0,
514 );
515
516 let mut accel = if v_mag < 1e-6 {
518 accel_gravity
519 } else {
520 let v_fps = v_mag * MPS_TO_FPS;
522
523 let altitude = inputs.altitude + pos.y;
531 let (_, speed_of_sound) = get_local_atmosphere_humid(
532 altitude,
533 inputs.altitude, inputs.temperature,
535 inputs.pressure,
536 1.0, 0.0, );
539 let mach = v_mag / speed_of_sound;
540
541 let bc_current = if has_bc_segments_data && inputs.bc_segments_data.is_some() {
543 get_bc_from_velocity_segments(v_fps, inputs.bc_segments_data.as_ref().unwrap())
544 } else if has_bc_segments && inputs.bc_segments.is_some() {
545 crate::derivatives::interpolated_bc(
546 mach,
547 inputs.bc_segments.as_ref().unwrap(),
548 Some(inputs),
549 )
550 } else {
551 bc
552 };
553 let bc_current = bc_current.max(1e-6);
556
557 let (drag_factor, retard_denom) = if let Some(ref table) = inputs.custom_drag_table {
568 (
569 table.interpolate(mach),
570 inputs.custom_drag_denominator(bc_current),
571 )
572 } else {
573 let base_cd = get_drag_coefficient(mach, drag_model);
574 let cd =
575 crate::transonic_drag::transonic_correction(mach, base_cd, projectile_shape, false);
576 let cd = crate::form_factor::apply_form_factor_to_drag(
580 cd,
581 inputs.bullet_model.as_deref(),
582 &inputs.bc_type,
583 inputs.use_form_factor,
584 );
585 (cd, bc_current)
586 };
587
588 let cd_to_retard = crate::constants::CD_TO_RETARD;
590 let standard_factor = drag_factor * cd_to_retard;
591 let density_scale = base_density / 1.225;
592
593 let a_drag_ft_s2 = (v_fps * v_fps) * standard_factor * density_scale / retard_denom;
595
596 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let accel_drag = -a_drag_m_s2 * (vel_adjusted / v_mag);
599
600 accel_drag + accel_gravity
602 };
603
604 if let Some(omega) = omega {
607 accel += -2.0 * omega.cross(&vel);
608 }
609
610 [vel.x, vel.y, vel.z, accel.x, accel.y, accel.z]
612}
613
614fn get_bc_from_velocity_segments(velocity_fps: f64, segments: &[BCSegmentData]) -> f64 {
616 for segment in segments {
617 if velocity_fps >= segment.velocity_min && velocity_fps <= segment.velocity_max {
618 return segment.bc_value;
619 }
620 }
621
622 if let Some(first) = segments.first() {
624 if velocity_fps < first.velocity_min {
625 return first.bc_value;
626 }
627 }
628
629 if let Some(last) = segments.last() {
630 if velocity_fps > last.velocity_max {
631 return last.bc_value;
632 }
633 }
634
635 0.5
637}
638
639pub fn fast_integrate_with_segments(
642 inputs: &BallisticInputs,
643 wind_segments: Vec<crate::wind::WindSegment>,
644 params: FastIntegrationParams,
645) -> FastSolution {
646 use crate::trajectory_integration::{integrate_trajectory, TrajectoryParams};
648
649 if !atmo_is_physical(params.atmo_params) {
651 return FastSolution::degenerate(¶ms.initial_state);
652 }
653
654 let mass_kg = inputs.bullet_mass; let bc = inputs.bc_value;
657 let drag_model = inputs.bc_type;
658
659 let omega_vector = if inputs.enable_coriolis && inputs.latitude.is_some() {
663 let omega_earth = 7.2921159e-5; let lat_rad = inputs.latitude.unwrap_or(0.0).to_radians();
669 let azimuth = inputs.shot_azimuth; Some(Vector3::new(
671 omega_earth * lat_rad.cos() * azimuth.cos(), omega_earth * lat_rad.sin(), -omega_earth * lat_rad.cos() * azimuth.sin(), ))
675 } else {
676 None
677 };
678
679 let traj_params = TrajectoryParams {
681 mass_kg,
682 bc,
683 drag_model,
684 wind_segments,
685 atmos_params: params.atmo_params,
686 omega_vector,
687 enable_spin_drift: inputs.enable_advanced_effects,
688 enable_magnus: inputs.enable_magnus,
689 enable_coriolis: inputs.enable_coriolis,
690 target_distance_m: params.horiz,
691 enable_wind_shear: inputs.enable_wind_shear,
692 wind_shear_model: inputs.wind_shear_model.clone(),
693 shooter_altitude_m: inputs.altitude,
694 is_twist_right: inputs.is_twist_right,
695 shooting_angle: inputs.shooting_angle,
696 bullet_diameter: inputs.bullet_diameter,
698 bullet_length: inputs.bullet_length,
699 twist_rate: inputs.twist_rate,
700 custom_drag_table: inputs.custom_drag_table.clone(),
701 bc_segments: inputs.bc_segments.clone(),
702 use_bc_segments: inputs.use_bc_segments,
703 ground_threshold: -1000.0,
707 };
708
709 let trajectory = integrate_trajectory(
711 params.initial_state,
712 params.t_span,
713 traj_params,
714 "RK45", 1e-6, 0.01, );
718
719 let n_points = trajectory.len();
721 let mut times = Vec::with_capacity(n_points);
722 let mut states = Vec::with_capacity(n_points);
723
724 let mut target_hit_time: Option<f64> = None;
725 let mut ground_hit_time: Option<f64> = None;
726 let mut max_ord_time = None;
727 let mut max_ord_y = 0.0;
728
729 for (t, state_vec) in trajectory {
730 let state = [
732 state_vec[0],
733 state_vec[1],
734 state_vec[2],
735 state_vec[3],
736 state_vec[4],
737 state_vec[5],
738 ];
739
740 if target_hit_time.is_none() && state[0] >= params.horiz {
745 target_hit_time = Some(t);
746 }
747
748 if ground_hit_time.is_none() && state[1] <= inputs.ground_threshold {
750 ground_hit_time = Some(t);
751 }
752
753 if state[1] > max_ord_y {
755 max_ord_y = state[1];
756 max_ord_time = Some(t);
757 }
758
759 times.push(t);
760 states.push(state);
761 }
762
763 let t_events = [
765 if let Some(t) = target_hit_time {
766 vec![t]
767 } else {
768 vec![]
769 },
770 if let Some(t) = max_ord_time {
771 vec![t]
772 } else {
773 vec![]
774 },
775 if let Some(t) = ground_hit_time {
776 vec![t]
777 } else {
778 vec![]
779 },
780 ];
781
782 if inputs.use_enhanced_spin_drift {
787 let (sd_temp_c, sd_press_hpa) = if params.atmo_params.2 > 0.0 {
791 (params.atmo_params.1, params.atmo_params.2)
792 } else {
793 (15.0, 1013.25)
794 };
795 let sg = crate::spin_drift::effective_sg_from_inputs(inputs, sd_temp_c, sd_press_hpa);
796 for (t, state) in times.iter().zip(states.iter_mut()) {
797 if *t > 0.0 {
798 state[2] += crate::spin_drift::litz_drift_meters(sg, *t, inputs.is_twist_right);
799 }
800 }
801 }
802
803 FastSolution::from_trajectory_data(times, states, t_events)
804}
805
806#[cfg(test)]
807mod tests {
808 use super::*;
809
810 #[test]
811 fn test_fast_solution_interpolation() {
812 let times = vec![0.0, 1.0, 2.0];
813 let states = vec![
814 [0.0, 0.0, 0.0, 100.0, 50.0, 0.0],
815 [100.0, 45.0, 0.0, 99.0, 40.0, 0.0],
816 [198.0, 80.0, 0.0, 98.0, 30.0, 0.0],
817 ];
818
819 let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
820
821 let result = solution.sol(&[1.5]);
823
824 assert!((result[0][0] - 149.0).abs() < 1e-10); assert!((result[1][0] - 62.5).abs() < 1e-10); assert!((result[3][0] - 98.5).abs() < 1e-10); }
828
829 #[test]
830 fn test_bc_from_velocity_segments() {
831 let segments = vec![
832 BCSegmentData {
833 velocity_min: 0.0,
834 velocity_max: 1000.0,
835 bc_value: 0.5,
836 },
837 BCSegmentData {
838 velocity_min: 1000.0,
839 velocity_max: 2000.0,
840 bc_value: 0.52,
841 },
842 BCSegmentData {
843 velocity_min: 2000.0,
844 velocity_max: 3000.0,
845 bc_value: 0.55,
846 },
847 ];
848
849 assert_eq!(get_bc_from_velocity_segments(500.0, &segments), 0.5);
850 assert_eq!(get_bc_from_velocity_segments(1500.0, &segments), 0.52);
851 assert_eq!(get_bc_from_velocity_segments(2500.0, &segments), 0.55);
852
853 assert_eq!(get_bc_from_velocity_segments(-100.0, &segments), 0.5); assert_eq!(get_bc_from_velocity_segments(3500.0, &segments), 0.55); }
857
858 #[test]
859 fn test_fast_solution_interpolation_edge_cases() {
860 let times = vec![0.0, 1.0, 2.0, 3.0];
861 let states = vec![
862 [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
863 [800.0, 40.0, 100.0, 750.0, 30.0, 0.0],
864 [1550.0, 60.0, 200.0, 700.0, 10.0, 0.0],
865 [2250.0, 50.0, 300.0, 650.0, -10.0, 0.0],
866 ];
867
868 let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
869
870 let result_before = solution.sol(&[-0.5]);
872 assert!((result_before[0][0] - 0.0).abs() < 1e-10); let result_after = solution.sol(&[5.0]);
876 assert!((result_after[0][0] - 2250.0).abs() < 1e-10); let result_exact = solution.sol(&[1.0]);
880 assert!((result_exact[0][0] - 800.0).abs() < 1e-10);
881
882 let result_multi = solution.sol(&[0.5, 1.5, 2.5]);
884 assert_eq!(result_multi[0].len(), 3);
885 }
886
887 #[test]
888 fn test_fast_solution_from_trajectory_data() {
889 let times = vec![0.0, 0.5, 1.0];
890 let states = vec![
891 [0.0, 1.0, 2.0, 3.0, 4.0, 5.0],
892 [10.0, 11.0, 12.0, 13.0, 14.0, 15.0],
893 [20.0, 21.0, 22.0, 23.0, 24.0, 25.0],
894 ];
895 let t_events = [vec![1.0], vec![0.5], vec![]];
896
897 let solution = FastSolution::from_trajectory_data(times.clone(), states, t_events);
898
899 assert_eq!(solution.t, times);
901 assert_eq!(solution.y.len(), 6); assert_eq!(solution.y[0].len(), 3); assert!(solution.success);
904
905 assert_eq!(solution.y[0][0], 0.0); assert_eq!(solution.y[1][0], 1.0); assert_eq!(solution.y[0][2], 20.0); }
910
911 #[test]
912 fn test_bc_segments_boundary_conditions() {
913 let single_segment = vec![BCSegmentData {
915 velocity_min: 1000.0,
916 velocity_max: 2000.0,
917 bc_value: 0.5,
918 }];
919
920 assert_eq!(get_bc_from_velocity_segments(500.0, &single_segment), 0.5); assert_eq!(get_bc_from_velocity_segments(1500.0, &single_segment), 0.5); assert_eq!(get_bc_from_velocity_segments(2500.0, &single_segment), 0.5); let segments = vec![
927 BCSegmentData {
928 velocity_min: 0.0,
929 velocity_max: 999.0, bc_value: 0.45,
931 },
932 BCSegmentData {
933 velocity_min: 1000.0,
934 velocity_max: 2000.0,
935 bc_value: 0.50,
936 },
937 ];
938
939 assert_eq!(get_bc_from_velocity_segments(1000.0, &segments), 0.50); assert_eq!(get_bc_from_velocity_segments(0.0, &segments), 0.45); assert_eq!(get_bc_from_velocity_segments(999.0, &segments), 0.45); }
943
944 #[test]
945 fn test_bc_segments_empty_fallback() {
946 let empty_segments: Vec<BCSegmentData> = vec![];
947
948 let result = get_bc_from_velocity_segments(1500.0, &empty_segments);
950 assert_eq!(result, 0.5); }
952
953 #[test]
954 fn test_fast_integration_params() {
955 let params = FastIntegrationParams {
957 horiz: 1000.0,
958 vert: 0.0,
959 initial_state: [0.0, 0.0, 0.0, 800.0, 50.0, 0.0], t_span: (0.0, 5.0),
961 atmo_params: (0.0, 59.0, 29.92, 0.0),
962 };
963
964 assert_eq!(params.horiz, 1000.0);
965 assert_eq!(params.t_span.0, 0.0);
966 assert_eq!(params.t_span.1, 5.0);
967 assert_eq!(params.initial_state[3], 800.0); }
969
970 #[test]
971 fn test_fast_solution_event_arrays() {
972 let times = vec![0.0, 1.0, 2.0];
973 let states = vec![
974 [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
975 [800.0, 40.0, 500.0, 750.0, 30.0, 0.0],
976 [1500.0, 20.0, 1000.0, 700.0, 10.0, 0.0],
977 ];
978
979 let t_events = [
981 vec![2.0], vec![0.5], vec![], ];
985
986 let solution = FastSolution::from_trajectory_data(times, states, t_events);
987
988 assert_eq!(solution.t_events[0], vec![2.0]); assert_eq!(solution.t_events[1], vec![0.5]); assert!(solution.t_events[2].is_empty()); }
992
993 #[test]
994 fn fast_path_coriolis_uses_shot_direction() {
995 use std::f64::consts::FRAC_PI_2;
1000 fn final_xy(shot_az: f64) -> (f64, f64) {
1002 let mut inputs = BallisticInputs::default();
1003 inputs.muzzle_velocity = 800.0;
1004 inputs.bc_value = 0.5;
1005 inputs.bc_type = DragModel::G7;
1006 inputs.enable_advanced_effects = true; inputs.enable_coriolis = true;
1008 inputs.latitude = Some(45.0);
1009 inputs.shot_azimuth = shot_az;
1010 let v = 800.0_f64;
1011 let elev = 0.02_f64;
1012 let params = FastIntegrationParams {
1013 horiz: 1000.0,
1014 vert: 0.0,
1015 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
1016 t_span: (0.0, 5.0),
1017 atmo_params: (0.0, 59.0, 29.92, 0.0),
1018 };
1019 let sol = fast_integrate_with_segments(&inputs, vec![], params);
1020 let n = sol.y[0].len();
1021 (sol.y[0][n - 1], sol.y[1][n - 1])
1022 }
1023 let (ex, ey) = final_xy(FRAC_PI_2); let (wx, wy) = final_xy(3.0 * FRAC_PI_2); assert!(
1028 (ex - wx).abs() < 0.5,
1029 "east/west downrange should be ~equal (ex={ex:.4}, wx={wx:.4})"
1030 );
1031 assert!(
1034 ey > wy,
1035 "fast-path east ({ey:.6}) must be higher than west ({wy:.6}) (Eotvos)"
1036 );
1037 assert!(
1038 (ey - wy) > 1e-5,
1039 "fast-path E-W vertical separation ({:.8} m) should be non-zero (the pre-fix bug was exact equality)",
1040 ey - wy
1041 );
1042 }
1043
1044 #[test]
1045 fn fast_path_coriolis_independent_of_advanced_effects() {
1046 use std::f64::consts::FRAC_PI_2;
1049 fn final_y(coriolis: bool, shot_az: f64) -> f64 {
1050 let mut inputs = BallisticInputs::default();
1051 inputs.muzzle_velocity = 800.0;
1052 inputs.bc_value = 0.5;
1053 inputs.bc_type = DragModel::G7;
1054 inputs.enable_coriolis = coriolis;
1055 inputs.enable_advanced_effects = false; inputs.latitude = Some(45.0);
1057 inputs.shot_azimuth = shot_az;
1058 let v = 800.0_f64;
1059 let elev = 0.02_f64;
1060 let params = FastIntegrationParams {
1061 horiz: 1000.0,
1062 vert: 0.0,
1063 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
1064 t_span: (0.0, 5.0),
1065 atmo_params: (0.0, 59.0, 29.92, 0.0),
1066 };
1067 let sol = fast_integrate_with_segments(&inputs, vec![], params);
1068 let n = sol.y[0].len();
1069 sol.y[1][n - 1]
1070 }
1071 let e = final_y(true, FRAC_PI_2);
1073 let w = final_y(true, 3.0 * FRAC_PI_2);
1074 assert!(
1075 e > w && (e - w) > 1e-5,
1076 "Coriolis-only (no advanced effects) must still be directional: E={e} W={w}"
1077 );
1078 let e2 = final_y(false, FRAC_PI_2);
1080 let w2 = final_y(false, 3.0 * FRAC_PI_2);
1081 assert!(
1082 (e2 - w2).abs() < 1e-9,
1083 "with enable_coriolis=false, east/west must be identical: E={e2} W={w2}"
1084 );
1085 }
1086
1087 #[test]
1088 fn fast_path_rejects_degenerate_atmosphere() {
1089 let mut inputs = BallisticInputs::default();
1090 inputs.muzzle_velocity = 800.0;
1091 inputs.bc_value = 0.5;
1092 inputs.bc_type = DragModel::G7;
1093 let v = 800.0_f64;
1094 let e = 0.02_f64;
1095 let mk = |atmo: (f64, f64, f64, f64)| FastIntegrationParams {
1096 horiz: 500.0,
1097 vert: 0.0,
1098 initial_state: [0.0, 0.0, 0.0, v * e.cos(), v * e.sin(), 0.0],
1099 t_span: (0.0, 5.0),
1100 atmo_params: atmo,
1101 };
1102 let zero_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 0.0, 50.0)));
1104 assert!(
1105 !zero_p.success,
1106 "pressure=0 atmosphere must yield success=false"
1107 );
1108 let nan_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, f64::NAN, 50.0)));
1110 assert!(!nan_p.success, "NaN pressure must yield success=false");
1111 let good = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 1013.25, 50.0)));
1113 assert!(good.success, "realistic atmosphere must yield success=true");
1114 let direct = fast_integrate_with_segments(&inputs, vec![], mk((1.225, 340.0, 0.0, 0.0)));
1117 assert!(
1118 direct.success,
1119 "direct-atmosphere mode (pressure=0 sentinel) must yield success=true"
1120 );
1121 }
1122
1123 #[test]
1124 fn fast_path_carries_real_bullet_geometry() {
1125 let run = |diameter: f64, twist: f64| {
1133 let mut inputs = BallisticInputs::default();
1134 inputs.muzzle_velocity = 800.0;
1135 inputs.bc_value = 0.5;
1136 inputs.bc_type = DragModel::G7;
1137 inputs.bullet_diameter = diameter;
1138 inputs.bullet_length = 0.0318;
1139 inputs.twist_rate = twist;
1140 inputs.enable_advanced_effects = true;
1141 inputs.enable_magnus = true;
1142 let v = 800.0_f64;
1143 let elev = 0.02_f64;
1144 let params = FastIntegrationParams {
1145 horiz: 1000.0,
1146 vert: 0.0,
1147 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
1148 t_span: (0.0, 5.0),
1149 atmo_params: (0.0, 15.0, 1013.25, 50.0),
1150 };
1151 fast_integrate_with_segments(&inputs, vec![], params)
1152 };
1153 assert!(run(0.00569, 7.0).success, ".224 geometry must solve");
1156 assert!(run(0.00858, 10.0).success, ".338 geometry must solve");
1157 }
1158}