1use crate::{
7 atmosphere::{calculate_air_density_cimp, get_local_atmosphere_humid, AtmoSock},
8 bc_estimation::velocity_segment_bc,
9 constants::{G_ACCEL_MPS2, MPS_TO_FPS, STANDARD_AIR_DENSITY},
10 drag::get_drag_coefficient,
11 wind::WindSock,
12 DragModel, InternalBallisticInputs as BallisticInputs,
13};
14use nalgebra::Vector3;
15
16#[derive(Debug, Clone)]
18pub struct FastSolution {
19 pub t: Vec<f64>,
21 pub y: Vec<Vec<f64>>,
23 pub t_events: [Vec<f64>; 3],
25 pub success: bool,
27}
28
29impl FastSolution {
30 pub fn sol(&self, t_query: &[f64]) -> Vec<Vec<f64>> {
32 let mut result = vec![vec![0.0; t_query.len()]; 6];
33
34 for (i, &tq) in t_query.iter().enumerate() {
35 let idx = match self
38 .t
39 .binary_search_by(|&t| t.partial_cmp(&tq).unwrap_or(std::cmp::Ordering::Greater))
40 {
41 Ok(idx) => idx,
42 Err(idx) => idx,
43 };
44
45 if idx == 0 {
46 for j in 0..6 {
48 result[j][i] = self.y[j][0];
49 }
50 } else if idx >= self.t.len() {
51 for j in 0..6 {
53 result[j][i] = self.y[j][self.t.len() - 1];
54 }
55 } else {
56 let t0 = self.t[idx - 1];
58 let t1 = self.t[idx];
59 let span = t1 - t0;
60
61 for j in 0..6 {
62 let y0 = self.y[j][idx - 1];
63 let y1 = self.y[j][idx];
64 result[j][i] = if span.abs() < f64::EPSILON {
65 y1
66 } else {
67 let frac = (tq - t0) / span;
68 y0 + frac * (y1 - y0)
69 };
70 }
71 }
72 }
73
74 result
75 }
76
77 pub fn from_trajectory_data(
79 times: Vec<f64>,
80 states: Vec<[f64; 6]>,
81 t_events: [Vec<f64>; 3],
82 ) -> Self {
83 let n_points = times.len();
84 let mut y = vec![vec![0.0; n_points]; 6];
85
86 for (i, state) in states.iter().enumerate() {
87 for j in 0..6 {
88 y[j][i] = state[j];
89 }
90 }
91
92 FastSolution {
93 t: times,
94 y,
95 t_events,
96 success: true,
97 }
98 }
99
100 fn degenerate(initial_state: &[f64; 6]) -> Self {
104 let mut y = vec![Vec::new(); 6];
105 for (j, slot) in y.iter_mut().enumerate() {
106 slot.push(initial_state[j]);
107 }
108 FastSolution {
109 t: vec![0.0],
110 y,
111 t_events: [Vec::new(), Vec::new(), Vec::new()],
112 success: false,
113 }
114 }
115}
116
117fn direct_atmosphere_values(
118 atmo_params: (f64, f64, f64, f64),
119) -> Option<(f64, f64)> {
120 let (a, b, c, d) = atmo_params;
121 (a.is_finite()
122 && b.is_finite()
123 && c == 0.0
124 && d == 0.0
125 && a > 0.0
126 && a < 2.0
127 && b > 200.0)
128 .then_some((a, b))
129}
130
131fn stability_atmosphere_params(atmo_params: (f64, f64, f64, f64)) -> (f64, f64, f64, f64) {
132 if let Some((air_density, _)) = direct_atmosphere_values(atmo_params) {
133 (0.0, 15.0, 1013.25 * air_density / STANDARD_AIR_DENSITY, 1.0)
137 } else {
138 atmo_params
139 }
140}
141
142const MAX_STANDARD_DENSITY_RATIO: f64 = 2.0;
143
144fn atmo_is_physical(atmo_params: (f64, f64, f64, f64)) -> bool {
157 let (a, b, c, d) = atmo_params;
158 if !(a.is_finite() && b.is_finite() && c.is_finite() && d.is_finite()) {
159 return false;
160 }
161 direct_atmosphere_values(atmo_params).is_some() || (c > 0.0 && d < MAX_STANDARD_DENSITY_RATIO)
166}
167
168#[derive(Debug, Clone, Copy)]
169enum FastAtmosphere {
170 Direct {
171 air_density: f64,
172 speed_of_sound: f64,
173 },
174 Standard {
175 base_density: f64,
176 },
177}
178
179pub struct FastIntegrationParams {
181 pub horiz: f64,
182 pub vert: f64,
183 pub initial_state: [f64; 6],
184 pub t_span: (f64, f64),
185 pub atmo_params: (f64, f64, f64, f64),
191 pub atmo_sock: Option<AtmoSock>,
196}
197
198pub fn aerodynamic_jump_launch_offset_rad(
206 inputs: &BallisticInputs,
207 atmo_params: (f64, f64, f64, f64),
208) -> f64 {
209 if !inputs.enable_aerodynamic_jump {
210 return 0.0;
211 }
212 let diameter = inputs.bullet_diameter;
213 if !(inputs.twist_rate.is_finite() && inputs.twist_rate != 0.0)
214 || !(diameter.is_finite() && diameter > 0.0)
215 || !(inputs.bullet_length.is_finite() && inputs.bullet_length > 0.0)
216 || !inputs.muzzle_velocity.is_finite()
217 {
218 return 0.0;
219 }
220 let stability_atmo = stability_atmosphere_params(atmo_params);
221 let sg = crate::stability::compute_stability_coefficient(inputs, stability_atmo);
222 if !(sg.is_finite() && sg > 0.0) {
223 return 0.0;
224 }
225 let length_cal = inputs.bullet_length / diameter;
226 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
227 let crosswind_from_right_mph = inputs.wind_speed * inputs.wind_angle.sin() * MS_TO_MPH;
228 let vertical_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
229 sg,
230 length_cal,
231 crosswind_from_right_mph,
232 inputs.is_twist_right,
233 );
234 if !vertical_moa.is_finite() {
235 return 0.0;
236 }
237 const MOA_PER_RAD: f64 = 3437.7467707849;
238 vertical_moa / MOA_PER_RAD
239}
240
241fn rotate_launch_velocity(state: &mut [f64; 6], theta_rad: f64) {
244 let (vx, vy, vz) = (state[3], state[4], state[5]);
245 let speed = (vx * vx + vy * vy + vz * vz).sqrt();
246 if speed <= 0.0 {
247 return;
248 }
249 let h = (vx * vx + vz * vz).sqrt(); let new_elev = vy.atan2(h) + theta_rad;
251 state[4] = speed * new_elev.sin();
252 let new_h = speed * new_elev.cos();
253 let scale = if h > 1e-12 { new_h / h } else { 0.0 };
254 state[3] = vx * scale;
255 state[5] = vz * scale;
256}
257
258fn launch_state_with_aerodynamic_jump(
259 inputs: &BallisticInputs,
260 atmo_params: (f64, f64, f64, f64),
261 mut initial_state: [f64; 6],
262) -> [f64; 6] {
263 let offset = aerodynamic_jump_launch_offset_rad(inputs, atmo_params);
264 if offset != 0.0 {
265 rotate_launch_velocity(&mut initial_state, offset);
266 }
267 initial_state
268}
269
270pub fn fast_integrate(
272 inputs: &BallisticInputs,
273 wind_sock: &WindSock,
274 params: FastIntegrationParams,
275) -> FastSolution {
276 if !atmo_is_physical(params.atmo_params) {
278 return FastSolution::degenerate(¶ms.initial_state);
279 }
280 let mut effective_inputs = inputs.clone();
281 if params.atmo_params.2 > 0.0 {
282 effective_inputs.altitude = params.atmo_params.0;
283 effective_inputs.temperature = params.atmo_params.1;
284 effective_inputs.pressure = params.atmo_params.2;
285 effective_inputs.humidity = params.atmo_params.3;
286 }
287 let inputs = &effective_inputs;
288 let _mass_kg = inputs.bullet_mass; let bc = inputs.bc_value;
291 let drag_model = &inputs.bc_type;
292
293 let has_bc_segments =
295 inputs.bc_segments.is_some() && !inputs.bc_segments.as_ref().unwrap().is_empty();
296 let has_bc_segments_data =
297 inputs.bc_segments_data.is_some() && !inputs.bc_segments_data.as_ref().unwrap().is_empty();
298
299 let dt = if params.horiz > 200.0 {
301 0.001
302 } else if params.horiz > 100.0 {
303 0.0005
304 } else {
305 0.0001
306 };
307
308 let initial_state =
311 launch_state_with_aerodynamic_jump(inputs, params.atmo_params, params.initial_state);
312 let vx = initial_state[3]; let n_steps = ((params.t_span.1 / dt) as usize) + 1;
326 let est_steps = if vx > 1e-6 && params.horiz > 0.0 {
327 (((4.0 * params.horiz / vx) / dt) as usize) + 1
328 } else {
329 n_steps
330 };
331 let cap = est_steps.min(n_steps);
332 let mut times = Vec::with_capacity(cap);
333 let mut states = Vec::with_capacity(cap);
334
335 times.push(0.0);
337 states.push(initial_state);
338
339 let atmosphere = if let Some((air_density, speed_of_sound)) =
344 direct_atmosphere_values(params.atmo_params)
345 {
346 FastAtmosphere::Direct {
347 air_density,
348 speed_of_sound,
349 }
350 } else {
351 let base_density = if params.atmo_params.3 > 0.0 {
352 params.atmo_params.3 * 1.225
353 } else {
354 1.225
355 };
356 FastAtmosphere::Standard { base_density }
357 };
358
359 let atmo_sock = params.atmo_sock.as_ref();
361
362 let drag_model_str: &str = match drag_model {
370 DragModel::G1 => "G1",
371 DragModel::G2 => "G2",
372 DragModel::G5 => "G5",
373 DragModel::G6 => "G6",
374 DragModel::G7 => "G7",
375 DragModel::G8 => "G8",
376 DragModel::GI => "GI",
377 DragModel::GS => "GS",
378 };
379
380 let caliber_in = if inputs.caliber_inches > 0.0 {
382 inputs.caliber_inches
383 } else {
384 inputs.bullet_diameter / 0.0254
385 };
386 let weight_gr = if inputs.weight_grains > 0.0 {
387 inputs.weight_grains
388 } else {
389 inputs.bullet_mass / 0.00006479891
390 };
391
392 let projectile_shape = crate::transonic_drag::resolve_projectile_shape(
395 inputs.bullet_model.as_deref(),
396 caliber_in,
397 weight_gr,
398 drag_model_str,
399 );
400
401 let omega_vector = if inputs.enable_coriolis && inputs.latitude.is_some() {
407 let omega_earth = 7.2921159e-5_f64; let lat = inputs.latitude.unwrap().to_radians();
409 let az = inputs.shot_azimuth; Some(Vector3::new(
411 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), ))
415 } else {
416 None
417 };
418 let wind_shear_model = if inputs.enable_wind_shear {
420 let model = crate::wind_shear::boundary_layer_model_from_name(&inputs.wind_shear_model);
421 (model != crate::wind_shear::WindShearModel::None).then_some(model)
422 } else {
423 None
424 };
425
426 let mut hit_target = false;
428 let mut hit_ground = false;
429 let mut max_ord_time = None;
430 let mut max_ord_y = 0.0;
431 let ground_threshold = inputs.ground_threshold;
432
433 for i in 0..n_steps - 1 {
435 let t = i as f64 * dt;
436 let state = states[i];
437
438 let pos = Vector3::new(state[0], state[1], state[2]);
439 let _vel = Vector3::new(state[3], state[4], state[5]);
440
441 if pos.x >= params.horiz {
443 hit_target = true;
444 break;
445 }
446
447 if pos.y <= ground_threshold {
448 hit_ground = true;
449 break;
450 }
451
452 if pos.y > max_ord_y {
454 max_ord_y = pos.y;
455 max_ord_time = Some(t);
456 }
457
458 let k1 = compute_derivatives(
460 &state,
461 inputs,
462 wind_sock,
463 atmosphere,
464 drag_model,
465 projectile_shape,
466 bc,
467 has_bc_segments,
468 has_bc_segments_data,
469 omega_vector,
470 wind_shear_model,
471 atmo_sock,
472 );
473
474 let mut state2 = state;
475 for j in 0..6 {
476 state2[j] = state[j] + 0.5 * dt * k1[j];
477 }
478 let k2 = compute_derivatives(
479 &state2,
480 inputs,
481 wind_sock,
482 atmosphere,
483 drag_model,
484 projectile_shape,
485 bc,
486 has_bc_segments,
487 has_bc_segments_data,
488 omega_vector,
489 wind_shear_model,
490 atmo_sock,
491 );
492
493 let mut state3 = state;
494 for j in 0..6 {
495 state3[j] = state[j] + 0.5 * dt * k2[j];
496 }
497 let k3 = compute_derivatives(
498 &state3,
499 inputs,
500 wind_sock,
501 atmosphere,
502 drag_model,
503 projectile_shape,
504 bc,
505 has_bc_segments,
506 has_bc_segments_data,
507 omega_vector,
508 wind_shear_model,
509 atmo_sock,
510 );
511
512 let mut state4 = state;
513 for j in 0..6 {
514 state4[j] = state[j] + dt * k3[j];
515 }
516 let k4 = compute_derivatives(
517 &state4,
518 inputs,
519 wind_sock,
520 atmosphere,
521 drag_model,
522 projectile_shape,
523 bc,
524 has_bc_segments,
525 has_bc_segments_data,
526 omega_vector,
527 wind_shear_model,
528 atmo_sock,
529 );
530
531 let mut new_state = state;
533 for j in 0..6 {
534 new_state[j] = state[j] + dt * (k1[j] + 2.0 * k2[j] + 2.0 * k3[j] + k4[j]) / 6.0;
535 }
536
537 if state[0] < params.horiz && new_state[0] >= params.horiz {
538 let alpha = (params.horiz - state[0]) / (new_state[0] - state[0]);
541 let mut target_state = state;
542 for j in 0..6 {
543 target_state[j] = state[j] + alpha * (new_state[j] - state[j]);
544 }
545 target_state[0] = params.horiz;
546 times.push(t + alpha * dt);
547 states.push(target_state);
548 hit_target = true;
549 break;
550 }
551
552 times.push(t + dt);
553 states.push(new_state);
554 }
555
556 let t_events = [
558 if hit_target {
559 vec![*times.last().unwrap()]
560 } else {
561 vec![]
562 },
563 if let Some(t) = max_ord_time {
564 vec![t]
565 } else {
566 vec![]
567 },
568 if hit_ground {
569 vec![*times.last().unwrap()]
570 } else {
571 vec![]
572 },
573 ];
574
575 if inputs.use_enhanced_spin_drift {
584 let (sd_temp_c, sd_press_hpa) = if params.atmo_params.2 > 0.0 {
588 (params.atmo_params.1, params.atmo_params.2)
589 } else {
590 (15.0, 1013.25)
591 };
592 let sg = crate::spin_drift::effective_sg_from_inputs(inputs, sd_temp_c, sd_press_hpa);
593 for (t, state) in times.iter().zip(states.iter_mut()) {
594 if *t > 0.0 {
595 state[2] += crate::spin_drift::litz_drift_meters(sg, *t, inputs.is_twist_right);
596 }
597 }
598 }
599
600 FastSolution::from_trajectory_data(times, states, t_events)
601}
602
603fn fast_magnus_acceleration(
604 inputs: &BallisticInputs,
605 air_velocity: Vector3<f64>,
606 air_density: f64,
607 mach: f64,
608 gravity_acceleration: Vector3<f64>,
609) -> Vector3<f64> {
610 if !inputs.enable_magnus
611 || inputs.use_enhanced_spin_drift
612 || inputs.bullet_diameter <= 0.0
613 || inputs.twist_rate <= 0.0
614 || inputs.bullet_mass <= 0.0
615 {
616 return Vector3::zeros();
617 }
618
619 let speed_air = air_velocity.norm();
620 let diameter_m = inputs.bullet_diameter;
621 let (spin_rate_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
622 inputs.muzzle_velocity,
623 speed_air,
624 inputs.twist_rate,
625 diameter_m,
626 );
627 let d_in = if inputs.caliber_inches > 0.0 {
628 inputs.caliber_inches
629 } else {
630 diameter_m / 0.0254
631 };
632 let m_gr = if inputs.weight_grains > 0.0 {
633 inputs.weight_grains
634 } else {
635 inputs.bullet_mass / 0.00006479891
636 };
637 let l_in = if inputs.bullet_length > 0.0 {
638 inputs.bullet_length / 0.0254
639 } else {
640 let estimated = crate::stability::estimate_bullet_length_m(diameter_m, inputs.bullet_mass);
641 if estimated > 0.0 {
642 estimated / 0.0254
643 } else {
644 4.5 * d_in.max(1e-9)
645 }
646 };
647 let sg = crate::spin_drift::calculate_dynamic_stability(
648 m_gr,
649 speed_air,
650 spin_rate_rad_s,
651 d_in,
652 l_in,
653 air_density,
654 );
655 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
656 sg,
657 speed_air,
658 spin_rate_rad_s,
659 0.0,
660 0.0,
661 air_density,
662 d_in,
663 l_in,
664 m_gr,
665 mach,
666 "match",
667 false,
668 );
669 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
670 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
671 let force = 0.5 * air_density * speed_air.powi(2) * area * c_np * spin_param * yaw_rad.sin();
672 if force <= 1e-12 {
673 return Vector3::zeros();
674 }
675
676 crate::derivatives::yaw_of_repose_magnus_direction(
677 air_velocity,
678 gravity_acceleration,
679 inputs.is_twist_right,
680 )
681 .map_or_else(Vector3::zeros, |direction| {
682 (force / inputs.bullet_mass) * direction
683 })
684}
685
686fn interpolated_vertical_apex(
687 previous_time: f64,
688 previous: &[f64; 6],
689 current_time: f64,
690 current: &[f64; 6],
691) -> Option<(f64, [f64; 6])> {
692 let dt = current_time - previous_time;
693 let previous_vy = previous[4];
694 let current_vy = current[4];
695 if !dt.is_finite()
696 || dt <= 0.0
697 || !previous_vy.is_finite()
698 || !current_vy.is_finite()
699 || previous_vy <= 0.0
700 || current_vy > 0.0
701 {
702 return None;
703 }
704
705 let denominator = previous_vy - current_vy;
706 if !denominator.is_finite() || denominator <= 0.0 {
707 return None;
708 }
709 let alpha = previous_vy / denominator;
710 if !alpha.is_finite() || !(0.0..1.0).contains(&alpha) {
713 return None;
714 }
715
716 let mut apex = [0.0; 6];
717 for component in 0..6 {
718 apex[component] = previous[component] + alpha * (current[component] - previous[component]);
719 }
720
721 let alpha2 = alpha * alpha;
724 let alpha3 = alpha2 * alpha;
725 let h00 = 2.0 * alpha3 - 3.0 * alpha2 + 1.0;
726 let h10 = alpha3 - 2.0 * alpha2 + alpha;
727 let h01 = -2.0 * alpha3 + 3.0 * alpha2;
728 let h11 = alpha3 - alpha2;
729 for axis in 0..3 {
730 apex[axis] = h00 * previous[axis]
731 + h10 * dt * previous[axis + 3]
732 + h01 * current[axis]
733 + h11 * dt * current[axis + 3];
734 }
735 apex[4] = 0.0;
736
737 apex.iter()
738 .all(|component| component.is_finite())
739 .then_some((previous_time + alpha * dt, apex))
740}
741
742#[allow(clippy::too_many_arguments)]
744fn compute_derivatives(
745 state: &[f64; 6],
746 inputs: &BallisticInputs,
747 wind_sock: &WindSock,
748 atmosphere: FastAtmosphere,
749 drag_model: &DragModel,
750 projectile_shape: crate::transonic_drag::ProjectileShape,
751 bc: f64,
752 has_bc_segments: bool,
753 has_bc_segments_data: bool,
754 omega: Option<Vector3<f64>>,
755 wind_shear_model: Option<crate::wind_shear::WindShearModel>,
756 atmo_sock: Option<&AtmoSock>,
758) -> [f64; 6] {
759 let pos = Vector3::new(state[0], state[1], state[2]);
760 let vel = Vector3::new(state[3], state[4], state[5]);
761
762 let level_wind = wind_sock.vector_for_range_stateless(pos.x);
765 let level_wind = if let Some(model) = wind_shear_model {
766 let height_rel_launch =
767 crate::atmosphere::shot_frame_altitude(0.0, pos.x, pos.y, inputs.shooting_angle);
768 crate::wind_shear::apply_boundary_layer_shear(level_wind, height_rel_launch, model)
769 } else {
770 level_wind
771 };
772 let wind_vector =
773 crate::derivatives::level_vector_to_shot_frame(level_wind, inputs.shooting_angle);
774
775 let vel_adjusted = vel - wind_vector;
777 let v_mag = vel_adjusted.norm();
778
779 let theta = inputs.shooting_angle;
782 let accel_gravity = Vector3::new(
783 -G_ACCEL_MPS2 * theta.sin(),
784 -G_ACCEL_MPS2 * theta.cos(),
785 0.0,
786 );
787
788 let mut accel = if v_mag < 1e-6 {
790 accel_gravity
791 } else {
792 let v_fps = v_mag * MPS_TO_FPS;
794
795 let (local_density, speed_of_sound) = match atmosphere {
817 FastAtmosphere::Direct {
818 air_density,
819 speed_of_sound,
820 } => (air_density, speed_of_sound),
821 FastAtmosphere::Standard { base_density } => {
822 let altitude = crate::atmosphere::shot_frame_altitude(
823 inputs.altitude,
824 pos.x,
825 pos.y,
826 inputs.shooting_angle,
827 );
828 let (base_temp_c, base_press_hpa, base_ratio) = match atmo_sock {
829 Some(sock) => {
830 let (zt, zp, zh) = sock.atmo_for_range(pos.x);
831 (zt, zp, calculate_air_density_cimp(zt, zp, zh) / 1.225)
832 }
833 None => (inputs.temperature, inputs.pressure, base_density / 1.225),
834 };
835 get_local_atmosphere_humid(
836 altitude,
837 inputs.altitude, base_temp_c,
839 base_press_hpa,
840 base_ratio,
841 0.0, )
843 }
844 };
845 let mach = v_mag / speed_of_sound;
846
847 let bc_current = if inputs.use_bc_segments
849 && has_bc_segments_data
850 && inputs.bc_segments_data.is_some()
851 {
852 velocity_segment_bc(v_fps, inputs.bc_segments_data.as_ref().unwrap(), bc)
853 } else if has_bc_segments && inputs.bc_segments.is_some() {
854 crate::derivatives::interpolated_bc(
855 mach,
856 inputs.bc_segments.as_ref().unwrap(),
857 Some(inputs),
858 )
859 } else {
860 bc
861 };
862 let bc_current = bc_current.max(1e-6);
865
866 let (drag_factor, retard_denom) = if let Some(ref table) = inputs.custom_drag_table {
877 (
878 table.interpolate(mach),
879 inputs.custom_drag_denominator(bc_current),
880 )
881 } else {
882 let base_cd = get_drag_coefficient(mach, drag_model);
883 let cd =
884 crate::transonic_drag::transonic_correction(mach, base_cd, projectile_shape, false);
885 (cd, bc_current)
886 };
887
888 let cd_to_retard = crate::constants::CD_TO_RETARD;
890 let standard_factor = drag_factor * cd_to_retard;
891 let density_scale = local_density / 1.225;
894
895 let a_drag_ft_s2 = (v_fps * v_fps) * standard_factor * density_scale / retard_denom;
897
898 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let accel_drag = -a_drag_m_s2 * (vel_adjusted / v_mag);
901
902 let accel_magnus =
905 fast_magnus_acceleration(inputs, vel_adjusted, local_density, mach, accel_gravity);
906
907 accel_drag + accel_gravity + accel_magnus
909 };
910
911 if let Some(omega) = omega {
914 let omega = crate::derivatives::level_vector_to_shot_frame(omega, inputs.shooting_angle);
915 accel += -2.0 * omega.cross(&vel);
916 }
917
918 [vel.x, vel.y, vel.z, accel.x, accel.y, accel.z]
920}
921
922pub fn fast_integrate_with_segments(
925 inputs: &BallisticInputs,
926 wind_segments: Vec<crate::wind::WindSegment>,
927 params: FastIntegrationParams,
928) -> FastSolution {
929 use crate::trajectory_integration::{integrate_trajectory, TrajectoryParams};
931
932 if !atmo_is_physical(params.atmo_params) {
934 return FastSolution::degenerate(¶ms.initial_state);
935 }
936
937 let initial_state =
940 launch_state_with_aerodynamic_jump(inputs, params.atmo_params, params.initial_state);
941
942 let mass_kg = inputs.bullet_mass; let bc = inputs.bc_value;
945 let drag_model = inputs.bc_type;
946
947 let omega_vector = if inputs.enable_coriolis && inputs.latitude.is_some() {
951 let omega_earth = 7.2921159e-5; let lat_rad = inputs.latitude.unwrap_or(0.0).to_radians();
957 let azimuth = inputs.shot_azimuth; Some(Vector3::new(
959 omega_earth * lat_rad.cos() * azimuth.cos(), omega_earth * lat_rad.sin(), -omega_earth * lat_rad.cos() * azimuth.sin(), ))
963 } else {
964 None
965 };
966
967 let traj_params = TrajectoryParams {
969 mass_kg,
970 bc,
971 drag_model,
972 wind_segments,
973 atmos_params: params.atmo_params,
974 omega_vector,
975 enable_spin_drift: inputs.use_enhanced_spin_drift,
976 enable_magnus: inputs.enable_magnus,
977 enable_coriolis: inputs.enable_coriolis,
978 target_distance_m: params.horiz,
979 enable_wind_shear: inputs.enable_wind_shear,
980 wind_shear_model: inputs.wind_shear_model.clone(),
981 shooter_altitude_m: inputs.altitude,
982 is_twist_right: inputs.is_twist_right,
983 shooting_angle: inputs.shooting_angle,
984 bullet_diameter: inputs.bullet_diameter,
986 bullet_length: inputs.bullet_length,
987 twist_rate: inputs.twist_rate,
988 custom_drag_table: inputs.custom_drag_table.clone(),
989 bc_segments: inputs.bc_segments.clone(),
990 use_bc_segments: inputs.use_bc_segments,
991 ground_threshold: -1000.0,
995 atmo_sock: params.atmo_sock,
997 };
998
999 let trajectory = integrate_trajectory(
1001 initial_state,
1002 params.t_span,
1003 traj_params,
1004 "RK45", 1e-6, 0.01, );
1008
1009 let n_points = trajectory.len();
1011 let mut times = Vec::with_capacity(n_points + 1);
1012 let mut states = Vec::with_capacity(n_points + 1);
1013
1014 let mut target_hit_time: Option<f64> = None;
1015 let mut ground_hit_time: Option<f64> = None;
1016 let mut max_ord_time = None;
1017 let mut max_ord_y = 0.0;
1018
1019 for (t, state_vec) in trajectory {
1020 let state = [
1022 state_vec[0],
1023 state_vec[1],
1024 state_vec[2],
1025 state_vec[3],
1026 state_vec[4],
1027 state_vec[5],
1028 ];
1029
1030 if let Some((&previous_time, &previous_state)) = times.last().zip(states.last()) {
1038 if let Some((apex_time, apex_state)) =
1039 interpolated_vertical_apex(previous_time, &previous_state, t, &state)
1040 {
1041 if apex_state[1] > max_ord_y {
1042 max_ord_y = apex_state[1];
1043 max_ord_time = Some(apex_time);
1044 }
1045 times.push(apex_time);
1046 states.push(apex_state);
1047 }
1048 }
1049
1050 if target_hit_time.is_none() && state[0] >= params.horiz {
1052 target_hit_time = Some(t);
1053 }
1054
1055 if ground_hit_time.is_none() && state[1] <= inputs.ground_threshold {
1057 ground_hit_time = Some(t);
1058 }
1059
1060 if state[1] > max_ord_y {
1062 max_ord_y = state[1];
1063 max_ord_time = Some(t);
1064 }
1065
1066 times.push(t);
1067 states.push(state);
1068 }
1069
1070 let t_events = [
1072 if let Some(t) = target_hit_time {
1073 vec![t]
1074 } else {
1075 vec![]
1076 },
1077 if let Some(t) = max_ord_time {
1078 vec![t]
1079 } else {
1080 vec![]
1081 },
1082 if let Some(t) = ground_hit_time {
1083 vec![t]
1084 } else {
1085 vec![]
1086 },
1087 ];
1088
1089 if inputs.use_enhanced_spin_drift {
1094 let (sd_temp_c, sd_press_hpa) = if params.atmo_params.2 > 0.0 {
1098 (params.atmo_params.1, params.atmo_params.2)
1099 } else {
1100 (15.0, 1013.25)
1101 };
1102 let sg = crate::spin_drift::effective_sg_from_inputs(inputs, sd_temp_c, sd_press_hpa);
1103 for (t, state) in times.iter().zip(states.iter_mut()) {
1104 if *t > 0.0 {
1105 state[2] += crate::spin_drift::litz_drift_meters(sg, *t, inputs.is_twist_right);
1106 }
1107 }
1108 }
1109
1110 FastSolution::from_trajectory_data(times, states, t_events)
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115 use super::*;
1116 use crate::BCSegmentData;
1117
1118 fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
1119 let (sin_angle, cos_angle) = angle.sin_cos();
1120 Vector3::new(
1121 level.x * cos_angle + level.y * sin_angle,
1122 -level.x * sin_angle + level.y * cos_angle,
1123 level.z,
1124 )
1125 }
1126
1127 #[test]
1128 fn measured_bc_fast_drag_ignores_name_based_form_factor_flag() {
1129 let derivatives_with_flag = |use_form_factor| {
1130 let inputs = BallisticInputs {
1131 bc_value: 0.462,
1132 bc_type: DragModel::G1,
1133 bullet_model: Some("168gr SMK Match".to_string()),
1134 use_form_factor,
1135 temperature: 15.0,
1136 pressure: 1013.25,
1137 ..BallisticInputs::default()
1138 };
1139
1140 compute_derivatives(
1141 &[0.0, 0.0, 0.0, 600.0, 0.0, 0.0],
1142 &inputs,
1143 &WindSock::new(vec![]),
1144 FastAtmosphere::Standard {
1145 base_density: 1.225,
1146 },
1147 &inputs.bc_type,
1148 crate::transonic_drag::ProjectileShape::Spitzer,
1149 inputs.bc_value,
1150 false,
1151 false,
1152 None,
1153 None,
1154 None,
1155 )
1156 };
1157
1158 let baseline = derivatives_with_flag(false);
1159 let flagged = derivatives_with_flag(true);
1160
1161 for component in 3..6 {
1162 assert_eq!(
1163 flagged[component].to_bits(),
1164 baseline[component].to_bits(),
1165 "published BC already encodes form factor: component {component}, baseline={} flagged={}",
1166 baseline[component],
1167 flagged[component]
1168 );
1169 }
1170 }
1171
1172 #[test]
1173 fn velocity_bc_data_requires_opt_in_in_plain_fast_kernel() {
1174 let acceleration = |inputs: &BallisticInputs| {
1175 let has_mach_segments = inputs
1176 .bc_segments
1177 .as_ref()
1178 .is_some_and(|segments| !segments.is_empty());
1179 let has_velocity_segments = inputs
1180 .bc_segments_data
1181 .as_ref()
1182 .is_some_and(|segments| !segments.is_empty());
1183 compute_derivatives(
1184 &[0.0, 0.0, 0.0, 600.0, 0.0, 0.0],
1185 inputs,
1186 &WindSock::new(vec![]),
1187 FastAtmosphere::Standard {
1188 base_density: 1.225,
1189 },
1190 &inputs.bc_type,
1191 crate::transonic_drag::ProjectileShape::Spitzer,
1192 inputs.bc_value,
1193 has_mach_segments,
1194 has_velocity_segments,
1195 None,
1196 None,
1197 None,
1198 )
1199 };
1200
1201 let scalar_inputs = BallisticInputs {
1202 bc_value: 0.5,
1203 bc_type: DragModel::G7,
1204 temperature: 15.0,
1205 pressure: 1013.25,
1206 ..BallisticInputs::default()
1207 };
1208 let mut disabled_inputs = scalar_inputs.clone();
1209 disabled_inputs.bc_segments_data = Some(vec![BCSegmentData {
1210 velocity_min: 0.0,
1211 velocity_max: 4_000.0,
1212 bc_value: 0.46,
1213 }]);
1214 disabled_inputs.use_bc_segments = false;
1215 let mut enabled_inputs = disabled_inputs.clone();
1216 enabled_inputs.use_bc_segments = true;
1217 let mut mach_only_inputs = scalar_inputs.clone();
1218 mach_only_inputs.bc_segments = Some(vec![(0.0, 0.4), (3.0, 0.4)]);
1219 let mut disabled_with_both = mach_only_inputs.clone();
1220 disabled_with_both.bc_segments_data = disabled_inputs.bc_segments_data.clone();
1221
1222 let scalar = acceleration(&scalar_inputs);
1223 let disabled = acceleration(&disabled_inputs);
1224 let enabled = acceleration(&enabled_inputs);
1225 let mach_only = acceleration(&mach_only_inputs);
1226 let disabled_with_both = acceleration(&disabled_with_both);
1227
1228 assert_eq!(
1229 disabled[3].to_bits(),
1230 scalar[3].to_bits(),
1231 "a populated velocity table must not change drag while use_bc_segments is false"
1232 );
1233 assert!(
1234 enabled[3] < disabled[3] - 1.0,
1235 "enabling the lower BC table must increase drag: disabled ax={} enabled ax={}",
1236 disabled[3],
1237 enabled[3]
1238 );
1239 assert_eq!(
1240 disabled_with_both[3].to_bits(),
1241 mach_only[3].to_bits(),
1242 "disabling velocity data must fall through to an explicit Mach table"
1243 );
1244 }
1245
1246 #[test]
1247 fn inclined_positions_at_same_world_altitude_have_same_fast_acceleration() {
1248 let angle = std::f64::consts::FRAC_PI_6;
1249 let inputs = BallisticInputs {
1250 altitude: 100.0,
1251 temperature: 15.0,
1252 pressure: 1013.25,
1253 shooting_angle: angle,
1254 ..BallisticInputs::default()
1255 };
1256 let wind_sock = WindSock::new(vec![]);
1257 let atmosphere = FastAtmosphere::Standard {
1258 base_density: 1.225,
1259 };
1260 let state_along_slant = [1_000.0, 0.0, 0.0, 600.0, 0.0, 0.0];
1261 let state_across_slant = [0.0, 500.0 / angle.cos(), 0.0, 600.0, 0.0, 0.0];
1262
1263 let a = compute_derivatives(
1264 &state_along_slant,
1265 &inputs,
1266 &wind_sock,
1267 atmosphere,
1268 &inputs.bc_type,
1269 crate::transonic_drag::ProjectileShape::Spitzer,
1270 inputs.bc_value,
1271 false,
1272 false,
1273 None,
1274 None,
1275 None,
1276 );
1277 let b = compute_derivatives(
1278 &state_across_slant,
1279 &inputs,
1280 &wind_sock,
1281 atmosphere,
1282 &inputs.bc_type,
1283 crate::transonic_drag::ProjectileShape::Spitzer,
1284 inputs.bc_value,
1285 false,
1286 false,
1287 None,
1288 None,
1289 None,
1290 );
1291
1292 for component in 3..6 {
1293 assert!(
1294 (a[component] - b[component]).abs() < 1e-10,
1295 "fast derivative component {component} differs at equal world altitude: {} vs {}",
1296 a[component],
1297 b[component]
1298 );
1299 }
1300 }
1301
1302 #[test]
1303 fn inclined_headwind_is_rotated_into_solver_frame() {
1304 let angle = std::f64::consts::FRAC_PI_6;
1305 let inputs = BallisticInputs {
1306 shooting_angle: angle,
1307 ..BallisticInputs::default()
1308 };
1309 let speed_mps = 360.0 * (1000.0 / 3600.0);
1310 let level_headwind = Vector3::new(-speed_mps, 0.0, 0.0);
1311 let velocity = expected_shot_frame_vector(level_headwind, angle);
1312 let state = [0.0, 0.0, 0.0, velocity.x, velocity.y, velocity.z];
1313 let actual = compute_derivatives(
1314 &state,
1315 &inputs,
1316 &WindSock::new(vec![(360.0, 0.0, 1000.0)]),
1317 FastAtmosphere::Direct {
1318 air_density: 1.225,
1319 speed_of_sound: 340.0,
1320 },
1321 &inputs.bc_type,
1322 crate::transonic_drag::ProjectileShape::Spitzer,
1323 inputs.bc_value,
1324 false,
1325 false,
1326 None,
1327 None,
1328 None,
1329 );
1330 let expected = Vector3::new(
1331 -G_ACCEL_MPS2 * angle.sin(),
1332 -G_ACCEL_MPS2 * angle.cos(),
1333 0.0,
1334 );
1335
1336 assert!(
1337 (Vector3::new(actual[3], actual[4], actual[5]) - expected).norm() < 1e-12,
1338 "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
1339 );
1340 }
1341
1342 #[test]
1343 fn plain_fast_kernel_applies_power_law_wind_shear() {
1344 let state = [500.0, 100.0, 0.0, 700.0, 0.0, 0.0];
1345 let run = |enable_wind_shear: bool, model: &str, wind_speed_kmh: f64| {
1346 let inputs = BallisticInputs {
1347 bc_value: 0.5,
1348 bc_type: DragModel::G7,
1349 enable_wind_shear,
1350 wind_shear_model: model.to_string(),
1351 ..BallisticInputs::default()
1352 };
1353 let wind_shear_model = enable_wind_shear
1354 .then(|| crate::wind_shear::boundary_layer_model_from_name(model))
1355 .filter(|model| *model != crate::wind_shear::WindShearModel::None);
1356 compute_derivatives(
1357 &state,
1358 &inputs,
1359 &WindSock::new(vec![(wind_speed_kmh, 90.0, 2_000.0)]),
1360 FastAtmosphere::Direct {
1361 air_density: 1.225,
1362 speed_of_sound: 340.0,
1363 },
1364 &inputs.bc_type,
1365 crate::transonic_drag::ProjectileShape::Spitzer,
1366 inputs.bc_value,
1367 false,
1368 false,
1369 None,
1370 wind_shear_model,
1371 None,
1372 )
1373 };
1374
1375 let uniform = run(false, "power_law", 36.0);
1376 let model_none = run(true, "none", 36.0);
1377 assert_eq!(model_none, uniform, "model=none must preserve uniform wind");
1378
1379 let sheared = run(true, "power_law", 36.0);
1380 assert!(
1381 sheared[5] < uniform[5],
1382 "stronger aloft crosswind must increase leftward acceleration: uniform={}, shear={}",
1383 uniform[5],
1384 sheared[5]
1385 );
1386
1387 let ratio = crate::wind_shear::boundary_layer_speed_ratio(
1388 state[1],
1389 crate::wind_shear::WindShearModel::PowerLaw,
1390 );
1391 let equivalent_uniform = run(false, "none", 36.0 * ratio);
1392 for component in 3..6 {
1393 assert!(
1394 (sheared[component] - equivalent_uniform[component]).abs() < 1e-12,
1395 "shear component {component} must equal base wind scaled by {ratio}: shear={}, expected={}",
1396 sheared[component],
1397 equivalent_uniform[component]
1398 );
1399 }
1400 }
1401
1402 #[test]
1403 fn plain_fast_path_wind_shear_changes_high_arc_drift() {
1404 let run = |enable_wind_shear: bool, model: &str| {
1405 let inputs = BallisticInputs {
1406 muzzle_velocity: 800.0,
1407 bc_value: 0.5,
1408 bc_type: DragModel::G7,
1409 bullet_mass: 168.0 * 0.00006479891,
1410 bullet_diameter: 0.308 * 0.0254,
1411 enable_wind_shear,
1412 wind_shear_model: model.to_string(),
1413 ground_threshold: -100.0,
1414 ..BallisticInputs::default()
1415 };
1416 let elevation = 0.12_f64;
1417 let solution = fast_integrate(
1418 &inputs,
1419 &WindSock::new(vec![(36.0, 90.0, 2_000.0)]),
1420 FastIntegrationParams {
1421 horiz: 1_000.0,
1422 vert: 0.0,
1423 initial_state: [
1424 0.0,
1425 0.0,
1426 0.0,
1427 inputs.muzzle_velocity * elevation.cos(),
1428 inputs.muzzle_velocity * elevation.sin(),
1429 0.0,
1430 ],
1431 t_span: (0.0, 5.0),
1432 atmo_params: (0.0, 15.0, 1013.25, 1.0),
1433 atmo_sock: None,
1434 },
1435 );
1436 let last = solution.t.len() - 1;
1437 assert_eq!(solution.y[0][last].to_bits(), 1_000.0_f64.to_bits());
1438 solution.y[2][last]
1439 };
1440
1441 let uniform = run(false, "power_law");
1442 let model_none = run(true, "none");
1443 assert_eq!(model_none.to_bits(), uniform.to_bits());
1444 let sheared = run(true, "power_law");
1445 assert!(
1446 sheared.abs() > uniform.abs() + 0.01,
1447 "aloft shear must increase drift magnitude: uniform={uniform}, shear={sheared}"
1448 );
1449 }
1450
1451 #[test]
1452 fn inclined_coriolis_is_rotated_into_solver_frame() {
1453 let angle = std::f64::consts::FRAC_PI_6;
1454 let inputs = BallisticInputs {
1455 shooting_angle: angle,
1456 ..BallisticInputs::default()
1457 };
1458 let velocity = Vector3::new(600.0, 20.0, 5.0);
1459 let state = [0.0, 0.0, 0.0, velocity.x, velocity.y, velocity.z];
1460 let level_omega = Vector3::new(3.0e-5, 6.0e-5, -2.0e-5);
1461 let run = |omega| {
1462 compute_derivatives(
1463 &state,
1464 &inputs,
1465 &WindSock::new(vec![]),
1466 FastAtmosphere::Direct {
1467 air_density: 1.225,
1468 speed_of_sound: 340.0,
1469 },
1470 &inputs.bc_type,
1471 crate::transonic_drag::ProjectileShape::Spitzer,
1472 inputs.bc_value,
1473 false,
1474 false,
1475 omega,
1476 None,
1477 None,
1478 )
1479 };
1480 let baseline = run(None);
1481 let with_coriolis = run(Some(level_omega));
1482 let actual = Vector3::new(
1483 with_coriolis[3] - baseline[3],
1484 with_coriolis[4] - baseline[4],
1485 with_coriolis[5] - baseline[5],
1486 );
1487 let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
1488
1489 assert!(
1490 (actual - expected).norm() < 1e-12,
1491 "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
1492 );
1493 }
1494
1495 #[test]
1496 fn test_fast_solution_interpolation() {
1497 let times = vec![0.0, 1.0, 2.0];
1498 let states = vec![
1499 [0.0, 0.0, 0.0, 100.0, 50.0, 0.0],
1500 [100.0, 45.0, 0.0, 99.0, 40.0, 0.0],
1501 [198.0, 80.0, 0.0, 98.0, 30.0, 0.0],
1502 ];
1503
1504 let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
1505
1506 let result = solution.sol(&[1.5]);
1508
1509 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); }
1513
1514 #[test]
1515 fn test_bc_from_velocity_segments() {
1516 let segments = vec![
1517 BCSegmentData {
1518 velocity_min: 0.0,
1519 velocity_max: 1000.0,
1520 bc_value: 0.5,
1521 },
1522 BCSegmentData {
1523 velocity_min: 1000.0,
1524 velocity_max: 2000.0,
1525 bc_value: 0.52,
1526 },
1527 BCSegmentData {
1528 velocity_min: 2000.0,
1529 velocity_max: 3000.0,
1530 bc_value: 0.55,
1531 },
1532 ];
1533
1534 assert_eq!(velocity_segment_bc(500.0, &segments, 0.5), 0.5);
1535 assert_eq!(velocity_segment_bc(1500.0, &segments, 0.5), 0.52);
1536 assert_eq!(velocity_segment_bc(2500.0, &segments, 0.5), 0.55);
1537
1538 assert_eq!(velocity_segment_bc(-100.0, &segments, 0.5), 0.5); assert_eq!(velocity_segment_bc(3500.0, &segments, 0.5), 0.55); }
1542
1543 #[test]
1544 fn test_fast_solution_interpolation_edge_cases() {
1545 let times = vec![0.0, 1.0, 2.0, 3.0];
1546 let states = vec![
1547 [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
1548 [800.0, 40.0, 100.0, 750.0, 30.0, 0.0],
1549 [1550.0, 60.0, 200.0, 700.0, 10.0, 0.0],
1550 [2250.0, 50.0, 300.0, 650.0, -10.0, 0.0],
1551 ];
1552
1553 let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
1554
1555 let result_before = solution.sol(&[-0.5]);
1557 assert!((result_before[0][0] - 0.0).abs() < 1e-10); let result_after = solution.sol(&[5.0]);
1561 assert!((result_after[0][0] - 2250.0).abs() < 1e-10); let result_exact = solution.sol(&[1.0]);
1565 assert!((result_exact[0][0] - 800.0).abs() < 1e-10);
1566
1567 let result_multi = solution.sol(&[0.5, 1.5, 2.5]);
1569 assert_eq!(result_multi[0].len(), 3);
1570 }
1571
1572 #[test]
1573 fn test_fast_solution_from_trajectory_data() {
1574 let times = vec![0.0, 0.5, 1.0];
1575 let states = vec![
1576 [0.0, 1.0, 2.0, 3.0, 4.0, 5.0],
1577 [10.0, 11.0, 12.0, 13.0, 14.0, 15.0],
1578 [20.0, 21.0, 22.0, 23.0, 24.0, 25.0],
1579 ];
1580 let t_events = [vec![1.0], vec![0.5], vec![]];
1581
1582 let solution = FastSolution::from_trajectory_data(times.clone(), states, t_events);
1583
1584 assert_eq!(solution.t, times);
1586 assert_eq!(solution.y.len(), 6); assert_eq!(solution.y[0].len(), 3); assert!(solution.success);
1589
1590 assert_eq!(solution.y[0][0], 0.0); assert_eq!(solution.y[1][0], 1.0); assert_eq!(solution.y[0][2], 20.0); }
1595
1596 #[test]
1597 fn test_bc_segments_boundary_conditions() {
1598 let single_segment = vec![BCSegmentData {
1600 velocity_min: 1000.0,
1601 velocity_max: 2000.0,
1602 bc_value: 0.5,
1603 }];
1604
1605 assert_eq!(velocity_segment_bc(500.0, &single_segment, 0.5), 0.5); assert_eq!(velocity_segment_bc(1500.0, &single_segment, 0.5), 0.5); assert_eq!(velocity_segment_bc(2500.0, &single_segment, 0.5), 0.5); let segments = vec![
1612 BCSegmentData {
1613 velocity_min: 0.0,
1614 velocity_max: 999.0, bc_value: 0.45,
1616 },
1617 BCSegmentData {
1618 velocity_min: 1000.0,
1619 velocity_max: 2000.0,
1620 bc_value: 0.50,
1621 },
1622 ];
1623
1624 assert_eq!(velocity_segment_bc(1000.0, &segments, 0.7), 0.50); assert_eq!(velocity_segment_bc(0.0, &segments, 0.7), 0.45); assert_eq!(velocity_segment_bc(998.999, &segments, 0.7), 0.45); assert_eq!(velocity_segment_bc(999.0, &segments, 0.7), 0.7); }
1629
1630 #[test]
1631 fn velocity_segment_gaps_and_clamps_do_not_depend_on_order() {
1632 let fallback_bc = 0.73;
1633 let ascending_with_gap = vec![
1634 BCSegmentData {
1635 velocity_min: 0.0,
1636 velocity_max: 999.0,
1637 bc_value: 0.6,
1638 },
1639 BCSegmentData {
1640 velocity_min: 1000.0,
1641 velocity_max: 2000.0,
1642 bc_value: 0.8,
1643 },
1644 ];
1645 assert_eq!(
1646 velocity_segment_bc(999.5, &ascending_with_gap, fallback_bc),
1647 fallback_bc,
1648 "coverage gaps must use the projectile's base BC"
1649 );
1650
1651 let mut descending = ascending_with_gap.clone();
1652 descending.reverse();
1653 assert_eq!(
1654 velocity_segment_bc(-100.0, &descending, fallback_bc),
1655 0.6,
1656 "below coverage must clamp to the lowest-velocity band"
1657 );
1658 assert_eq!(
1659 velocity_segment_bc(2500.0, &descending, fallback_bc),
1660 0.8,
1661 "above coverage must clamp to the highest-velocity band"
1662 );
1663 }
1664
1665 #[test]
1666 fn test_bc_segments_empty_fallback() {
1667 let empty_segments: Vec<BCSegmentData> = vec![];
1668
1669 let result = velocity_segment_bc(1500.0, &empty_segments, 0.73);
1671 assert_eq!(result, 0.73); }
1673
1674 #[test]
1675 fn test_fast_integration_params() {
1676 let params = FastIntegrationParams {
1678 horiz: 1000.0,
1679 vert: 0.0,
1680 initial_state: [0.0, 0.0, 0.0, 800.0, 50.0, 0.0], t_span: (0.0, 5.0),
1682 atmo_params: (0.0, 15.0, 1013.25, 1.0),
1683 atmo_sock: None,
1684 };
1685
1686 assert_eq!(params.horiz, 1000.0);
1687 assert_eq!(params.t_span.0, 0.0);
1688 assert_eq!(params.t_span.1, 5.0);
1689 assert_eq!(params.initial_state[3], 800.0); }
1691
1692 #[test]
1693 fn test_fast_solution_event_arrays() {
1694 let times = vec![0.0, 1.0, 2.0];
1695 let states = vec![
1696 [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
1697 [800.0, 40.0, 500.0, 750.0, 30.0, 0.0],
1698 [1500.0, 20.0, 1000.0, 700.0, 10.0, 0.0],
1699 ];
1700
1701 let t_events = [
1703 vec![2.0], vec![0.5], vec![], ];
1707
1708 let solution = FastSolution::from_trajectory_data(times, states, t_events);
1709
1710 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()); }
1714
1715 #[test]
1716 fn segmented_fast_path_interpolates_max_ordinate_between_saved_points() {
1717 let expected_apex_time = 5.105_f64;
1718 let downrange_velocity = 100.0_f64;
1719 let vertical_velocity = G_ACCEL_MPS2 * expected_apex_time;
1720 let inputs = BallisticInputs {
1721 muzzle_velocity: downrange_velocity.hypot(vertical_velocity),
1722 bc_value: 0.5,
1723 bc_type: DragModel::G7,
1724 use_enhanced_spin_drift: false,
1725 ..BallisticInputs::default()
1726 };
1727 let solution = fast_integrate_with_segments(
1728 &inputs,
1729 vec![],
1730 FastIntegrationParams {
1731 horiz: 1_000.0,
1732 vert: 0.0,
1733 initial_state: [0.0, 0.0, 0.0, downrange_velocity, vertical_velocity, 0.0],
1734 t_span: (0.0, 12.0),
1735 atmo_params: (1e-12, 340.0, 0.0, 0.0),
1738 atmo_sock: None,
1739 },
1740 );
1741
1742 assert!(solution.success);
1743 assert_eq!(solution.t_events[1].len(), 1);
1744 let reported_time = solution.t_events[1][0];
1745 assert!(
1746 (reported_time - expected_apex_time).abs() < 0.006,
1747 "max-ordinate time must be interpolated between coarse saves: reported={reported_time} expected={expected_apex_time}"
1748 );
1749 let event_index = solution
1750 .t
1751 .iter()
1752 .position(|time| time.to_bits() == reported_time.to_bits())
1753 .expect("the interpolated apex must be retained in the solution");
1754 assert_eq!(solution.y[4][event_index].to_bits(), 0.0_f64.to_bits());
1755
1756 let expected_height = vertical_velocity * expected_apex_time
1757 - 0.5 * G_ACCEL_MPS2 * expected_apex_time.powi(2);
1758 let event_state = solution.sol(&[reported_time]);
1759 assert!(
1760 (event_state[1][0] - expected_height).abs() < 2e-4,
1761 "max-ordinate state must preserve the interpolated apex height: reported={} expected={expected_height}",
1762 event_state[1][0]
1763 );
1764 }
1765
1766 #[test]
1767 fn plain_fast_path_interpolates_the_target_crossing() {
1768 let target = 500.123456789;
1769 let initial_state = [0.0, 0.0, 0.25, 800.0, 12.0, -2.5];
1770 let inputs = BallisticInputs {
1771 muzzle_velocity: 800.0,
1772 bc_value: 0.5,
1773 bc_type: DragModel::G7,
1774 ground_threshold: -100.0,
1775 use_enhanced_spin_drift: false,
1776 ..BallisticInputs::default()
1777 };
1778 let run = |horiz| {
1779 fast_integrate(
1780 &inputs,
1781 &WindSock::new(vec![]),
1782 FastIntegrationParams {
1783 horiz,
1784 vert: 0.0,
1785 initial_state,
1786 t_span: (0.0, 2.0),
1787 atmo_params: (0.0, 15.0, 1013.25, 1.0),
1788 atmo_sock: None,
1789 },
1790 )
1791 };
1792
1793 let reference = run(target + 2.0);
1795 let left = reference.y[0]
1796 .windows(2)
1797 .position(|x| x[0] < target && x[1] > target)
1798 .expect("reference trajectory must bracket target");
1799 let right = left + 1;
1800 let alpha =
1801 (target - reference.y[0][left]) / (reference.y[0][right] - reference.y[0][left]);
1802
1803 let solution = run(target);
1804 let last = solution.t.len() - 1;
1805 assert_eq!(solution.y[0][last].to_bits(), target.to_bits());
1806 let expected_time = reference.t[left] + alpha * (reference.t[right] - reference.t[left]);
1807 assert!((solution.t[last] - expected_time).abs() < 1e-12);
1808 assert_eq!(solution.t_events[0], vec![solution.t[last]]);
1809
1810 for component in 0..6 {
1811 assert_eq!(solution.y[component].len(), solution.t.len());
1812 let expected = reference.y[component][left]
1813 + alpha * (reference.y[component][right] - reference.y[component][left]);
1814 assert!(
1815 (solution.y[component][last] - expected).abs() < 1e-9,
1816 "component {component} is not at the crossing: actual={}, expected={expected}",
1817 solution.y[component][last]
1818 );
1819 }
1820 }
1821
1822 #[test]
1823 fn fast_path_coriolis_uses_shot_direction() {
1824 use std::f64::consts::FRAC_PI_2;
1829 fn final_xy(shot_az: f64) -> (f64, f64) {
1831 let mut inputs = BallisticInputs::default();
1832 inputs.muzzle_velocity = 800.0;
1833 inputs.bc_value = 0.5;
1834 inputs.bc_type = DragModel::G7;
1835 inputs.enable_advanced_effects = true; inputs.enable_coriolis = true;
1837 inputs.latitude = Some(45.0);
1838 inputs.shot_azimuth = shot_az;
1839 let v = 800.0_f64;
1840 let elev = 0.02_f64;
1841 let params = FastIntegrationParams {
1842 horiz: 1000.0,
1843 vert: 0.0,
1844 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
1845 t_span: (0.0, 5.0),
1846 atmo_params: (0.0, 15.0, 1013.25, 1.0),
1847 atmo_sock: None,
1848 };
1849 let sol = fast_integrate_with_segments(&inputs, vec![], params);
1850 let n = sol.y[0].len();
1851 (sol.y[0][n - 1], sol.y[1][n - 1])
1852 }
1853 let (ex, ey) = final_xy(FRAC_PI_2); let (wx, wy) = final_xy(3.0 * FRAC_PI_2); assert!(
1858 (ex - wx).abs() < 0.5,
1859 "east/west downrange should be ~equal (ex={ex:.4}, wx={wx:.4})"
1860 );
1861 assert!(
1864 ey > wy,
1865 "fast-path east ({ey:.6}) must be higher than west ({wy:.6}) (Eotvos)"
1866 );
1867 assert!(
1868 (ey - wy) > 1e-5,
1869 "fast-path E-W vertical separation ({:.8} m) should be non-zero (the pre-fix bug was exact equality)",
1870 ey - wy
1871 );
1872 }
1873
1874 #[test]
1875 fn fast_path_coriolis_independent_of_advanced_effects() {
1876 use std::f64::consts::FRAC_PI_2;
1879 fn final_y(coriolis: bool, shot_az: f64) -> f64 {
1880 let mut inputs = BallisticInputs::default();
1881 inputs.muzzle_velocity = 800.0;
1882 inputs.bc_value = 0.5;
1883 inputs.bc_type = DragModel::G7;
1884 inputs.enable_coriolis = coriolis;
1885 inputs.enable_advanced_effects = false; inputs.latitude = Some(45.0);
1887 inputs.shot_azimuth = shot_az;
1888 let v = 800.0_f64;
1889 let elev = 0.02_f64;
1890 let params = FastIntegrationParams {
1891 horiz: 1000.0,
1892 vert: 0.0,
1893 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
1894 t_span: (0.0, 5.0),
1895 atmo_params: (0.0, 15.0, 1013.25, 1.0),
1896 atmo_sock: None,
1897 };
1898 let sol = fast_integrate_with_segments(&inputs, vec![], params);
1899 let n = sol.y[0].len();
1900 sol.y[1][n - 1]
1901 }
1902 let e = final_y(true, FRAC_PI_2);
1904 let w = final_y(true, 3.0 * FRAC_PI_2);
1905 assert!(
1906 e > w && (e - w) > 1e-5,
1907 "Coriolis-only (no advanced effects) must still be directional: E={e} W={w}"
1908 );
1909 let e2 = final_y(false, FRAC_PI_2);
1911 let w2 = final_y(false, 3.0 * FRAC_PI_2);
1912 assert!(
1913 (e2 - w2).abs() < 1e-9,
1914 "with enable_coriolis=false, east/west must be identical: E={e2} W={w2}"
1915 );
1916 }
1917
1918 #[test]
1919 fn fast_path_rejects_degenerate_atmosphere() {
1920 let mut inputs = BallisticInputs::default();
1921 inputs.muzzle_velocity = 800.0;
1922 inputs.bc_value = 0.5;
1923 inputs.bc_type = DragModel::G7;
1924 let v = 800.0_f64;
1925 let e = 0.02_f64;
1926 let mk = |atmo: (f64, f64, f64, f64)| FastIntegrationParams {
1927 horiz: 500.0,
1928 vert: 0.0,
1929 initial_state: [0.0, 0.0, 0.0, v * e.cos(), v * e.sin(), 0.0],
1930 t_span: (0.0, 5.0),
1931 atmo_params: atmo,
1932 atmo_sock: None,
1933 };
1934 let zero_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 0.0, 1.0)));
1936 assert!(
1937 !zero_p.success,
1938 "pressure=0 atmosphere must yield success=false"
1939 );
1940 let nan_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, f64::NAN, 1.0)));
1942 assert!(!nan_p.success, "NaN pressure must yield success=false");
1943 let segmented_too_dense =
1945 fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 1013.25, 50.0)));
1946 let plain_too_dense = fast_integrate(
1947 &inputs,
1948 &WindSock::new(vec![]),
1949 mk((0.0, 15.0, 1013.25, 50.0)),
1950 );
1951 assert!(
1952 !segmented_too_dense.success && !plain_too_dense.success,
1953 "ratio=50 atmosphere must fail in both wrappers: segmented={}, plain={}",
1954 segmented_too_dense.success,
1955 plain_too_dense.success
1956 );
1957 let good = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 1013.25, 1.0)));
1959 assert!(good.success, "realistic atmosphere must yield success=true");
1960 let direct = fast_integrate_with_segments(&inputs, vec![], mk((1.225, 340.0, 0.0, 0.0)));
1963 assert!(
1964 direct.success,
1965 "direct-atmosphere mode (pressure=0 sentinel) must yield success=true"
1966 );
1967 }
1968
1969 #[test]
1970 fn plain_fast_path_honors_direct_atmosphere_values() {
1971 fn final_speed(muzzle_velocity: f64, atmo_params: (f64, f64, f64, f64)) -> f64 {
1972 let mut inputs = BallisticInputs::default();
1973 inputs.muzzle_velocity = muzzle_velocity;
1974 inputs.bc_value = 0.5;
1975 inputs.bc_type = DragModel::G7;
1976 inputs.ground_threshold = -100.0;
1977
1978 let wind_sock = WindSock::new(vec![]);
1979 let solution = fast_integrate(
1980 &inputs,
1981 &wind_sock,
1982 FastIntegrationParams {
1983 horiz: 10_000.0,
1984 vert: 0.0,
1985 initial_state: [0.0, 0.0, 0.0, muzzle_velocity, 0.0, 0.0],
1986 t_span: (0.0, 0.2),
1987 atmo_params,
1988 atmo_sock: None,
1989 },
1990 );
1991 assert!(solution.success);
1992
1993 let last = solution.y[0].len() - 1;
1994 (solution.y[3][last].powi(2)
1995 + solution.y[4][last].powi(2)
1996 + solution.y[5][last].powi(2))
1997 .sqrt()
1998 }
1999
2000 let thin_air = final_speed(800.0, (0.905, 340.0, 0.0, 0.0));
2001 let dense_air = final_speed(800.0, (1.225, 340.0, 0.0, 0.0));
2002 assert!(
2003 thin_air > dense_air,
2004 "lower supplied density must retain more velocity: thin={thin_air}, dense={dense_air}"
2005 );
2006
2007 let low_sound_speed = final_speed(340.0, (1.0, 300.0, 0.0, 0.0));
2008 let high_sound_speed = final_speed(340.0, (1.0, 400.0, 0.0, 0.0));
2009 assert!(
2010 (low_sound_speed - high_sound_speed).abs() > 1e-6,
2011 "supplied sound speed must affect Mach-dependent drag"
2012 );
2013 }
2014
2015 #[test]
2016 fn segmented_fast_path_nonpositive_density_ratio_uses_standard_fallback() {
2017 fn terminal_velocity(base_ratio: f64) -> f64 {
2018 let mut inputs = BallisticInputs::default();
2019 inputs.muzzle_velocity = 800.0;
2020 inputs.bc_value = 0.5;
2021 inputs.bc_type = DragModel::G7;
2022 inputs.ground_threshold = -100.0;
2023
2024 let solution = fast_integrate_with_segments(
2025 &inputs,
2026 vec![],
2027 FastIntegrationParams {
2028 horiz: 500.0,
2029 vert: 0.0,
2030 initial_state: [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
2031 t_span: (0.0, 5.0),
2032 atmo_params: (0.0, 15.0, 1013.25, base_ratio),
2033 atmo_sock: None,
2034 },
2035 );
2036 assert!(solution.success);
2037
2038 let last = solution.y[3].len() - 1;
2039 solution.y[3][last]
2040 }
2041
2042 let explicit_sea_level = terminal_velocity(1.0);
2043 for base_ratio in [0.0, -1.0] {
2044 let missing_ratio = terminal_velocity(base_ratio);
2045 assert!(
2046 missing_ratio < 800.0,
2047 "missing density ratio must not create a vacuum trajectory: {missing_ratio}"
2048 );
2049 assert!((missing_ratio - explicit_sea_level).abs() < 1e-9);
2050 }
2051 }
2052
2053 #[test]
2054 fn fast_path_carries_real_bullet_geometry() {
2055 let run = |diameter: f64, twist: f64| {
2063 let mut inputs = BallisticInputs::default();
2064 inputs.muzzle_velocity = 800.0;
2065 inputs.bc_value = 0.5;
2066 inputs.bc_type = DragModel::G7;
2067 inputs.bullet_diameter = diameter;
2068 inputs.bullet_length = 0.0318;
2069 inputs.twist_rate = twist;
2070 inputs.enable_advanced_effects = true;
2071 inputs.enable_magnus = true;
2072 let v = 800.0_f64;
2073 let elev = 0.02_f64;
2074 let params = FastIntegrationParams {
2075 horiz: 1000.0,
2076 vert: 0.0,
2077 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
2078 t_span: (0.0, 5.0),
2079 atmo_params: (0.0, 15.0, 1013.25, 1.0),
2080 atmo_sock: None,
2081 };
2082 fast_integrate_with_segments(&inputs, vec![], params)
2083 };
2084 assert!(run(0.00569, 7.0).success, ".224 geometry must solve");
2087 assert!(run(0.00858, 10.0).success, ".338 geometry must solve");
2088 }
2089
2090 #[test]
2091 fn segmented_fast_spin_flags_do_not_depend_on_advanced_umbrella() {
2092 fn endpoint(
2093 enable_advanced_effects: bool,
2094 enable_magnus: bool,
2095 use_enhanced_spin_drift: bool,
2096 ) -> Vector3<f64> {
2097 let inputs = BallisticInputs {
2098 muzzle_velocity: 823.0,
2099 bullet_mass: 168.0 * 0.00006479891,
2100 bullet_diameter: 0.308 * 0.0254,
2101 bullet_length: 1.215 * 0.0254,
2102 caliber_inches: 0.308,
2103 weight_grains: 168.0,
2104 bc_value: 0.475,
2105 bc_type: DragModel::G1,
2106 twist_rate: 12.0,
2107 is_twist_right: true,
2108 enable_advanced_effects,
2109 enable_magnus,
2110 use_enhanced_spin_drift,
2111 ..BallisticInputs::default()
2112 };
2113 let elevation = 0.02_f64;
2114 let solution = fast_integrate_with_segments(
2115 &inputs,
2116 vec![],
2117 FastIntegrationParams {
2118 horiz: 1_000.0,
2119 vert: 0.0,
2120 initial_state: [
2121 0.0,
2122 0.0,
2123 0.0,
2124 inputs.muzzle_velocity * elevation.cos(),
2125 inputs.muzzle_velocity * elevation.sin(),
2126 0.0,
2127 ],
2128 t_span: (0.0, 5.0),
2129 atmo_params: (0.0, 15.0, 1013.25, 1.0),
2130 atmo_sock: None,
2131 },
2132 );
2133 assert!(solution.success);
2134 let last = solution.t.len() - 1;
2135 Vector3::new(
2136 solution.y[0][last],
2137 solution.y[1][last],
2138 solution.y[2][last],
2139 )
2140 }
2141
2142 let baseline = endpoint(false, false, false);
2143 let magnus_without_umbrella = endpoint(false, true, false);
2144 let magnus_with_umbrella = endpoint(true, true, false);
2145 assert!(
2146 (magnus_without_umbrella - baseline).norm() > 1e-5,
2147 "test shot must produce a measurable Magnus displacement"
2148 );
2149 assert!(
2150 (magnus_with_umbrella - magnus_without_umbrella).norm() < 1e-12,
2151 "the legacy umbrella must not suppress explicitly enabled Magnus: without={magnus_without_umbrella:?} with={magnus_with_umbrella:?}"
2152 );
2153
2154 let litz_only = endpoint(false, false, true);
2155 let litz_without_umbrella = endpoint(false, true, true);
2156 let litz_with_umbrella = endpoint(true, true, true);
2157 assert!(
2158 (litz_without_umbrella - litz_only).norm() < 1e-12,
2159 "Litz mode must suppress explicitly enabled Magnus: litz={litz_only:?} both={litz_without_umbrella:?}"
2160 );
2161 assert!(
2162 (litz_with_umbrella - litz_without_umbrella).norm() < 1e-12,
2163 "the legacy umbrella must not change Magnus suppression in Litz mode: without={litz_without_umbrella:?} with={litz_with_umbrella:?}"
2164 );
2165 }
2166}