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 (result_component, source_component) in result.iter_mut().zip(&self.y) {
48 result_component[i] = source_component[0];
49 }
50 } else if idx >= self.t.len() {
51 for (result_component, source_component) in result.iter_mut().zip(&self.y) {
53 result_component[i] = source_component[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 (result_component, source_component) in result.iter_mut().zip(&self.y) {
62 let y0 = source_component[idx - 1];
63 let y1 = source_component[idx];
64 result_component[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 let crosswind_from_right_mps = inputs.wind_speed * inputs.wind_angle.sin();
210 aerodynamic_jump_launch_offset_for_crosswind_rad(
211 inputs,
212 atmo_params,
213 crosswind_from_right_mps,
214 )
215}
216
217fn aerodynamic_jump_launch_offset_for_crosswind_rad(
218 inputs: &BallisticInputs,
219 atmo_params: (f64, f64, f64, f64),
220 crosswind_from_right_mps: f64,
221) -> f64 {
222 if !inputs.enable_aerodynamic_jump {
223 return 0.0;
224 }
225 let diameter = inputs.bullet_diameter;
226 if !(inputs.twist_rate.is_finite()
227 && inputs.twist_rate != 0.0
228 && diameter.is_finite()
229 && diameter > 0.0
230 && inputs.bullet_length.is_finite()
231 && inputs.bullet_length > 0.0
232 && inputs.muzzle_velocity.is_finite())
233 {
234 return 0.0;
235 }
236 let stability_atmo = stability_atmosphere_params(atmo_params);
237 let sg = crate::stability::compute_stability_coefficient(inputs, stability_atmo);
238 if !(sg.is_finite() && sg > 0.0) {
239 return 0.0;
240 }
241 let length_cal = inputs.bullet_length / diameter;
242 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
243 let crosswind_from_right_mph = crosswind_from_right_mps * MS_TO_MPH;
244 let vertical_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
245 sg,
246 length_cal,
247 crosswind_from_right_mph,
248 inputs.is_twist_right,
249 );
250 if !vertical_moa.is_finite() {
251 return 0.0;
252 }
253 const MOA_PER_RAD: f64 = 3437.7467707849;
254 vertical_moa / MOA_PER_RAD
255}
256
257fn rotate_launch_velocity(state: &mut [f64; 6], theta_rad: f64) {
260 let (vx, vy, vz) = (state[3], state[4], state[5]);
261 let speed = (vx * vx + vy * vy + vz * vz).sqrt();
262 if speed <= 0.0 {
263 return;
264 }
265 let h = (vx * vx + vz * vz).sqrt(); let new_elev = vy.atan2(h) + theta_rad;
267 state[4] = speed * new_elev.sin();
268 let new_h = speed * new_elev.cos();
269 let scale = if h > 1e-12 { new_h / h } else { 0.0 };
270 state[3] = vx * scale;
271 state[5] = vz * scale;
272}
273
274fn launch_state_with_aerodynamic_jump(
275 inputs: &BallisticInputs,
276 atmo_params: (f64, f64, f64, f64),
277 segmented_crosswind_from_right_mps: Option<f64>,
278 mut initial_state: [f64; 6],
279) -> [f64; 6] {
280 let offset = match segmented_crosswind_from_right_mps {
281 Some(crosswind) => aerodynamic_jump_launch_offset_for_crosswind_rad(
282 inputs,
283 atmo_params,
284 crosswind,
285 ),
286 None => aerodynamic_jump_launch_offset_rad(inputs, atmo_params),
287 };
288 if offset != 0.0 {
289 rotate_launch_velocity(&mut initial_state, offset);
290 }
291 initial_state
292}
293
294pub fn fast_integrate(
296 inputs: &BallisticInputs,
297 wind_sock: &WindSock,
298 params: FastIntegrationParams,
299) -> FastSolution {
300 if wind_sock.validate_segments().is_err() || !atmo_is_physical(params.atmo_params) {
302 return FastSolution::degenerate(¶ms.initial_state);
303 }
304 let mut effective_inputs = inputs.clone();
305 if params.atmo_params.2 > 0.0 {
306 effective_inputs.altitude = params.atmo_params.0;
307 effective_inputs.temperature = params.atmo_params.1;
308 effective_inputs.pressure = params.atmo_params.2;
309 effective_inputs.humidity = params.atmo_params.3;
310 }
311 let inputs = &effective_inputs;
312 let _mass_kg = inputs.bullet_mass; let bc = inputs.bc_value;
315 let drag_model = &inputs.bc_type;
316
317 let has_bc_segments =
319 inputs.bc_segments.is_some() && !inputs.bc_segments.as_ref().unwrap().is_empty();
320 let has_bc_segments_data =
321 inputs.bc_segments_data.is_some() && !inputs.bc_segments_data.as_ref().unwrap().is_empty();
322
323 let dt = if params.horiz > 200.0 {
325 0.001
326 } else if params.horiz > 100.0 {
327 0.0005
328 } else {
329 0.0001
330 };
331
332 let segmented_crosswind_from_right_mps = if inputs.enable_aerodynamic_jump {
335 wind_sock.muzzle_crosswind_from_right_mps()
336 } else {
337 None
338 };
339 let initial_state = launch_state_with_aerodynamic_jump(
340 inputs,
341 params.atmo_params,
342 segmented_crosswind_from_right_mps,
343 params.initial_state,
344 );
345 let vx = initial_state[3]; let n_steps = ((params.t_span.1 / dt) as usize) + 1;
359 let est_steps = if vx > 1e-6 && params.horiz > 0.0 {
360 (((4.0 * params.horiz / vx) / dt) as usize) + 1
361 } else {
362 n_steps
363 };
364 let cap = est_steps.min(n_steps);
365 let mut times = Vec::with_capacity(cap);
366 let mut states = Vec::with_capacity(cap);
367
368 times.push(0.0);
370 states.push(initial_state);
371
372 let atmosphere = if let Some((air_density, speed_of_sound)) =
377 direct_atmosphere_values(params.atmo_params)
378 {
379 FastAtmosphere::Direct {
380 air_density,
381 speed_of_sound,
382 }
383 } else {
384 let base_density = if params.atmo_params.3 > 0.0 {
385 params.atmo_params.3 * 1.225
386 } else {
387 1.225
388 };
389 FastAtmosphere::Standard { base_density }
390 };
391
392 let atmo_sock = params.atmo_sock.as_ref();
394
395 let drag_model_str: &str = match drag_model {
403 DragModel::G1 => "G1",
404 DragModel::G2 => "G2",
405 DragModel::G5 => "G5",
406 DragModel::G6 => "G6",
407 DragModel::G7 => "G7",
408 DragModel::G8 => "G8",
409 DragModel::GI => "GI",
410 DragModel::GS => "GS",
411 };
412
413 let caliber_in = if inputs.caliber_inches > 0.0 {
415 inputs.caliber_inches
416 } else {
417 inputs.bullet_diameter / 0.0254
418 };
419 let weight_gr = if inputs.weight_grains > 0.0 {
420 inputs.weight_grains
421 } else {
422 inputs.bullet_mass / 0.00006479891
423 };
424
425 let projectile_shape = crate::transonic_drag::resolve_projectile_shape(
428 inputs.bullet_model.as_deref(),
429 caliber_in,
430 weight_gr,
431 drag_model_str,
432 );
433
434 let omega_vector = match inputs.latitude {
440 Some(latitude) if inputs.enable_coriolis => {
441 let omega_earth = 7.2921159e-5_f64; let lat = latitude.to_radians();
443 let az = inputs.shot_azimuth; Some(Vector3::new(
445 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), ))
449 }
450 _ => None,
451 };
452 let wind_shear_model = if inputs.enable_wind_shear {
454 let model = crate::wind_shear::boundary_layer_model_from_name(&inputs.wind_shear_model);
455 (model != crate::wind_shear::WindShearModel::None).then_some(model)
456 } else {
457 None
458 };
459
460 let mut hit_target = false;
462 let mut hit_ground = false;
463 let mut max_ord_time = None;
464 let mut max_ord_y = 0.0;
465 let ground_threshold = inputs.ground_threshold;
466
467 for i in 0..n_steps - 1 {
469 let t = i as f64 * dt;
470 let state = states[i];
471
472 let pos = Vector3::new(state[0], state[1], state[2]);
473 let _vel = Vector3::new(state[3], state[4], state[5]);
474
475 if pos.x >= params.horiz {
477 hit_target = true;
478 break;
479 }
480
481 if pos.y <= ground_threshold {
482 hit_ground = true;
483 break;
484 }
485
486 if pos.y > max_ord_y {
488 max_ord_y = pos.y;
489 max_ord_time = Some(t);
490 }
491
492 let k1 = compute_derivatives(
494 &state,
495 inputs,
496 wind_sock,
497 atmosphere,
498 drag_model,
499 projectile_shape,
500 bc,
501 has_bc_segments,
502 has_bc_segments_data,
503 omega_vector,
504 wind_shear_model,
505 atmo_sock,
506 );
507
508 let mut state2 = state;
509 for j in 0..6 {
510 state2[j] = state[j] + 0.5 * dt * k1[j];
511 }
512 let k2 = compute_derivatives(
513 &state2,
514 inputs,
515 wind_sock,
516 atmosphere,
517 drag_model,
518 projectile_shape,
519 bc,
520 has_bc_segments,
521 has_bc_segments_data,
522 omega_vector,
523 wind_shear_model,
524 atmo_sock,
525 );
526
527 let mut state3 = state;
528 for j in 0..6 {
529 state3[j] = state[j] + 0.5 * dt * k2[j];
530 }
531 let k3 = compute_derivatives(
532 &state3,
533 inputs,
534 wind_sock,
535 atmosphere,
536 drag_model,
537 projectile_shape,
538 bc,
539 has_bc_segments,
540 has_bc_segments_data,
541 omega_vector,
542 wind_shear_model,
543 atmo_sock,
544 );
545
546 let mut state4 = state;
547 for j in 0..6 {
548 state4[j] = state[j] + dt * k3[j];
549 }
550 let k4 = compute_derivatives(
551 &state4,
552 inputs,
553 wind_sock,
554 atmosphere,
555 drag_model,
556 projectile_shape,
557 bc,
558 has_bc_segments,
559 has_bc_segments_data,
560 omega_vector,
561 wind_shear_model,
562 atmo_sock,
563 );
564
565 let mut new_state = state;
567 for j in 0..6 {
568 new_state[j] = state[j] + dt * (k1[j] + 2.0 * k2[j] + 2.0 * k3[j] + k4[j]) / 6.0;
569 }
570
571 if state[0] < params.horiz && new_state[0] >= params.horiz {
572 let alpha = (params.horiz - state[0]) / (new_state[0] - state[0]);
575 let mut target_state = state;
576 for j in 0..6 {
577 target_state[j] = state[j] + alpha * (new_state[j] - state[j]);
578 }
579 target_state[0] = params.horiz;
580 times.push(t + alpha * dt);
581 states.push(target_state);
582 hit_target = true;
583 break;
584 }
585
586 times.push(t + dt);
587 states.push(new_state);
588 }
589
590 let t_events = [
592 if hit_target {
593 vec![*times.last().unwrap()]
594 } else {
595 vec![]
596 },
597 if let Some(t) = max_ord_time {
598 vec![t]
599 } else {
600 vec![]
601 },
602 if hit_ground {
603 vec![*times.last().unwrap()]
604 } else {
605 vec![]
606 },
607 ];
608
609 if inputs.use_enhanced_spin_drift {
618 let (sd_temp_c, sd_press_hpa) = if params.atmo_params.2 > 0.0 {
622 (params.atmo_params.1, params.atmo_params.2)
623 } else {
624 (15.0, 1013.25)
625 };
626 let sg = crate::spin_drift::effective_sg_from_inputs(inputs, sd_temp_c, sd_press_hpa);
627 for (t, state) in times.iter().zip(states.iter_mut()) {
628 if *t > 0.0 {
629 state[2] += crate::spin_drift::litz_drift_meters(sg, *t, inputs.is_twist_right);
630 }
631 }
632 }
633
634 FastSolution::from_trajectory_data(times, states, t_events)
635}
636
637fn fast_magnus_acceleration(
638 inputs: &BallisticInputs,
639 air_velocity: Vector3<f64>,
640 air_density: f64,
641 mach: f64,
642 gravity_acceleration: Vector3<f64>,
643) -> Vector3<f64> {
644 if !inputs.enable_magnus
645 || inputs.use_enhanced_spin_drift
646 || inputs.bullet_diameter <= 0.0
647 || inputs.twist_rate <= 0.0
648 || inputs.bullet_mass <= 0.0
649 {
650 return Vector3::zeros();
651 }
652
653 let speed_air = air_velocity.norm();
654 let diameter_m = inputs.bullet_diameter;
655 let (spin_rate_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
656 inputs.muzzle_velocity,
657 speed_air,
658 inputs.twist_rate,
659 diameter_m,
660 );
661 let d_in = if inputs.caliber_inches > 0.0 {
662 inputs.caliber_inches
663 } else {
664 diameter_m / 0.0254
665 };
666 let m_gr = if inputs.weight_grains > 0.0 {
667 inputs.weight_grains
668 } else {
669 inputs.bullet_mass / 0.00006479891
670 };
671 let l_in = if inputs.bullet_length > 0.0 {
672 inputs.bullet_length / 0.0254
673 } else {
674 let estimated = crate::stability::estimate_bullet_length_m(diameter_m, inputs.bullet_mass);
675 if estimated > 0.0 {
676 estimated / 0.0254
677 } else {
678 4.5 * d_in.max(1e-9)
679 }
680 };
681 let sg = crate::spin_drift::calculate_dynamic_stability(
682 m_gr,
683 speed_air,
684 spin_rate_rad_s,
685 d_in,
686 l_in,
687 air_density,
688 );
689 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
690 sg,
691 speed_air,
692 spin_rate_rad_s,
693 0.0,
694 0.0,
695 air_density,
696 d_in,
697 l_in,
698 m_gr,
699 mach,
700 "match",
701 false,
702 );
703 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
704 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
705 let force = 0.5 * air_density * speed_air.powi(2) * area * c_np * spin_param * yaw_rad.sin();
706 if force <= 1e-12 {
707 return Vector3::zeros();
708 }
709
710 crate::derivatives::yaw_of_repose_magnus_direction(
711 air_velocity,
712 gravity_acceleration,
713 inputs.is_twist_right,
714 )
715 .map_or_else(Vector3::zeros, |direction| {
716 (force / inputs.bullet_mass) * direction
717 })
718}
719
720fn interpolated_vertical_apex(
721 previous_time: f64,
722 previous: &[f64; 6],
723 current_time: f64,
724 current: &[f64; 6],
725) -> Option<(f64, [f64; 6])> {
726 let dt = current_time - previous_time;
727 let previous_vy = previous[4];
728 let current_vy = current[4];
729 if !dt.is_finite()
730 || dt <= 0.0
731 || !previous_vy.is_finite()
732 || !current_vy.is_finite()
733 || previous_vy <= 0.0
734 || current_vy > 0.0
735 {
736 return None;
737 }
738
739 let denominator = previous_vy - current_vy;
740 if !denominator.is_finite() || denominator <= 0.0 {
741 return None;
742 }
743 let alpha = previous_vy / denominator;
744 if !alpha.is_finite() || !(0.0..1.0).contains(&alpha) {
747 return None;
748 }
749
750 let mut apex = [0.0; 6];
751 for component in 0..6 {
752 apex[component] = previous[component] + alpha * (current[component] - previous[component]);
753 }
754
755 let alpha2 = alpha * alpha;
758 let alpha3 = alpha2 * alpha;
759 let h00 = 2.0 * alpha3 - 3.0 * alpha2 + 1.0;
760 let h10 = alpha3 - 2.0 * alpha2 + alpha;
761 let h01 = -2.0 * alpha3 + 3.0 * alpha2;
762 let h11 = alpha3 - alpha2;
763 for axis in 0..3 {
764 apex[axis] = h00 * previous[axis]
765 + h10 * dt * previous[axis + 3]
766 + h01 * current[axis]
767 + h11 * dt * current[axis + 3];
768 }
769 apex[4] = 0.0;
770
771 apex.iter()
772 .all(|component| component.is_finite())
773 .then_some((previous_time + alpha * dt, apex))
774}
775
776#[allow(clippy::too_many_arguments)]
778fn compute_derivatives(
779 state: &[f64; 6],
780 inputs: &BallisticInputs,
781 wind_sock: &WindSock,
782 atmosphere: FastAtmosphere,
783 drag_model: &DragModel,
784 projectile_shape: crate::transonic_drag::ProjectileShape,
785 bc: f64,
786 has_bc_segments: bool,
787 has_bc_segments_data: bool,
788 omega: Option<Vector3<f64>>,
789 wind_shear_model: Option<crate::wind_shear::WindShearModel>,
790 atmo_sock: Option<&AtmoSock>,
792) -> [f64; 6] {
793 let pos = Vector3::new(state[0], state[1], state[2]);
794 let vel = Vector3::new(state[3], state[4], state[5]);
795
796 let level_wind = wind_sock.vector_for_range_stateless(pos.x);
799 let level_wind = if let Some(model) = wind_shear_model {
800 let height_rel_launch =
801 crate::atmosphere::shot_frame_altitude(0.0, pos.x, pos.y, inputs.shooting_angle);
802 crate::wind_shear::apply_boundary_layer_shear(level_wind, height_rel_launch, model)
803 } else {
804 level_wind
805 };
806 let wind_vector =
807 crate::derivatives::level_vector_to_shot_frame(level_wind, inputs.shooting_angle);
808
809 let vel_adjusted = vel - wind_vector;
811 let v_mag = vel_adjusted.norm();
812
813 let theta = inputs.shooting_angle;
816 let accel_gravity = Vector3::new(
817 -G_ACCEL_MPS2 * theta.sin(),
818 -G_ACCEL_MPS2 * theta.cos(),
819 0.0,
820 );
821
822 let mut accel = if v_mag < 1e-6 {
824 accel_gravity
825 } else {
826 let v_fps = v_mag * MPS_TO_FPS;
828
829 let (local_density, speed_of_sound) = match atmosphere {
851 FastAtmosphere::Direct {
852 air_density,
853 speed_of_sound,
854 } => (air_density, speed_of_sound),
855 FastAtmosphere::Standard { base_density } => {
856 let altitude = crate::atmosphere::shot_frame_altitude(
857 inputs.altitude,
858 pos.x,
859 pos.y,
860 inputs.shooting_angle,
861 );
862 let (base_temp_c, base_press_hpa, base_ratio) = match atmo_sock {
863 Some(sock) => {
864 let (zt, zp, zh) = sock.atmo_for_range(pos.x);
865 (zt, zp, calculate_air_density_cimp(zt, zp, zh) / 1.225)
866 }
867 None => (inputs.temperature, inputs.pressure, base_density / 1.225),
868 };
869 get_local_atmosphere_humid(
870 altitude,
871 inputs.altitude, base_temp_c,
873 base_press_hpa,
874 base_ratio,
875 0.0, )
877 }
878 };
879 let mach = v_mag / speed_of_sound;
880
881 let bc_current = match (
883 inputs.bc_segments_data.as_ref(),
884 inputs.bc_segments.as_ref(),
885 ) {
886 (Some(segments_data), _) if inputs.use_bc_segments && has_bc_segments_data => {
887 velocity_segment_bc(v_fps, segments_data, bc)
888 }
889 (_, Some(segments)) if has_bc_segments => {
890 crate::derivatives::interpolated_bc(mach, segments, Some(inputs))
891 }
892 _ => bc,
893 };
894 let bc_current = bc_current.max(1e-6);
897
898 let (drag_factor, retard_denom) = if let Some(ref table) = inputs.custom_drag_table {
909 (
910 table.interpolate(mach),
911 inputs.custom_drag_denominator(bc_current),
912 )
913 } else {
914 let base_cd = get_drag_coefficient(mach, drag_model);
915 let cd =
916 crate::transonic_drag::transonic_correction(mach, base_cd, projectile_shape, false);
917 (cd, bc_current)
918 };
919
920 let cd_to_retard = crate::constants::CD_TO_RETARD;
922 let standard_factor = drag_factor * cd_to_retard;
923 let density_scale = local_density / 1.225;
926
927 let a_drag_ft_s2 = (v_fps * v_fps) * standard_factor * density_scale / retard_denom;
929
930 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let accel_drag = -a_drag_m_s2 * (vel_adjusted / v_mag);
933
934 let accel_magnus =
937 fast_magnus_acceleration(inputs, vel_adjusted, local_density, mach, accel_gravity);
938
939 accel_drag + accel_gravity + accel_magnus
941 };
942
943 if let Some(omega) = omega {
946 let omega = crate::derivatives::level_vector_to_shot_frame(omega, inputs.shooting_angle);
947 accel += -2.0 * omega.cross(&vel);
948 }
949
950 [vel.x, vel.y, vel.z, accel.x, accel.y, accel.z]
952}
953
954pub fn fast_integrate_with_segments(
957 inputs: &BallisticInputs,
958 wind_segments: Vec<crate::wind::WindSegment>,
959 params: FastIntegrationParams,
960) -> FastSolution {
961 use crate::trajectory_integration::{integrate_trajectory, TrajectoryParams};
963
964 if crate::wind::validate_wind_segments(&wind_segments).is_err()
966 || !atmo_is_physical(params.atmo_params)
967 {
968 return FastSolution::degenerate(¶ms.initial_state);
969 }
970
971 let segmented_crosswind_from_right_mps = if inputs.enable_aerodynamic_jump
974 && !wind_segments.is_empty()
975 {
976 WindSock::new(wind_segments.clone()).muzzle_crosswind_from_right_mps()
977 } else {
978 None
979 };
980 let initial_state = launch_state_with_aerodynamic_jump(
981 inputs,
982 params.atmo_params,
983 segmented_crosswind_from_right_mps,
984 params.initial_state,
985 );
986
987 let mass_kg = inputs.bullet_mass; let bc = inputs.bc_value;
990 let drag_model = inputs.bc_type;
991
992 let omega_vector = if inputs.enable_coriolis && inputs.latitude.is_some() {
996 let omega_earth = 7.2921159e-5; let lat_rad = inputs.latitude.unwrap_or(0.0).to_radians();
1002 let azimuth = inputs.shot_azimuth; Some(Vector3::new(
1004 omega_earth * lat_rad.cos() * azimuth.cos(), omega_earth * lat_rad.sin(), -omega_earth * lat_rad.cos() * azimuth.sin(), ))
1008 } else {
1009 None
1010 };
1011
1012 let traj_params = TrajectoryParams {
1014 mass_kg,
1015 bc,
1016 drag_model,
1017 wind_segments,
1018 atmos_params: params.atmo_params,
1019 omega_vector,
1020 enable_spin_drift: inputs.use_enhanced_spin_drift,
1021 enable_magnus: inputs.enable_magnus,
1022 enable_coriolis: inputs.enable_coriolis,
1023 target_distance_m: params.horiz,
1024 enable_wind_shear: inputs.enable_wind_shear,
1025 wind_shear_model: inputs.wind_shear_model.clone(),
1026 shooter_altitude_m: inputs.altitude,
1027 is_twist_right: inputs.is_twist_right,
1028 shooting_angle: inputs.shooting_angle,
1029 bullet_diameter: inputs.bullet_diameter,
1031 bullet_length: inputs.bullet_length,
1032 twist_rate: inputs.twist_rate,
1033 custom_drag_table: inputs.custom_drag_table.clone(),
1034 bc_segments: inputs.bc_segments.clone(),
1035 use_bc_segments: inputs.use_bc_segments,
1036 ground_threshold: -1000.0,
1040 atmo_sock: params.atmo_sock,
1042 };
1043
1044 let trajectory = integrate_trajectory(
1046 initial_state,
1047 params.t_span,
1048 traj_params,
1049 "RK45", 1e-6, 0.01, );
1053
1054 let n_points = trajectory.len();
1056 let mut times = Vec::with_capacity(n_points + 1);
1057 let mut states = Vec::with_capacity(n_points + 1);
1058
1059 let mut target_hit_time: Option<f64> = None;
1060 let mut ground_hit_time: Option<f64> = None;
1061 let mut max_ord_time = None;
1062 let mut max_ord_y = 0.0;
1063
1064 for (t, state_vec) in trajectory {
1065 let state = [
1067 state_vec[0],
1068 state_vec[1],
1069 state_vec[2],
1070 state_vec[3],
1071 state_vec[4],
1072 state_vec[5],
1073 ];
1074
1075 if let Some((&previous_time, &previous_state)) = times.last().zip(states.last()) {
1083 if let Some((apex_time, apex_state)) =
1084 interpolated_vertical_apex(previous_time, &previous_state, t, &state)
1085 {
1086 if apex_state[1] > max_ord_y {
1087 max_ord_y = apex_state[1];
1088 max_ord_time = Some(apex_time);
1089 }
1090 times.push(apex_time);
1091 states.push(apex_state);
1092 }
1093 }
1094
1095 if target_hit_time.is_none() && state[0] >= params.horiz {
1097 target_hit_time = Some(t);
1098 }
1099
1100 if ground_hit_time.is_none() && state[1] <= inputs.ground_threshold {
1102 ground_hit_time = Some(t);
1103 }
1104
1105 if state[1] > max_ord_y {
1107 max_ord_y = state[1];
1108 max_ord_time = Some(t);
1109 }
1110
1111 times.push(t);
1112 states.push(state);
1113 }
1114
1115 let t_events = [
1117 if let Some(t) = target_hit_time {
1118 vec![t]
1119 } else {
1120 vec![]
1121 },
1122 if let Some(t) = max_ord_time {
1123 vec![t]
1124 } else {
1125 vec![]
1126 },
1127 if let Some(t) = ground_hit_time {
1128 vec![t]
1129 } else {
1130 vec![]
1131 },
1132 ];
1133
1134 if inputs.use_enhanced_spin_drift {
1139 let (sd_temp_c, sd_press_hpa) = if params.atmo_params.2 > 0.0 {
1143 (params.atmo_params.1, params.atmo_params.2)
1144 } else {
1145 (15.0, 1013.25)
1146 };
1147 let sg = crate::spin_drift::effective_sg_from_inputs(inputs, sd_temp_c, sd_press_hpa);
1148 for (t, state) in times.iter().zip(states.iter_mut()) {
1149 if *t > 0.0 {
1150 state[2] += crate::spin_drift::litz_drift_meters(sg, *t, inputs.is_twist_right);
1151 }
1152 }
1153 }
1154
1155 FastSolution::from_trajectory_data(times, states, t_events)
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160 use super::*;
1161 use crate::BCSegmentData;
1162
1163 fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
1164 let (sin_angle, cos_angle) = angle.sin_cos();
1165 Vector3::new(
1166 level.x * cos_angle + level.y * sin_angle,
1167 -level.x * sin_angle + level.y * cos_angle,
1168 level.z,
1169 )
1170 }
1171
1172 #[test]
1173 fn measured_bc_fast_drag_ignores_name_based_form_factor_flag() {
1174 let derivatives_with_flag = |use_form_factor| {
1175 let inputs = BallisticInputs {
1176 bc_value: 0.462,
1177 bc_type: DragModel::G1,
1178 bullet_model: Some("168gr SMK Match".to_string()),
1179 use_form_factor,
1180 temperature: 15.0,
1181 pressure: 1013.25,
1182 ..BallisticInputs::default()
1183 };
1184
1185 compute_derivatives(
1186 &[0.0, 0.0, 0.0, 600.0, 0.0, 0.0],
1187 &inputs,
1188 &WindSock::new(vec![]),
1189 FastAtmosphere::Standard {
1190 base_density: 1.225,
1191 },
1192 &inputs.bc_type,
1193 crate::transonic_drag::ProjectileShape::Spitzer,
1194 inputs.bc_value,
1195 false,
1196 false,
1197 None,
1198 None,
1199 None,
1200 )
1201 };
1202
1203 let baseline = derivatives_with_flag(false);
1204 let flagged = derivatives_with_flag(true);
1205
1206 for component in 3..6 {
1207 assert_eq!(
1208 flagged[component].to_bits(),
1209 baseline[component].to_bits(),
1210 "published BC already encodes form factor: component {component}, baseline={} flagged={}",
1211 baseline[component],
1212 flagged[component]
1213 );
1214 }
1215 }
1216
1217 #[test]
1218 fn velocity_bc_data_requires_opt_in_in_plain_fast_kernel() {
1219 let acceleration = |inputs: &BallisticInputs| {
1220 let has_mach_segments = inputs
1221 .bc_segments
1222 .as_ref()
1223 .is_some_and(|segments| !segments.is_empty());
1224 let has_velocity_segments = inputs
1225 .bc_segments_data
1226 .as_ref()
1227 .is_some_and(|segments| !segments.is_empty());
1228 compute_derivatives(
1229 &[0.0, 0.0, 0.0, 600.0, 0.0, 0.0],
1230 inputs,
1231 &WindSock::new(vec![]),
1232 FastAtmosphere::Standard {
1233 base_density: 1.225,
1234 },
1235 &inputs.bc_type,
1236 crate::transonic_drag::ProjectileShape::Spitzer,
1237 inputs.bc_value,
1238 has_mach_segments,
1239 has_velocity_segments,
1240 None,
1241 None,
1242 None,
1243 )
1244 };
1245
1246 let scalar_inputs = BallisticInputs {
1247 bc_value: 0.5,
1248 bc_type: DragModel::G7,
1249 temperature: 15.0,
1250 pressure: 1013.25,
1251 ..BallisticInputs::default()
1252 };
1253 let mut disabled_inputs = scalar_inputs.clone();
1254 disabled_inputs.bc_segments_data = Some(vec![BCSegmentData {
1255 velocity_min: 0.0,
1256 velocity_max: 4_000.0,
1257 bc_value: 0.46,
1258 }]);
1259 disabled_inputs.use_bc_segments = false;
1260 let mut enabled_inputs = disabled_inputs.clone();
1261 enabled_inputs.use_bc_segments = true;
1262 let mut mach_only_inputs = scalar_inputs.clone();
1263 mach_only_inputs.bc_segments = Some(vec![(0.0, 0.4), (3.0, 0.4)]);
1264 let mut disabled_with_both = mach_only_inputs.clone();
1265 disabled_with_both.bc_segments_data = disabled_inputs.bc_segments_data.clone();
1266
1267 let scalar = acceleration(&scalar_inputs);
1268 let disabled = acceleration(&disabled_inputs);
1269 let enabled = acceleration(&enabled_inputs);
1270 let mach_only = acceleration(&mach_only_inputs);
1271 let disabled_with_both = acceleration(&disabled_with_both);
1272
1273 assert_eq!(
1274 disabled[3].to_bits(),
1275 scalar[3].to_bits(),
1276 "a populated velocity table must not change drag while use_bc_segments is false"
1277 );
1278 assert!(
1279 enabled[3] < disabled[3] - 1.0,
1280 "enabling the lower BC table must increase drag: disabled ax={} enabled ax={}",
1281 disabled[3],
1282 enabled[3]
1283 );
1284 assert_eq!(
1285 disabled_with_both[3].to_bits(),
1286 mach_only[3].to_bits(),
1287 "disabling velocity data must fall through to an explicit Mach table"
1288 );
1289 }
1290
1291 #[test]
1292 fn inclined_positions_at_same_world_altitude_have_same_fast_acceleration() {
1293 let angle = std::f64::consts::FRAC_PI_6;
1294 let inputs = BallisticInputs {
1295 altitude: 100.0,
1296 temperature: 15.0,
1297 pressure: 1013.25,
1298 shooting_angle: angle,
1299 ..BallisticInputs::default()
1300 };
1301 let wind_sock = WindSock::new(vec![]);
1302 let atmosphere = FastAtmosphere::Standard {
1303 base_density: 1.225,
1304 };
1305 let state_along_slant = [1_000.0, 0.0, 0.0, 600.0, 0.0, 0.0];
1306 let state_across_slant = [0.0, 500.0 / angle.cos(), 0.0, 600.0, 0.0, 0.0];
1307
1308 let a = compute_derivatives(
1309 &state_along_slant,
1310 &inputs,
1311 &wind_sock,
1312 atmosphere,
1313 &inputs.bc_type,
1314 crate::transonic_drag::ProjectileShape::Spitzer,
1315 inputs.bc_value,
1316 false,
1317 false,
1318 None,
1319 None,
1320 None,
1321 );
1322 let b = compute_derivatives(
1323 &state_across_slant,
1324 &inputs,
1325 &wind_sock,
1326 atmosphere,
1327 &inputs.bc_type,
1328 crate::transonic_drag::ProjectileShape::Spitzer,
1329 inputs.bc_value,
1330 false,
1331 false,
1332 None,
1333 None,
1334 None,
1335 );
1336
1337 for component in 3..6 {
1338 assert!(
1339 (a[component] - b[component]).abs() < 1e-10,
1340 "fast derivative component {component} differs at equal world altitude: {} vs {}",
1341 a[component],
1342 b[component]
1343 );
1344 }
1345 }
1346
1347 #[test]
1348 fn inclined_headwind_is_rotated_into_solver_frame() {
1349 let angle = std::f64::consts::FRAC_PI_6;
1350 let inputs = BallisticInputs {
1351 shooting_angle: angle,
1352 ..BallisticInputs::default()
1353 };
1354 let speed_mps = 360.0 * (1000.0 / 3600.0);
1355 let level_headwind = Vector3::new(-speed_mps, 0.0, 0.0);
1356 let velocity = expected_shot_frame_vector(level_headwind, angle);
1357 let state = [0.0, 0.0, 0.0, velocity.x, velocity.y, velocity.z];
1358 let actual = compute_derivatives(
1359 &state,
1360 &inputs,
1361 &WindSock::new(vec![crate::wind::WindSegment::new(360.0, 0.0, 1000.0)]),
1362 FastAtmosphere::Direct {
1363 air_density: 1.225,
1364 speed_of_sound: 340.0,
1365 },
1366 &inputs.bc_type,
1367 crate::transonic_drag::ProjectileShape::Spitzer,
1368 inputs.bc_value,
1369 false,
1370 false,
1371 None,
1372 None,
1373 None,
1374 );
1375 let expected = Vector3::new(
1376 -G_ACCEL_MPS2 * angle.sin(),
1377 -G_ACCEL_MPS2 * angle.cos(),
1378 0.0,
1379 );
1380
1381 assert!(
1382 (Vector3::new(actual[3], actual[4], actual[5]) - expected).norm() < 1e-12,
1383 "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
1384 );
1385 }
1386
1387 #[test]
1388 fn plain_fast_kernel_applies_power_law_wind_shear() {
1389 let state = [500.0, 100.0, 0.0, 700.0, 0.0, 0.0];
1390 let run = |enable_wind_shear: bool, model: &str, wind_speed_kmh: f64| {
1391 let inputs = BallisticInputs {
1392 bc_value: 0.5,
1393 bc_type: DragModel::G7,
1394 enable_wind_shear,
1395 wind_shear_model: model.to_string(),
1396 ..BallisticInputs::default()
1397 };
1398 let wind_shear_model = enable_wind_shear
1399 .then(|| crate::wind_shear::boundary_layer_model_from_name(model))
1400 .filter(|model| *model != crate::wind_shear::WindShearModel::None);
1401 compute_derivatives(
1402 &state,
1403 &inputs,
1404 &WindSock::new(vec![crate::wind::WindSegment::new(wind_speed_kmh, 90.0, 2_000.0)]),
1405 FastAtmosphere::Direct {
1406 air_density: 1.225,
1407 speed_of_sound: 340.0,
1408 },
1409 &inputs.bc_type,
1410 crate::transonic_drag::ProjectileShape::Spitzer,
1411 inputs.bc_value,
1412 false,
1413 false,
1414 None,
1415 wind_shear_model,
1416 None,
1417 )
1418 };
1419
1420 let uniform = run(false, "power_law", 36.0);
1421 let model_none = run(true, "none", 36.0);
1422 assert_eq!(model_none, uniform, "model=none must preserve uniform wind");
1423
1424 let sheared = run(true, "power_law", 36.0);
1425 assert!(
1426 sheared[5] < uniform[5],
1427 "stronger aloft crosswind must increase leftward acceleration: uniform={}, shear={}",
1428 uniform[5],
1429 sheared[5]
1430 );
1431
1432 let ratio = crate::wind_shear::boundary_layer_speed_ratio(
1433 state[1],
1434 crate::wind_shear::WindShearModel::PowerLaw,
1435 );
1436 let equivalent_uniform = run(false, "none", 36.0 * ratio);
1437 for component in 3..6 {
1438 assert!(
1439 (sheared[component] - equivalent_uniform[component]).abs() < 1e-12,
1440 "shear component {component} must equal base wind scaled by {ratio}: shear={}, expected={}",
1441 sheared[component],
1442 equivalent_uniform[component]
1443 );
1444 }
1445 }
1446
1447 #[test]
1448 fn plain_fast_path_wind_shear_changes_high_arc_drift() {
1449 let run = |enable_wind_shear: bool, model: &str| {
1450 let inputs = BallisticInputs {
1451 muzzle_velocity: 800.0,
1452 bc_value: 0.5,
1453 bc_type: DragModel::G7,
1454 bullet_mass: 168.0 * 0.00006479891,
1455 bullet_diameter: 0.308 * 0.0254,
1456 enable_wind_shear,
1457 wind_shear_model: model.to_string(),
1458 ground_threshold: -100.0,
1459 ..BallisticInputs::default()
1460 };
1461 let elevation = 0.12_f64;
1462 let solution = fast_integrate(
1463 &inputs,
1464 &WindSock::new(vec![crate::wind::WindSegment::new(36.0, 90.0, 2_000.0)]),
1465 FastIntegrationParams {
1466 horiz: 1_000.0,
1467 vert: 0.0,
1468 initial_state: [
1469 0.0,
1470 0.0,
1471 0.0,
1472 inputs.muzzle_velocity * elevation.cos(),
1473 inputs.muzzle_velocity * elevation.sin(),
1474 0.0,
1475 ],
1476 t_span: (0.0, 5.0),
1477 atmo_params: (0.0, 15.0, 1013.25, 1.0),
1478 atmo_sock: None,
1479 },
1480 );
1481 let last = solution.t.len() - 1;
1482 assert_eq!(solution.y[0][last].to_bits(), 1_000.0_f64.to_bits());
1483 solution.y[2][last]
1484 };
1485
1486 let uniform = run(false, "power_law");
1487 let model_none = run(true, "none");
1488 assert_eq!(model_none.to_bits(), uniform.to_bits());
1489 let sheared = run(true, "power_law");
1490 assert!(
1491 sheared.abs() > uniform.abs() + 0.01,
1492 "aloft shear must increase drift magnitude: uniform={uniform}, shear={sheared}"
1493 );
1494 }
1495
1496 #[test]
1497 fn inclined_coriolis_is_rotated_into_solver_frame() {
1498 let angle = std::f64::consts::FRAC_PI_6;
1499 let inputs = BallisticInputs {
1500 shooting_angle: angle,
1501 ..BallisticInputs::default()
1502 };
1503 let velocity = Vector3::new(600.0, 20.0, 5.0);
1504 let state = [0.0, 0.0, 0.0, velocity.x, velocity.y, velocity.z];
1505 let level_omega = Vector3::new(3.0e-5, 6.0e-5, -2.0e-5);
1506 let run = |omega| {
1507 compute_derivatives(
1508 &state,
1509 &inputs,
1510 &WindSock::new(vec![]),
1511 FastAtmosphere::Direct {
1512 air_density: 1.225,
1513 speed_of_sound: 340.0,
1514 },
1515 &inputs.bc_type,
1516 crate::transonic_drag::ProjectileShape::Spitzer,
1517 inputs.bc_value,
1518 false,
1519 false,
1520 omega,
1521 None,
1522 None,
1523 )
1524 };
1525 let baseline = run(None);
1526 let with_coriolis = run(Some(level_omega));
1527 let actual = Vector3::new(
1528 with_coriolis[3] - baseline[3],
1529 with_coriolis[4] - baseline[4],
1530 with_coriolis[5] - baseline[5],
1531 );
1532 let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
1533
1534 assert!(
1535 (actual - expected).norm() < 1e-12,
1536 "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
1537 );
1538 }
1539
1540 #[test]
1541 fn test_fast_solution_interpolation() {
1542 let times = vec![0.0, 1.0, 2.0];
1543 let states = vec![
1544 [0.0, 0.0, 0.0, 100.0, 50.0, 0.0],
1545 [100.0, 45.0, 0.0, 99.0, 40.0, 0.0],
1546 [198.0, 80.0, 0.0, 98.0, 30.0, 0.0],
1547 ];
1548
1549 let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
1550
1551 let result = solution.sol(&[1.5]);
1553
1554 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); }
1558
1559 #[test]
1560 fn test_bc_from_velocity_segments() {
1561 let segments = vec![
1562 BCSegmentData {
1563 velocity_min: 0.0,
1564 velocity_max: 1000.0,
1565 bc_value: 0.5,
1566 },
1567 BCSegmentData {
1568 velocity_min: 1000.0,
1569 velocity_max: 2000.0,
1570 bc_value: 0.52,
1571 },
1572 BCSegmentData {
1573 velocity_min: 2000.0,
1574 velocity_max: 3000.0,
1575 bc_value: 0.55,
1576 },
1577 ];
1578
1579 assert_eq!(velocity_segment_bc(500.0, &segments, 0.5), 0.5);
1580 assert_eq!(velocity_segment_bc(1500.0, &segments, 0.5), 0.52);
1581 assert_eq!(velocity_segment_bc(2500.0, &segments, 0.5), 0.55);
1582
1583 assert_eq!(velocity_segment_bc(-100.0, &segments, 0.5), 0.5); assert_eq!(velocity_segment_bc(3500.0, &segments, 0.5), 0.55); }
1587
1588 #[test]
1589 fn test_fast_solution_interpolation_edge_cases() {
1590 let times = vec![0.0, 1.0, 2.0, 3.0];
1591 let states = vec![
1592 [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
1593 [800.0, 40.0, 100.0, 750.0, 30.0, 0.0],
1594 [1550.0, 60.0, 200.0, 700.0, 10.0, 0.0],
1595 [2250.0, 50.0, 300.0, 650.0, -10.0, 0.0],
1596 ];
1597
1598 let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
1599
1600 let result_before = solution.sol(&[-0.5]);
1602 assert!((result_before[0][0] - 0.0).abs() < 1e-10); let result_after = solution.sol(&[5.0]);
1606 assert!((result_after[0][0] - 2250.0).abs() < 1e-10); let result_exact = solution.sol(&[1.0]);
1610 assert!((result_exact[0][0] - 800.0).abs() < 1e-10);
1611
1612 let result_multi = solution.sol(&[0.5, 1.5, 2.5]);
1614 assert_eq!(result_multi[0].len(), 3);
1615 }
1616
1617 #[test]
1618 fn test_fast_solution_from_trajectory_data() {
1619 let times = vec![0.0, 0.5, 1.0];
1620 let states = vec![
1621 [0.0, 1.0, 2.0, 3.0, 4.0, 5.0],
1622 [10.0, 11.0, 12.0, 13.0, 14.0, 15.0],
1623 [20.0, 21.0, 22.0, 23.0, 24.0, 25.0],
1624 ];
1625 let t_events = [vec![1.0], vec![0.5], vec![]];
1626
1627 let solution = FastSolution::from_trajectory_data(times.clone(), states, t_events);
1628
1629 assert_eq!(solution.t, times);
1631 assert_eq!(solution.y.len(), 6); assert_eq!(solution.y[0].len(), 3); assert!(solution.success);
1634
1635 assert_eq!(solution.y[0][0], 0.0); assert_eq!(solution.y[1][0], 1.0); assert_eq!(solution.y[0][2], 20.0); }
1640
1641 #[test]
1642 fn test_bc_segments_boundary_conditions() {
1643 let single_segment = vec![BCSegmentData {
1645 velocity_min: 1000.0,
1646 velocity_max: 2000.0,
1647 bc_value: 0.5,
1648 }];
1649
1650 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![
1657 BCSegmentData {
1658 velocity_min: 0.0,
1659 velocity_max: 999.0, bc_value: 0.45,
1661 },
1662 BCSegmentData {
1663 velocity_min: 1000.0,
1664 velocity_max: 2000.0,
1665 bc_value: 0.50,
1666 },
1667 ];
1668
1669 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); }
1674
1675 #[test]
1676 fn velocity_segment_gaps_and_clamps_do_not_depend_on_order() {
1677 let fallback_bc = 0.73;
1678 let ascending_with_gap = vec![
1679 BCSegmentData {
1680 velocity_min: 0.0,
1681 velocity_max: 999.0,
1682 bc_value: 0.6,
1683 },
1684 BCSegmentData {
1685 velocity_min: 1000.0,
1686 velocity_max: 2000.0,
1687 bc_value: 0.8,
1688 },
1689 ];
1690 assert_eq!(
1691 velocity_segment_bc(999.5, &ascending_with_gap, fallback_bc),
1692 fallback_bc,
1693 "coverage gaps must use the projectile's base BC"
1694 );
1695
1696 let mut descending = ascending_with_gap.clone();
1697 descending.reverse();
1698 assert_eq!(
1699 velocity_segment_bc(-100.0, &descending, fallback_bc),
1700 0.6,
1701 "below coverage must clamp to the lowest-velocity band"
1702 );
1703 assert_eq!(
1704 velocity_segment_bc(2500.0, &descending, fallback_bc),
1705 0.8,
1706 "above coverage must clamp to the highest-velocity band"
1707 );
1708 }
1709
1710 #[test]
1711 fn test_bc_segments_empty_fallback() {
1712 let empty_segments: Vec<BCSegmentData> = vec![];
1713
1714 let result = velocity_segment_bc(1500.0, &empty_segments, 0.73);
1716 assert_eq!(result, 0.73); }
1718
1719 #[test]
1720 fn test_fast_integration_params() {
1721 let params = FastIntegrationParams {
1723 horiz: 1000.0,
1724 vert: 0.0,
1725 initial_state: [0.0, 0.0, 0.0, 800.0, 50.0, 0.0], t_span: (0.0, 5.0),
1727 atmo_params: (0.0, 15.0, 1013.25, 1.0),
1728 atmo_sock: None,
1729 };
1730
1731 assert_eq!(params.horiz, 1000.0);
1732 assert_eq!(params.t_span.0, 0.0);
1733 assert_eq!(params.t_span.1, 5.0);
1734 assert_eq!(params.initial_state[3], 800.0); }
1736
1737 #[test]
1738 fn test_fast_solution_event_arrays() {
1739 let times = vec![0.0, 1.0, 2.0];
1740 let states = vec![
1741 [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
1742 [800.0, 40.0, 500.0, 750.0, 30.0, 0.0],
1743 [1500.0, 20.0, 1000.0, 700.0, 10.0, 0.0],
1744 ];
1745
1746 let t_events = [
1748 vec![2.0], vec![0.5], vec![], ];
1752
1753 let solution = FastSolution::from_trajectory_data(times, states, t_events);
1754
1755 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()); }
1759
1760 #[test]
1761 fn segmented_fast_path_interpolates_max_ordinate_between_saved_points() {
1762 let expected_apex_time = 5.105_f64;
1763 let downrange_velocity = 100.0_f64;
1764 let vertical_velocity = G_ACCEL_MPS2 * expected_apex_time;
1765 let inputs = BallisticInputs {
1766 muzzle_velocity: downrange_velocity.hypot(vertical_velocity),
1767 bc_value: 0.5,
1768 bc_type: DragModel::G7,
1769 use_enhanced_spin_drift: false,
1770 ..BallisticInputs::default()
1771 };
1772 let solution = fast_integrate_with_segments(
1773 &inputs,
1774 vec![],
1775 FastIntegrationParams {
1776 horiz: 1_000.0,
1777 vert: 0.0,
1778 initial_state: [0.0, 0.0, 0.0, downrange_velocity, vertical_velocity, 0.0],
1779 t_span: (0.0, 12.0),
1780 atmo_params: (1e-12, 340.0, 0.0, 0.0),
1783 atmo_sock: None,
1784 },
1785 );
1786
1787 assert!(solution.success);
1788 assert_eq!(solution.t_events[1].len(), 1);
1789 let reported_time = solution.t_events[1][0];
1790 assert!(
1791 (reported_time - expected_apex_time).abs() < 0.006,
1792 "max-ordinate time must be interpolated between coarse saves: reported={reported_time} expected={expected_apex_time}"
1793 );
1794 let event_index = solution
1795 .t
1796 .iter()
1797 .position(|time| time.to_bits() == reported_time.to_bits())
1798 .expect("the interpolated apex must be retained in the solution");
1799 assert_eq!(solution.y[4][event_index].to_bits(), 0.0_f64.to_bits());
1800
1801 let expected_height = vertical_velocity * expected_apex_time
1802 - 0.5 * G_ACCEL_MPS2 * expected_apex_time.powi(2);
1803 let event_state = solution.sol(&[reported_time]);
1804 assert!(
1805 (event_state[1][0] - expected_height).abs() < 2e-4,
1806 "max-ordinate state must preserve the interpolated apex height: reported={} expected={expected_height}",
1807 event_state[1][0]
1808 );
1809 }
1810
1811 #[test]
1812 fn plain_fast_path_interpolates_the_target_crossing() {
1813 let target = 500.123456789;
1814 let initial_state = [0.0, 0.0, 0.25, 800.0, 12.0, -2.5];
1815 let inputs = BallisticInputs {
1816 muzzle_velocity: 800.0,
1817 bc_value: 0.5,
1818 bc_type: DragModel::G7,
1819 ground_threshold: -100.0,
1820 use_enhanced_spin_drift: false,
1821 ..BallisticInputs::default()
1822 };
1823 let run = |horiz| {
1824 fast_integrate(
1825 &inputs,
1826 &WindSock::new(vec![]),
1827 FastIntegrationParams {
1828 horiz,
1829 vert: 0.0,
1830 initial_state,
1831 t_span: (0.0, 2.0),
1832 atmo_params: (0.0, 15.0, 1013.25, 1.0),
1833 atmo_sock: None,
1834 },
1835 )
1836 };
1837
1838 let reference = run(target + 2.0);
1840 let left = reference.y[0]
1841 .windows(2)
1842 .position(|x| x[0] < target && x[1] > target)
1843 .expect("reference trajectory must bracket target");
1844 let right = left + 1;
1845 let alpha =
1846 (target - reference.y[0][left]) / (reference.y[0][right] - reference.y[0][left]);
1847
1848 let solution = run(target);
1849 let last = solution.t.len() - 1;
1850 assert_eq!(solution.y[0][last].to_bits(), target.to_bits());
1851 let expected_time = reference.t[left] + alpha * (reference.t[right] - reference.t[left]);
1852 assert!((solution.t[last] - expected_time).abs() < 1e-12);
1853 assert_eq!(solution.t_events[0], vec![solution.t[last]]);
1854
1855 for component in 0..6 {
1856 assert_eq!(solution.y[component].len(), solution.t.len());
1857 let expected = reference.y[component][left]
1858 + alpha * (reference.y[component][right] - reference.y[component][left]);
1859 assert!(
1860 (solution.y[component][last] - expected).abs() < 1e-9,
1861 "component {component} is not at the crossing: actual={}, expected={expected}",
1862 solution.y[component][last]
1863 );
1864 }
1865 }
1866
1867 #[test]
1868 fn fast_path_coriolis_uses_shot_direction() {
1869 use std::f64::consts::FRAC_PI_2;
1874 fn final_xy(shot_az: f64) -> (f64, f64) {
1876 let inputs = BallisticInputs {
1877 muzzle_velocity: 800.0,
1878 bc_value: 0.5,
1879 bc_type: DragModel::G7,
1880 enable_advanced_effects: true, enable_coriolis: true,
1882 latitude: Some(45.0),
1883 shot_azimuth: shot_az,
1884 ..BallisticInputs::default()
1885 };
1886 let v = 800.0_f64;
1887 let elev = 0.02_f64;
1888 let params = FastIntegrationParams {
1889 horiz: 1000.0,
1890 vert: 0.0,
1891 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
1892 t_span: (0.0, 5.0),
1893 atmo_params: (0.0, 15.0, 1013.25, 1.0),
1894 atmo_sock: None,
1895 };
1896 let sol = fast_integrate_with_segments(&inputs, vec![], params);
1897 let n = sol.y[0].len();
1898 (sol.y[0][n - 1], sol.y[1][n - 1])
1899 }
1900 let (ex, ey) = final_xy(FRAC_PI_2); let (wx, wy) = final_xy(3.0 * FRAC_PI_2); assert!(
1905 (ex - wx).abs() < 0.5,
1906 "east/west downrange should be ~equal (ex={ex:.4}, wx={wx:.4})"
1907 );
1908 assert!(
1911 ey > wy,
1912 "fast-path east ({ey:.6}) must be higher than west ({wy:.6}) (Eotvos)"
1913 );
1914 assert!(
1915 (ey - wy) > 1e-5,
1916 "fast-path E-W vertical separation ({:.8} m) should be non-zero (the pre-fix bug was exact equality)",
1917 ey - wy
1918 );
1919 }
1920
1921 #[test]
1922 fn fast_path_coriolis_independent_of_advanced_effects() {
1923 use std::f64::consts::FRAC_PI_2;
1926 fn final_y(coriolis: bool, shot_az: f64) -> f64 {
1927 let inputs = BallisticInputs {
1928 muzzle_velocity: 800.0,
1929 bc_value: 0.5,
1930 bc_type: DragModel::G7,
1931 enable_coriolis: coriolis,
1932 enable_advanced_effects: false, latitude: Some(45.0),
1934 shot_azimuth: shot_az,
1935 ..BallisticInputs::default()
1936 };
1937 let v = 800.0_f64;
1938 let elev = 0.02_f64;
1939 let params = FastIntegrationParams {
1940 horiz: 1000.0,
1941 vert: 0.0,
1942 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
1943 t_span: (0.0, 5.0),
1944 atmo_params: (0.0, 15.0, 1013.25, 1.0),
1945 atmo_sock: None,
1946 };
1947 let sol = fast_integrate_with_segments(&inputs, vec![], params);
1948 let n = sol.y[0].len();
1949 sol.y[1][n - 1]
1950 }
1951 let e = final_y(true, FRAC_PI_2);
1953 let w = final_y(true, 3.0 * FRAC_PI_2);
1954 assert!(
1955 e > w && (e - w) > 1e-5,
1956 "Coriolis-only (no advanced effects) must still be directional: E={e} W={w}"
1957 );
1958 let e2 = final_y(false, FRAC_PI_2);
1960 let w2 = final_y(false, 3.0 * FRAC_PI_2);
1961 assert!(
1962 (e2 - w2).abs() < 1e-9,
1963 "with enable_coriolis=false, east/west must be identical: E={e2} W={w2}"
1964 );
1965 }
1966
1967 #[test]
1968 fn fast_path_rejects_degenerate_atmosphere() {
1969 let inputs = BallisticInputs {
1970 muzzle_velocity: 800.0,
1971 bc_value: 0.5,
1972 bc_type: DragModel::G7,
1973 ..BallisticInputs::default()
1974 };
1975 let v = 800.0_f64;
1976 let e = 0.02_f64;
1977 let mk = |atmo: (f64, f64, f64, f64)| FastIntegrationParams {
1978 horiz: 500.0,
1979 vert: 0.0,
1980 initial_state: [0.0, 0.0, 0.0, v * e.cos(), v * e.sin(), 0.0],
1981 t_span: (0.0, 5.0),
1982 atmo_params: atmo,
1983 atmo_sock: None,
1984 };
1985 let zero_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 0.0, 1.0)));
1987 assert!(
1988 !zero_p.success,
1989 "pressure=0 atmosphere must yield success=false"
1990 );
1991 let nan_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, f64::NAN, 1.0)));
1993 assert!(!nan_p.success, "NaN pressure must yield success=false");
1994 let segmented_too_dense =
1996 fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 1013.25, 50.0)));
1997 let plain_too_dense = fast_integrate(
1998 &inputs,
1999 &WindSock::new(vec![]),
2000 mk((0.0, 15.0, 1013.25, 50.0)),
2001 );
2002 assert!(
2003 !segmented_too_dense.success && !plain_too_dense.success,
2004 "ratio=50 atmosphere must fail in both wrappers: segmented={}, plain={}",
2005 segmented_too_dense.success,
2006 plain_too_dense.success
2007 );
2008 let good = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 1013.25, 1.0)));
2010 assert!(good.success, "realistic atmosphere must yield success=true");
2011 let direct = fast_integrate_with_segments(&inputs, vec![], mk((1.225, 340.0, 0.0, 0.0)));
2014 assert!(
2015 direct.success,
2016 "direct-atmosphere mode (pressure=0 sentinel) must yield success=true"
2017 );
2018 }
2019
2020 #[test]
2021 fn fast_paths_reject_invalid_wind_segments() {
2022 let inputs = BallisticInputs {
2023 muzzle_velocity: 800.0,
2024 bc_value: 0.5,
2025 bc_type: DragModel::G7,
2026 ..BallisticInputs::default()
2027 };
2028 let mk = || FastIntegrationParams {
2029 horiz: 100.0,
2030 vert: 0.0,
2031 initial_state: [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
2032 t_span: (0.0, 5.0),
2033 atmo_params: (0.0, 15.0, 1013.25, 1.0),
2034 atmo_sock: None,
2035 };
2036 let invalid = crate::wind::WindSegment::new(10.0, 90.0, f64::NAN);
2037
2038 let plain = fast_integrate(&inputs, &WindSock::new(vec![invalid]), mk());
2039 let segmented = fast_integrate_with_segments(&inputs, vec![invalid], mk());
2040
2041 assert!(
2042 !plain.success && !segmented.success,
2043 "invalid segmented wind must fail in both fast wrappers: plain={}, segmented={}",
2044 plain.success,
2045 segmented.success
2046 );
2047 }
2048
2049 #[test]
2050 fn plain_fast_path_honors_direct_atmosphere_values() {
2051 fn final_speed(muzzle_velocity: f64, atmo_params: (f64, f64, f64, f64)) -> f64 {
2052 let inputs = BallisticInputs {
2053 muzzle_velocity,
2054 bc_value: 0.5,
2055 bc_type: DragModel::G7,
2056 ground_threshold: -100.0,
2057 ..BallisticInputs::default()
2058 };
2059
2060 let wind_sock = WindSock::new(vec![]);
2061 let solution = fast_integrate(
2062 &inputs,
2063 &wind_sock,
2064 FastIntegrationParams {
2065 horiz: 10_000.0,
2066 vert: 0.0,
2067 initial_state: [0.0, 0.0, 0.0, muzzle_velocity, 0.0, 0.0],
2068 t_span: (0.0, 0.2),
2069 atmo_params,
2070 atmo_sock: None,
2071 },
2072 );
2073 assert!(solution.success);
2074
2075 let last = solution.y[0].len() - 1;
2076 (solution.y[3][last].powi(2)
2077 + solution.y[4][last].powi(2)
2078 + solution.y[5][last].powi(2))
2079 .sqrt()
2080 }
2081
2082 let thin_air = final_speed(800.0, (0.905, 340.0, 0.0, 0.0));
2083 let dense_air = final_speed(800.0, (1.225, 340.0, 0.0, 0.0));
2084 assert!(
2085 thin_air > dense_air,
2086 "lower supplied density must retain more velocity: thin={thin_air}, dense={dense_air}"
2087 );
2088
2089 let low_sound_speed = final_speed(340.0, (1.0, 300.0, 0.0, 0.0));
2090 let high_sound_speed = final_speed(340.0, (1.0, 400.0, 0.0, 0.0));
2091 assert!(
2092 (low_sound_speed - high_sound_speed).abs() > 1e-6,
2093 "supplied sound speed must affect Mach-dependent drag"
2094 );
2095 }
2096
2097 #[test]
2098 fn segmented_fast_path_nonpositive_density_ratio_uses_standard_fallback() {
2099 fn terminal_velocity(base_ratio: f64) -> f64 {
2100 let inputs = BallisticInputs {
2101 muzzle_velocity: 800.0,
2102 bc_value: 0.5,
2103 bc_type: DragModel::G7,
2104 ground_threshold: -100.0,
2105 ..BallisticInputs::default()
2106 };
2107
2108 let solution = fast_integrate_with_segments(
2109 &inputs,
2110 vec![],
2111 FastIntegrationParams {
2112 horiz: 500.0,
2113 vert: 0.0,
2114 initial_state: [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
2115 t_span: (0.0, 5.0),
2116 atmo_params: (0.0, 15.0, 1013.25, base_ratio),
2117 atmo_sock: None,
2118 },
2119 );
2120 assert!(solution.success);
2121
2122 let last = solution.y[3].len() - 1;
2123 solution.y[3][last]
2124 }
2125
2126 let explicit_sea_level = terminal_velocity(1.0);
2127 for base_ratio in [0.0, -1.0] {
2128 let missing_ratio = terminal_velocity(base_ratio);
2129 assert!(
2130 missing_ratio < 800.0,
2131 "missing density ratio must not create a vacuum trajectory: {missing_ratio}"
2132 );
2133 assert!((missing_ratio - explicit_sea_level).abs() < 1e-9);
2134 }
2135 }
2136
2137 #[test]
2138 fn fast_path_carries_real_bullet_geometry() {
2139 let run = |diameter: f64, twist: f64| {
2147 let inputs = BallisticInputs {
2148 muzzle_velocity: 800.0,
2149 bc_value: 0.5,
2150 bc_type: DragModel::G7,
2151 bullet_diameter: diameter,
2152 bullet_length: 0.0318,
2153 twist_rate: twist,
2154 enable_advanced_effects: true,
2155 enable_magnus: true,
2156 ..BallisticInputs::default()
2157 };
2158 let v = 800.0_f64;
2159 let elev = 0.02_f64;
2160 let params = FastIntegrationParams {
2161 horiz: 1000.0,
2162 vert: 0.0,
2163 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
2164 t_span: (0.0, 5.0),
2165 atmo_params: (0.0, 15.0, 1013.25, 1.0),
2166 atmo_sock: None,
2167 };
2168 fast_integrate_with_segments(&inputs, vec![], params)
2169 };
2170 assert!(run(0.00569, 7.0).success, ".224 geometry must solve");
2173 assert!(run(0.00858, 10.0).success, ".338 geometry must solve");
2174 }
2175
2176 #[test]
2177 fn segmented_fast_spin_flags_do_not_depend_on_advanced_umbrella() {
2178 fn endpoint(
2179 enable_advanced_effects: bool,
2180 enable_magnus: bool,
2181 use_enhanced_spin_drift: bool,
2182 ) -> Vector3<f64> {
2183 let inputs = BallisticInputs {
2184 muzzle_velocity: 823.0,
2185 bullet_mass: 168.0 * 0.00006479891,
2186 bullet_diameter: 0.308 * 0.0254,
2187 bullet_length: 1.215 * 0.0254,
2188 caliber_inches: 0.308,
2189 weight_grains: 168.0,
2190 bc_value: 0.475,
2191 bc_type: DragModel::G1,
2192 twist_rate: 12.0,
2193 is_twist_right: true,
2194 enable_advanced_effects,
2195 enable_magnus,
2196 use_enhanced_spin_drift,
2197 ..BallisticInputs::default()
2198 };
2199 let elevation = 0.02_f64;
2200 let solution = fast_integrate_with_segments(
2201 &inputs,
2202 vec![],
2203 FastIntegrationParams {
2204 horiz: 1_000.0,
2205 vert: 0.0,
2206 initial_state: [
2207 0.0,
2208 0.0,
2209 0.0,
2210 inputs.muzzle_velocity * elevation.cos(),
2211 inputs.muzzle_velocity * elevation.sin(),
2212 0.0,
2213 ],
2214 t_span: (0.0, 5.0),
2215 atmo_params: (0.0, 15.0, 1013.25, 1.0),
2216 atmo_sock: None,
2217 },
2218 );
2219 assert!(solution.success);
2220 let last = solution.t.len() - 1;
2221 Vector3::new(
2222 solution.y[0][last],
2223 solution.y[1][last],
2224 solution.y[2][last],
2225 )
2226 }
2227
2228 let baseline = endpoint(false, false, false);
2229 let magnus_without_umbrella = endpoint(false, true, false);
2230 let magnus_with_umbrella = endpoint(true, true, false);
2231 assert!(
2232 (magnus_without_umbrella - baseline).norm() > 1e-5,
2233 "test shot must produce a measurable Magnus displacement"
2234 );
2235 assert!(
2236 (magnus_with_umbrella - magnus_without_umbrella).norm() < 1e-12,
2237 "the legacy umbrella must not suppress explicitly enabled Magnus: without={magnus_without_umbrella:?} with={magnus_with_umbrella:?}"
2238 );
2239
2240 let litz_only = endpoint(false, false, true);
2241 let litz_without_umbrella = endpoint(false, true, true);
2242 let litz_with_umbrella = endpoint(true, true, true);
2243 assert!(
2244 (litz_without_umbrella - litz_only).norm() < 1e-12,
2245 "Litz mode must suppress explicitly enabled Magnus: litz={litz_only:?} both={litz_without_umbrella:?}"
2246 );
2247 assert!(
2248 (litz_with_umbrella - litz_without_umbrella).norm() < 1e-12,
2249 "the legacy umbrella must not change Magnus suppression in Litz mode: without={litz_without_umbrella:?} with={litz_with_umbrella:?}"
2250 );
2251 }
2252}