1#[cfg(test)]
7use crate::constants::G_ACCEL_MPS2;
8use crate::gravity::Gravity;
9use crate::{
10 atmosphere::{calculate_air_density_cimp, get_local_atmosphere_humid_with_gravity, AtmoSock},
11 bc_estimation::velocity_segment_bc,
12 constants::{MPS_TO_FPS, STANDARD_AIR_DENSITY},
13 drag::get_drag_coefficient,
14 wind::WindSock,
15 DragModel, InternalBallisticInputs as BallisticInputs,
16};
17use nalgebra::Vector3;
18
19#[derive(Debug, Clone)]
21pub struct FastSolution {
22 pub t: Vec<f64>,
24 pub y: Vec<Vec<f64>>,
26 pub t_events: [Vec<f64>; 3],
28 pub success: bool,
30}
31
32impl FastSolution {
33 pub fn sol(&self, t_query: &[f64]) -> Vec<Vec<f64>> {
35 let mut result = vec![vec![0.0; t_query.len()]; 6];
36
37 for (i, &tq) in t_query.iter().enumerate() {
38 let idx = match self
41 .t
42 .binary_search_by(|&t| t.partial_cmp(&tq).unwrap_or(std::cmp::Ordering::Greater))
43 {
44 Ok(idx) => idx,
45 Err(idx) => idx,
46 };
47
48 if idx == 0 {
49 for (result_component, source_component) in result.iter_mut().zip(&self.y) {
51 result_component[i] = source_component[0];
52 }
53 } else if idx >= self.t.len() {
54 for (result_component, source_component) in result.iter_mut().zip(&self.y) {
56 result_component[i] = source_component[self.t.len() - 1];
57 }
58 } else {
59 let t0 = self.t[idx - 1];
61 let t1 = self.t[idx];
62 let span = t1 - t0;
63
64 for (result_component, source_component) in result.iter_mut().zip(&self.y) {
65 let y0 = source_component[idx - 1];
66 let y1 = source_component[idx];
67 result_component[i] = if span.abs() < f64::EPSILON {
68 y1
69 } else {
70 let frac = (tq - t0) / span;
71 y0 + frac * (y1 - y0)
72 };
73 }
74 }
75 }
76
77 result
78 }
79
80 pub fn from_trajectory_data(
82 times: Vec<f64>,
83 states: Vec<[f64; 6]>,
84 t_events: [Vec<f64>; 3],
85 ) -> Self {
86 let n_points = times.len();
87 let mut y = vec![vec![0.0; n_points]; 6];
88
89 for (i, state) in states.iter().enumerate() {
90 for j in 0..6 {
91 y[j][i] = state[j];
92 }
93 }
94
95 FastSolution {
96 t: times,
97 y,
98 t_events,
99 success: true,
100 }
101 }
102
103 fn degenerate(initial_state: &[f64; 6]) -> Self {
107 let mut y = vec![Vec::new(); 6];
108 for (j, slot) in y.iter_mut().enumerate() {
109 slot.push(initial_state[j]);
110 }
111 FastSolution {
112 t: vec![0.0],
113 y,
114 t_events: [Vec::new(), Vec::new(), Vec::new()],
115 success: false,
116 }
117 }
118}
119
120fn direct_atmosphere_values(atmo_params: (f64, f64, f64, f64)) -> Option<(f64, f64)> {
121 let (a, b, c, d) = atmo_params;
122 (a.is_finite() && b.is_finite() && c == 0.0 && d == 0.0 && a > 0.0 && a < 2.0 && b > 200.0)
123 .then_some((a, b))
124}
125
126fn stability_atmosphere_params(atmo_params: (f64, f64, f64, f64)) -> (f64, f64, f64, f64) {
127 if let Some((air_density, _)) = direct_atmosphere_values(atmo_params) {
128 (0.0, 15.0, 1013.25 * air_density / STANDARD_AIR_DENSITY, 1.0)
132 } else {
133 atmo_params
134 }
135}
136
137const MAX_STANDARD_DENSITY_RATIO: f64 = 2.0;
138
139fn atmo_is_physical(atmo_params: (f64, f64, f64, f64)) -> bool {
152 let (a, b, c, d) = atmo_params;
153 if !(a.is_finite() && b.is_finite() && c.is_finite() && d.is_finite()) {
154 return false;
155 }
156 direct_atmosphere_values(atmo_params).is_some() || (c > 0.0 && d < MAX_STANDARD_DENSITY_RATIO)
161}
162
163#[derive(Debug, Clone, Copy)]
164enum FastAtmosphere {
165 Direct {
166 air_density: f64,
167 speed_of_sound: f64,
168 },
169 Standard {
170 base_density: f64,
171 },
172}
173
174pub struct FastIntegrationParams {
176 pub horiz: f64,
177 pub vert: f64,
178 pub initial_state: [f64; 6],
183 pub t_span: (f64, f64),
184 pub atmo_params: (f64, f64, f64, f64),
190 pub atmo_sock: Option<AtmoSock>,
195}
196
197pub fn aerodynamic_jump_launch_offset_rad(
205 inputs: &BallisticInputs,
206 atmo_params: (f64, f64, f64, f64),
207) -> f64 {
208 let crosswind_from_right_mps = inputs.wind_speed * inputs.wind_angle.sin();
209 aerodynamic_jump_launch_offset_for_crosswind_rad(inputs, atmo_params, crosswind_from_right_mps)
210}
211
212fn aerodynamic_jump_launch_offset_for_crosswind_rad(
213 inputs: &BallisticInputs,
214 atmo_params: (f64, f64, f64, f64),
215 crosswind_from_right_mps: f64,
216) -> f64 {
217 if !inputs.enable_aerodynamic_jump {
218 return 0.0;
219 }
220 let diameter = inputs.bullet_diameter;
221 if !(inputs.twist_rate.is_finite()
222 && inputs.twist_rate != 0.0
223 && diameter.is_finite()
224 && diameter > 0.0
225 && inputs.bullet_length.is_finite()
226 && inputs.bullet_length > 0.0
227 && inputs.muzzle_velocity.is_finite())
228 {
229 return 0.0;
230 }
231 let stability_atmo = stability_atmosphere_params(atmo_params);
232 let sg = crate::stability::compute_stability_coefficient(inputs, stability_atmo);
233 if !(sg.is_finite() && sg > 0.0) {
234 return 0.0;
235 }
236 let length_cal = inputs.bullet_length / diameter;
237 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
238 let crosswind_from_right_mph = crosswind_from_right_mps * MS_TO_MPH;
239 let vertical_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
240 sg,
241 length_cal,
242 crosswind_from_right_mph,
243 inputs.is_twist_right,
244 );
245 if !vertical_moa.is_finite() {
246 return 0.0;
247 }
248 const MOA_PER_RAD: f64 = 3437.7467707849;
249 vertical_moa / MOA_PER_RAD
250}
251
252fn rotate_launch_velocity(state: &mut [f64; 6], theta_rad: f64) {
255 let (vx, vy, vz) = (state[3], state[4], state[5]);
256 let speed = (vx * vx + vy * vy + vz * vz).sqrt();
257 if speed <= 0.0 {
258 return;
259 }
260 let h = (vx * vx + vz * vz).sqrt(); let new_elev = vy.atan2(h) + theta_rad;
262 state[4] = speed * new_elev.sin();
263 let new_h = speed * new_elev.cos();
264 let scale = if h > 1e-12 { new_h / h } else { 0.0 };
265 state[3] = vx * scale;
266 state[5] = vz * scale;
267}
268
269fn launch_state_with_aerodynamic_jump(
270 inputs: &BallisticInputs,
271 atmo_params: (f64, f64, f64, f64),
272 segmented_crosswind_from_right_mps: Option<f64>,
273 mut initial_state: [f64; 6],
274) -> [f64; 6] {
275 let offset = match segmented_crosswind_from_right_mps {
276 Some(crosswind) => {
277 aerodynamic_jump_launch_offset_for_crosswind_rad(inputs, atmo_params, crosswind)
278 }
279 None => aerodynamic_jump_launch_offset_rad(inputs, atmo_params),
280 };
281 if offset != 0.0 {
282 rotate_launch_velocity(&mut initial_state, offset);
283 }
284 initial_state
285}
286
287pub fn fast_integrate(
289 inputs: &BallisticInputs,
290 wind_sock: &WindSock,
291 params: FastIntegrationParams,
292) -> FastSolution {
293 fast_integrate_with_gravity(inputs, wind_sock, params, Gravity::EARTH)
294}
295
296pub(crate) fn fast_integrate_with_gravity(
297 inputs: &BallisticInputs,
298 wind_sock: &WindSock,
299 params: FastIntegrationParams,
300 gravity: Gravity,
301) -> FastSolution {
302 if wind_sock.validate_segments().is_err() || !atmo_is_physical(params.atmo_params) {
304 return FastSolution::degenerate(¶ms.initial_state);
305 }
306 let mut effective_inputs = inputs.clone();
307 effective_inputs.normalize_for_solve();
312 if params.atmo_params.2 > 0.0 {
313 effective_inputs.altitude = params.atmo_params.0;
314 effective_inputs.temperature = params.atmo_params.1;
315 effective_inputs.pressure = params.atmo_params.2;
316 effective_inputs.humidity = params.atmo_params.3;
317 }
318 let inputs = &effective_inputs;
319 let _mass_kg = inputs.bullet_mass; let bc = inputs.bc_value;
322 let drag_model = &inputs.bc_type;
323
324 let has_bc_segments =
326 inputs.bc_segments.is_some() && !inputs.bc_segments.as_ref().unwrap().is_empty();
327 let has_bc_segments_data =
328 inputs.bc_segments_data.is_some() && !inputs.bc_segments_data.as_ref().unwrap().is_empty();
329
330 let dt = if params.horiz > 200.0 {
332 0.001
333 } else if params.horiz > 100.0 {
334 0.0005
335 } else {
336 0.0001
337 };
338
339 let segmented_crosswind_from_right_mps = if inputs.enable_aerodynamic_jump {
342 wind_sock.muzzle_crosswind_from_right_mps()
343 } else {
344 None
345 };
346 let initial_state = launch_state_with_aerodynamic_jump(
347 inputs,
348 params.atmo_params,
349 segmented_crosswind_from_right_mps,
350 params.initial_state,
351 );
352 let vx = initial_state[3]; let n_steps = ((params.t_span.1 / dt) as usize) + 1;
366 let est_steps = if vx > 1e-6 && params.horiz > 0.0 {
367 (((4.0 * params.horiz / vx) / dt) as usize) + 1
368 } else {
369 n_steps
370 };
371 let cap = est_steps.min(n_steps);
372 let mut times = Vec::with_capacity(cap);
373 let mut states = Vec::with_capacity(cap);
374
375 times.push(0.0);
377 states.push(initial_state);
378
379 let atmosphere =
384 if let Some((air_density, speed_of_sound)) = direct_atmosphere_values(params.atmo_params) {
385 FastAtmosphere::Direct {
386 air_density,
387 speed_of_sound,
388 }
389 } else {
390 let base_density = if params.atmo_params.3 > 0.0 {
391 params.atmo_params.3 * 1.225
392 } else {
393 1.225
394 };
395 FastAtmosphere::Standard { base_density }
396 };
397
398 let atmo_sock = params.atmo_sock.as_ref();
400
401 let drag_model_str: &str = match drag_model {
409 DragModel::G1 => "G1",
410 DragModel::G2 => "G2",
411 DragModel::G5 => "G5",
412 DragModel::G6 => "G6",
413 DragModel::G7 => "G7",
414 DragModel::G8 => "G8",
415 DragModel::GI => "GI",
416 DragModel::GS => "GS",
417 DragModel::RA4 => "RA4",
418 };
419
420 let caliber_in = if inputs.caliber_inches > 0.0 {
422 inputs.caliber_inches
423 } else {
424 inputs.bullet_diameter / 0.0254
425 };
426 let weight_gr = if inputs.weight_grains > 0.0 {
427 inputs.weight_grains
428 } else {
429 inputs.bullet_mass / crate::constants::GRAINS_TO_KG
430 };
431
432 let projectile_shape = crate::transonic_drag::resolve_projectile_shape(
435 inputs.bullet_model.as_deref(),
436 caliber_in,
437 weight_gr,
438 drag_model_str,
439 );
440
441 let omega_vector = match inputs.latitude {
447 Some(latitude) if inputs.enable_coriolis => {
448 let omega_earth = 7.2921159e-5_f64; let lat = latitude.to_radians();
450 let az = inputs.shot_azimuth; Some(Vector3::new(
452 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), ))
456 }
457 _ => None,
458 };
459 let wind_shear_model = if inputs.enable_wind_shear {
461 let model = crate::wind_shear::boundary_layer_model_from_name(&inputs.wind_shear_model);
462 (model != crate::wind_shear::WindShearModel::None).then_some(model)
463 } else {
464 None
465 };
466
467 let mut hit_target = false;
469 let mut hit_ground = false;
470 let mut max_ord_time = None;
471 let mut max_ord_y = 0.0;
472 let ground_threshold = inputs.ground_threshold;
473
474 for i in 0..n_steps - 1 {
476 let t = i as f64 * dt;
477 let state = states[i];
478
479 let pos = Vector3::new(state[0], state[1], state[2]);
480 let _vel = Vector3::new(state[3], state[4], state[5]);
481
482 if pos.x >= params.horiz {
484 hit_target = true;
485 break;
486 }
487
488 if pos.y <= ground_threshold {
489 hit_ground = true;
490 break;
491 }
492
493 if pos.y > max_ord_y {
495 max_ord_y = pos.y;
496 max_ord_time = Some(t);
497 }
498
499 let k1 = compute_derivatives_with_gravity(
501 &state,
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 gravity,
514 );
515
516 let mut state2 = state;
517 for j in 0..6 {
518 state2[j] = state[j] + 0.5 * dt * k1[j];
519 }
520 let k2 = compute_derivatives_with_gravity(
521 &state2,
522 inputs,
523 wind_sock,
524 atmosphere,
525 drag_model,
526 projectile_shape,
527 bc,
528 has_bc_segments,
529 has_bc_segments_data,
530 omega_vector,
531 wind_shear_model,
532 atmo_sock,
533 gravity,
534 );
535
536 let mut state3 = state;
537 for j in 0..6 {
538 state3[j] = state[j] + 0.5 * dt * k2[j];
539 }
540 let k3 = compute_derivatives_with_gravity(
541 &state3,
542 inputs,
543 wind_sock,
544 atmosphere,
545 drag_model,
546 projectile_shape,
547 bc,
548 has_bc_segments,
549 has_bc_segments_data,
550 omega_vector,
551 wind_shear_model,
552 atmo_sock,
553 gravity,
554 );
555
556 let mut state4 = state;
557 for j in 0..6 {
558 state4[j] = state[j] + dt * k3[j];
559 }
560 let k4 = compute_derivatives_with_gravity(
561 &state4,
562 inputs,
563 wind_sock,
564 atmosphere,
565 drag_model,
566 projectile_shape,
567 bc,
568 has_bc_segments,
569 has_bc_segments_data,
570 omega_vector,
571 wind_shear_model,
572 atmo_sock,
573 gravity,
574 );
575
576 let mut new_state = state;
578 for j in 0..6 {
579 new_state[j] = state[j] + dt * (k1[j] + 2.0 * k2[j] + 2.0 * k3[j] + k4[j]) / 6.0;
580 }
581
582 if state[0] < params.horiz && new_state[0] >= params.horiz {
583 let alpha = (params.horiz - state[0]) / (new_state[0] - state[0]);
586 let mut target_state = state;
587 for j in 0..6 {
588 target_state[j] = state[j] + alpha * (new_state[j] - state[j]);
589 }
590 target_state[0] = params.horiz;
591 times.push(t + alpha * dt);
592 states.push(target_state);
593 hit_target = true;
594 break;
595 }
596
597 times.push(t + dt);
598 states.push(new_state);
599 }
600
601 let t_events = [
603 if hit_target {
604 vec![*times.last().unwrap()]
605 } else {
606 vec![]
607 },
608 if let Some(t) = max_ord_time {
609 vec![t]
610 } else {
611 vec![]
612 },
613 if hit_ground {
614 vec![*times.last().unwrap()]
615 } else {
616 vec![]
617 },
618 ];
619
620 if inputs.use_enhanced_spin_drift {
629 let (sd_temp_c, sd_press_hpa) = if params.atmo_params.2 > 0.0 {
633 (params.atmo_params.1, params.atmo_params.2)
634 } else {
635 (15.0, 1013.25)
636 };
637 let sg = crate::spin_drift::effective_sg_from_inputs(inputs, sd_temp_c, sd_press_hpa);
638 for (t, state) in times.iter().zip(states.iter_mut()) {
639 if *t > 0.0 {
640 state[2] += crate::spin_drift::litz_drift_meters(sg, *t, inputs.is_twist_right);
641 }
642 }
643 }
644
645 FastSolution::from_trajectory_data(times, states, t_events)
646}
647
648fn fast_magnus_acceleration(
649 inputs: &BallisticInputs,
650 air_velocity: Vector3<f64>,
651 air_density: f64,
652 mach: f64,
653 gravity_acceleration: Vector3<f64>,
654 gravity: Gravity,
655) -> Vector3<f64> {
656 if !inputs.enable_magnus
657 || inputs.use_enhanced_spin_drift
658 || inputs.bullet_diameter <= 0.0
659 || inputs.twist_rate <= 0.0
660 || inputs.bullet_mass <= 0.0
661 {
662 return Vector3::zeros();
663 }
664
665 let speed_air = air_velocity.norm();
666 let diameter_m = inputs.bullet_diameter;
667 let (spin_rate_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
668 inputs.muzzle_velocity,
669 speed_air,
670 inputs.twist_rate,
671 diameter_m,
672 );
673 let d_in = if inputs.caliber_inches > 0.0 {
674 inputs.caliber_inches
675 } else {
676 diameter_m / 0.0254
677 };
678 let m_gr = if inputs.weight_grains > 0.0 {
679 inputs.weight_grains
680 } else {
681 inputs.bullet_mass / crate::constants::GRAINS_TO_KG
682 };
683 let l_in = if inputs.bullet_length > 0.0 {
684 inputs.bullet_length / 0.0254
685 } else {
686 let estimated = crate::stability::estimate_bullet_length_m(diameter_m, inputs.bullet_mass);
687 if estimated > 0.0 {
688 estimated / 0.0254
689 } else {
690 4.5 * d_in.max(1e-9)
691 }
692 };
693 let sg = crate::spin_drift::calculate_dynamic_stability(
694 m_gr,
695 speed_air,
696 spin_rate_rad_s,
697 d_in,
698 l_in,
699 air_density,
700 );
701 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose_with_gravity(
702 sg,
703 speed_air,
704 spin_rate_rad_s,
705 0.0,
706 0.0,
707 air_density,
708 d_in,
709 l_in,
710 m_gr,
711 mach,
712 "match",
713 false,
714 gravity,
715 );
716 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
717 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
718 let force = 0.5 * air_density * speed_air.powi(2) * area * c_np * spin_param * yaw_rad.sin();
719 if force <= 1e-12 {
720 return Vector3::zeros();
721 }
722
723 crate::derivatives::yaw_of_repose_magnus_direction(
724 air_velocity,
725 gravity_acceleration,
726 inputs.is_twist_right,
727 )
728 .map_or_else(Vector3::zeros, |direction| {
729 (force / inputs.bullet_mass) * direction
730 })
731}
732
733fn interpolated_vertical_apex(
734 previous_time: f64,
735 previous: &[f64; 6],
736 current_time: f64,
737 current: &[f64; 6],
738) -> Option<(f64, [f64; 6])> {
739 let dt = current_time - previous_time;
740 let previous_vy = previous[4];
741 let current_vy = current[4];
742 if !dt.is_finite()
743 || dt <= 0.0
744 || !previous_vy.is_finite()
745 || !current_vy.is_finite()
746 || previous_vy <= 0.0
747 || current_vy > 0.0
748 {
749 return None;
750 }
751
752 let denominator = previous_vy - current_vy;
753 if !denominator.is_finite() || denominator <= 0.0 {
754 return None;
755 }
756 let alpha = previous_vy / denominator;
757 if !alpha.is_finite() || !(0.0..1.0).contains(&alpha) {
760 return None;
761 }
762
763 let mut apex = [0.0; 6];
764 for component in 0..6 {
765 apex[component] = previous[component] + alpha * (current[component] - previous[component]);
766 }
767
768 let alpha2 = alpha * alpha;
771 let alpha3 = alpha2 * alpha;
772 let h00 = 2.0 * alpha3 - 3.0 * alpha2 + 1.0;
773 let h10 = alpha3 - 2.0 * alpha2 + alpha;
774 let h01 = -2.0 * alpha3 + 3.0 * alpha2;
775 let h11 = alpha3 - alpha2;
776 for axis in 0..3 {
777 apex[axis] = h00 * previous[axis]
778 + h10 * dt * previous[axis + 3]
779 + h01 * current[axis]
780 + h11 * dt * current[axis + 3];
781 }
782 apex[4] = 0.0;
783
784 apex.iter()
785 .all(|component| component.is_finite())
786 .then_some((previous_time + alpha * dt, apex))
787}
788
789#[allow(clippy::too_many_arguments)]
791fn compute_derivatives_with_gravity(
792 state: &[f64; 6],
793 inputs: &BallisticInputs,
794 wind_sock: &WindSock,
795 atmosphere: FastAtmosphere,
796 drag_model: &DragModel,
797 projectile_shape: crate::transonic_drag::ProjectileShape,
798 bc: f64,
799 has_bc_segments: bool,
800 has_bc_segments_data: bool,
801 omega: Option<Vector3<f64>>,
802 wind_shear_model: Option<crate::wind_shear::WindShearModel>,
803 atmo_sock: Option<&AtmoSock>,
805 gravity: Gravity,
806) -> [f64; 6] {
807 let pos = Vector3::new(state[0], state[1], state[2]);
808 let vel = Vector3::new(state[3], state[4], state[5]);
809
810 let level_wind = wind_sock.vector_for_range_stateless(pos.x);
817 let level_wind = if let Some(model) = wind_shear_model {
818 let height_agl = crate::wind_shear::shear_height_agl_m(pos.y, inputs.muzzle_height);
819 crate::wind_shear::apply_boundary_layer_shear_agl(level_wind, height_agl, model)
820 } else {
821 level_wind
822 };
823 let wind_vector =
824 crate::derivatives::level_vector_to_shot_frame(level_wind, inputs.shooting_angle);
825
826 let vel_adjusted = vel - wind_vector;
828 let v_mag = vel_adjusted.norm();
829
830 let theta = inputs.shooting_angle;
833 let accel_gravity = gravity.in_frame(theta);
834
835 let mut accel = if v_mag < 1e-6 {
837 accel_gravity
838 } else {
839 let v_fps = v_mag * MPS_TO_FPS;
841
842 let (local_density, speed_of_sound) = match atmosphere {
864 FastAtmosphere::Direct {
865 air_density,
866 speed_of_sound,
867 } => (air_density, speed_of_sound),
868 FastAtmosphere::Standard { base_density } => {
869 let altitude = crate::atmosphere::shot_frame_altitude(
870 inputs.altitude,
871 pos.x,
872 pos.y,
873 inputs.shooting_angle,
874 );
875 let (base_temp_c, base_press_hpa, base_ratio) = match atmo_sock {
876 Some(sock) => {
877 let (zt, zp, zh) = sock.atmo_for_range(pos.x);
878 (zt, zp, calculate_air_density_cimp(zt, zp, zh) / 1.225)
879 }
880 None => (inputs.temperature, inputs.pressure, base_density / 1.225),
881 };
882 get_local_atmosphere_humid_with_gravity(
883 altitude,
884 inputs.altitude, base_temp_c,
886 base_press_hpa,
887 base_ratio,
888 0.0, gravity,
890 )
891 }
892 };
893 let mach = v_mag / speed_of_sound;
894
895 let bc_current = match (
897 inputs.bc_segments_data.as_ref(),
898 inputs.bc_segments.as_ref(),
899 ) {
900 (Some(segments_data), _) if inputs.use_bc_segments && has_bc_segments_data => {
901 velocity_segment_bc(v_fps, segments_data, bc)
902 }
903 (_, Some(segments)) if has_bc_segments => {
904 crate::derivatives::interpolated_bc(mach, segments, Some(inputs))
905 }
906 _ => bc,
907 };
908 let bc_current = bc_current.max(1e-6);
911
912 let (drag_factor, retard_denom) = if let Some(ref table) = inputs.custom_drag_table {
925 (
926 table.interpolate(mach) * inputs.cd_scale,
931 inputs.custom_drag_denominator(bc_current),
932 )
933 } else {
934 let base_cd = get_drag_coefficient(mach, drag_model);
935 let cd =
936 crate::transonic_drag::transonic_correction(mach, base_cd, projectile_shape, false);
937 (cd, bc_current)
938 };
939
940 let cd_to_retard = crate::constants::CD_TO_RETARD;
942 let standard_factor = drag_factor * cd_to_retard;
943 let density_scale = local_density / 1.225;
946
947 let a_drag_ft_s2 = (v_fps * v_fps) * standard_factor * density_scale / retard_denom;
949
950 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let accel_drag = -a_drag_m_s2 * (vel_adjusted / v_mag);
953
954 let accel_magnus = fast_magnus_acceleration(
957 inputs,
958 vel_adjusted,
959 local_density,
960 mach,
961 accel_gravity,
962 gravity,
963 );
964
965 accel_drag + accel_gravity + accel_magnus
967 };
968
969 if let Some(omega) = omega {
972 let omega = crate::derivatives::level_vector_to_shot_frame(omega, inputs.shooting_angle);
973 accel += -2.0 * omega.cross(&vel);
974 }
975
976 [vel.x, vel.y, vel.z, accel.x, accel.y, accel.z]
978}
979
980pub fn fast_integrate_with_segments(
988 inputs: &BallisticInputs,
989 wind_segments: Vec<crate::wind::WindSegment>,
990 params: FastIntegrationParams,
991) -> FastSolution {
992 fast_integrate_with_segments_with_gravity(inputs, wind_segments, params, Gravity::EARTH)
993}
994
995pub(crate) fn fast_integrate_with_segments_with_gravity(
996 inputs: &BallisticInputs,
997 wind_segments: Vec<crate::wind::WindSegment>,
998 params: FastIntegrationParams,
999 gravity: Gravity,
1000) -> FastSolution {
1001 use crate::trajectory_integration::{
1003 integrate_trajectory_from_muzzle_height, TrajectoryParams,
1004 };
1005
1006 if crate::wind::validate_wind_segments(&wind_segments).is_err()
1008 || !atmo_is_physical(params.atmo_params)
1009 {
1010 return FastSolution::degenerate(¶ms.initial_state);
1011 }
1012
1013 let mut normalized_inputs = inputs.clone();
1019 normalized_inputs.normalize_for_solve();
1020 let inputs = &normalized_inputs;
1021
1022 let segmented_crosswind_from_right_mps =
1025 if inputs.enable_aerodynamic_jump && !wind_segments.is_empty() {
1026 WindSock::new(wind_segments.clone()).muzzle_crosswind_from_right_mps()
1027 } else {
1028 None
1029 };
1030 let initial_state = launch_state_with_aerodynamic_jump(
1031 inputs,
1032 params.atmo_params,
1033 segmented_crosswind_from_right_mps,
1034 params.initial_state,
1035 );
1036
1037 let mass_kg = inputs.bullet_mass; let bc = inputs.bc_value;
1040 let drag_model = inputs.bc_type;
1041
1042 let omega_vector = if inputs.enable_coriolis && inputs.latitude.is_some() {
1046 let omega_earth = 7.2921159e-5; let lat_rad = inputs.latitude.unwrap_or(0.0).to_radians();
1052 let azimuth = inputs.shot_azimuth; Some(Vector3::new(
1054 omega_earth * lat_rad.cos() * azimuth.cos(), omega_earth * lat_rad.sin(), -omega_earth * lat_rad.cos() * azimuth.sin(), ))
1058 } else {
1059 None
1060 };
1061
1062 let traj_params = TrajectoryParams {
1064 mass_kg,
1065 bc,
1066 drag_model,
1067 wind_segments,
1068 atmos_params: params.atmo_params,
1069 omega_vector,
1070 enable_spin_drift: inputs.use_enhanced_spin_drift,
1071 enable_magnus: inputs.enable_magnus,
1072 enable_coriolis: inputs.enable_coriolis,
1073 target_distance_m: params.horiz,
1074 enable_wind_shear: inputs.enable_wind_shear,
1075 wind_shear_model: inputs.wind_shear_model.clone(),
1076 shooter_altitude_m: inputs.altitude,
1077 is_twist_right: inputs.is_twist_right,
1078 shooting_angle: inputs.shooting_angle,
1079 bullet_diameter: inputs.bullet_diameter,
1081 bullet_length: inputs.bullet_length,
1082 twist_rate: inputs.twist_rate,
1083 cd_scale: inputs.cd_scale,
1084 custom_drag_reference: inputs.custom_drag_reference,
1085 custom_drag_table: inputs.custom_drag_table.clone(),
1086 bc_segments: inputs.bc_segments.clone(),
1087 use_bc_segments: inputs.use_bc_segments,
1088 bc_segments_data: inputs.bc_segments_data.clone(),
1091 ground_threshold: -1000.0,
1095 atmo_sock: params.atmo_sock,
1097 };
1098
1099 let trajectory = integrate_trajectory_from_muzzle_height(
1103 initial_state,
1104 params.t_span,
1105 traj_params,
1106 "RK45", 1e-6, 0.01, gravity,
1110 inputs.muzzle_height,
1111 );
1112
1113 let n_points = trajectory.len();
1115 let mut times = Vec::with_capacity(n_points + 1);
1116 let mut states = Vec::with_capacity(n_points + 1);
1117
1118 let mut target_hit_time: Option<f64> = None;
1119 let mut ground_hit_time: Option<f64> = None;
1120 let mut max_ord_time = None;
1121 let mut max_ord_y = 0.0;
1122
1123 for (t, state_vec) in trajectory {
1124 let state = [
1126 state_vec[0],
1127 state_vec[1],
1128 state_vec[2],
1129 state_vec[3],
1130 state_vec[4],
1131 state_vec[5],
1132 ];
1133
1134 if let Some((&previous_time, &previous_state)) = times.last().zip(states.last()) {
1142 if let Some((apex_time, apex_state)) =
1143 interpolated_vertical_apex(previous_time, &previous_state, t, &state)
1144 {
1145 if apex_state[1] > max_ord_y {
1146 max_ord_y = apex_state[1];
1147 max_ord_time = Some(apex_time);
1148 }
1149 times.push(apex_time);
1150 states.push(apex_state);
1151 }
1152 }
1153
1154 if target_hit_time.is_none() && state[0] >= params.horiz {
1156 target_hit_time = Some(t);
1157 }
1158
1159 if ground_hit_time.is_none() && state[1] <= inputs.ground_threshold {
1161 ground_hit_time = Some(t);
1162 }
1163
1164 if state[1] > max_ord_y {
1166 max_ord_y = state[1];
1167 max_ord_time = Some(t);
1168 }
1169
1170 times.push(t);
1171 states.push(state);
1172 }
1173
1174 let t_events = [
1176 if let Some(t) = target_hit_time {
1177 vec![t]
1178 } else {
1179 vec![]
1180 },
1181 if let Some(t) = max_ord_time {
1182 vec![t]
1183 } else {
1184 vec![]
1185 },
1186 if let Some(t) = ground_hit_time {
1187 vec![t]
1188 } else {
1189 vec![]
1190 },
1191 ];
1192
1193 if inputs.use_enhanced_spin_drift {
1198 let (sd_temp_c, sd_press_hpa) = if params.atmo_params.2 > 0.0 {
1202 (params.atmo_params.1, params.atmo_params.2)
1203 } else {
1204 (15.0, 1013.25)
1205 };
1206 let sg = crate::spin_drift::effective_sg_from_inputs(inputs, sd_temp_c, sd_press_hpa);
1207 for (t, state) in times.iter().zip(states.iter_mut()) {
1208 if *t > 0.0 {
1209 state[2] += crate::spin_drift::litz_drift_meters(sg, *t, inputs.is_twist_right);
1210 }
1211 }
1212 }
1213
1214 FastSolution::from_trajectory_data(times, states, t_events)
1215}
1216
1217#[cfg(test)]
1218mod tests {
1219 use super::*;
1220 use crate::BCSegmentData;
1221
1222 #[test]
1223 fn uniform_gravity_reaches_both_fast_integration_paths() {
1224 let inputs = BallisticInputs {
1225 bullet_mass: 0.0459,
1226 bullet_diameter: 0.0427,
1227 bullet_length: 0.0427,
1228 muzzle_velocity: 10.0,
1229 ..BallisticInputs::default()
1230 };
1231 let params = || FastIntegrationParams {
1232 horiz: 10.0,
1233 vert: 0.0,
1234 initial_state: [0.0, 10.0, 0.0, 10.0, 2.0, 0.0],
1235 t_span: (0.0, 2.0),
1236 atmo_params: (1e-12, 340.0, 0.0, 0.0),
1237 atmo_sock: None,
1238 };
1239 for magnitude in [0.0, 1.62, 9.80665, 20.0] {
1240 let gravity = Gravity::new(magnitude).unwrap();
1241 for segmented in [false, true] {
1242 let result = if segmented {
1243 fast_integrate_with_segments_with_gravity(&inputs, vec![], params(), gravity)
1244 } else {
1245 fast_integrate_with_gravity(&inputs, &WindSock::new(vec![]), params(), gravity)
1246 };
1247 assert!(result.success);
1248 let time = *result.t.last().unwrap();
1249 assert!((time - 1.0).abs() < 1e-8);
1250 let step: f64 = if segmented { 0.01 } else { 0.0001 };
1252 let tolerance = magnitude * step * step / 8.0 + 1e-8;
1253 assert!(
1254 (result.y[1].last().unwrap() - (12.0 - 0.5 * magnitude)).abs() < tolerance,
1255 "g={magnitude}, segmented={segmented}, y={:?}, time={time}",
1256 result.y[1].last()
1257 );
1258 assert!((result.y[4].last().unwrap() - (2.0 - magnitude)).abs() < 1e-8);
1259 if magnitude == 9.80665 {
1260 let public = if segmented {
1261 fast_integrate_with_segments(&inputs, vec![], params())
1262 } else {
1263 fast_integrate(&inputs, &WindSock::new(vec![]), params())
1264 };
1265 assert_eq!(public.t, result.t);
1266 assert_eq!(public.y, result.y);
1267 assert_eq!(public.t_events, result.t_events);
1268 }
1269 }
1270 }
1271 }
1272
1273 fn match_308_at_1000_m() -> BallisticInputs {
1274 BallisticInputs {
1275 muzzle_velocity: 800.0,
1276 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
1277 bullet_diameter: 0.308 * 0.0254,
1278 bullet_length: 1.215 * 0.0254,
1279 caliber_inches: 0.308,
1280 weight_grains: 168.0,
1281 twist_rate: 10.0,
1282 altitude: 1000.0,
1283 temperature: 15.0,
1284 pressure: 1013.25,
1285 ..BallisticInputs::default()
1286 }
1287 }
1288
1289 fn aerodynamic_acceleration_with_gravity(
1293 state: &[f64; 6],
1294 inputs: &BallisticInputs,
1295 gravity: Gravity,
1296 ) -> Vector3<f64> {
1297 let result = compute_derivatives_with_gravity(
1298 state,
1299 inputs,
1300 &WindSock::new(vec![]),
1301 FastAtmosphere::Standard {
1302 base_density: 1.225,
1303 },
1304 &inputs.bc_type,
1305 crate::transonic_drag::ProjectileShape::Spitzer,
1306 inputs.bc_value,
1307 false,
1308 false,
1309 None,
1310 None,
1311 None,
1312 gravity,
1313 );
1314 Vector3::new(result[3], result[4], result[5]) - gravity.in_frame(inputs.shooting_angle)
1315 }
1316
1317 #[test]
1318 fn local_atmosphere_uses_the_simulation_gravity() {
1319 let inputs = match_308_at_1000_m(); let low_gravity = Gravity::new(3.71).unwrap();
1321 let aerodynamic = |height_above_station_m, gravity| {
1322 let state = [0.0, height_above_station_m, 0.0, 600.0, 0.0, 0.0];
1323 aerodynamic_acceleration_with_gravity(&state, &inputs, gravity)
1324 };
1325
1326 let earth = aerodynamic(0.0, Gravity::EARTH);
1329 let low = aerodynamic(0.0, low_gravity);
1330 assert!(
1331 (earth - low).norm() < 1e-12,
1332 "station-altitude drag must not depend on gravity: earth={earth:?}, low={low:?}"
1333 );
1334
1335 let earth = aerodynamic(2_000.0, Gravity::EARTH);
1338 let low = aerodynamic(2_000.0, low_gravity);
1339 let ratio = low.x / earth.x;
1340 assert!(earth.x < 0.0, "drag must oppose flight: {earth:?}");
1341 assert!(
1342 (ratio - 1.162_702).abs() < 1e-4,
1343 "drag aloft must follow the simulation's hydrostatic density: low/earth={ratio}, \
1344 expected 1.162702 (standard lapse rate, 1000 m station, 2000 m above it)"
1345 );
1346 }
1347
1348 #[test]
1349 fn magnus_yaw_of_repose_uses_the_simulation_gravity() {
1350 let low_gravity = Gravity::new(3.71).unwrap();
1351 let state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
1353 let aerodynamic = |enable_magnus, gravity| {
1354 let inputs = BallisticInputs {
1355 enable_magnus,
1356 ..match_308_at_1000_m()
1357 };
1358 aerodynamic_acceleration_with_gravity(&state, &inputs, gravity)
1359 };
1360
1361 let earth = aerodynamic(false, Gravity::EARTH);
1363 let low = aerodynamic(false, low_gravity);
1364 assert!(
1365 (earth - low).norm() < 1e-12,
1366 "Magnus-off drag must not depend on gravity: earth={earth:?}, low={low:?}"
1367 );
1368
1369 let earth = aerodynamic(true, Gravity::EARTH) - earth;
1371 let low = aerodynamic(true, low_gravity) - low;
1372 let ratio = low.y / earth.y;
1373 let expected = low_gravity.magnitude() / Gravity::EARTH.magnitude();
1374 assert!(
1375 earth.y < 0.0,
1376 "right-hand Magnus must point down: {earth:?}"
1377 );
1378 assert!(
1379 (ratio - expected).abs() < 1e-6,
1380 "Magnus must scale with the simulation's gravity: low/earth={ratio}, expected={expected}"
1381 );
1382 }
1383
1384 fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
1385 let (sin_angle, cos_angle) = angle.sin_cos();
1386 Vector3::new(
1387 level.x * cos_angle + level.y * sin_angle,
1388 -level.x * sin_angle + level.y * cos_angle,
1389 level.z,
1390 )
1391 }
1392
1393 #[test]
1394 fn measured_bc_fast_drag_ignores_name_based_form_factor_flag() {
1395 let derivatives_with_flag = |use_form_factor| {
1396 let inputs = BallisticInputs {
1397 bc_value: 0.462,
1398 bc_type: DragModel::G1,
1399 bullet_model: Some("168gr SMK Match".to_string()),
1400 use_form_factor,
1401 temperature: 15.0,
1402 pressure: 1013.25,
1403 ..BallisticInputs::default()
1404 };
1405
1406 compute_derivatives_with_gravity(
1407 &[0.0, 0.0, 0.0, 600.0, 0.0, 0.0],
1408 &inputs,
1409 &WindSock::new(vec![]),
1410 FastAtmosphere::Standard {
1411 base_density: 1.225,
1412 },
1413 &inputs.bc_type,
1414 crate::transonic_drag::ProjectileShape::Spitzer,
1415 inputs.bc_value,
1416 false,
1417 false,
1418 None,
1419 None,
1420 None,
1421 Gravity::EARTH,
1422 )
1423 };
1424
1425 let baseline = derivatives_with_flag(false);
1426 let flagged = derivatives_with_flag(true);
1427
1428 for component in 3..6 {
1429 assert_eq!(
1430 flagged[component].to_bits(),
1431 baseline[component].to_bits(),
1432 "published BC already encodes form factor: component {component}, baseline={} flagged={}",
1433 baseline[component],
1434 flagged[component]
1435 );
1436 }
1437 }
1438
1439 #[test]
1440 fn velocity_bc_data_requires_opt_in_in_plain_fast_kernel() {
1441 let acceleration = |inputs: &BallisticInputs| {
1442 let has_mach_segments = inputs
1443 .bc_segments
1444 .as_ref()
1445 .is_some_and(|segments| !segments.is_empty());
1446 let has_velocity_segments = inputs
1447 .bc_segments_data
1448 .as_ref()
1449 .is_some_and(|segments| !segments.is_empty());
1450 compute_derivatives_with_gravity(
1451 &[0.0, 0.0, 0.0, 600.0, 0.0, 0.0],
1452 inputs,
1453 &WindSock::new(vec![]),
1454 FastAtmosphere::Standard {
1455 base_density: 1.225,
1456 },
1457 &inputs.bc_type,
1458 crate::transonic_drag::ProjectileShape::Spitzer,
1459 inputs.bc_value,
1460 has_mach_segments,
1461 has_velocity_segments,
1462 None,
1463 None,
1464 None,
1465 Gravity::EARTH,
1466 )
1467 };
1468
1469 let scalar_inputs = BallisticInputs {
1470 bc_value: 0.5,
1471 bc_type: DragModel::G7,
1472 temperature: 15.0,
1473 pressure: 1013.25,
1474 ..BallisticInputs::default()
1475 };
1476 let mut disabled_inputs = scalar_inputs.clone();
1477 disabled_inputs.bc_segments_data = Some(vec![BCSegmentData {
1478 velocity_min: 0.0,
1479 velocity_max: 4_000.0,
1480 bc_value: 0.46,
1481 }]);
1482 disabled_inputs.use_bc_segments = false;
1483 let mut enabled_inputs = disabled_inputs.clone();
1484 enabled_inputs.use_bc_segments = true;
1485 let mut mach_only_inputs = scalar_inputs.clone();
1486 mach_only_inputs.bc_segments = Some(vec![(0.0, 0.4), (3.0, 0.4)]);
1487 let mut disabled_with_both = mach_only_inputs.clone();
1488 disabled_with_both.bc_segments_data = disabled_inputs.bc_segments_data.clone();
1489
1490 let scalar = acceleration(&scalar_inputs);
1491 let disabled = acceleration(&disabled_inputs);
1492 let enabled = acceleration(&enabled_inputs);
1493 let mach_only = acceleration(&mach_only_inputs);
1494 let disabled_with_both = acceleration(&disabled_with_both);
1495
1496 assert_eq!(
1497 disabled[3].to_bits(),
1498 scalar[3].to_bits(),
1499 "a populated velocity table must not change drag while use_bc_segments is false"
1500 );
1501 assert!(
1502 enabled[3] < disabled[3] - 1.0,
1503 "enabling the lower BC table must increase drag: disabled ax={} enabled ax={}",
1504 disabled[3],
1505 enabled[3]
1506 );
1507 assert_eq!(
1508 disabled_with_both[3].to_bits(),
1509 mach_only[3].to_bits(),
1510 "disabling velocity data must fall through to an explicit Mach table"
1511 );
1512 }
1513
1514 #[test]
1515 fn inclined_positions_at_same_world_altitude_have_same_fast_acceleration() {
1516 let angle = std::f64::consts::FRAC_PI_6;
1517 let inputs = BallisticInputs {
1518 altitude: 100.0,
1519 temperature: 15.0,
1520 pressure: 1013.25,
1521 shooting_angle: angle,
1522 ..BallisticInputs::default()
1523 };
1524 let wind_sock = WindSock::new(vec![]);
1525 let atmosphere = FastAtmosphere::Standard {
1526 base_density: 1.225,
1527 };
1528 let state_along_slant = [1_000.0, 0.0, 0.0, 600.0, 0.0, 0.0];
1529 let state_across_slant = [0.0, 500.0 / angle.cos(), 0.0, 600.0, 0.0, 0.0];
1530
1531 let a = compute_derivatives_with_gravity(
1532 &state_along_slant,
1533 &inputs,
1534 &wind_sock,
1535 atmosphere,
1536 &inputs.bc_type,
1537 crate::transonic_drag::ProjectileShape::Spitzer,
1538 inputs.bc_value,
1539 false,
1540 false,
1541 None,
1542 None,
1543 None,
1544 Gravity::EARTH,
1545 );
1546 let b = compute_derivatives_with_gravity(
1547 &state_across_slant,
1548 &inputs,
1549 &wind_sock,
1550 atmosphere,
1551 &inputs.bc_type,
1552 crate::transonic_drag::ProjectileShape::Spitzer,
1553 inputs.bc_value,
1554 false,
1555 false,
1556 None,
1557 None,
1558 None,
1559 Gravity::EARTH,
1560 );
1561
1562 for component in 3..6 {
1563 assert!(
1564 (a[component] - b[component]).abs() < 1e-10,
1565 "fast derivative component {component} differs at equal world altitude: {} vs {}",
1566 a[component],
1567 b[component]
1568 );
1569 }
1570 }
1571
1572 #[test]
1573 fn inclined_headwind_is_rotated_into_solver_frame() {
1574 let angle = std::f64::consts::FRAC_PI_6;
1575 let inputs = BallisticInputs {
1576 shooting_angle: angle,
1577 ..BallisticInputs::default()
1578 };
1579 let speed_mps = 360.0 * (1000.0 / 3600.0);
1580 let level_headwind = Vector3::new(-speed_mps, 0.0, 0.0);
1581 let velocity = expected_shot_frame_vector(level_headwind, angle);
1582 let state = [0.0, 0.0, 0.0, velocity.x, velocity.y, velocity.z];
1583 let actual = compute_derivatives_with_gravity(
1584 &state,
1585 &inputs,
1586 &WindSock::new(vec![crate::wind::WindSegment::new(360.0, 0.0, 1000.0)]),
1587 FastAtmosphere::Direct {
1588 air_density: 1.225,
1589 speed_of_sound: 340.0,
1590 },
1591 &inputs.bc_type,
1592 crate::transonic_drag::ProjectileShape::Spitzer,
1593 inputs.bc_value,
1594 false,
1595 false,
1596 None,
1597 None,
1598 None,
1599 Gravity::EARTH,
1600 );
1601 let expected = Vector3::new(
1602 -G_ACCEL_MPS2 * angle.sin(),
1603 -G_ACCEL_MPS2 * angle.cos(),
1604 0.0,
1605 );
1606
1607 assert!(
1608 (Vector3::new(actual[3], actual[4], actual[5]) - expected).norm() < 1e-12,
1609 "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
1610 );
1611 }
1612
1613 #[test]
1614 fn plain_fast_kernel_applies_power_law_wind_shear() {
1615 let state = [500.0, 100.0, 0.0, 700.0, 0.0, 0.0];
1616 let run = |enable_wind_shear: bool, model: &str, wind_speed_kmh: f64| {
1617 let inputs = BallisticInputs {
1618 bc_value: 0.5,
1619 bc_type: DragModel::G7,
1620 enable_wind_shear,
1621 wind_shear_model: model.to_string(),
1622 ..BallisticInputs::default()
1623 };
1624 let wind_shear_model = enable_wind_shear
1625 .then(|| crate::wind_shear::boundary_layer_model_from_name(model))
1626 .filter(|model| *model != crate::wind_shear::WindShearModel::None);
1627 compute_derivatives_with_gravity(
1628 &state,
1629 &inputs,
1630 &WindSock::new(vec![crate::wind::WindSegment::new(
1631 wind_speed_kmh,
1632 90.0,
1633 2_000.0,
1634 )]),
1635 FastAtmosphere::Direct {
1636 air_density: 1.225,
1637 speed_of_sound: 340.0,
1638 },
1639 &inputs.bc_type,
1640 crate::transonic_drag::ProjectileShape::Spitzer,
1641 inputs.bc_value,
1642 false,
1643 false,
1644 None,
1645 wind_shear_model,
1646 None,
1647 Gravity::EARTH,
1648 )
1649 };
1650
1651 let uniform = run(false, "power_law", 36.0);
1652 let model_none = run(true, "none", 36.0);
1653 assert_eq!(model_none, uniform, "model=none must preserve uniform wind");
1654
1655 let sheared = run(true, "power_law", 36.0);
1656 assert!(
1657 sheared[5] < uniform[5],
1658 "stronger aloft crosswind must increase leftward acceleration: uniform={}, shear={}",
1659 uniform[5],
1660 sheared[5]
1661 );
1662
1663 let ratio = crate::wind_shear::boundary_layer_speed_ratio(
1664 state[1],
1665 crate::wind_shear::WindShearModel::PowerLaw,
1666 );
1667 let equivalent_uniform = run(false, "none", 36.0 * ratio);
1668 for component in 3..6 {
1669 assert!(
1670 (sheared[component] - equivalent_uniform[component]).abs() < 1e-12,
1671 "shear component {component} must equal base wind scaled by {ratio}: shear={}, expected={}",
1672 sheared[component],
1673 equivalent_uniform[component]
1674 );
1675 }
1676 }
1677
1678 #[test]
1679 fn plain_fast_path_wind_shear_changes_high_arc_drift() {
1680 let run = |enable_wind_shear: bool, model: &str| {
1681 let inputs = BallisticInputs {
1682 muzzle_velocity: 800.0,
1683 bc_value: 0.5,
1684 bc_type: DragModel::G7,
1685 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
1686 bullet_diameter: 0.308 * 0.0254,
1687 enable_wind_shear,
1688 wind_shear_model: model.to_string(),
1689 ground_threshold: -100.0,
1690 ..BallisticInputs::default()
1691 };
1692 let elevation = 0.12_f64;
1693 let solution = fast_integrate(
1694 &inputs,
1695 &WindSock::new(vec![crate::wind::WindSegment::new(36.0, 90.0, 2_000.0)]),
1696 FastIntegrationParams {
1697 horiz: 1_000.0,
1698 vert: 0.0,
1699 initial_state: [
1700 0.0,
1701 0.0,
1702 0.0,
1703 inputs.muzzle_velocity * elevation.cos(),
1704 inputs.muzzle_velocity * elevation.sin(),
1705 0.0,
1706 ],
1707 t_span: (0.0, 5.0),
1708 atmo_params: (0.0, 15.0, 1013.25, 1.0),
1709 atmo_sock: None,
1710 },
1711 );
1712 let last = solution.t.len() - 1;
1713 assert_eq!(solution.y[0][last].to_bits(), 1_000.0_f64.to_bits());
1714 solution.y[2][last]
1715 };
1716
1717 let uniform = run(false, "power_law");
1718 let model_none = run(true, "none");
1719 assert_eq!(model_none.to_bits(), uniform.to_bits());
1720 let sheared = run(true, "power_law");
1721 assert!(
1722 sheared.abs() > uniform.abs() + 0.01,
1723 "aloft shear must increase drift magnitude: uniform={uniform}, shear={sheared}"
1724 );
1725 }
1726
1727 fn shear_ratio_rel_launch(height_rel_launch_m: f64, model: &str) -> f64 {
1728 crate::wind_shear::boundary_layer_speed_ratio(
1729 height_rel_launch_m,
1730 crate::wind_shear::boundary_layer_model_from_name(model),
1731 )
1732 }
1733
1734 fn assert_fast_shear_ratio(
1738 model: &str,
1739 muzzle_height: f64,
1740 shooting_angle: f64,
1741 (x, y): (f64, f64),
1742 expected_ratio: f64,
1743 ) {
1744 let state = [x, y, 0.0, 700.0, 5.0, 0.0];
1745 let run = |enable_wind_shear: bool, wind_speed_kmh: f64| {
1746 let inputs = BallisticInputs {
1747 bc_value: 0.5,
1748 bc_type: DragModel::G7,
1749 muzzle_height,
1750 shooting_angle,
1751 enable_wind_shear,
1752 wind_shear_model: model.to_string(),
1753 ..BallisticInputs::default()
1754 };
1755 let wind_shear_model = enable_wind_shear
1756 .then(|| crate::wind_shear::boundary_layer_model_from_name(model))
1757 .filter(|model| *model != crate::wind_shear::WindShearModel::None);
1758 compute_derivatives_with_gravity(
1759 &state,
1760 &inputs,
1761 &WindSock::new(vec![crate::wind::WindSegment::new(
1762 wind_speed_kmh,
1763 90.0,
1764 2_000.0,
1765 )]),
1766 FastAtmosphere::Direct {
1767 air_density: 1.225,
1768 speed_of_sound: 340.0,
1769 },
1770 &inputs.bc_type,
1771 crate::transonic_drag::ProjectileShape::Spitzer,
1772 inputs.bc_value,
1773 false,
1774 false,
1775 None,
1776 wind_shear_model,
1777 None,
1778 Gravity::EARTH,
1779 )
1780 };
1781 let sheared = run(true, 36.0);
1782 let scaled = run(false, 36.0 * expected_ratio);
1783 for component in 3..6 {
1784 assert!(
1785 (sheared[component] - scaled[component]).abs() < 1e-12,
1786 "{model}, muzzle_height {muzzle_height} m, shooting angle {shooting_angle} rad, \
1787 ({x}, {y}): the shear multiplier is not {expected_ratio} (component \
1788 {component}: {} vs {})",
1789 sheared[component],
1790 scaled[component]
1791 );
1792 }
1793 }
1794
1795 #[test]
1800 fn fast_shear_reads_one_multiplier_at_one_height_above_ground() {
1801 for model in ["logarithmic", "power_law"] {
1802 let expected = shear_ratio_rel_launch(58.5, model);
1803 assert!(
1804 expected > 1.2,
1805 "{model}: the fixture must be well above the floor"
1806 );
1807 assert_fast_shear_ratio(model, 1.5, 0.0, (1_000.0, 60.0), expected);
1808 assert_fast_shear_ratio(model, 0.0, 0.0, (1_000.0, 58.5), expected);
1809 }
1810 }
1811
1812 #[test]
1819 fn inclined_fast_shear_measures_height_along_the_line_of_sight() {
1820 let (x, y) = (800.0, 60.0);
1821 for angle in [0.3_f64, -0.3] {
1822 let world_height = crate::atmosphere::shot_frame_altitude(0.0, x, y, angle);
1823 for model in ["logarithmic", "power_law"] {
1824 let bore_frame = shear_ratio_rel_launch(y, model);
1825 let ground_frame = shear_ratio_rel_launch(y - 1.5, model);
1826 assert!(
1827 bore_frame > 1.25 && ground_frame > 1.25,
1828 "{model}: fixture too low"
1829 );
1830 assert!(
1831 (shear_ratio_rel_launch(world_height, model) - bore_frame).abs() > 0.1,
1832 "{model}, angle {angle}: the fixture must tell the world height apart"
1833 );
1834 assert_fast_shear_ratio(model, 0.0, angle, (x, y), bore_frame);
1835 assert_fast_shear_ratio(model, 1.5, angle, (x, y), ground_frame);
1836 }
1837 }
1838 }
1839
1840 #[test]
1845 fn inclined_flat_fast_fire_keeps_the_operative_wind_with_shear_on() {
1846 let run = |shooting_angle: f64, model: Option<&str>, segmented: bool| {
1847 let inputs = BallisticInputs {
1848 muzzle_velocity: 800.0,
1849 bc_value: 0.5,
1850 bc_type: DragModel::G7,
1851 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
1852 bullet_diameter: 0.308 * 0.0254,
1853 muzzle_height: 1.5,
1854 shooting_angle,
1855 enable_wind_shear: model.is_some(),
1856 wind_shear_model: model.unwrap_or("none").to_string(),
1857 ground_threshold: -1_000.0,
1858 ..BallisticInputs::default()
1859 };
1860 let elevation = 0.002_f64;
1861 let params = FastIntegrationParams {
1862 horiz: 600.0,
1863 vert: 1.5,
1864 initial_state: [
1865 0.0,
1866 1.5,
1867 0.0,
1868 inputs.muzzle_velocity * elevation.cos(),
1869 inputs.muzzle_velocity * elevation.sin(),
1870 0.0,
1871 ],
1872 t_span: (0.0, 3.0),
1873 atmo_params: (1.225, 340.0, 0.0, 0.0),
1874 atmo_sock: None,
1875 };
1876 let wind = vec![crate::wind::WindSegment::new(36.0, 90.0, 2_000.0)];
1877 let solution = if segmented {
1878 fast_integrate_with_segments(&inputs, wind, params)
1879 } else {
1880 fast_integrate(&inputs, &WindSock::new(wind), params)
1881 };
1882 assert!(solution.success);
1883 let last = solution.t.len() - 1;
1884 let peak_y = solution.y[1]
1885 .iter()
1886 .copied()
1887 .fold(f64::NEG_INFINITY, f64::max);
1888 (solution.y[2][last], peak_y)
1889 };
1890
1891 for segmented in [false, true] {
1892 for shooting_angle in [15.0_f64.to_radians(), (-10.0_f64).to_radians()] {
1893 let (off, peak_y) = run(shooting_angle, None, segmented);
1894 assert!(
1895 peak_y < 8.0 && off.abs() > 0.5,
1896 "the fixture must stay low and drift: peak y {peak_y} m, drift {off} m"
1897 );
1898 for model in ["logarithmic", "power_law"] {
1899 let (on, _) = run(shooting_angle, Some(model), segmented);
1900 assert!(
1903 (on - off).abs() <= 1e-4 * off.abs(),
1904 "segmented={segmented}, {model}, shooting angle {shooting_angle} rad: \
1905 shear moved a flat shot ({on} m vs {off} m)"
1906 );
1907 }
1908 }
1909 }
1910 }
1911
1912 #[test]
1918 fn fast_entry_points_shear_one_shot_the_same_whichever_way_its_bore_height_is_stated() {
1919 let run = |muzzle_height: f64, segmented: bool| {
1920 let inputs = BallisticInputs {
1921 muzzle_velocity: 800.0,
1922 bc_value: 0.5,
1923 bc_type: DragModel::G7,
1924 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
1925 bullet_diameter: 0.308 * 0.0254,
1926 muzzle_height,
1927 enable_wind_shear: true,
1928 wind_shear_model: "power_law".to_string(),
1929 ground_threshold: -1_000.0,
1930 ..BallisticInputs::default()
1931 };
1932 let elevation = 0.12_f64;
1933 let params = FastIntegrationParams {
1934 horiz: 1_000.0,
1935 vert: muzzle_height,
1936 initial_state: [
1937 0.0,
1938 muzzle_height,
1939 0.0,
1940 inputs.muzzle_velocity * elevation.cos(),
1941 inputs.muzzle_velocity * elevation.sin(),
1942 0.0,
1943 ],
1944 t_span: (0.0, 5.0),
1945 atmo_params: (1.225, 340.0, 0.0, 0.0),
1946 atmo_sock: None,
1947 };
1948 let wind = vec![crate::wind::WindSegment::new(36.0, 90.0, 2_000.0)];
1949 let solution = if segmented {
1950 fast_integrate_with_segments(&inputs, wind, params)
1951 } else {
1952 fast_integrate(&inputs, &WindSock::new(wind), params)
1953 };
1954 assert!(solution.success);
1955 let last = solution.t.len() - 1;
1956 assert_eq!(solution.y[0][last].to_bits(), 1_000.0_f64.to_bits());
1957 (solution.y[1][last] - muzzle_height, solution.y[2][last])
1958 };
1959
1960 for segmented in [false, true] {
1961 let (rise_bore, drift_bore) = run(0.0, segmented);
1962 let (rise_ground, drift_ground) = run(1.5, segmented);
1963 assert!(drift_bore.abs() > 1.0, "the fixture must drift");
1964 assert!(
1965 (drift_ground - drift_bore).abs() < 1e-9,
1966 "segmented={segmented}: the same shot drifted {drift_bore} m stated from the bore \
1967 and {drift_ground} m stated from the ground"
1968 );
1969 assert!(
1970 (rise_ground - rise_bore).abs() < 1e-9,
1971 "segmented={segmented}: height above the bore {rise_bore} vs {rise_ground} m"
1972 );
1973 }
1974 }
1975
1976 #[test]
1977 fn inclined_coriolis_is_rotated_into_solver_frame() {
1978 let angle = std::f64::consts::FRAC_PI_6;
1979 let inputs = BallisticInputs {
1980 shooting_angle: angle,
1981 ..BallisticInputs::default()
1982 };
1983 let velocity = Vector3::new(600.0, 20.0, 5.0);
1984 let state = [0.0, 0.0, 0.0, velocity.x, velocity.y, velocity.z];
1985 let level_omega = Vector3::new(3.0e-5, 6.0e-5, -2.0e-5);
1986 let run = |omega| {
1987 compute_derivatives_with_gravity(
1988 &state,
1989 &inputs,
1990 &WindSock::new(vec![]),
1991 FastAtmosphere::Direct {
1992 air_density: 1.225,
1993 speed_of_sound: 340.0,
1994 },
1995 &inputs.bc_type,
1996 crate::transonic_drag::ProjectileShape::Spitzer,
1997 inputs.bc_value,
1998 false,
1999 false,
2000 omega,
2001 None,
2002 None,
2003 Gravity::EARTH,
2004 )
2005 };
2006 let baseline = run(None);
2007 let with_coriolis = run(Some(level_omega));
2008 let actual = Vector3::new(
2009 with_coriolis[3] - baseline[3],
2010 with_coriolis[4] - baseline[4],
2011 with_coriolis[5] - baseline[5],
2012 );
2013 let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
2014
2015 assert!(
2016 (actual - expected).norm() < 1e-12,
2017 "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
2018 );
2019 }
2020
2021 #[test]
2022 fn test_fast_solution_interpolation() {
2023 let times = vec![0.0, 1.0, 2.0];
2024 let states = vec![
2025 [0.0, 0.0, 0.0, 100.0, 50.0, 0.0],
2026 [100.0, 45.0, 0.0, 99.0, 40.0, 0.0],
2027 [198.0, 80.0, 0.0, 98.0, 30.0, 0.0],
2028 ];
2029
2030 let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
2031
2032 let result = solution.sol(&[1.5]);
2034
2035 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); }
2039
2040 #[test]
2041 fn test_bc_from_velocity_segments() {
2042 let segments = vec![
2043 BCSegmentData {
2044 velocity_min: 0.0,
2045 velocity_max: 1000.0,
2046 bc_value: 0.5,
2047 },
2048 BCSegmentData {
2049 velocity_min: 1000.0,
2050 velocity_max: 2000.0,
2051 bc_value: 0.52,
2052 },
2053 BCSegmentData {
2054 velocity_min: 2000.0,
2055 velocity_max: 3000.0,
2056 bc_value: 0.55,
2057 },
2058 ];
2059
2060 assert_eq!(velocity_segment_bc(500.0, &segments, 0.5), 0.5);
2065 assert_eq!(velocity_segment_bc(1500.0, &segments, 0.5), 0.52);
2066 assert_eq!(velocity_segment_bc(2500.0, &segments, 0.5), 0.55);
2067
2068 assert_eq!(velocity_segment_bc(-100.0, &segments, 0.5), 0.5); assert_eq!(velocity_segment_bc(3500.0, &segments, 0.5), 0.55); }
2072
2073 #[test]
2074 fn test_fast_solution_interpolation_edge_cases() {
2075 let times = vec![0.0, 1.0, 2.0, 3.0];
2076 let states = vec![
2077 [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
2078 [800.0, 40.0, 100.0, 750.0, 30.0, 0.0],
2079 [1550.0, 60.0, 200.0, 700.0, 10.0, 0.0],
2080 [2250.0, 50.0, 300.0, 650.0, -10.0, 0.0],
2081 ];
2082
2083 let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
2084
2085 let result_before = solution.sol(&[-0.5]);
2087 assert!((result_before[0][0] - 0.0).abs() < 1e-10); let result_after = solution.sol(&[5.0]);
2091 assert!((result_after[0][0] - 2250.0).abs() < 1e-10); let result_exact = solution.sol(&[1.0]);
2095 assert!((result_exact[0][0] - 800.0).abs() < 1e-10);
2096
2097 let result_multi = solution.sol(&[0.5, 1.5, 2.5]);
2099 assert_eq!(result_multi[0].len(), 3);
2100 }
2101
2102 #[test]
2103 fn test_fast_solution_from_trajectory_data() {
2104 let times = vec![0.0, 0.5, 1.0];
2105 let states = vec![
2106 [0.0, 1.0, 2.0, 3.0, 4.0, 5.0],
2107 [10.0, 11.0, 12.0, 13.0, 14.0, 15.0],
2108 [20.0, 21.0, 22.0, 23.0, 24.0, 25.0],
2109 ];
2110 let t_events = [vec![1.0], vec![0.5], vec![]];
2111
2112 let solution = FastSolution::from_trajectory_data(times.clone(), states, t_events);
2113
2114 assert_eq!(solution.t, times);
2116 assert_eq!(solution.y.len(), 6); assert_eq!(solution.y[0].len(), 3); assert!(solution.success);
2119
2120 assert_eq!(solution.y[0][0], 0.0); assert_eq!(solution.y[1][0], 1.0); assert_eq!(solution.y[0][2], 20.0); }
2125
2126 #[test]
2127 fn test_bc_segments_boundary_conditions() {
2128 let single_segment = vec![BCSegmentData {
2132 velocity_min: 1000.0,
2133 velocity_max: 2000.0,
2134 bc_value: 0.5,
2135 }];
2136
2137 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![
2145 BCSegmentData {
2146 velocity_min: 0.0,
2147 velocity_max: 999.0, bc_value: 0.45,
2149 },
2150 BCSegmentData {
2151 velocity_min: 1000.0,
2152 velocity_max: 2000.0,
2153 bc_value: 0.50,
2154 },
2155 ];
2156
2157 assert_eq!(velocity_segment_bc(1000.0, &segments, 0.7), 0.6); assert_eq!(velocity_segment_bc(0.0, &segments, 0.7), 0.45); assert_eq!(
2172 velocity_segment_bc(998.999, &segments, 0.7),
2173 0.5735000320000354
2174 ); assert_eq!(velocity_segment_bc(999.0, &segments, 0.7), 0.575); }
2179
2180 #[test]
2181 fn velocity_segment_gaps_and_clamps_do_not_depend_on_order() {
2182 let fallback_bc = 0.73;
2183 let ascending_with_gap = vec![
2184 BCSegmentData {
2185 velocity_min: 0.0,
2186 velocity_max: 999.0,
2187 bc_value: 0.6,
2188 },
2189 BCSegmentData {
2190 velocity_min: 1000.0,
2191 velocity_max: 2000.0,
2192 bc_value: 0.8,
2193 },
2194 ];
2195 assert_eq!(
2199 velocity_segment_bc(999.5, &ascending_with_gap, fallback_bc),
2200 fallback_bc,
2201 "coverage gaps must use the projectile's base BC"
2202 );
2203
2204 let mut descending = ascending_with_gap.clone();
2205 descending.reverse();
2206 assert_eq!(
2209 velocity_segment_bc(-100.0, &descending, fallback_bc),
2210 0.6,
2211 "below coverage must clamp to the lowest-velocity band"
2212 );
2213 assert_eq!(
2214 velocity_segment_bc(2500.0, &descending, fallback_bc),
2215 0.8,
2216 "above coverage must clamp to the highest-velocity band"
2217 );
2218 }
2219
2220 #[test]
2221 fn test_bc_segments_empty_fallback() {
2222 let empty_segments: Vec<BCSegmentData> = vec![];
2223
2224 let result = velocity_segment_bc(1500.0, &empty_segments, 0.73);
2228 assert_eq!(result, 0.73); }
2230
2231 #[test]
2232 fn test_fast_integration_params() {
2233 let params = FastIntegrationParams {
2235 horiz: 1000.0,
2236 vert: 0.0,
2237 initial_state: [0.0, 0.0, 0.0, 800.0, 50.0, 0.0], t_span: (0.0, 5.0),
2239 atmo_params: (0.0, 15.0, 1013.25, 1.0),
2240 atmo_sock: None,
2241 };
2242
2243 assert_eq!(params.horiz, 1000.0);
2244 assert_eq!(params.t_span.0, 0.0);
2245 assert_eq!(params.t_span.1, 5.0);
2246 assert_eq!(params.initial_state[3], 800.0); }
2248
2249 #[test]
2250 fn test_fast_solution_event_arrays() {
2251 let times = vec![0.0, 1.0, 2.0];
2252 let states = vec![
2253 [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
2254 [800.0, 40.0, 500.0, 750.0, 30.0, 0.0],
2255 [1500.0, 20.0, 1000.0, 700.0, 10.0, 0.0],
2256 ];
2257
2258 let t_events = [
2260 vec![2.0], vec![0.5], vec![], ];
2264
2265 let solution = FastSolution::from_trajectory_data(times, states, t_events);
2266
2267 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()); }
2271
2272 #[test]
2273 fn segmented_fast_path_interpolates_max_ordinate_between_saved_points() {
2274 let expected_apex_time = 5.105_f64;
2275 let downrange_velocity = 100.0_f64;
2276 let vertical_velocity = G_ACCEL_MPS2 * expected_apex_time;
2277 let inputs = BallisticInputs {
2278 muzzle_velocity: downrange_velocity.hypot(vertical_velocity),
2279 bc_value: 0.5,
2280 bc_type: DragModel::G7,
2281 use_enhanced_spin_drift: false,
2282 ..BallisticInputs::default()
2283 };
2284 let solution = fast_integrate_with_segments(
2285 &inputs,
2286 vec![],
2287 FastIntegrationParams {
2288 horiz: 1_000.0,
2289 vert: 0.0,
2290 initial_state: [0.0, 0.0, 0.0, downrange_velocity, vertical_velocity, 0.0],
2291 t_span: (0.0, 12.0),
2292 atmo_params: (1e-12, 340.0, 0.0, 0.0),
2295 atmo_sock: None,
2296 },
2297 );
2298
2299 assert!(solution.success);
2300 assert_eq!(solution.t_events[1].len(), 1);
2301 let reported_time = solution.t_events[1][0];
2302 assert!(
2303 (reported_time - expected_apex_time).abs() < 0.006,
2304 "max-ordinate time must be interpolated between coarse saves: reported={reported_time} expected={expected_apex_time}"
2305 );
2306 let event_index = solution
2307 .t
2308 .iter()
2309 .position(|time| time.to_bits() == reported_time.to_bits())
2310 .expect("the interpolated apex must be retained in the solution");
2311 assert_eq!(solution.y[4][event_index].to_bits(), 0.0_f64.to_bits());
2312
2313 let expected_height = vertical_velocity * expected_apex_time
2314 - 0.5 * G_ACCEL_MPS2 * expected_apex_time.powi(2);
2315 let event_state = solution.sol(&[reported_time]);
2316 assert!(
2317 (event_state[1][0] - expected_height).abs() < 2e-4,
2318 "max-ordinate state must preserve the interpolated apex height: reported={} expected={expected_height}",
2319 event_state[1][0]
2320 );
2321 }
2322
2323 #[test]
2324 fn plain_fast_path_interpolates_the_target_crossing() {
2325 let target = 500.123456789;
2326 let initial_state = [0.0, 0.0, 0.25, 800.0, 12.0, -2.5];
2327 let inputs = BallisticInputs {
2328 muzzle_velocity: 800.0,
2329 bc_value: 0.5,
2330 bc_type: DragModel::G7,
2331 ground_threshold: -100.0,
2332 use_enhanced_spin_drift: false,
2333 ..BallisticInputs::default()
2334 };
2335 let run = |horiz| {
2336 fast_integrate(
2337 &inputs,
2338 &WindSock::new(vec![]),
2339 FastIntegrationParams {
2340 horiz,
2341 vert: 0.0,
2342 initial_state,
2343 t_span: (0.0, 2.0),
2344 atmo_params: (0.0, 15.0, 1013.25, 1.0),
2345 atmo_sock: None,
2346 },
2347 )
2348 };
2349
2350 let reference = run(target + 2.0);
2352 let left = reference.y[0]
2353 .windows(2)
2354 .position(|x| x[0] < target && x[1] > target)
2355 .expect("reference trajectory must bracket target");
2356 let right = left + 1;
2357 let alpha =
2358 (target - reference.y[0][left]) / (reference.y[0][right] - reference.y[0][left]);
2359
2360 let solution = run(target);
2361 let last = solution.t.len() - 1;
2362 assert_eq!(solution.y[0][last].to_bits(), target.to_bits());
2363 let expected_time = reference.t[left] + alpha * (reference.t[right] - reference.t[left]);
2364 assert!((solution.t[last] - expected_time).abs() < 1e-12);
2365 assert_eq!(solution.t_events[0], vec![solution.t[last]]);
2366
2367 for component in 0..6 {
2368 assert_eq!(solution.y[component].len(), solution.t.len());
2369 let expected = reference.y[component][left]
2370 + alpha * (reference.y[component][right] - reference.y[component][left]);
2371 assert!(
2372 (solution.y[component][last] - expected).abs() < 1e-9,
2373 "component {component} is not at the crossing: actual={}, expected={expected}",
2374 solution.y[component][last]
2375 );
2376 }
2377 }
2378
2379 #[test]
2380 fn fast_path_coriolis_uses_shot_direction() {
2381 use std::f64::consts::FRAC_PI_2;
2386 fn final_xy(shot_az: f64) -> (f64, f64) {
2388 let inputs = BallisticInputs {
2389 muzzle_velocity: 800.0,
2390 bc_value: 0.5,
2391 bc_type: DragModel::G7,
2392 enable_advanced_effects: true, enable_coriolis: true,
2394 latitude: Some(45.0),
2395 shot_azimuth: shot_az,
2396 ..BallisticInputs::default()
2397 };
2398 let v = 800.0_f64;
2399 let elev = 0.02_f64;
2400 let params = FastIntegrationParams {
2401 horiz: 1000.0,
2402 vert: 0.0,
2403 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
2404 t_span: (0.0, 5.0),
2405 atmo_params: (0.0, 15.0, 1013.25, 1.0),
2406 atmo_sock: None,
2407 };
2408 let sol = fast_integrate_with_segments(&inputs, vec![], params);
2409 let n = sol.y[0].len();
2410 (sol.y[0][n - 1], sol.y[1][n - 1])
2411 }
2412 let (ex, ey) = final_xy(FRAC_PI_2); let (wx, wy) = final_xy(3.0 * FRAC_PI_2); assert!(
2417 (ex - wx).abs() < 0.5,
2418 "east/west downrange should be ~equal (ex={ex:.4}, wx={wx:.4})"
2419 );
2420 assert!(
2423 ey > wy,
2424 "fast-path east ({ey:.6}) must be higher than west ({wy:.6}) (Eotvos)"
2425 );
2426 assert!(
2427 (ey - wy) > 1e-5,
2428 "fast-path E-W vertical separation ({:.8} m) should be non-zero (the pre-fix bug was exact equality)",
2429 ey - wy
2430 );
2431 }
2432
2433 #[test]
2434 fn fast_path_coriolis_independent_of_advanced_effects() {
2435 use std::f64::consts::FRAC_PI_2;
2438 fn final_y(coriolis: bool, shot_az: f64) -> f64 {
2439 let inputs = BallisticInputs {
2440 muzzle_velocity: 800.0,
2441 bc_value: 0.5,
2442 bc_type: DragModel::G7,
2443 enable_coriolis: coriolis,
2444 enable_advanced_effects: false, latitude: Some(45.0),
2446 shot_azimuth: shot_az,
2447 ..BallisticInputs::default()
2448 };
2449 let v = 800.0_f64;
2450 let elev = 0.02_f64;
2451 let params = FastIntegrationParams {
2452 horiz: 1000.0,
2453 vert: 0.0,
2454 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
2455 t_span: (0.0, 5.0),
2456 atmo_params: (0.0, 15.0, 1013.25, 1.0),
2457 atmo_sock: None,
2458 };
2459 let sol = fast_integrate_with_segments(&inputs, vec![], params);
2460 let n = sol.y[0].len();
2461 sol.y[1][n - 1]
2462 }
2463 let e = final_y(true, FRAC_PI_2);
2465 let w = final_y(true, 3.0 * FRAC_PI_2);
2466 assert!(
2467 e > w && (e - w) > 1e-5,
2468 "Coriolis-only (no advanced effects) must still be directional: E={e} W={w}"
2469 );
2470 let e2 = final_y(false, FRAC_PI_2);
2472 let w2 = final_y(false, 3.0 * FRAC_PI_2);
2473 assert!(
2474 (e2 - w2).abs() < 1e-9,
2475 "with enable_coriolis=false, east/west must be identical: E={e2} W={w2}"
2476 );
2477 }
2478
2479 #[test]
2480 fn fast_path_rejects_degenerate_atmosphere() {
2481 let inputs = BallisticInputs {
2482 muzzle_velocity: 800.0,
2483 bc_value: 0.5,
2484 bc_type: DragModel::G7,
2485 ..BallisticInputs::default()
2486 };
2487 let v = 800.0_f64;
2488 let e = 0.02_f64;
2489 let mk = |atmo: (f64, f64, f64, f64)| FastIntegrationParams {
2490 horiz: 500.0,
2491 vert: 0.0,
2492 initial_state: [0.0, 0.0, 0.0, v * e.cos(), v * e.sin(), 0.0],
2493 t_span: (0.0, 5.0),
2494 atmo_params: atmo,
2495 atmo_sock: None,
2496 };
2497 let zero_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 0.0, 1.0)));
2499 assert!(
2500 !zero_p.success,
2501 "pressure=0 atmosphere must yield success=false"
2502 );
2503 let nan_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, f64::NAN, 1.0)));
2505 assert!(!nan_p.success, "NaN pressure must yield success=false");
2506 let segmented_too_dense =
2508 fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 1013.25, 50.0)));
2509 let plain_too_dense = fast_integrate(
2510 &inputs,
2511 &WindSock::new(vec![]),
2512 mk((0.0, 15.0, 1013.25, 50.0)),
2513 );
2514 assert!(
2515 !segmented_too_dense.success && !plain_too_dense.success,
2516 "ratio=50 atmosphere must fail in both wrappers: segmented={}, plain={}",
2517 segmented_too_dense.success,
2518 plain_too_dense.success
2519 );
2520 let good = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 1013.25, 1.0)));
2522 assert!(good.success, "realistic atmosphere must yield success=true");
2523 let direct = fast_integrate_with_segments(&inputs, vec![], mk((1.225, 340.0, 0.0, 0.0)));
2526 assert!(
2527 direct.success,
2528 "direct-atmosphere mode (pressure=0 sentinel) must yield success=true"
2529 );
2530 }
2531
2532 #[test]
2533 fn fast_paths_reject_invalid_wind_segments() {
2534 let inputs = BallisticInputs {
2535 muzzle_velocity: 800.0,
2536 bc_value: 0.5,
2537 bc_type: DragModel::G7,
2538 ..BallisticInputs::default()
2539 };
2540 let mk = || FastIntegrationParams {
2541 horiz: 100.0,
2542 vert: 0.0,
2543 initial_state: [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
2544 t_span: (0.0, 5.0),
2545 atmo_params: (0.0, 15.0, 1013.25, 1.0),
2546 atmo_sock: None,
2547 };
2548 let invalid = crate::wind::WindSegment::new(10.0, 90.0, f64::NAN);
2549
2550 let plain = fast_integrate(&inputs, &WindSock::new(vec![invalid]), mk());
2551 let segmented = fast_integrate_with_segments(&inputs, vec![invalid], mk());
2552
2553 assert!(
2554 !plain.success && !segmented.success,
2555 "invalid segmented wind must fail in both fast wrappers: plain={}, segmented={}",
2556 plain.success,
2557 segmented.success
2558 );
2559 }
2560
2561 #[test]
2562 fn plain_fast_path_honors_direct_atmosphere_values() {
2563 fn final_speed(muzzle_velocity: f64, atmo_params: (f64, f64, f64, f64)) -> f64 {
2564 let inputs = BallisticInputs {
2565 muzzle_velocity,
2566 bc_value: 0.5,
2567 bc_type: DragModel::G7,
2568 ground_threshold: -100.0,
2569 ..BallisticInputs::default()
2570 };
2571
2572 let wind_sock = WindSock::new(vec![]);
2573 let solution = fast_integrate(
2574 &inputs,
2575 &wind_sock,
2576 FastIntegrationParams {
2577 horiz: 10_000.0,
2578 vert: 0.0,
2579 initial_state: [0.0, 0.0, 0.0, muzzle_velocity, 0.0, 0.0],
2580 t_span: (0.0, 0.2),
2581 atmo_params,
2582 atmo_sock: None,
2583 },
2584 );
2585 assert!(solution.success);
2586
2587 let last = solution.y[0].len() - 1;
2588 (solution.y[3][last].powi(2)
2589 + solution.y[4][last].powi(2)
2590 + solution.y[5][last].powi(2))
2591 .sqrt()
2592 }
2593
2594 let thin_air = final_speed(800.0, (0.905, 340.0, 0.0, 0.0));
2595 let dense_air = final_speed(800.0, (1.225, 340.0, 0.0, 0.0));
2596 assert!(
2597 thin_air > dense_air,
2598 "lower supplied density must retain more velocity: thin={thin_air}, dense={dense_air}"
2599 );
2600
2601 let low_sound_speed = final_speed(340.0, (1.0, 300.0, 0.0, 0.0));
2602 let high_sound_speed = final_speed(340.0, (1.0, 400.0, 0.0, 0.0));
2603 assert!(
2604 (low_sound_speed - high_sound_speed).abs() > 1e-6,
2605 "supplied sound speed must affect Mach-dependent drag"
2606 );
2607 }
2608
2609 #[test]
2610 fn segmented_fast_path_nonpositive_density_ratio_uses_standard_fallback() {
2611 fn terminal_velocity(base_ratio: f64) -> f64 {
2612 let inputs = BallisticInputs {
2613 muzzle_velocity: 800.0,
2614 bc_value: 0.5,
2615 bc_type: DragModel::G7,
2616 ground_threshold: -100.0,
2617 ..BallisticInputs::default()
2618 };
2619
2620 let solution = fast_integrate_with_segments(
2621 &inputs,
2622 vec![],
2623 FastIntegrationParams {
2624 horiz: 500.0,
2625 vert: 0.0,
2626 initial_state: [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
2627 t_span: (0.0, 5.0),
2628 atmo_params: (0.0, 15.0, 1013.25, base_ratio),
2629 atmo_sock: None,
2630 },
2631 );
2632 assert!(solution.success);
2633
2634 let last = solution.y[3].len() - 1;
2635 solution.y[3][last]
2636 }
2637
2638 let explicit_sea_level = terminal_velocity(1.0);
2639 for base_ratio in [0.0, -1.0] {
2640 let missing_ratio = terminal_velocity(base_ratio);
2641 assert!(
2642 missing_ratio < 800.0,
2643 "missing density ratio must not create a vacuum trajectory: {missing_ratio}"
2644 );
2645 assert!((missing_ratio - explicit_sea_level).abs() < 1e-9);
2646 }
2647 }
2648
2649 #[test]
2650 fn fast_path_carries_real_bullet_geometry() {
2651 let run = |diameter: f64, twist: f64| {
2659 let inputs = BallisticInputs {
2660 muzzle_velocity: 800.0,
2661 bc_value: 0.5,
2662 bc_type: DragModel::G7,
2663 bullet_diameter: diameter,
2664 bullet_length: 0.0318,
2665 twist_rate: twist,
2666 enable_advanced_effects: true,
2667 enable_magnus: true,
2668 ..BallisticInputs::default()
2669 };
2670 let v = 800.0_f64;
2671 let elev = 0.02_f64;
2672 let params = FastIntegrationParams {
2673 horiz: 1000.0,
2674 vert: 0.0,
2675 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
2676 t_span: (0.0, 5.0),
2677 atmo_params: (0.0, 15.0, 1013.25, 1.0),
2678 atmo_sock: None,
2679 };
2680 fast_integrate_with_segments(&inputs, vec![], params)
2681 };
2682 assert!(run(0.00569, 7.0).success, ".224 geometry must solve");
2685 assert!(run(0.00858, 10.0).success, ".338 geometry must solve");
2686 }
2687
2688 #[test]
2689 fn segmented_fast_spin_flags_do_not_depend_on_advanced_umbrella() {
2690 fn endpoint(
2691 enable_advanced_effects: bool,
2692 enable_magnus: bool,
2693 use_enhanced_spin_drift: bool,
2694 ) -> Vector3<f64> {
2695 let inputs = BallisticInputs {
2696 muzzle_velocity: 823.0,
2697 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
2698 bullet_diameter: 0.308 * 0.0254,
2699 bullet_length: 1.215 * 0.0254,
2700 caliber_inches: 0.308,
2701 weight_grains: 168.0,
2702 bc_value: 0.475,
2703 bc_type: DragModel::G1,
2704 twist_rate: 12.0,
2705 is_twist_right: true,
2706 enable_advanced_effects,
2707 enable_magnus,
2708 use_enhanced_spin_drift,
2709 ..BallisticInputs::default()
2710 };
2711 let elevation = 0.02_f64;
2712 let solution = fast_integrate_with_segments(
2713 &inputs,
2714 vec![],
2715 FastIntegrationParams {
2716 horiz: 1_000.0,
2717 vert: 0.0,
2718 initial_state: [
2719 0.0,
2720 0.0,
2721 0.0,
2722 inputs.muzzle_velocity * elevation.cos(),
2723 inputs.muzzle_velocity * elevation.sin(),
2724 0.0,
2725 ],
2726 t_span: (0.0, 5.0),
2727 atmo_params: (0.0, 15.0, 1013.25, 1.0),
2728 atmo_sock: None,
2729 },
2730 );
2731 assert!(solution.success);
2732 let last = solution.t.len() - 1;
2733 Vector3::new(
2734 solution.y[0][last],
2735 solution.y[1][last],
2736 solution.y[2][last],
2737 )
2738 }
2739
2740 let baseline = endpoint(false, false, false);
2741 let magnus_without_umbrella = endpoint(false, true, false);
2742 let magnus_with_umbrella = endpoint(true, true, false);
2743 assert!(
2744 (magnus_without_umbrella - baseline).norm() > 1e-5,
2745 "test shot must produce a measurable Magnus displacement"
2746 );
2747 assert!(
2748 (magnus_with_umbrella - magnus_without_umbrella).norm() < 1e-12,
2749 "the legacy umbrella must not suppress explicitly enabled Magnus: without={magnus_without_umbrella:?} with={magnus_with_umbrella:?}"
2750 );
2751
2752 let litz_only = endpoint(false, false, true);
2753 let litz_without_umbrella = endpoint(false, true, true);
2754 let litz_with_umbrella = endpoint(true, true, true);
2755 assert!(
2756 (litz_without_umbrella - litz_only).norm() < 1e-12,
2757 "Litz mode must suppress explicitly enabled Magnus: litz={litz_only:?} both={litz_without_umbrella:?}"
2758 );
2759 assert!(
2760 (litz_with_umbrella - litz_without_umbrella).norm() < 1e-12,
2761 "the legacy umbrella must not change Magnus suppression in Litz mode: without={litz_without_umbrella:?} with={litz_with_umbrella:?}"
2762 );
2763 }
2764
2765 fn deck_test_inputs(cd_scale: f64) -> BallisticInputs {
2767 BallisticInputs {
2768 bullet_mass: 0.0106,
2769 bullet_diameter: 0.00782,
2770 muzzle_velocity: 850.0,
2771 custom_drag_table: Some(crate::drag::DragTable::new(
2772 vec![0.5, 1.0, 2.0, 3.0],
2773 vec![0.23, 0.40, 0.30, 0.26],
2774 )),
2775 cd_scale,
2776 temperature: 15.0,
2777 pressure: 1013.25,
2778 ..BallisticInputs::default()
2779 }
2780 }
2781
2782 fn deck_accel_x(cd_scale: f64) -> f64 {
2783 let inputs = deck_test_inputs(cd_scale);
2784 compute_derivatives_with_gravity(
2785 &[0.0, 0.0, 0.0, 700.0, 0.0, 0.0],
2786 &inputs,
2787 &WindSock::new(vec![]),
2788 FastAtmosphere::Standard {
2789 base_density: 1.225,
2790 },
2791 &inputs.bc_type,
2792 crate::transonic_drag::ProjectileShape::Spitzer,
2793 inputs.bc_value,
2794 false,
2795 false,
2796 None,
2797 None,
2798 None,
2799 Gravity::EARTH,
2800 )[3]
2801 }
2802
2803 #[test]
2804 fn cd_scale_default_is_one_and_absent_matches_explicit() {
2805 assert_eq!(BallisticInputs::default().cd_scale, 1.0);
2806
2807 let omitted_inputs = BallisticInputs {
2808 bullet_mass: 0.0106,
2809 bullet_diameter: 0.00782,
2810 muzzle_velocity: 850.0,
2811 custom_drag_table: Some(crate::drag::DragTable::new(
2812 vec![0.5, 1.0, 2.0, 3.0],
2813 vec![0.23, 0.40, 0.30, 0.26],
2814 )),
2815 temperature: 15.0,
2816 pressure: 1013.25,
2817 ..BallisticInputs::default()
2818 };
2819 assert_eq!(omitted_inputs.cd_scale, 1.0);
2820
2821 let a_omitted = compute_derivatives_with_gravity(
2822 &[0.0, 0.0, 0.0, 700.0, 0.0, 0.0],
2823 &omitted_inputs,
2824 &WindSock::new(vec![]),
2825 FastAtmosphere::Standard {
2826 base_density: 1.225,
2827 },
2828 &omitted_inputs.bc_type,
2829 crate::transonic_drag::ProjectileShape::Spitzer,
2830 omitted_inputs.bc_value,
2831 false,
2832 false,
2833 None,
2834 None,
2835 None,
2836 Gravity::EARTH,
2837 )[3];
2838 let a_explicit = deck_accel_x(1.0);
2839 assert_eq!(
2840 a_omitted.to_bits(),
2841 a_explicit.to_bits(),
2842 "omitted cd_scale (Default) must be bit-identical to an explicit 1.0"
2843 );
2844 }
2845
2846 #[test]
2849 fn cd_scale_direction_on_fast_trajectory_kernel() {
2850 let baseline = deck_accel_x(1.0);
2851 let scaled_up = deck_accel_x(1.10);
2852 let scaled_down = deck_accel_x(0.90);
2853
2854 assert!(
2855 scaled_up < baseline,
2856 "cd_scale=1.10 must increase drag deceleration (more negative ax): \
2857 base={baseline} up={scaled_up}"
2858 );
2859 assert!(
2860 scaled_down > baseline,
2861 "cd_scale=0.90 must decrease drag deceleration (less negative ax): \
2862 base={baseline} down={scaled_down}"
2863 );
2864 }
2865
2866 #[test]
2868 fn cd_scale_is_inert_without_a_custom_drag_table() {
2869 let make = |cd_scale: f64| BallisticInputs {
2870 bc_value: 0.5,
2871 bc_type: DragModel::G1,
2872 temperature: 15.0,
2873 pressure: 1013.25,
2874 cd_scale,
2875 ..BallisticInputs::default()
2876 };
2877 let accel = |cd_scale: f64| {
2878 let inputs = make(cd_scale);
2879 compute_derivatives_with_gravity(
2880 &[0.0, 0.0, 0.0, 700.0, 0.0, 0.0],
2881 &inputs,
2882 &WindSock::new(vec![]),
2883 FastAtmosphere::Standard {
2884 base_density: 1.225,
2885 },
2886 &inputs.bc_type,
2887 crate::transonic_drag::ProjectileShape::Spitzer,
2888 inputs.bc_value,
2889 false,
2890 false,
2891 None,
2892 None,
2893 None,
2894 Gravity::EARTH,
2895 )[3]
2896 };
2897 assert_eq!(
2898 accel(1.0).to_bits(),
2899 accel(1.5).to_bits(),
2900 "cd_scale must not affect the G-model/BC drag path"
2901 );
2902 }
2903
2904 fn segmented_speed_at_1_5_s(inputs: &BallisticInputs) -> f64 {
2906 let v = inputs.muzzle_velocity;
2907 let elev = 0.012_f64;
2908 let solution = fast_integrate_with_segments(
2909 inputs,
2910 vec![],
2911 FastIntegrationParams {
2912 horiz: 1000.0,
2913 vert: 0.0,
2914 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
2915 t_span: (0.0, 5.0),
2916 atmo_params: (0.0, 15.0, 1013.25, 1.0),
2917 atmo_sock: None,
2918 },
2919 );
2920 assert!(solution.success);
2921 let s = solution.sol(&[1.5]);
2922 (s[3][0].powi(2) + s[4][0].powi(2) + s[5][0].powi(2)).sqrt()
2923 }
2924
2925 fn mba1602_308_inputs() -> BallisticInputs {
2926 BallisticInputs {
2927 muzzle_velocity: 792.48,
2928 bullet_mass: 0.01134,
2929 bullet_diameter: 0.0078232,
2930 bullet_length: 0.031496,
2931 bc_value: 0.505,
2932 bc_type: DragModel::G1,
2933 ..BallisticInputs::default()
2934 }
2935 }
2936
2937 #[test]
2938 fn segmented_fast_path_flies_caller_velocity_bc_ladder() {
2939 let base = mba1602_308_inputs();
2944 let mut ladder = base.clone();
2945 ladder.use_bc_segments = true;
2946 ladder.bc_segments_data = Some(vec![BCSegmentData {
2947 velocity_min: 0.0,
2948 velocity_max: 5000.0,
2949 bc_value: 0.300,
2950 }]);
2951 let scalar = BallisticInputs {
2952 bc_value: 0.300,
2953 ..base.clone()
2954 };
2955 let mut estimated = base.clone();
2956 estimated.use_bc_segments = true;
2957
2958 let flown = segmented_speed_at_1_5_s(&ladder);
2959 let expected = segmented_speed_at_1_5_s(&scalar);
2960 let auto = segmented_speed_at_1_5_s(&estimated);
2961 assert!(
2962 (flown - expected).abs() < 1e-9,
2963 "a one-band 0.300 ladder must fly as scalar BC 0.300: ladder={flown} scalar={expected}"
2964 );
2965 assert!(
2966 (flown - auto).abs() > 10.0,
2967 "the caller's ladder must not be replaced by the auto-estimate: ladder={flown} estimate={auto}"
2968 );
2969 }
2970
2971 #[test]
2972 fn segmented_fast_path_converts_army_standard_metro_caller_ladder() {
2973 let base = mba1602_308_inputs();
2976 let mut ladder = base.clone();
2977 ladder.bc_reference_standard = crate::cli_api::BcReferenceStandard::ArmyStandardMetro;
2978 ladder.use_bc_segments = true;
2979 ladder.bc_segments_data = Some(vec![BCSegmentData {
2980 velocity_min: 0.0,
2981 velocity_max: 5000.0,
2982 bc_value: 0.300,
2983 }]);
2984 let scalar = BallisticInputs {
2985 bc_value: 0.300,
2986 bc_reference_standard: crate::cli_api::BcReferenceStandard::ArmyStandardMetro,
2987 ..base
2988 };
2989 let flown = segmented_speed_at_1_5_s(&ladder);
2990 let expected = segmented_speed_at_1_5_s(&scalar);
2991 assert!(
2992 (flown - expected).abs() < 1e-9,
2993 "an ASM ladder must be converted like an ASM scalar: ladder={flown} scalar={expected}"
2994 );
2995 }
2996
2997 #[test]
2998 fn segmented_fast_path_empty_ladder_matches_absent_ladder() {
2999 let mut absent = mba1602_308_inputs();
3002 absent.use_bc_segments = true;
3003 let mut empty = absent.clone();
3004 empty.bc_segments_data = Some(Vec::new());
3005 assert_eq!(
3006 segmented_speed_at_1_5_s(&empty).to_bits(),
3007 segmented_speed_at_1_5_s(&absent).to_bits(),
3008 "Some(empty) must behave exactly like None"
3009 );
3010 }
3011
3012 fn reference_table_inputs(band_bc: f64) -> BallisticInputs {
3014 BallisticInputs {
3015 custom_drag_table: Some(crate::drag::DragTable::new(
3016 vec![0.5, 1.0, 2.0, 3.0],
3017 vec![0.23, 0.40, 0.30, 0.26],
3018 )),
3019 custom_drag_reference: true,
3020 use_bc_segments: true,
3021 bc_segments_data: Some(vec![BCSegmentData {
3022 velocity_min: 0.0,
3023 velocity_max: 5000.0,
3024 bc_value: band_bc,
3025 }]),
3026 ..mba1602_308_inputs()
3027 }
3028 }
3029
3030 fn own_curve_oracle(bc: f64) -> BallisticInputs {
3033 let base = mba1602_308_inputs();
3034 let mut normalized = base.clone();
3037 normalized.normalize_for_solve();
3038 let sd = normalized.sectional_density_lb_in2().expect("SD");
3039 BallisticInputs {
3040 custom_drag_table: reference_table_inputs(bc).custom_drag_table,
3041 cd_scale: sd / bc,
3042 ..base
3043 }
3044 }
3045
3046 #[test]
3051 fn segmented_fast_path_flies_a_reference_table_over_the_ladder_bc() {
3052 for band_bc in [0.30, 0.40] {
3053 let flown = segmented_speed_at_1_5_s(&reference_table_inputs(band_bc));
3054 let expected = segmented_speed_at_1_5_s(&own_curve_oracle(band_bc));
3055 assert!(
3056 (flown - expected).abs() < 1e-9 * expected,
3057 "band {band_bc}: flown {flown}, expected {expected}"
3058 );
3059 }
3060 let dropped = BallisticInputs {
3061 custom_drag_reference: false,
3062 ..reference_table_inputs(0.30)
3063 };
3064 let flown = segmented_speed_at_1_5_s(&reference_table_inputs(0.30));
3065 let own_curve = segmented_speed_at_1_5_s(&dropped);
3066 assert!(
3067 (own_curve - flown).abs() > 5.0,
3068 "the fixture must tell the readings apart: own curve {own_curve} vs reference {flown}"
3069 );
3070 }
3071
3072 #[test]
3074 fn fast_path_flies_a_reference_table_over_the_ladder_bc() {
3075 let final_speed = |inputs: &BallisticInputs| {
3076 let v = inputs.muzzle_velocity;
3077 let solution = fast_integrate(
3078 inputs,
3079 &WindSock::new(vec![]),
3080 FastIntegrationParams {
3081 horiz: 500.0,
3082 vert: 0.0,
3083 initial_state: [0.0, 0.0, 0.0, v, 0.0, 0.0],
3084 t_span: (0.0, 5.0),
3085 atmo_params: (0.0, 15.0, 1013.25, 1.0),
3086 atmo_sock: None,
3087 },
3088 );
3089 assert!(solution.success);
3090 let last = solution.t.len() - 1;
3091 (solution.y[3][last].powi(2)
3092 + solution.y[4][last].powi(2)
3093 + solution.y[5][last].powi(2))
3094 .sqrt()
3095 };
3096 for band_bc in [0.30, 0.40] {
3097 let flown = final_speed(&reference_table_inputs(band_bc));
3098 let expected = final_speed(&own_curve_oracle(band_bc));
3099 assert!(
3100 (flown - expected).abs() < 1e-9 * expected,
3101 "band {band_bc}: flown {flown}, expected {expected}"
3102 );
3103 }
3104 let dropped = BallisticInputs {
3105 custom_drag_reference: false,
3106 ..reference_table_inputs(0.30)
3107 };
3108 assert!(
3109 (final_speed(&dropped) - final_speed(&reference_table_inputs(0.30))).abs() > 5.0,
3110 "the fixture must tell the readings apart"
3111 );
3112 }
3113}