1use crate::{
7 atmosphere::{calculate_air_density_cimp, get_local_atmosphere_humid, AtmoSock},
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 pub atmo_sock: Option<AtmoSock>,
155}
156
157pub fn aerodynamic_jump_launch_offset_rad(
165 inputs: &BallisticInputs,
166 atmo_params: (f64, f64, f64, f64),
167) -> f64 {
168 if !inputs.enable_aerodynamic_jump {
169 return 0.0;
170 }
171 let diameter = inputs.bullet_diameter;
172 if !(inputs.twist_rate.is_finite() && inputs.twist_rate != 0.0)
173 || !(diameter.is_finite() && diameter > 0.0)
174 || !(inputs.bullet_length.is_finite() && inputs.bullet_length > 0.0)
175 || !inputs.muzzle_velocity.is_finite()
176 {
177 return 0.0;
178 }
179 let sg = crate::stability::compute_stability_coefficient(inputs, atmo_params);
180 if !(sg.is_finite() && sg > 0.0) {
181 return 0.0;
182 }
183 let length_cal = inputs.bullet_length / diameter;
184 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
185 let crosswind_from_right_mph = inputs.wind_speed * inputs.wind_angle.sin() * MS_TO_MPH;
186 let vertical_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
187 sg,
188 length_cal,
189 crosswind_from_right_mph,
190 inputs.is_twist_right,
191 );
192 if !vertical_moa.is_finite() {
193 return 0.0;
194 }
195 const MOA_PER_RAD: f64 = 3437.7467707849;
196 vertical_moa / MOA_PER_RAD
197}
198
199fn rotate_launch_velocity(state: &mut [f64; 6], theta_rad: f64) {
202 let (vx, vy, vz) = (state[3], state[4], state[5]);
203 let speed = (vx * vx + vy * vy + vz * vz).sqrt();
204 if speed <= 0.0 {
205 return;
206 }
207 let h = (vx * vx + vz * vz).sqrt(); let new_elev = vy.atan2(h) + theta_rad;
209 state[4] = speed * new_elev.sin();
210 let new_h = speed * new_elev.cos();
211 let scale = if h > 1e-12 { new_h / h } else { 0.0 };
212 state[3] = vx * scale;
213 state[5] = vz * scale;
214}
215
216pub fn fast_integrate(
218 inputs: &BallisticInputs,
219 wind_sock: &WindSock,
220 params: FastIntegrationParams,
221) -> FastSolution {
222 if !atmo_is_physical(params.atmo_params) {
224 return FastSolution::degenerate(¶ms.initial_state);
225 }
226 let mut effective_inputs = inputs.clone();
227 if params.atmo_params.2 > 0.0 {
228 effective_inputs.altitude = params.atmo_params.0;
229 effective_inputs.temperature = params.atmo_params.1;
230 effective_inputs.pressure = params.atmo_params.2;
231 effective_inputs.humidity = params.atmo_params.3;
232 }
233 let inputs = &effective_inputs;
234 let _mass_kg = inputs.bullet_mass; let bc = inputs.bc_value;
237 let drag_model = &inputs.bc_type;
238
239 let has_bc_segments =
241 inputs.bc_segments.is_some() && !inputs.bc_segments.as_ref().unwrap().is_empty();
242 let has_bc_segments_data =
243 inputs.bc_segments_data.is_some() && !inputs.bc_segments_data.as_ref().unwrap().is_empty();
244
245 let dt = if params.horiz > 200.0 {
247 0.001
248 } else if params.horiz > 100.0 {
249 0.0005
250 } else {
251 0.0001
252 };
253
254 let mut initial_state = params.initial_state;
257 let aj_offset = aerodynamic_jump_launch_offset_rad(inputs, params.atmo_params);
258 if aj_offset != 0.0 {
259 rotate_launch_velocity(&mut initial_state, aj_offset);
260 }
261 let vx = initial_state[3]; let n_steps = ((params.t_span.1 / dt) as usize) + 1;
275 let est_steps = if vx > 1e-6 && params.horiz > 0.0 {
276 (((4.0 * params.horiz / vx) / dt) as usize) + 1
277 } else {
278 n_steps
279 };
280 let cap = est_steps.min(n_steps);
281 let mut times = Vec::with_capacity(cap);
282 let mut states = Vec::with_capacity(cap);
283
284 times.push(0.0);
286 states.push(initial_state);
287
288 let base_density = if params.atmo_params.3 > 0.0 {
298 params.atmo_params.3 * 1.225
299 } else {
300 1.225 };
302
303 let atmo_sock = params.atmo_sock.as_ref();
305
306 let drag_model_str: &str = match drag_model {
314 DragModel::G1 => "G1",
315 DragModel::G2 => "G2",
316 DragModel::G5 => "G5",
317 DragModel::G6 => "G6",
318 DragModel::G7 => "G7",
319 DragModel::G8 => "G8",
320 DragModel::GI => "GI",
321 DragModel::GS => "GS",
322 };
323
324 let caliber_in = if inputs.caliber_inches > 0.0 {
326 inputs.caliber_inches
327 } else {
328 inputs.bullet_diameter / 0.0254
329 };
330 let weight_gr = if inputs.weight_grains > 0.0 {
331 inputs.weight_grains
332 } else {
333 inputs.bullet_mass / 0.00006479891
334 };
335
336 let projectile_shape = crate::transonic_drag::resolve_projectile_shape(
339 inputs.bullet_model.as_deref(),
340 caliber_in,
341 weight_gr,
342 drag_model_str,
343 );
344
345 let omega_vector = if inputs.enable_coriolis && inputs.latitude.is_some() {
351 let omega_earth = 7.2921159e-5_f64; let lat = inputs.latitude.unwrap().to_radians();
353 let az = inputs.shot_azimuth; Some(Vector3::new(
355 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), ))
359 } else {
360 None
361 };
362
363 let mut hit_target = false;
365 let mut hit_ground = false;
366 let mut max_ord_time = None;
367 let mut max_ord_y = 0.0;
368 let ground_threshold = inputs.ground_threshold;
369
370 for i in 0..n_steps - 1 {
372 let t = i as f64 * dt;
373 let state = states[i];
374
375 let pos = Vector3::new(state[0], state[1], state[2]);
376 let _vel = Vector3::new(state[3], state[4], state[5]);
377
378 if pos.x >= params.horiz {
380 hit_target = true;
381 break;
382 }
383
384 if pos.y <= ground_threshold {
385 hit_ground = true;
386 break;
387 }
388
389 if pos.y > max_ord_y {
391 max_ord_y = pos.y;
392 max_ord_time = Some(t);
393 }
394
395 let k1 = compute_derivatives(
397 &state,
398 inputs,
399 wind_sock,
400 base_density,
401 drag_model,
402 projectile_shape,
403 bc,
404 has_bc_segments,
405 has_bc_segments_data,
406 omega_vector,
407 atmo_sock,
408 );
409
410 let mut state2 = state;
411 for j in 0..6 {
412 state2[j] = state[j] + 0.5 * dt * k1[j];
413 }
414 let k2 = compute_derivatives(
415 &state2,
416 inputs,
417 wind_sock,
418 base_density,
419 drag_model,
420 projectile_shape,
421 bc,
422 has_bc_segments,
423 has_bc_segments_data,
424 omega_vector,
425 atmo_sock,
426 );
427
428 let mut state3 = state;
429 for j in 0..6 {
430 state3[j] = state[j] + 0.5 * dt * k2[j];
431 }
432 let k3 = compute_derivatives(
433 &state3,
434 inputs,
435 wind_sock,
436 base_density,
437 drag_model,
438 projectile_shape,
439 bc,
440 has_bc_segments,
441 has_bc_segments_data,
442 omega_vector,
443 atmo_sock,
444 );
445
446 let mut state4 = state;
447 for j in 0..6 {
448 state4[j] = state[j] + dt * k3[j];
449 }
450 let k4 = compute_derivatives(
451 &state4,
452 inputs,
453 wind_sock,
454 base_density,
455 drag_model,
456 projectile_shape,
457 bc,
458 has_bc_segments,
459 has_bc_segments_data,
460 omega_vector,
461 atmo_sock,
462 );
463
464 let mut new_state = state;
466 for j in 0..6 {
467 new_state[j] = state[j] + dt * (k1[j] + 2.0 * k2[j] + 2.0 * k3[j] + k4[j]) / 6.0;
468 }
469
470 times.push(t + dt);
471 states.push(new_state);
472 }
473
474 let t_events = [
476 if hit_target {
477 vec![*times.last().unwrap()]
478 } else {
479 vec![]
480 },
481 if let Some(t) = max_ord_time {
482 vec![t]
483 } else {
484 vec![]
485 },
486 if hit_ground {
487 vec![*times.last().unwrap()]
488 } else {
489 vec![]
490 },
491 ];
492
493 if inputs.use_enhanced_spin_drift {
502 let (sd_temp_c, sd_press_hpa) = if params.atmo_params.2 > 0.0 {
506 (params.atmo_params.1, params.atmo_params.2)
507 } else {
508 (15.0, 1013.25)
509 };
510 let sg = crate::spin_drift::effective_sg_from_inputs(inputs, sd_temp_c, sd_press_hpa);
511 for (t, state) in times.iter().zip(states.iter_mut()) {
512 if *t > 0.0 {
513 state[2] += crate::spin_drift::litz_drift_meters(sg, *t, inputs.is_twist_right);
514 }
515 }
516 }
517
518 FastSolution::from_trajectory_data(times, states, t_events)
519}
520
521#[allow(clippy::too_many_arguments)]
523fn compute_derivatives(
524 state: &[f64; 6],
525 inputs: &BallisticInputs,
526 wind_sock: &WindSock,
527 base_density: f64,
528 drag_model: &DragModel,
529 projectile_shape: crate::transonic_drag::ProjectileShape,
530 bc: f64,
531 has_bc_segments: bool,
532 has_bc_segments_data: bool,
533 omega: Option<Vector3<f64>>,
534 atmo_sock: Option<&AtmoSock>,
536) -> [f64; 6] {
537 let pos = Vector3::new(state[0], state[1], state[2]);
538 let vel = Vector3::new(state[3], state[4], state[5]);
539
540 let wind_vector = wind_sock.vector_for_range_stateless(pos.x);
542
543 let vel_adjusted = vel - wind_vector;
545 let v_mag = vel_adjusted.norm();
546
547 let theta = inputs.shooting_angle;
550 let accel_gravity = Vector3::new(
551 -G_ACCEL_MPS2 * theta.sin(),
552 -G_ACCEL_MPS2 * theta.cos(),
553 0.0,
554 );
555
556 let mut accel = if v_mag < 1e-6 {
558 accel_gravity
559 } else {
560 let v_fps = v_mag * MPS_TO_FPS;
562
563 let altitude = inputs.altitude + pos.y;
583 let (base_temp_c, base_press_hpa, base_ratio) = match atmo_sock {
584 Some(sock) => {
585 let (zt, zp, zh) = sock.atmo_for_range(pos.x);
586 (zt, zp, calculate_air_density_cimp(zt, zp, zh) / 1.225)
587 }
588 None => (inputs.temperature, inputs.pressure, base_density / 1.225),
589 };
590 let (local_density, speed_of_sound) = get_local_atmosphere_humid(
591 altitude,
592 inputs.altitude, base_temp_c,
594 base_press_hpa,
595 base_ratio,
596 0.0, );
598 let mach = v_mag / speed_of_sound;
599
600 let bc_current = if has_bc_segments_data && inputs.bc_segments_data.is_some() {
602 get_bc_from_velocity_segments(v_fps, inputs.bc_segments_data.as_ref().unwrap())
603 } else if has_bc_segments && inputs.bc_segments.is_some() {
604 crate::derivatives::interpolated_bc(
605 mach,
606 inputs.bc_segments.as_ref().unwrap(),
607 Some(inputs),
608 )
609 } else {
610 bc
611 };
612 let bc_current = bc_current.max(1e-6);
615
616 let (drag_factor, retard_denom) = if let Some(ref table) = inputs.custom_drag_table {
627 (
628 table.interpolate(mach),
629 inputs.custom_drag_denominator(bc_current),
630 )
631 } else {
632 let base_cd = get_drag_coefficient(mach, drag_model);
633 let cd =
634 crate::transonic_drag::transonic_correction(mach, base_cd, projectile_shape, false);
635 let cd = crate::form_factor::apply_form_factor_to_drag(
639 cd,
640 inputs.bullet_model.as_deref(),
641 &inputs.bc_type,
642 inputs.use_form_factor,
643 );
644 (cd, bc_current)
645 };
646
647 let cd_to_retard = crate::constants::CD_TO_RETARD;
649 let standard_factor = drag_factor * cd_to_retard;
650 let density_scale = local_density / 1.225;
653
654 let a_drag_ft_s2 = (v_fps * v_fps) * standard_factor * density_scale / retard_denom;
656
657 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let accel_drag = -a_drag_m_s2 * (vel_adjusted / v_mag);
660
661 accel_drag + accel_gravity
663 };
664
665 if let Some(omega) = omega {
668 accel += -2.0 * omega.cross(&vel);
669 }
670
671 [vel.x, vel.y, vel.z, accel.x, accel.y, accel.z]
673}
674
675fn get_bc_from_velocity_segments(velocity_fps: f64, segments: &[BCSegmentData]) -> f64 {
677 for segment in segments {
678 if velocity_fps >= segment.velocity_min && velocity_fps <= segment.velocity_max {
679 return segment.bc_value;
680 }
681 }
682
683 if let Some(first) = segments.first() {
685 if velocity_fps < first.velocity_min {
686 return first.bc_value;
687 }
688 }
689
690 if let Some(last) = segments.last() {
691 if velocity_fps > last.velocity_max {
692 return last.bc_value;
693 }
694 }
695
696 0.5
698}
699
700pub fn fast_integrate_with_segments(
703 inputs: &BallisticInputs,
704 wind_segments: Vec<crate::wind::WindSegment>,
705 params: FastIntegrationParams,
706) -> FastSolution {
707 use crate::trajectory_integration::{integrate_trajectory, TrajectoryParams};
709
710 if !atmo_is_physical(params.atmo_params) {
712 return FastSolution::degenerate(¶ms.initial_state);
713 }
714
715 let mass_kg = inputs.bullet_mass; let bc = inputs.bc_value;
718 let drag_model = inputs.bc_type;
719
720 let omega_vector = if inputs.enable_coriolis && inputs.latitude.is_some() {
724 let omega_earth = 7.2921159e-5; let lat_rad = inputs.latitude.unwrap_or(0.0).to_radians();
730 let azimuth = inputs.shot_azimuth; Some(Vector3::new(
732 omega_earth * lat_rad.cos() * azimuth.cos(), omega_earth * lat_rad.sin(), -omega_earth * lat_rad.cos() * azimuth.sin(), ))
736 } else {
737 None
738 };
739
740 let traj_params = TrajectoryParams {
742 mass_kg,
743 bc,
744 drag_model,
745 wind_segments,
746 atmos_params: params.atmo_params,
747 omega_vector,
748 enable_spin_drift: inputs.enable_advanced_effects,
749 enable_magnus: inputs.enable_magnus,
750 enable_coriolis: inputs.enable_coriolis,
751 target_distance_m: params.horiz,
752 enable_wind_shear: inputs.enable_wind_shear,
753 wind_shear_model: inputs.wind_shear_model.clone(),
754 shooter_altitude_m: inputs.altitude,
755 is_twist_right: inputs.is_twist_right,
756 shooting_angle: inputs.shooting_angle,
757 bullet_diameter: inputs.bullet_diameter,
759 bullet_length: inputs.bullet_length,
760 twist_rate: inputs.twist_rate,
761 custom_drag_table: inputs.custom_drag_table.clone(),
762 bc_segments: inputs.bc_segments.clone(),
763 use_bc_segments: inputs.use_bc_segments,
764 ground_threshold: -1000.0,
768 atmo_sock: params.atmo_sock,
770 };
771
772 let trajectory = integrate_trajectory(
774 params.initial_state,
775 params.t_span,
776 traj_params,
777 "RK45", 1e-6, 0.01, );
781
782 let n_points = trajectory.len();
784 let mut times = Vec::with_capacity(n_points);
785 let mut states = Vec::with_capacity(n_points);
786
787 let mut target_hit_time: Option<f64> = None;
788 let mut ground_hit_time: Option<f64> = None;
789 let mut max_ord_time = None;
790 let mut max_ord_y = 0.0;
791
792 for (t, state_vec) in trajectory {
793 let state = [
795 state_vec[0],
796 state_vec[1],
797 state_vec[2],
798 state_vec[3],
799 state_vec[4],
800 state_vec[5],
801 ];
802
803 if target_hit_time.is_none() && state[0] >= params.horiz {
808 target_hit_time = Some(t);
809 }
810
811 if ground_hit_time.is_none() && state[1] <= inputs.ground_threshold {
813 ground_hit_time = Some(t);
814 }
815
816 if state[1] > max_ord_y {
818 max_ord_y = state[1];
819 max_ord_time = Some(t);
820 }
821
822 times.push(t);
823 states.push(state);
824 }
825
826 let t_events = [
828 if let Some(t) = target_hit_time {
829 vec![t]
830 } else {
831 vec![]
832 },
833 if let Some(t) = max_ord_time {
834 vec![t]
835 } else {
836 vec![]
837 },
838 if let Some(t) = ground_hit_time {
839 vec![t]
840 } else {
841 vec![]
842 },
843 ];
844
845 if inputs.use_enhanced_spin_drift {
850 let (sd_temp_c, sd_press_hpa) = if params.atmo_params.2 > 0.0 {
854 (params.atmo_params.1, params.atmo_params.2)
855 } else {
856 (15.0, 1013.25)
857 };
858 let sg = crate::spin_drift::effective_sg_from_inputs(inputs, sd_temp_c, sd_press_hpa);
859 for (t, state) in times.iter().zip(states.iter_mut()) {
860 if *t > 0.0 {
861 state[2] += crate::spin_drift::litz_drift_meters(sg, *t, inputs.is_twist_right);
862 }
863 }
864 }
865
866 FastSolution::from_trajectory_data(times, states, t_events)
867}
868
869#[cfg(test)]
870mod tests {
871 use super::*;
872
873 #[test]
874 fn test_fast_solution_interpolation() {
875 let times = vec![0.0, 1.0, 2.0];
876 let states = vec![
877 [0.0, 0.0, 0.0, 100.0, 50.0, 0.0],
878 [100.0, 45.0, 0.0, 99.0, 40.0, 0.0],
879 [198.0, 80.0, 0.0, 98.0, 30.0, 0.0],
880 ];
881
882 let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
883
884 let result = solution.sol(&[1.5]);
886
887 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); }
891
892 #[test]
893 fn test_bc_from_velocity_segments() {
894 let segments = vec![
895 BCSegmentData {
896 velocity_min: 0.0,
897 velocity_max: 1000.0,
898 bc_value: 0.5,
899 },
900 BCSegmentData {
901 velocity_min: 1000.0,
902 velocity_max: 2000.0,
903 bc_value: 0.52,
904 },
905 BCSegmentData {
906 velocity_min: 2000.0,
907 velocity_max: 3000.0,
908 bc_value: 0.55,
909 },
910 ];
911
912 assert_eq!(get_bc_from_velocity_segments(500.0, &segments), 0.5);
913 assert_eq!(get_bc_from_velocity_segments(1500.0, &segments), 0.52);
914 assert_eq!(get_bc_from_velocity_segments(2500.0, &segments), 0.55);
915
916 assert_eq!(get_bc_from_velocity_segments(-100.0, &segments), 0.5); assert_eq!(get_bc_from_velocity_segments(3500.0, &segments), 0.55); }
920
921 #[test]
922 fn test_fast_solution_interpolation_edge_cases() {
923 let times = vec![0.0, 1.0, 2.0, 3.0];
924 let states = vec![
925 [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
926 [800.0, 40.0, 100.0, 750.0, 30.0, 0.0],
927 [1550.0, 60.0, 200.0, 700.0, 10.0, 0.0],
928 [2250.0, 50.0, 300.0, 650.0, -10.0, 0.0],
929 ];
930
931 let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
932
933 let result_before = solution.sol(&[-0.5]);
935 assert!((result_before[0][0] - 0.0).abs() < 1e-10); let result_after = solution.sol(&[5.0]);
939 assert!((result_after[0][0] - 2250.0).abs() < 1e-10); let result_exact = solution.sol(&[1.0]);
943 assert!((result_exact[0][0] - 800.0).abs() < 1e-10);
944
945 let result_multi = solution.sol(&[0.5, 1.5, 2.5]);
947 assert_eq!(result_multi[0].len(), 3);
948 }
949
950 #[test]
951 fn test_fast_solution_from_trajectory_data() {
952 let times = vec![0.0, 0.5, 1.0];
953 let states = vec![
954 [0.0, 1.0, 2.0, 3.0, 4.0, 5.0],
955 [10.0, 11.0, 12.0, 13.0, 14.0, 15.0],
956 [20.0, 21.0, 22.0, 23.0, 24.0, 25.0],
957 ];
958 let t_events = [vec![1.0], vec![0.5], vec![]];
959
960 let solution = FastSolution::from_trajectory_data(times.clone(), states, t_events);
961
962 assert_eq!(solution.t, times);
964 assert_eq!(solution.y.len(), 6); assert_eq!(solution.y[0].len(), 3); assert!(solution.success);
967
968 assert_eq!(solution.y[0][0], 0.0); assert_eq!(solution.y[1][0], 1.0); assert_eq!(solution.y[0][2], 20.0); }
973
974 #[test]
975 fn test_bc_segments_boundary_conditions() {
976 let single_segment = vec![BCSegmentData {
978 velocity_min: 1000.0,
979 velocity_max: 2000.0,
980 bc_value: 0.5,
981 }];
982
983 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![
990 BCSegmentData {
991 velocity_min: 0.0,
992 velocity_max: 999.0, bc_value: 0.45,
994 },
995 BCSegmentData {
996 velocity_min: 1000.0,
997 velocity_max: 2000.0,
998 bc_value: 0.50,
999 },
1000 ];
1001
1002 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); }
1006
1007 #[test]
1008 fn test_bc_segments_empty_fallback() {
1009 let empty_segments: Vec<BCSegmentData> = vec![];
1010
1011 let result = get_bc_from_velocity_segments(1500.0, &empty_segments);
1013 assert_eq!(result, 0.5); }
1015
1016 #[test]
1017 fn test_fast_integration_params() {
1018 let params = FastIntegrationParams {
1020 horiz: 1000.0,
1021 vert: 0.0,
1022 initial_state: [0.0, 0.0, 0.0, 800.0, 50.0, 0.0], t_span: (0.0, 5.0),
1024 atmo_params: (0.0, 59.0, 29.92, 0.0),
1025 atmo_sock: None,
1026 };
1027
1028 assert_eq!(params.horiz, 1000.0);
1029 assert_eq!(params.t_span.0, 0.0);
1030 assert_eq!(params.t_span.1, 5.0);
1031 assert_eq!(params.initial_state[3], 800.0); }
1033
1034 #[test]
1035 fn test_fast_solution_event_arrays() {
1036 let times = vec![0.0, 1.0, 2.0];
1037 let states = vec![
1038 [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
1039 [800.0, 40.0, 500.0, 750.0, 30.0, 0.0],
1040 [1500.0, 20.0, 1000.0, 700.0, 10.0, 0.0],
1041 ];
1042
1043 let t_events = [
1045 vec![2.0], vec![0.5], vec![], ];
1049
1050 let solution = FastSolution::from_trajectory_data(times, states, t_events);
1051
1052 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()); }
1056
1057 #[test]
1058 fn fast_path_coriolis_uses_shot_direction() {
1059 use std::f64::consts::FRAC_PI_2;
1064 fn final_xy(shot_az: f64) -> (f64, f64) {
1066 let mut inputs = BallisticInputs::default();
1067 inputs.muzzle_velocity = 800.0;
1068 inputs.bc_value = 0.5;
1069 inputs.bc_type = DragModel::G7;
1070 inputs.enable_advanced_effects = true; inputs.enable_coriolis = true;
1072 inputs.latitude = Some(45.0);
1073 inputs.shot_azimuth = shot_az;
1074 let v = 800.0_f64;
1075 let elev = 0.02_f64;
1076 let params = FastIntegrationParams {
1077 horiz: 1000.0,
1078 vert: 0.0,
1079 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
1080 t_span: (0.0, 5.0),
1081 atmo_params: (0.0, 59.0, 29.92, 0.0),
1082 atmo_sock: None,
1083 };
1084 let sol = fast_integrate_with_segments(&inputs, vec![], params);
1085 let n = sol.y[0].len();
1086 (sol.y[0][n - 1], sol.y[1][n - 1])
1087 }
1088 let (ex, ey) = final_xy(FRAC_PI_2); let (wx, wy) = final_xy(3.0 * FRAC_PI_2); assert!(
1093 (ex - wx).abs() < 0.5,
1094 "east/west downrange should be ~equal (ex={ex:.4}, wx={wx:.4})"
1095 );
1096 assert!(
1099 ey > wy,
1100 "fast-path east ({ey:.6}) must be higher than west ({wy:.6}) (Eotvos)"
1101 );
1102 assert!(
1103 (ey - wy) > 1e-5,
1104 "fast-path E-W vertical separation ({:.8} m) should be non-zero (the pre-fix bug was exact equality)",
1105 ey - wy
1106 );
1107 }
1108
1109 #[test]
1110 fn fast_path_coriolis_independent_of_advanced_effects() {
1111 use std::f64::consts::FRAC_PI_2;
1114 fn final_y(coriolis: bool, shot_az: f64) -> f64 {
1115 let mut inputs = BallisticInputs::default();
1116 inputs.muzzle_velocity = 800.0;
1117 inputs.bc_value = 0.5;
1118 inputs.bc_type = DragModel::G7;
1119 inputs.enable_coriolis = coriolis;
1120 inputs.enable_advanced_effects = false; inputs.latitude = Some(45.0);
1122 inputs.shot_azimuth = shot_az;
1123 let v = 800.0_f64;
1124 let elev = 0.02_f64;
1125 let params = FastIntegrationParams {
1126 horiz: 1000.0,
1127 vert: 0.0,
1128 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
1129 t_span: (0.0, 5.0),
1130 atmo_params: (0.0, 59.0, 29.92, 0.0),
1131 atmo_sock: None,
1132 };
1133 let sol = fast_integrate_with_segments(&inputs, vec![], params);
1134 let n = sol.y[0].len();
1135 sol.y[1][n - 1]
1136 }
1137 let e = final_y(true, FRAC_PI_2);
1139 let w = final_y(true, 3.0 * FRAC_PI_2);
1140 assert!(
1141 e > w && (e - w) > 1e-5,
1142 "Coriolis-only (no advanced effects) must still be directional: E={e} W={w}"
1143 );
1144 let e2 = final_y(false, FRAC_PI_2);
1146 let w2 = final_y(false, 3.0 * FRAC_PI_2);
1147 assert!(
1148 (e2 - w2).abs() < 1e-9,
1149 "with enable_coriolis=false, east/west must be identical: E={e2} W={w2}"
1150 );
1151 }
1152
1153 #[test]
1154 fn fast_path_rejects_degenerate_atmosphere() {
1155 let mut inputs = BallisticInputs::default();
1156 inputs.muzzle_velocity = 800.0;
1157 inputs.bc_value = 0.5;
1158 inputs.bc_type = DragModel::G7;
1159 let v = 800.0_f64;
1160 let e = 0.02_f64;
1161 let mk = |atmo: (f64, f64, f64, f64)| FastIntegrationParams {
1162 horiz: 500.0,
1163 vert: 0.0,
1164 initial_state: [0.0, 0.0, 0.0, v * e.cos(), v * e.sin(), 0.0],
1165 t_span: (0.0, 5.0),
1166 atmo_params: atmo,
1167 atmo_sock: None,
1168 };
1169 let zero_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 0.0, 50.0)));
1171 assert!(
1172 !zero_p.success,
1173 "pressure=0 atmosphere must yield success=false"
1174 );
1175 let nan_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, f64::NAN, 50.0)));
1177 assert!(!nan_p.success, "NaN pressure must yield success=false");
1178 let good = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 1013.25, 50.0)));
1180 assert!(good.success, "realistic atmosphere must yield success=true");
1181 let direct = fast_integrate_with_segments(&inputs, vec![], mk((1.225, 340.0, 0.0, 0.0)));
1184 assert!(
1185 direct.success,
1186 "direct-atmosphere mode (pressure=0 sentinel) must yield success=true"
1187 );
1188 }
1189
1190 #[test]
1191 fn fast_path_carries_real_bullet_geometry() {
1192 let run = |diameter: f64, twist: f64| {
1200 let mut inputs = BallisticInputs::default();
1201 inputs.muzzle_velocity = 800.0;
1202 inputs.bc_value = 0.5;
1203 inputs.bc_type = DragModel::G7;
1204 inputs.bullet_diameter = diameter;
1205 inputs.bullet_length = 0.0318;
1206 inputs.twist_rate = twist;
1207 inputs.enable_advanced_effects = true;
1208 inputs.enable_magnus = true;
1209 let v = 800.0_f64;
1210 let elev = 0.02_f64;
1211 let params = FastIntegrationParams {
1212 horiz: 1000.0,
1213 vert: 0.0,
1214 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
1215 t_span: (0.0, 5.0),
1216 atmo_params: (0.0, 15.0, 1013.25, 50.0),
1217 atmo_sock: None,
1218 };
1219 fast_integrate_with_segments(&inputs, vec![], params)
1220 };
1221 assert!(run(0.00569, 7.0).success, ".224 geometry must solve");
1224 assert!(run(0.00858, 10.0).success, ".338 geometry must solve");
1225 }
1226}