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