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(atmo_params: (f64, f64, f64, f64)) -> Option<(f64, f64)> {
118 let (a, b, c, d) = atmo_params;
119 (a.is_finite() && b.is_finite() && c == 0.0 && d == 0.0 && a > 0.0 && a < 2.0 && b > 200.0)
120 .then_some((a, b))
121}
122
123fn stability_atmosphere_params(atmo_params: (f64, f64, f64, f64)) -> (f64, f64, f64, f64) {
124 if let Some((air_density, _)) = direct_atmosphere_values(atmo_params) {
125 (0.0, 15.0, 1013.25 * air_density / STANDARD_AIR_DENSITY, 1.0)
129 } else {
130 atmo_params
131 }
132}
133
134const MAX_STANDARD_DENSITY_RATIO: f64 = 2.0;
135
136fn atmo_is_physical(atmo_params: (f64, f64, f64, f64)) -> bool {
149 let (a, b, c, d) = atmo_params;
150 if !(a.is_finite() && b.is_finite() && c.is_finite() && d.is_finite()) {
151 return false;
152 }
153 direct_atmosphere_values(atmo_params).is_some() || (c > 0.0 && d < MAX_STANDARD_DENSITY_RATIO)
158}
159
160#[derive(Debug, Clone, Copy)]
161enum FastAtmosphere {
162 Direct {
163 air_density: f64,
164 speed_of_sound: f64,
165 },
166 Standard {
167 base_density: f64,
168 },
169}
170
171pub struct FastIntegrationParams {
173 pub horiz: f64,
174 pub vert: f64,
175 pub initial_state: [f64; 6],
176 pub t_span: (f64, f64),
177 pub atmo_params: (f64, f64, f64, f64),
183 pub atmo_sock: Option<AtmoSock>,
188}
189
190pub fn aerodynamic_jump_launch_offset_rad(
198 inputs: &BallisticInputs,
199 atmo_params: (f64, f64, f64, f64),
200) -> f64 {
201 let crosswind_from_right_mps = inputs.wind_speed * inputs.wind_angle.sin();
202 aerodynamic_jump_launch_offset_for_crosswind_rad(inputs, atmo_params, crosswind_from_right_mps)
203}
204
205fn aerodynamic_jump_launch_offset_for_crosswind_rad(
206 inputs: &BallisticInputs,
207 atmo_params: (f64, f64, f64, f64),
208 crosswind_from_right_mps: f64,
209) -> f64 {
210 if !inputs.enable_aerodynamic_jump {
211 return 0.0;
212 }
213 let diameter = inputs.bullet_diameter;
214 if !(inputs.twist_rate.is_finite()
215 && inputs.twist_rate != 0.0
216 && diameter.is_finite()
217 && diameter > 0.0
218 && inputs.bullet_length.is_finite()
219 && inputs.bullet_length > 0.0
220 && inputs.muzzle_velocity.is_finite())
221 {
222 return 0.0;
223 }
224 let stability_atmo = stability_atmosphere_params(atmo_params);
225 let sg = crate::stability::compute_stability_coefficient(inputs, stability_atmo);
226 if !(sg.is_finite() && sg > 0.0) {
227 return 0.0;
228 }
229 let length_cal = inputs.bullet_length / diameter;
230 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
231 let crosswind_from_right_mph = crosswind_from_right_mps * MS_TO_MPH;
232 let vertical_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
233 sg,
234 length_cal,
235 crosswind_from_right_mph,
236 inputs.is_twist_right,
237 );
238 if !vertical_moa.is_finite() {
239 return 0.0;
240 }
241 const MOA_PER_RAD: f64 = 3437.7467707849;
242 vertical_moa / MOA_PER_RAD
243}
244
245fn rotate_launch_velocity(state: &mut [f64; 6], theta_rad: f64) {
248 let (vx, vy, vz) = (state[3], state[4], state[5]);
249 let speed = (vx * vx + vy * vy + vz * vz).sqrt();
250 if speed <= 0.0 {
251 return;
252 }
253 let h = (vx * vx + vz * vz).sqrt(); let new_elev = vy.atan2(h) + theta_rad;
255 state[4] = speed * new_elev.sin();
256 let new_h = speed * new_elev.cos();
257 let scale = if h > 1e-12 { new_h / h } else { 0.0 };
258 state[3] = vx * scale;
259 state[5] = vz * scale;
260}
261
262fn launch_state_with_aerodynamic_jump(
263 inputs: &BallisticInputs,
264 atmo_params: (f64, f64, f64, f64),
265 segmented_crosswind_from_right_mps: Option<f64>,
266 mut initial_state: [f64; 6],
267) -> [f64; 6] {
268 let offset = match segmented_crosswind_from_right_mps {
269 Some(crosswind) => {
270 aerodynamic_jump_launch_offset_for_crosswind_rad(inputs, atmo_params, crosswind)
271 }
272 None => aerodynamic_jump_launch_offset_rad(inputs, atmo_params),
273 };
274 if offset != 0.0 {
275 rotate_launch_velocity(&mut initial_state, offset);
276 }
277 initial_state
278}
279
280pub fn fast_integrate(
282 inputs: &BallisticInputs,
283 wind_sock: &WindSock,
284 params: FastIntegrationParams,
285) -> FastSolution {
286 if wind_sock.validate_segments().is_err() || !atmo_is_physical(params.atmo_params) {
288 return FastSolution::degenerate(¶ms.initial_state);
289 }
290 let mut effective_inputs = inputs.clone();
291 effective_inputs.normalize_for_solve();
296 if params.atmo_params.2 > 0.0 {
297 effective_inputs.altitude = params.atmo_params.0;
298 effective_inputs.temperature = params.atmo_params.1;
299 effective_inputs.pressure = params.atmo_params.2;
300 effective_inputs.humidity = params.atmo_params.3;
301 }
302 let inputs = &effective_inputs;
303 let _mass_kg = inputs.bullet_mass; let bc = inputs.bc_value;
306 let drag_model = &inputs.bc_type;
307
308 let has_bc_segments =
310 inputs.bc_segments.is_some() && !inputs.bc_segments.as_ref().unwrap().is_empty();
311 let has_bc_segments_data =
312 inputs.bc_segments_data.is_some() && !inputs.bc_segments_data.as_ref().unwrap().is_empty();
313
314 let dt = if params.horiz > 200.0 {
316 0.001
317 } else if params.horiz > 100.0 {
318 0.0005
319 } else {
320 0.0001
321 };
322
323 let segmented_crosswind_from_right_mps = if inputs.enable_aerodynamic_jump {
326 wind_sock.muzzle_crosswind_from_right_mps()
327 } else {
328 None
329 };
330 let initial_state = launch_state_with_aerodynamic_jump(
331 inputs,
332 params.atmo_params,
333 segmented_crosswind_from_right_mps,
334 params.initial_state,
335 );
336 let vx = initial_state[3]; let n_steps = ((params.t_span.1 / dt) as usize) + 1;
350 let est_steps = if vx > 1e-6 && params.horiz > 0.0 {
351 (((4.0 * params.horiz / vx) / dt) as usize) + 1
352 } else {
353 n_steps
354 };
355 let cap = est_steps.min(n_steps);
356 let mut times = Vec::with_capacity(cap);
357 let mut states = Vec::with_capacity(cap);
358
359 times.push(0.0);
361 states.push(initial_state);
362
363 let atmosphere =
368 if let Some((air_density, speed_of_sound)) = direct_atmosphere_values(params.atmo_params) {
369 FastAtmosphere::Direct {
370 air_density,
371 speed_of_sound,
372 }
373 } else {
374 let base_density = if params.atmo_params.3 > 0.0 {
375 params.atmo_params.3 * 1.225
376 } else {
377 1.225
378 };
379 FastAtmosphere::Standard { base_density }
380 };
381
382 let atmo_sock = params.atmo_sock.as_ref();
384
385 let drag_model_str: &str = match drag_model {
393 DragModel::G1 => "G1",
394 DragModel::G2 => "G2",
395 DragModel::G5 => "G5",
396 DragModel::G6 => "G6",
397 DragModel::G7 => "G7",
398 DragModel::G8 => "G8",
399 DragModel::GI => "GI",
400 DragModel::GS => "GS",
401 DragModel::RA4 => "RA4",
402 };
403
404 let caliber_in = if inputs.caliber_inches > 0.0 {
406 inputs.caliber_inches
407 } else {
408 inputs.bullet_diameter / 0.0254
409 };
410 let weight_gr = if inputs.weight_grains > 0.0 {
411 inputs.weight_grains
412 } else {
413 inputs.bullet_mass / crate::constants::GRAINS_TO_KG
414 };
415
416 let projectile_shape = crate::transonic_drag::resolve_projectile_shape(
419 inputs.bullet_model.as_deref(),
420 caliber_in,
421 weight_gr,
422 drag_model_str,
423 );
424
425 let omega_vector = match inputs.latitude {
431 Some(latitude) if inputs.enable_coriolis => {
432 let omega_earth = 7.2921159e-5_f64; let lat = latitude.to_radians();
434 let az = inputs.shot_azimuth; Some(Vector3::new(
436 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), ))
440 }
441 _ => None,
442 };
443 let wind_shear_model = if inputs.enable_wind_shear {
445 let model = crate::wind_shear::boundary_layer_model_from_name(&inputs.wind_shear_model);
446 (model != crate::wind_shear::WindShearModel::None).then_some(model)
447 } else {
448 None
449 };
450
451 let mut hit_target = false;
453 let mut hit_ground = false;
454 let mut max_ord_time = None;
455 let mut max_ord_y = 0.0;
456 let ground_threshold = inputs.ground_threshold;
457
458 for i in 0..n_steps - 1 {
460 let t = i as f64 * dt;
461 let state = states[i];
462
463 let pos = Vector3::new(state[0], state[1], state[2]);
464 let _vel = Vector3::new(state[3], state[4], state[5]);
465
466 if pos.x >= params.horiz {
468 hit_target = true;
469 break;
470 }
471
472 if pos.y <= ground_threshold {
473 hit_ground = true;
474 break;
475 }
476
477 if pos.y > max_ord_y {
479 max_ord_y = pos.y;
480 max_ord_time = Some(t);
481 }
482
483 let k1 = compute_derivatives(
485 &state,
486 inputs,
487 wind_sock,
488 atmosphere,
489 drag_model,
490 projectile_shape,
491 bc,
492 has_bc_segments,
493 has_bc_segments_data,
494 omega_vector,
495 wind_shear_model,
496 atmo_sock,
497 );
498
499 let mut state2 = state;
500 for j in 0..6 {
501 state2[j] = state[j] + 0.5 * dt * k1[j];
502 }
503 let k2 = compute_derivatives(
504 &state2,
505 inputs,
506 wind_sock,
507 atmosphere,
508 drag_model,
509 projectile_shape,
510 bc,
511 has_bc_segments,
512 has_bc_segments_data,
513 omega_vector,
514 wind_shear_model,
515 atmo_sock,
516 );
517
518 let mut state3 = state;
519 for j in 0..6 {
520 state3[j] = state[j] + 0.5 * dt * k2[j];
521 }
522 let k3 = compute_derivatives(
523 &state3,
524 inputs,
525 wind_sock,
526 atmosphere,
527 drag_model,
528 projectile_shape,
529 bc,
530 has_bc_segments,
531 has_bc_segments_data,
532 omega_vector,
533 wind_shear_model,
534 atmo_sock,
535 );
536
537 let mut state4 = state;
538 for j in 0..6 {
539 state4[j] = state[j] + dt * k3[j];
540 }
541 let k4 = compute_derivatives(
542 &state4,
543 inputs,
544 wind_sock,
545 atmosphere,
546 drag_model,
547 projectile_shape,
548 bc,
549 has_bc_segments,
550 has_bc_segments_data,
551 omega_vector,
552 wind_shear_model,
553 atmo_sock,
554 );
555
556 let mut new_state = state;
558 for j in 0..6 {
559 new_state[j] = state[j] + dt * (k1[j] + 2.0 * k2[j] + 2.0 * k3[j] + k4[j]) / 6.0;
560 }
561
562 if state[0] < params.horiz && new_state[0] >= params.horiz {
563 let alpha = (params.horiz - state[0]) / (new_state[0] - state[0]);
566 let mut target_state = state;
567 for j in 0..6 {
568 target_state[j] = state[j] + alpha * (new_state[j] - state[j]);
569 }
570 target_state[0] = params.horiz;
571 times.push(t + alpha * dt);
572 states.push(target_state);
573 hit_target = true;
574 break;
575 }
576
577 times.push(t + dt);
578 states.push(new_state);
579 }
580
581 let t_events = [
583 if hit_target {
584 vec![*times.last().unwrap()]
585 } else {
586 vec![]
587 },
588 if let Some(t) = max_ord_time {
589 vec![t]
590 } else {
591 vec![]
592 },
593 if hit_ground {
594 vec![*times.last().unwrap()]
595 } else {
596 vec![]
597 },
598 ];
599
600 if inputs.use_enhanced_spin_drift {
609 let (sd_temp_c, sd_press_hpa) = if params.atmo_params.2 > 0.0 {
613 (params.atmo_params.1, params.atmo_params.2)
614 } else {
615 (15.0, 1013.25)
616 };
617 let sg = crate::spin_drift::effective_sg_from_inputs(inputs, sd_temp_c, sd_press_hpa);
618 for (t, state) in times.iter().zip(states.iter_mut()) {
619 if *t > 0.0 {
620 state[2] += crate::spin_drift::litz_drift_meters(sg, *t, inputs.is_twist_right);
621 }
622 }
623 }
624
625 FastSolution::from_trajectory_data(times, states, t_events)
626}
627
628fn fast_magnus_acceleration(
629 inputs: &BallisticInputs,
630 air_velocity: Vector3<f64>,
631 air_density: f64,
632 mach: f64,
633 gravity_acceleration: Vector3<f64>,
634) -> Vector3<f64> {
635 if !inputs.enable_magnus
636 || inputs.use_enhanced_spin_drift
637 || inputs.bullet_diameter <= 0.0
638 || inputs.twist_rate <= 0.0
639 || inputs.bullet_mass <= 0.0
640 {
641 return Vector3::zeros();
642 }
643
644 let speed_air = air_velocity.norm();
645 let diameter_m = inputs.bullet_diameter;
646 let (spin_rate_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
647 inputs.muzzle_velocity,
648 speed_air,
649 inputs.twist_rate,
650 diameter_m,
651 );
652 let d_in = if inputs.caliber_inches > 0.0 {
653 inputs.caliber_inches
654 } else {
655 diameter_m / 0.0254
656 };
657 let m_gr = if inputs.weight_grains > 0.0 {
658 inputs.weight_grains
659 } else {
660 inputs.bullet_mass / crate::constants::GRAINS_TO_KG
661 };
662 let l_in = if inputs.bullet_length > 0.0 {
663 inputs.bullet_length / 0.0254
664 } else {
665 let estimated = crate::stability::estimate_bullet_length_m(diameter_m, inputs.bullet_mass);
666 if estimated > 0.0 {
667 estimated / 0.0254
668 } else {
669 4.5 * d_in.max(1e-9)
670 }
671 };
672 let sg = crate::spin_drift::calculate_dynamic_stability(
673 m_gr,
674 speed_air,
675 spin_rate_rad_s,
676 d_in,
677 l_in,
678 air_density,
679 );
680 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
681 sg,
682 speed_air,
683 spin_rate_rad_s,
684 0.0,
685 0.0,
686 air_density,
687 d_in,
688 l_in,
689 m_gr,
690 mach,
691 "match",
692 false,
693 );
694 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
695 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
696 let force = 0.5 * air_density * speed_air.powi(2) * area * c_np * spin_param * yaw_rad.sin();
697 if force <= 1e-12 {
698 return Vector3::zeros();
699 }
700
701 crate::derivatives::yaw_of_repose_magnus_direction(
702 air_velocity,
703 gravity_acceleration,
704 inputs.is_twist_right,
705 )
706 .map_or_else(Vector3::zeros, |direction| {
707 (force / inputs.bullet_mass) * direction
708 })
709}
710
711fn interpolated_vertical_apex(
712 previous_time: f64,
713 previous: &[f64; 6],
714 current_time: f64,
715 current: &[f64; 6],
716) -> Option<(f64, [f64; 6])> {
717 let dt = current_time - previous_time;
718 let previous_vy = previous[4];
719 let current_vy = current[4];
720 if !dt.is_finite()
721 || dt <= 0.0
722 || !previous_vy.is_finite()
723 || !current_vy.is_finite()
724 || previous_vy <= 0.0
725 || current_vy > 0.0
726 {
727 return None;
728 }
729
730 let denominator = previous_vy - current_vy;
731 if !denominator.is_finite() || denominator <= 0.0 {
732 return None;
733 }
734 let alpha = previous_vy / denominator;
735 if !alpha.is_finite() || !(0.0..1.0).contains(&alpha) {
738 return None;
739 }
740
741 let mut apex = [0.0; 6];
742 for component in 0..6 {
743 apex[component] = previous[component] + alpha * (current[component] - previous[component]);
744 }
745
746 let alpha2 = alpha * alpha;
749 let alpha3 = alpha2 * alpha;
750 let h00 = 2.0 * alpha3 - 3.0 * alpha2 + 1.0;
751 let h10 = alpha3 - 2.0 * alpha2 + alpha;
752 let h01 = -2.0 * alpha3 + 3.0 * alpha2;
753 let h11 = alpha3 - alpha2;
754 for axis in 0..3 {
755 apex[axis] = h00 * previous[axis]
756 + h10 * dt * previous[axis + 3]
757 + h01 * current[axis]
758 + h11 * dt * current[axis + 3];
759 }
760 apex[4] = 0.0;
761
762 apex.iter()
763 .all(|component| component.is_finite())
764 .then_some((previous_time + alpha * dt, apex))
765}
766
767#[allow(clippy::too_many_arguments)]
769fn compute_derivatives(
770 state: &[f64; 6],
771 inputs: &BallisticInputs,
772 wind_sock: &WindSock,
773 atmosphere: FastAtmosphere,
774 drag_model: &DragModel,
775 projectile_shape: crate::transonic_drag::ProjectileShape,
776 bc: f64,
777 has_bc_segments: bool,
778 has_bc_segments_data: bool,
779 omega: Option<Vector3<f64>>,
780 wind_shear_model: Option<crate::wind_shear::WindShearModel>,
781 atmo_sock: Option<&AtmoSock>,
783) -> [f64; 6] {
784 let pos = Vector3::new(state[0], state[1], state[2]);
785 let vel = Vector3::new(state[3], state[4], state[5]);
786
787 let level_wind = wind_sock.vector_for_range_stateless(pos.x);
790 let level_wind = if let Some(model) = wind_shear_model {
791 let height_rel_launch =
792 crate::atmosphere::shot_frame_altitude(0.0, pos.x, pos.y, inputs.shooting_angle);
793 crate::wind_shear::apply_boundary_layer_shear(level_wind, height_rel_launch, model)
794 } else {
795 level_wind
796 };
797 let wind_vector =
798 crate::derivatives::level_vector_to_shot_frame(level_wind, inputs.shooting_angle);
799
800 let vel_adjusted = vel - wind_vector;
802 let v_mag = vel_adjusted.norm();
803
804 let theta = inputs.shooting_angle;
807 let accel_gravity = Vector3::new(
808 -G_ACCEL_MPS2 * theta.sin(),
809 -G_ACCEL_MPS2 * theta.cos(),
810 0.0,
811 );
812
813 let mut accel = if v_mag < 1e-6 {
815 accel_gravity
816 } else {
817 let v_fps = v_mag * MPS_TO_FPS;
819
820 let (local_density, speed_of_sound) = match atmosphere {
842 FastAtmosphere::Direct {
843 air_density,
844 speed_of_sound,
845 } => (air_density, speed_of_sound),
846 FastAtmosphere::Standard { base_density } => {
847 let altitude = crate::atmosphere::shot_frame_altitude(
848 inputs.altitude,
849 pos.x,
850 pos.y,
851 inputs.shooting_angle,
852 );
853 let (base_temp_c, base_press_hpa, base_ratio) = match atmo_sock {
854 Some(sock) => {
855 let (zt, zp, zh) = sock.atmo_for_range(pos.x);
856 (zt, zp, calculate_air_density_cimp(zt, zp, zh) / 1.225)
857 }
858 None => (inputs.temperature, inputs.pressure, base_density / 1.225),
859 };
860 get_local_atmosphere_humid(
861 altitude,
862 inputs.altitude, base_temp_c,
864 base_press_hpa,
865 base_ratio,
866 0.0, )
868 }
869 };
870 let mach = v_mag / speed_of_sound;
871
872 let bc_current = match (
874 inputs.bc_segments_data.as_ref(),
875 inputs.bc_segments.as_ref(),
876 ) {
877 (Some(segments_data), _) if inputs.use_bc_segments && has_bc_segments_data => {
878 velocity_segment_bc(v_fps, segments_data, bc)
879 }
880 (_, Some(segments)) if has_bc_segments => {
881 crate::derivatives::interpolated_bc(mach, segments, Some(inputs))
882 }
883 _ => bc,
884 };
885 let bc_current = bc_current.max(1e-6);
888
889 let (drag_factor, retard_denom) = if let Some(ref table) = inputs.custom_drag_table {
902 (
903 table.interpolate(mach) * inputs.cd_scale,
908 inputs.custom_drag_denominator(bc_current),
909 )
910 } else {
911 let base_cd = get_drag_coefficient(mach, drag_model);
912 let cd =
913 crate::transonic_drag::transonic_correction(mach, base_cd, projectile_shape, false);
914 (cd, bc_current)
915 };
916
917 let cd_to_retard = crate::constants::CD_TO_RETARD;
919 let standard_factor = drag_factor * cd_to_retard;
920 let density_scale = local_density / 1.225;
923
924 let a_drag_ft_s2 = (v_fps * v_fps) * standard_factor * density_scale / retard_denom;
926
927 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let accel_drag = -a_drag_m_s2 * (vel_adjusted / v_mag);
930
931 let accel_magnus =
934 fast_magnus_acceleration(inputs, vel_adjusted, local_density, mach, accel_gravity);
935
936 accel_drag + accel_gravity + accel_magnus
938 };
939
940 if let Some(omega) = omega {
943 let omega = crate::derivatives::level_vector_to_shot_frame(omega, inputs.shooting_angle);
944 accel += -2.0 * omega.cross(&vel);
945 }
946
947 [vel.x, vel.y, vel.z, accel.x, accel.y, accel.z]
949}
950
951pub fn fast_integrate_with_segments(
959 inputs: &BallisticInputs,
960 wind_segments: Vec<crate::wind::WindSegment>,
961 params: FastIntegrationParams,
962) -> FastSolution {
963 use crate::trajectory_integration::{integrate_trajectory, TrajectoryParams};
965
966 if crate::wind::validate_wind_segments(&wind_segments).is_err()
968 || !atmo_is_physical(params.atmo_params)
969 {
970 return FastSolution::degenerate(¶ms.initial_state);
971 }
972
973 let mut normalized_inputs = inputs.clone();
979 normalized_inputs.normalize_for_solve();
980 let inputs = &normalized_inputs;
981
982 let segmented_crosswind_from_right_mps =
985 if inputs.enable_aerodynamic_jump && !wind_segments.is_empty() {
986 WindSock::new(wind_segments.clone()).muzzle_crosswind_from_right_mps()
987 } else {
988 None
989 };
990 let initial_state = launch_state_with_aerodynamic_jump(
991 inputs,
992 params.atmo_params,
993 segmented_crosswind_from_right_mps,
994 params.initial_state,
995 );
996
997 let mass_kg = inputs.bullet_mass; let bc = inputs.bc_value;
1000 let drag_model = inputs.bc_type;
1001
1002 let omega_vector = if inputs.enable_coriolis && inputs.latitude.is_some() {
1006 let omega_earth = 7.2921159e-5; let lat_rad = inputs.latitude.unwrap_or(0.0).to_radians();
1012 let azimuth = inputs.shot_azimuth; Some(Vector3::new(
1014 omega_earth * lat_rad.cos() * azimuth.cos(), omega_earth * lat_rad.sin(), -omega_earth * lat_rad.cos() * azimuth.sin(), ))
1018 } else {
1019 None
1020 };
1021
1022 let traj_params = TrajectoryParams {
1024 mass_kg,
1025 bc,
1026 drag_model,
1027 wind_segments,
1028 atmos_params: params.atmo_params,
1029 omega_vector,
1030 enable_spin_drift: inputs.use_enhanced_spin_drift,
1031 enable_magnus: inputs.enable_magnus,
1032 enable_coriolis: inputs.enable_coriolis,
1033 target_distance_m: params.horiz,
1034 enable_wind_shear: inputs.enable_wind_shear,
1035 wind_shear_model: inputs.wind_shear_model.clone(),
1036 shooter_altitude_m: inputs.altitude,
1037 is_twist_right: inputs.is_twist_right,
1038 shooting_angle: inputs.shooting_angle,
1039 bullet_diameter: inputs.bullet_diameter,
1041 bullet_length: inputs.bullet_length,
1042 twist_rate: inputs.twist_rate,
1043 cd_scale: inputs.cd_scale,
1044 custom_drag_reference: inputs.custom_drag_reference,
1045 custom_drag_table: inputs.custom_drag_table.clone(),
1046 bc_segments: inputs.bc_segments.clone(),
1047 use_bc_segments: inputs.use_bc_segments,
1048 bc_segments_data: inputs.bc_segments_data.clone(),
1051 ground_threshold: -1000.0,
1055 atmo_sock: params.atmo_sock,
1057 };
1058
1059 let trajectory = integrate_trajectory(
1061 initial_state,
1062 params.t_span,
1063 traj_params,
1064 "RK45", 1e-6, 0.01, );
1068
1069 let n_points = trajectory.len();
1071 let mut times = Vec::with_capacity(n_points + 1);
1072 let mut states = Vec::with_capacity(n_points + 1);
1073
1074 let mut target_hit_time: Option<f64> = None;
1075 let mut ground_hit_time: Option<f64> = None;
1076 let mut max_ord_time = None;
1077 let mut max_ord_y = 0.0;
1078
1079 for (t, state_vec) in trajectory {
1080 let state = [
1082 state_vec[0],
1083 state_vec[1],
1084 state_vec[2],
1085 state_vec[3],
1086 state_vec[4],
1087 state_vec[5],
1088 ];
1089
1090 if let Some((&previous_time, &previous_state)) = times.last().zip(states.last()) {
1098 if let Some((apex_time, apex_state)) =
1099 interpolated_vertical_apex(previous_time, &previous_state, t, &state)
1100 {
1101 if apex_state[1] > max_ord_y {
1102 max_ord_y = apex_state[1];
1103 max_ord_time = Some(apex_time);
1104 }
1105 times.push(apex_time);
1106 states.push(apex_state);
1107 }
1108 }
1109
1110 if target_hit_time.is_none() && state[0] >= params.horiz {
1112 target_hit_time = Some(t);
1113 }
1114
1115 if ground_hit_time.is_none() && state[1] <= inputs.ground_threshold {
1117 ground_hit_time = Some(t);
1118 }
1119
1120 if state[1] > max_ord_y {
1122 max_ord_y = state[1];
1123 max_ord_time = Some(t);
1124 }
1125
1126 times.push(t);
1127 states.push(state);
1128 }
1129
1130 let t_events = [
1132 if let Some(t) = target_hit_time {
1133 vec![t]
1134 } else {
1135 vec![]
1136 },
1137 if let Some(t) = max_ord_time {
1138 vec![t]
1139 } else {
1140 vec![]
1141 },
1142 if let Some(t) = ground_hit_time {
1143 vec![t]
1144 } else {
1145 vec![]
1146 },
1147 ];
1148
1149 if inputs.use_enhanced_spin_drift {
1154 let (sd_temp_c, sd_press_hpa) = if params.atmo_params.2 > 0.0 {
1158 (params.atmo_params.1, params.atmo_params.2)
1159 } else {
1160 (15.0, 1013.25)
1161 };
1162 let sg = crate::spin_drift::effective_sg_from_inputs(inputs, sd_temp_c, sd_press_hpa);
1163 for (t, state) in times.iter().zip(states.iter_mut()) {
1164 if *t > 0.0 {
1165 state[2] += crate::spin_drift::litz_drift_meters(sg, *t, inputs.is_twist_right);
1166 }
1167 }
1168 }
1169
1170 FastSolution::from_trajectory_data(times, states, t_events)
1171}
1172
1173#[cfg(test)]
1174mod tests {
1175 use super::*;
1176 use crate::BCSegmentData;
1177
1178 fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
1179 let (sin_angle, cos_angle) = angle.sin_cos();
1180 Vector3::new(
1181 level.x * cos_angle + level.y * sin_angle,
1182 -level.x * sin_angle + level.y * cos_angle,
1183 level.z,
1184 )
1185 }
1186
1187 #[test]
1188 fn measured_bc_fast_drag_ignores_name_based_form_factor_flag() {
1189 let derivatives_with_flag = |use_form_factor| {
1190 let inputs = BallisticInputs {
1191 bc_value: 0.462,
1192 bc_type: DragModel::G1,
1193 bullet_model: Some("168gr SMK Match".to_string()),
1194 use_form_factor,
1195 temperature: 15.0,
1196 pressure: 1013.25,
1197 ..BallisticInputs::default()
1198 };
1199
1200 compute_derivatives(
1201 &[0.0, 0.0, 0.0, 600.0, 0.0, 0.0],
1202 &inputs,
1203 &WindSock::new(vec![]),
1204 FastAtmosphere::Standard {
1205 base_density: 1.225,
1206 },
1207 &inputs.bc_type,
1208 crate::transonic_drag::ProjectileShape::Spitzer,
1209 inputs.bc_value,
1210 false,
1211 false,
1212 None,
1213 None,
1214 None,
1215 )
1216 };
1217
1218 let baseline = derivatives_with_flag(false);
1219 let flagged = derivatives_with_flag(true);
1220
1221 for component in 3..6 {
1222 assert_eq!(
1223 flagged[component].to_bits(),
1224 baseline[component].to_bits(),
1225 "published BC already encodes form factor: component {component}, baseline={} flagged={}",
1226 baseline[component],
1227 flagged[component]
1228 );
1229 }
1230 }
1231
1232 #[test]
1233 fn velocity_bc_data_requires_opt_in_in_plain_fast_kernel() {
1234 let acceleration = |inputs: &BallisticInputs| {
1235 let has_mach_segments = inputs
1236 .bc_segments
1237 .as_ref()
1238 .is_some_and(|segments| !segments.is_empty());
1239 let has_velocity_segments = inputs
1240 .bc_segments_data
1241 .as_ref()
1242 .is_some_and(|segments| !segments.is_empty());
1243 compute_derivatives(
1244 &[0.0, 0.0, 0.0, 600.0, 0.0, 0.0],
1245 inputs,
1246 &WindSock::new(vec![]),
1247 FastAtmosphere::Standard {
1248 base_density: 1.225,
1249 },
1250 &inputs.bc_type,
1251 crate::transonic_drag::ProjectileShape::Spitzer,
1252 inputs.bc_value,
1253 has_mach_segments,
1254 has_velocity_segments,
1255 None,
1256 None,
1257 None,
1258 )
1259 };
1260
1261 let scalar_inputs = BallisticInputs {
1262 bc_value: 0.5,
1263 bc_type: DragModel::G7,
1264 temperature: 15.0,
1265 pressure: 1013.25,
1266 ..BallisticInputs::default()
1267 };
1268 let mut disabled_inputs = scalar_inputs.clone();
1269 disabled_inputs.bc_segments_data = Some(vec![BCSegmentData {
1270 velocity_min: 0.0,
1271 velocity_max: 4_000.0,
1272 bc_value: 0.46,
1273 }]);
1274 disabled_inputs.use_bc_segments = false;
1275 let mut enabled_inputs = disabled_inputs.clone();
1276 enabled_inputs.use_bc_segments = true;
1277 let mut mach_only_inputs = scalar_inputs.clone();
1278 mach_only_inputs.bc_segments = Some(vec![(0.0, 0.4), (3.0, 0.4)]);
1279 let mut disabled_with_both = mach_only_inputs.clone();
1280 disabled_with_both.bc_segments_data = disabled_inputs.bc_segments_data.clone();
1281
1282 let scalar = acceleration(&scalar_inputs);
1283 let disabled = acceleration(&disabled_inputs);
1284 let enabled = acceleration(&enabled_inputs);
1285 let mach_only = acceleration(&mach_only_inputs);
1286 let disabled_with_both = acceleration(&disabled_with_both);
1287
1288 assert_eq!(
1289 disabled[3].to_bits(),
1290 scalar[3].to_bits(),
1291 "a populated velocity table must not change drag while use_bc_segments is false"
1292 );
1293 assert!(
1294 enabled[3] < disabled[3] - 1.0,
1295 "enabling the lower BC table must increase drag: disabled ax={} enabled ax={}",
1296 disabled[3],
1297 enabled[3]
1298 );
1299 assert_eq!(
1300 disabled_with_both[3].to_bits(),
1301 mach_only[3].to_bits(),
1302 "disabling velocity data must fall through to an explicit Mach table"
1303 );
1304 }
1305
1306 #[test]
1307 fn inclined_positions_at_same_world_altitude_have_same_fast_acceleration() {
1308 let angle = std::f64::consts::FRAC_PI_6;
1309 let inputs = BallisticInputs {
1310 altitude: 100.0,
1311 temperature: 15.0,
1312 pressure: 1013.25,
1313 shooting_angle: angle,
1314 ..BallisticInputs::default()
1315 };
1316 let wind_sock = WindSock::new(vec![]);
1317 let atmosphere = FastAtmosphere::Standard {
1318 base_density: 1.225,
1319 };
1320 let state_along_slant = [1_000.0, 0.0, 0.0, 600.0, 0.0, 0.0];
1321 let state_across_slant = [0.0, 500.0 / angle.cos(), 0.0, 600.0, 0.0, 0.0];
1322
1323 let a = compute_derivatives(
1324 &state_along_slant,
1325 &inputs,
1326 &wind_sock,
1327 atmosphere,
1328 &inputs.bc_type,
1329 crate::transonic_drag::ProjectileShape::Spitzer,
1330 inputs.bc_value,
1331 false,
1332 false,
1333 None,
1334 None,
1335 None,
1336 );
1337 let b = compute_derivatives(
1338 &state_across_slant,
1339 &inputs,
1340 &wind_sock,
1341 atmosphere,
1342 &inputs.bc_type,
1343 crate::transonic_drag::ProjectileShape::Spitzer,
1344 inputs.bc_value,
1345 false,
1346 false,
1347 None,
1348 None,
1349 None,
1350 );
1351
1352 for component in 3..6 {
1353 assert!(
1354 (a[component] - b[component]).abs() < 1e-10,
1355 "fast derivative component {component} differs at equal world altitude: {} vs {}",
1356 a[component],
1357 b[component]
1358 );
1359 }
1360 }
1361
1362 #[test]
1363 fn inclined_headwind_is_rotated_into_solver_frame() {
1364 let angle = std::f64::consts::FRAC_PI_6;
1365 let inputs = BallisticInputs {
1366 shooting_angle: angle,
1367 ..BallisticInputs::default()
1368 };
1369 let speed_mps = 360.0 * (1000.0 / 3600.0);
1370 let level_headwind = Vector3::new(-speed_mps, 0.0, 0.0);
1371 let velocity = expected_shot_frame_vector(level_headwind, angle);
1372 let state = [0.0, 0.0, 0.0, velocity.x, velocity.y, velocity.z];
1373 let actual = compute_derivatives(
1374 &state,
1375 &inputs,
1376 &WindSock::new(vec![crate::wind::WindSegment::new(360.0, 0.0, 1000.0)]),
1377 FastAtmosphere::Direct {
1378 air_density: 1.225,
1379 speed_of_sound: 340.0,
1380 },
1381 &inputs.bc_type,
1382 crate::transonic_drag::ProjectileShape::Spitzer,
1383 inputs.bc_value,
1384 false,
1385 false,
1386 None,
1387 None,
1388 None,
1389 );
1390 let expected = Vector3::new(
1391 -G_ACCEL_MPS2 * angle.sin(),
1392 -G_ACCEL_MPS2 * angle.cos(),
1393 0.0,
1394 );
1395
1396 assert!(
1397 (Vector3::new(actual[3], actual[4], actual[5]) - expected).norm() < 1e-12,
1398 "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
1399 );
1400 }
1401
1402 #[test]
1403 fn plain_fast_kernel_applies_power_law_wind_shear() {
1404 let state = [500.0, 100.0, 0.0, 700.0, 0.0, 0.0];
1405 let run = |enable_wind_shear: bool, model: &str, wind_speed_kmh: f64| {
1406 let inputs = BallisticInputs {
1407 bc_value: 0.5,
1408 bc_type: DragModel::G7,
1409 enable_wind_shear,
1410 wind_shear_model: model.to_string(),
1411 ..BallisticInputs::default()
1412 };
1413 let wind_shear_model = enable_wind_shear
1414 .then(|| crate::wind_shear::boundary_layer_model_from_name(model))
1415 .filter(|model| *model != crate::wind_shear::WindShearModel::None);
1416 compute_derivatives(
1417 &state,
1418 &inputs,
1419 &WindSock::new(vec![crate::wind::WindSegment::new(
1420 wind_speed_kmh,
1421 90.0,
1422 2_000.0,
1423 )]),
1424 FastAtmosphere::Direct {
1425 air_density: 1.225,
1426 speed_of_sound: 340.0,
1427 },
1428 &inputs.bc_type,
1429 crate::transonic_drag::ProjectileShape::Spitzer,
1430 inputs.bc_value,
1431 false,
1432 false,
1433 None,
1434 wind_shear_model,
1435 None,
1436 )
1437 };
1438
1439 let uniform = run(false, "power_law", 36.0);
1440 let model_none = run(true, "none", 36.0);
1441 assert_eq!(model_none, uniform, "model=none must preserve uniform wind");
1442
1443 let sheared = run(true, "power_law", 36.0);
1444 assert!(
1445 sheared[5] < uniform[5],
1446 "stronger aloft crosswind must increase leftward acceleration: uniform={}, shear={}",
1447 uniform[5],
1448 sheared[5]
1449 );
1450
1451 let ratio = crate::wind_shear::boundary_layer_speed_ratio(
1452 state[1],
1453 crate::wind_shear::WindShearModel::PowerLaw,
1454 );
1455 let equivalent_uniform = run(false, "none", 36.0 * ratio);
1456 for component in 3..6 {
1457 assert!(
1458 (sheared[component] - equivalent_uniform[component]).abs() < 1e-12,
1459 "shear component {component} must equal base wind scaled by {ratio}: shear={}, expected={}",
1460 sheared[component],
1461 equivalent_uniform[component]
1462 );
1463 }
1464 }
1465
1466 #[test]
1467 fn plain_fast_path_wind_shear_changes_high_arc_drift() {
1468 let run = |enable_wind_shear: bool, model: &str| {
1469 let inputs = BallisticInputs {
1470 muzzle_velocity: 800.0,
1471 bc_value: 0.5,
1472 bc_type: DragModel::G7,
1473 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
1474 bullet_diameter: 0.308 * 0.0254,
1475 enable_wind_shear,
1476 wind_shear_model: model.to_string(),
1477 ground_threshold: -100.0,
1478 ..BallisticInputs::default()
1479 };
1480 let elevation = 0.12_f64;
1481 let solution = fast_integrate(
1482 &inputs,
1483 &WindSock::new(vec![crate::wind::WindSegment::new(36.0, 90.0, 2_000.0)]),
1484 FastIntegrationParams {
1485 horiz: 1_000.0,
1486 vert: 0.0,
1487 initial_state: [
1488 0.0,
1489 0.0,
1490 0.0,
1491 inputs.muzzle_velocity * elevation.cos(),
1492 inputs.muzzle_velocity * elevation.sin(),
1493 0.0,
1494 ],
1495 t_span: (0.0, 5.0),
1496 atmo_params: (0.0, 15.0, 1013.25, 1.0),
1497 atmo_sock: None,
1498 },
1499 );
1500 let last = solution.t.len() - 1;
1501 assert_eq!(solution.y[0][last].to_bits(), 1_000.0_f64.to_bits());
1502 solution.y[2][last]
1503 };
1504
1505 let uniform = run(false, "power_law");
1506 let model_none = run(true, "none");
1507 assert_eq!(model_none.to_bits(), uniform.to_bits());
1508 let sheared = run(true, "power_law");
1509 assert!(
1510 sheared.abs() > uniform.abs() + 0.01,
1511 "aloft shear must increase drift magnitude: uniform={uniform}, shear={sheared}"
1512 );
1513 }
1514
1515 #[test]
1516 fn inclined_coriolis_is_rotated_into_solver_frame() {
1517 let angle = std::f64::consts::FRAC_PI_6;
1518 let inputs = BallisticInputs {
1519 shooting_angle: angle,
1520 ..BallisticInputs::default()
1521 };
1522 let velocity = Vector3::new(600.0, 20.0, 5.0);
1523 let state = [0.0, 0.0, 0.0, velocity.x, velocity.y, velocity.z];
1524 let level_omega = Vector3::new(3.0e-5, 6.0e-5, -2.0e-5);
1525 let run = |omega| {
1526 compute_derivatives(
1527 &state,
1528 &inputs,
1529 &WindSock::new(vec![]),
1530 FastAtmosphere::Direct {
1531 air_density: 1.225,
1532 speed_of_sound: 340.0,
1533 },
1534 &inputs.bc_type,
1535 crate::transonic_drag::ProjectileShape::Spitzer,
1536 inputs.bc_value,
1537 false,
1538 false,
1539 omega,
1540 None,
1541 None,
1542 )
1543 };
1544 let baseline = run(None);
1545 let with_coriolis = run(Some(level_omega));
1546 let actual = Vector3::new(
1547 with_coriolis[3] - baseline[3],
1548 with_coriolis[4] - baseline[4],
1549 with_coriolis[5] - baseline[5],
1550 );
1551 let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
1552
1553 assert!(
1554 (actual - expected).norm() < 1e-12,
1555 "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
1556 );
1557 }
1558
1559 #[test]
1560 fn test_fast_solution_interpolation() {
1561 let times = vec![0.0, 1.0, 2.0];
1562 let states = vec![
1563 [0.0, 0.0, 0.0, 100.0, 50.0, 0.0],
1564 [100.0, 45.0, 0.0, 99.0, 40.0, 0.0],
1565 [198.0, 80.0, 0.0, 98.0, 30.0, 0.0],
1566 ];
1567
1568 let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
1569
1570 let result = solution.sol(&[1.5]);
1572
1573 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); }
1577
1578 #[test]
1579 fn test_bc_from_velocity_segments() {
1580 let segments = vec![
1581 BCSegmentData {
1582 velocity_min: 0.0,
1583 velocity_max: 1000.0,
1584 bc_value: 0.5,
1585 },
1586 BCSegmentData {
1587 velocity_min: 1000.0,
1588 velocity_max: 2000.0,
1589 bc_value: 0.52,
1590 },
1591 BCSegmentData {
1592 velocity_min: 2000.0,
1593 velocity_max: 3000.0,
1594 bc_value: 0.55,
1595 },
1596 ];
1597
1598 assert_eq!(velocity_segment_bc(500.0, &segments, 0.5), 0.5);
1603 assert_eq!(velocity_segment_bc(1500.0, &segments, 0.5), 0.52);
1604 assert_eq!(velocity_segment_bc(2500.0, &segments, 0.5), 0.55);
1605
1606 assert_eq!(velocity_segment_bc(-100.0, &segments, 0.5), 0.5); assert_eq!(velocity_segment_bc(3500.0, &segments, 0.5), 0.55); }
1610
1611 #[test]
1612 fn test_fast_solution_interpolation_edge_cases() {
1613 let times = vec![0.0, 1.0, 2.0, 3.0];
1614 let states = vec![
1615 [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
1616 [800.0, 40.0, 100.0, 750.0, 30.0, 0.0],
1617 [1550.0, 60.0, 200.0, 700.0, 10.0, 0.0],
1618 [2250.0, 50.0, 300.0, 650.0, -10.0, 0.0],
1619 ];
1620
1621 let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
1622
1623 let result_before = solution.sol(&[-0.5]);
1625 assert!((result_before[0][0] - 0.0).abs() < 1e-10); let result_after = solution.sol(&[5.0]);
1629 assert!((result_after[0][0] - 2250.0).abs() < 1e-10); let result_exact = solution.sol(&[1.0]);
1633 assert!((result_exact[0][0] - 800.0).abs() < 1e-10);
1634
1635 let result_multi = solution.sol(&[0.5, 1.5, 2.5]);
1637 assert_eq!(result_multi[0].len(), 3);
1638 }
1639
1640 #[test]
1641 fn test_fast_solution_from_trajectory_data() {
1642 let times = vec![0.0, 0.5, 1.0];
1643 let states = vec![
1644 [0.0, 1.0, 2.0, 3.0, 4.0, 5.0],
1645 [10.0, 11.0, 12.0, 13.0, 14.0, 15.0],
1646 [20.0, 21.0, 22.0, 23.0, 24.0, 25.0],
1647 ];
1648 let t_events = [vec![1.0], vec![0.5], vec![]];
1649
1650 let solution = FastSolution::from_trajectory_data(times.clone(), states, t_events);
1651
1652 assert_eq!(solution.t, times);
1654 assert_eq!(solution.y.len(), 6); assert_eq!(solution.y[0].len(), 3); assert!(solution.success);
1657
1658 assert_eq!(solution.y[0][0], 0.0); assert_eq!(solution.y[1][0], 1.0); assert_eq!(solution.y[0][2], 20.0); }
1663
1664 #[test]
1665 fn test_bc_segments_boundary_conditions() {
1666 let single_segment = vec![BCSegmentData {
1670 velocity_min: 1000.0,
1671 velocity_max: 2000.0,
1672 bc_value: 0.5,
1673 }];
1674
1675 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![
1683 BCSegmentData {
1684 velocity_min: 0.0,
1685 velocity_max: 999.0, bc_value: 0.45,
1687 },
1688 BCSegmentData {
1689 velocity_min: 1000.0,
1690 velocity_max: 2000.0,
1691 bc_value: 0.50,
1692 },
1693 ];
1694
1695 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!(
1710 velocity_segment_bc(998.999, &segments, 0.7),
1711 0.5735000320000354
1712 ); assert_eq!(velocity_segment_bc(999.0, &segments, 0.7), 0.575); }
1717
1718 #[test]
1719 fn velocity_segment_gaps_and_clamps_do_not_depend_on_order() {
1720 let fallback_bc = 0.73;
1721 let ascending_with_gap = vec![
1722 BCSegmentData {
1723 velocity_min: 0.0,
1724 velocity_max: 999.0,
1725 bc_value: 0.6,
1726 },
1727 BCSegmentData {
1728 velocity_min: 1000.0,
1729 velocity_max: 2000.0,
1730 bc_value: 0.8,
1731 },
1732 ];
1733 assert_eq!(
1737 velocity_segment_bc(999.5, &ascending_with_gap, fallback_bc),
1738 fallback_bc,
1739 "coverage gaps must use the projectile's base BC"
1740 );
1741
1742 let mut descending = ascending_with_gap.clone();
1743 descending.reverse();
1744 assert_eq!(
1747 velocity_segment_bc(-100.0, &descending, fallback_bc),
1748 0.6,
1749 "below coverage must clamp to the lowest-velocity band"
1750 );
1751 assert_eq!(
1752 velocity_segment_bc(2500.0, &descending, fallback_bc),
1753 0.8,
1754 "above coverage must clamp to the highest-velocity band"
1755 );
1756 }
1757
1758 #[test]
1759 fn test_bc_segments_empty_fallback() {
1760 let empty_segments: Vec<BCSegmentData> = vec![];
1761
1762 let result = velocity_segment_bc(1500.0, &empty_segments, 0.73);
1766 assert_eq!(result, 0.73); }
1768
1769 #[test]
1770 fn test_fast_integration_params() {
1771 let params = FastIntegrationParams {
1773 horiz: 1000.0,
1774 vert: 0.0,
1775 initial_state: [0.0, 0.0, 0.0, 800.0, 50.0, 0.0], t_span: (0.0, 5.0),
1777 atmo_params: (0.0, 15.0, 1013.25, 1.0),
1778 atmo_sock: None,
1779 };
1780
1781 assert_eq!(params.horiz, 1000.0);
1782 assert_eq!(params.t_span.0, 0.0);
1783 assert_eq!(params.t_span.1, 5.0);
1784 assert_eq!(params.initial_state[3], 800.0); }
1786
1787 #[test]
1788 fn test_fast_solution_event_arrays() {
1789 let times = vec![0.0, 1.0, 2.0];
1790 let states = vec![
1791 [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
1792 [800.0, 40.0, 500.0, 750.0, 30.0, 0.0],
1793 [1500.0, 20.0, 1000.0, 700.0, 10.0, 0.0],
1794 ];
1795
1796 let t_events = [
1798 vec![2.0], vec![0.5], vec![], ];
1802
1803 let solution = FastSolution::from_trajectory_data(times, states, t_events);
1804
1805 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()); }
1809
1810 #[test]
1811 fn segmented_fast_path_interpolates_max_ordinate_between_saved_points() {
1812 let expected_apex_time = 5.105_f64;
1813 let downrange_velocity = 100.0_f64;
1814 let vertical_velocity = G_ACCEL_MPS2 * expected_apex_time;
1815 let inputs = BallisticInputs {
1816 muzzle_velocity: downrange_velocity.hypot(vertical_velocity),
1817 bc_value: 0.5,
1818 bc_type: DragModel::G7,
1819 use_enhanced_spin_drift: false,
1820 ..BallisticInputs::default()
1821 };
1822 let solution = fast_integrate_with_segments(
1823 &inputs,
1824 vec![],
1825 FastIntegrationParams {
1826 horiz: 1_000.0,
1827 vert: 0.0,
1828 initial_state: [0.0, 0.0, 0.0, downrange_velocity, vertical_velocity, 0.0],
1829 t_span: (0.0, 12.0),
1830 atmo_params: (1e-12, 340.0, 0.0, 0.0),
1833 atmo_sock: None,
1834 },
1835 );
1836
1837 assert!(solution.success);
1838 assert_eq!(solution.t_events[1].len(), 1);
1839 let reported_time = solution.t_events[1][0];
1840 assert!(
1841 (reported_time - expected_apex_time).abs() < 0.006,
1842 "max-ordinate time must be interpolated between coarse saves: reported={reported_time} expected={expected_apex_time}"
1843 );
1844 let event_index = solution
1845 .t
1846 .iter()
1847 .position(|time| time.to_bits() == reported_time.to_bits())
1848 .expect("the interpolated apex must be retained in the solution");
1849 assert_eq!(solution.y[4][event_index].to_bits(), 0.0_f64.to_bits());
1850
1851 let expected_height = vertical_velocity * expected_apex_time
1852 - 0.5 * G_ACCEL_MPS2 * expected_apex_time.powi(2);
1853 let event_state = solution.sol(&[reported_time]);
1854 assert!(
1855 (event_state[1][0] - expected_height).abs() < 2e-4,
1856 "max-ordinate state must preserve the interpolated apex height: reported={} expected={expected_height}",
1857 event_state[1][0]
1858 );
1859 }
1860
1861 #[test]
1862 fn plain_fast_path_interpolates_the_target_crossing() {
1863 let target = 500.123456789;
1864 let initial_state = [0.0, 0.0, 0.25, 800.0, 12.0, -2.5];
1865 let inputs = BallisticInputs {
1866 muzzle_velocity: 800.0,
1867 bc_value: 0.5,
1868 bc_type: DragModel::G7,
1869 ground_threshold: -100.0,
1870 use_enhanced_spin_drift: false,
1871 ..BallisticInputs::default()
1872 };
1873 let run = |horiz| {
1874 fast_integrate(
1875 &inputs,
1876 &WindSock::new(vec![]),
1877 FastIntegrationParams {
1878 horiz,
1879 vert: 0.0,
1880 initial_state,
1881 t_span: (0.0, 2.0),
1882 atmo_params: (0.0, 15.0, 1013.25, 1.0),
1883 atmo_sock: None,
1884 },
1885 )
1886 };
1887
1888 let reference = run(target + 2.0);
1890 let left = reference.y[0]
1891 .windows(2)
1892 .position(|x| x[0] < target && x[1] > target)
1893 .expect("reference trajectory must bracket target");
1894 let right = left + 1;
1895 let alpha =
1896 (target - reference.y[0][left]) / (reference.y[0][right] - reference.y[0][left]);
1897
1898 let solution = run(target);
1899 let last = solution.t.len() - 1;
1900 assert_eq!(solution.y[0][last].to_bits(), target.to_bits());
1901 let expected_time = reference.t[left] + alpha * (reference.t[right] - reference.t[left]);
1902 assert!((solution.t[last] - expected_time).abs() < 1e-12);
1903 assert_eq!(solution.t_events[0], vec![solution.t[last]]);
1904
1905 for component in 0..6 {
1906 assert_eq!(solution.y[component].len(), solution.t.len());
1907 let expected = reference.y[component][left]
1908 + alpha * (reference.y[component][right] - reference.y[component][left]);
1909 assert!(
1910 (solution.y[component][last] - expected).abs() < 1e-9,
1911 "component {component} is not at the crossing: actual={}, expected={expected}",
1912 solution.y[component][last]
1913 );
1914 }
1915 }
1916
1917 #[test]
1918 fn fast_path_coriolis_uses_shot_direction() {
1919 use std::f64::consts::FRAC_PI_2;
1924 fn final_xy(shot_az: f64) -> (f64, f64) {
1926 let inputs = BallisticInputs {
1927 muzzle_velocity: 800.0,
1928 bc_value: 0.5,
1929 bc_type: DragModel::G7,
1930 enable_advanced_effects: true, enable_coriolis: true,
1932 latitude: Some(45.0),
1933 shot_azimuth: shot_az,
1934 ..BallisticInputs::default()
1935 };
1936 let v = 800.0_f64;
1937 let elev = 0.02_f64;
1938 let params = FastIntegrationParams {
1939 horiz: 1000.0,
1940 vert: 0.0,
1941 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
1942 t_span: (0.0, 5.0),
1943 atmo_params: (0.0, 15.0, 1013.25, 1.0),
1944 atmo_sock: None,
1945 };
1946 let sol = fast_integrate_with_segments(&inputs, vec![], params);
1947 let n = sol.y[0].len();
1948 (sol.y[0][n - 1], sol.y[1][n - 1])
1949 }
1950 let (ex, ey) = final_xy(FRAC_PI_2); let (wx, wy) = final_xy(3.0 * FRAC_PI_2); assert!(
1955 (ex - wx).abs() < 0.5,
1956 "east/west downrange should be ~equal (ex={ex:.4}, wx={wx:.4})"
1957 );
1958 assert!(
1961 ey > wy,
1962 "fast-path east ({ey:.6}) must be higher than west ({wy:.6}) (Eotvos)"
1963 );
1964 assert!(
1965 (ey - wy) > 1e-5,
1966 "fast-path E-W vertical separation ({:.8} m) should be non-zero (the pre-fix bug was exact equality)",
1967 ey - wy
1968 );
1969 }
1970
1971 #[test]
1972 fn fast_path_coriolis_independent_of_advanced_effects() {
1973 use std::f64::consts::FRAC_PI_2;
1976 fn final_y(coriolis: bool, shot_az: f64) -> f64 {
1977 let inputs = BallisticInputs {
1978 muzzle_velocity: 800.0,
1979 bc_value: 0.5,
1980 bc_type: DragModel::G7,
1981 enable_coriolis: coriolis,
1982 enable_advanced_effects: false, latitude: Some(45.0),
1984 shot_azimuth: shot_az,
1985 ..BallisticInputs::default()
1986 };
1987 let v = 800.0_f64;
1988 let elev = 0.02_f64;
1989 let params = FastIntegrationParams {
1990 horiz: 1000.0,
1991 vert: 0.0,
1992 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
1993 t_span: (0.0, 5.0),
1994 atmo_params: (0.0, 15.0, 1013.25, 1.0),
1995 atmo_sock: None,
1996 };
1997 let sol = fast_integrate_with_segments(&inputs, vec![], params);
1998 let n = sol.y[0].len();
1999 sol.y[1][n - 1]
2000 }
2001 let e = final_y(true, FRAC_PI_2);
2003 let w = final_y(true, 3.0 * FRAC_PI_2);
2004 assert!(
2005 e > w && (e - w) > 1e-5,
2006 "Coriolis-only (no advanced effects) must still be directional: E={e} W={w}"
2007 );
2008 let e2 = final_y(false, FRAC_PI_2);
2010 let w2 = final_y(false, 3.0 * FRAC_PI_2);
2011 assert!(
2012 (e2 - w2).abs() < 1e-9,
2013 "with enable_coriolis=false, east/west must be identical: E={e2} W={w2}"
2014 );
2015 }
2016
2017 #[test]
2018 fn fast_path_rejects_degenerate_atmosphere() {
2019 let inputs = BallisticInputs {
2020 muzzle_velocity: 800.0,
2021 bc_value: 0.5,
2022 bc_type: DragModel::G7,
2023 ..BallisticInputs::default()
2024 };
2025 let v = 800.0_f64;
2026 let e = 0.02_f64;
2027 let mk = |atmo: (f64, f64, f64, f64)| FastIntegrationParams {
2028 horiz: 500.0,
2029 vert: 0.0,
2030 initial_state: [0.0, 0.0, 0.0, v * e.cos(), v * e.sin(), 0.0],
2031 t_span: (0.0, 5.0),
2032 atmo_params: atmo,
2033 atmo_sock: None,
2034 };
2035 let zero_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 0.0, 1.0)));
2037 assert!(
2038 !zero_p.success,
2039 "pressure=0 atmosphere must yield success=false"
2040 );
2041 let nan_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, f64::NAN, 1.0)));
2043 assert!(!nan_p.success, "NaN pressure must yield success=false");
2044 let segmented_too_dense =
2046 fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 1013.25, 50.0)));
2047 let plain_too_dense = fast_integrate(
2048 &inputs,
2049 &WindSock::new(vec![]),
2050 mk((0.0, 15.0, 1013.25, 50.0)),
2051 );
2052 assert!(
2053 !segmented_too_dense.success && !plain_too_dense.success,
2054 "ratio=50 atmosphere must fail in both wrappers: segmented={}, plain={}",
2055 segmented_too_dense.success,
2056 plain_too_dense.success
2057 );
2058 let good = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 1013.25, 1.0)));
2060 assert!(good.success, "realistic atmosphere must yield success=true");
2061 let direct = fast_integrate_with_segments(&inputs, vec![], mk((1.225, 340.0, 0.0, 0.0)));
2064 assert!(
2065 direct.success,
2066 "direct-atmosphere mode (pressure=0 sentinel) must yield success=true"
2067 );
2068 }
2069
2070 #[test]
2071 fn fast_paths_reject_invalid_wind_segments() {
2072 let inputs = BallisticInputs {
2073 muzzle_velocity: 800.0,
2074 bc_value: 0.5,
2075 bc_type: DragModel::G7,
2076 ..BallisticInputs::default()
2077 };
2078 let mk = || FastIntegrationParams {
2079 horiz: 100.0,
2080 vert: 0.0,
2081 initial_state: [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
2082 t_span: (0.0, 5.0),
2083 atmo_params: (0.0, 15.0, 1013.25, 1.0),
2084 atmo_sock: None,
2085 };
2086 let invalid = crate::wind::WindSegment::new(10.0, 90.0, f64::NAN);
2087
2088 let plain = fast_integrate(&inputs, &WindSock::new(vec![invalid]), mk());
2089 let segmented = fast_integrate_with_segments(&inputs, vec![invalid], mk());
2090
2091 assert!(
2092 !plain.success && !segmented.success,
2093 "invalid segmented wind must fail in both fast wrappers: plain={}, segmented={}",
2094 plain.success,
2095 segmented.success
2096 );
2097 }
2098
2099 #[test]
2100 fn plain_fast_path_honors_direct_atmosphere_values() {
2101 fn final_speed(muzzle_velocity: f64, atmo_params: (f64, f64, f64, f64)) -> f64 {
2102 let inputs = BallisticInputs {
2103 muzzle_velocity,
2104 bc_value: 0.5,
2105 bc_type: DragModel::G7,
2106 ground_threshold: -100.0,
2107 ..BallisticInputs::default()
2108 };
2109
2110 let wind_sock = WindSock::new(vec![]);
2111 let solution = fast_integrate(
2112 &inputs,
2113 &wind_sock,
2114 FastIntegrationParams {
2115 horiz: 10_000.0,
2116 vert: 0.0,
2117 initial_state: [0.0, 0.0, 0.0, muzzle_velocity, 0.0, 0.0],
2118 t_span: (0.0, 0.2),
2119 atmo_params,
2120 atmo_sock: None,
2121 },
2122 );
2123 assert!(solution.success);
2124
2125 let last = solution.y[0].len() - 1;
2126 (solution.y[3][last].powi(2)
2127 + solution.y[4][last].powi(2)
2128 + solution.y[5][last].powi(2))
2129 .sqrt()
2130 }
2131
2132 let thin_air = final_speed(800.0, (0.905, 340.0, 0.0, 0.0));
2133 let dense_air = final_speed(800.0, (1.225, 340.0, 0.0, 0.0));
2134 assert!(
2135 thin_air > dense_air,
2136 "lower supplied density must retain more velocity: thin={thin_air}, dense={dense_air}"
2137 );
2138
2139 let low_sound_speed = final_speed(340.0, (1.0, 300.0, 0.0, 0.0));
2140 let high_sound_speed = final_speed(340.0, (1.0, 400.0, 0.0, 0.0));
2141 assert!(
2142 (low_sound_speed - high_sound_speed).abs() > 1e-6,
2143 "supplied sound speed must affect Mach-dependent drag"
2144 );
2145 }
2146
2147 #[test]
2148 fn segmented_fast_path_nonpositive_density_ratio_uses_standard_fallback() {
2149 fn terminal_velocity(base_ratio: f64) -> f64 {
2150 let inputs = BallisticInputs {
2151 muzzle_velocity: 800.0,
2152 bc_value: 0.5,
2153 bc_type: DragModel::G7,
2154 ground_threshold: -100.0,
2155 ..BallisticInputs::default()
2156 };
2157
2158 let solution = fast_integrate_with_segments(
2159 &inputs,
2160 vec![],
2161 FastIntegrationParams {
2162 horiz: 500.0,
2163 vert: 0.0,
2164 initial_state: [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
2165 t_span: (0.0, 5.0),
2166 atmo_params: (0.0, 15.0, 1013.25, base_ratio),
2167 atmo_sock: None,
2168 },
2169 );
2170 assert!(solution.success);
2171
2172 let last = solution.y[3].len() - 1;
2173 solution.y[3][last]
2174 }
2175
2176 let explicit_sea_level = terminal_velocity(1.0);
2177 for base_ratio in [0.0, -1.0] {
2178 let missing_ratio = terminal_velocity(base_ratio);
2179 assert!(
2180 missing_ratio < 800.0,
2181 "missing density ratio must not create a vacuum trajectory: {missing_ratio}"
2182 );
2183 assert!((missing_ratio - explicit_sea_level).abs() < 1e-9);
2184 }
2185 }
2186
2187 #[test]
2188 fn fast_path_carries_real_bullet_geometry() {
2189 let run = |diameter: f64, twist: f64| {
2197 let inputs = BallisticInputs {
2198 muzzle_velocity: 800.0,
2199 bc_value: 0.5,
2200 bc_type: DragModel::G7,
2201 bullet_diameter: diameter,
2202 bullet_length: 0.0318,
2203 twist_rate: twist,
2204 enable_advanced_effects: true,
2205 enable_magnus: true,
2206 ..BallisticInputs::default()
2207 };
2208 let v = 800.0_f64;
2209 let elev = 0.02_f64;
2210 let params = FastIntegrationParams {
2211 horiz: 1000.0,
2212 vert: 0.0,
2213 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
2214 t_span: (0.0, 5.0),
2215 atmo_params: (0.0, 15.0, 1013.25, 1.0),
2216 atmo_sock: None,
2217 };
2218 fast_integrate_with_segments(&inputs, vec![], params)
2219 };
2220 assert!(run(0.00569, 7.0).success, ".224 geometry must solve");
2223 assert!(run(0.00858, 10.0).success, ".338 geometry must solve");
2224 }
2225
2226 #[test]
2227 fn segmented_fast_spin_flags_do_not_depend_on_advanced_umbrella() {
2228 fn endpoint(
2229 enable_advanced_effects: bool,
2230 enable_magnus: bool,
2231 use_enhanced_spin_drift: bool,
2232 ) -> Vector3<f64> {
2233 let inputs = BallisticInputs {
2234 muzzle_velocity: 823.0,
2235 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
2236 bullet_diameter: 0.308 * 0.0254,
2237 bullet_length: 1.215 * 0.0254,
2238 caliber_inches: 0.308,
2239 weight_grains: 168.0,
2240 bc_value: 0.475,
2241 bc_type: DragModel::G1,
2242 twist_rate: 12.0,
2243 is_twist_right: true,
2244 enable_advanced_effects,
2245 enable_magnus,
2246 use_enhanced_spin_drift,
2247 ..BallisticInputs::default()
2248 };
2249 let elevation = 0.02_f64;
2250 let solution = fast_integrate_with_segments(
2251 &inputs,
2252 vec![],
2253 FastIntegrationParams {
2254 horiz: 1_000.0,
2255 vert: 0.0,
2256 initial_state: [
2257 0.0,
2258 0.0,
2259 0.0,
2260 inputs.muzzle_velocity * elevation.cos(),
2261 inputs.muzzle_velocity * elevation.sin(),
2262 0.0,
2263 ],
2264 t_span: (0.0, 5.0),
2265 atmo_params: (0.0, 15.0, 1013.25, 1.0),
2266 atmo_sock: None,
2267 },
2268 );
2269 assert!(solution.success);
2270 let last = solution.t.len() - 1;
2271 Vector3::new(
2272 solution.y[0][last],
2273 solution.y[1][last],
2274 solution.y[2][last],
2275 )
2276 }
2277
2278 let baseline = endpoint(false, false, false);
2279 let magnus_without_umbrella = endpoint(false, true, false);
2280 let magnus_with_umbrella = endpoint(true, true, false);
2281 assert!(
2282 (magnus_without_umbrella - baseline).norm() > 1e-5,
2283 "test shot must produce a measurable Magnus displacement"
2284 );
2285 assert!(
2286 (magnus_with_umbrella - magnus_without_umbrella).norm() < 1e-12,
2287 "the legacy umbrella must not suppress explicitly enabled Magnus: without={magnus_without_umbrella:?} with={magnus_with_umbrella:?}"
2288 );
2289
2290 let litz_only = endpoint(false, false, true);
2291 let litz_without_umbrella = endpoint(false, true, true);
2292 let litz_with_umbrella = endpoint(true, true, true);
2293 assert!(
2294 (litz_without_umbrella - litz_only).norm() < 1e-12,
2295 "Litz mode must suppress explicitly enabled Magnus: litz={litz_only:?} both={litz_without_umbrella:?}"
2296 );
2297 assert!(
2298 (litz_with_umbrella - litz_without_umbrella).norm() < 1e-12,
2299 "the legacy umbrella must not change Magnus suppression in Litz mode: without={litz_without_umbrella:?} with={litz_with_umbrella:?}"
2300 );
2301 }
2302
2303 fn deck_test_inputs(cd_scale: f64) -> BallisticInputs {
2305 BallisticInputs {
2306 bullet_mass: 0.0106,
2307 bullet_diameter: 0.00782,
2308 muzzle_velocity: 850.0,
2309 custom_drag_table: Some(crate::drag::DragTable::new(
2310 vec![0.5, 1.0, 2.0, 3.0],
2311 vec![0.23, 0.40, 0.30, 0.26],
2312 )),
2313 cd_scale,
2314 temperature: 15.0,
2315 pressure: 1013.25,
2316 ..BallisticInputs::default()
2317 }
2318 }
2319
2320 fn deck_accel_x(cd_scale: f64) -> f64 {
2321 let inputs = deck_test_inputs(cd_scale);
2322 compute_derivatives(
2323 &[0.0, 0.0, 0.0, 700.0, 0.0, 0.0],
2324 &inputs,
2325 &WindSock::new(vec![]),
2326 FastAtmosphere::Standard {
2327 base_density: 1.225,
2328 },
2329 &inputs.bc_type,
2330 crate::transonic_drag::ProjectileShape::Spitzer,
2331 inputs.bc_value,
2332 false,
2333 false,
2334 None,
2335 None,
2336 None,
2337 )[3]
2338 }
2339
2340 #[test]
2341 fn cd_scale_default_is_one_and_absent_matches_explicit() {
2342 assert_eq!(BallisticInputs::default().cd_scale, 1.0);
2343
2344 let omitted_inputs = BallisticInputs {
2345 bullet_mass: 0.0106,
2346 bullet_diameter: 0.00782,
2347 muzzle_velocity: 850.0,
2348 custom_drag_table: Some(crate::drag::DragTable::new(
2349 vec![0.5, 1.0, 2.0, 3.0],
2350 vec![0.23, 0.40, 0.30, 0.26],
2351 )),
2352 temperature: 15.0,
2353 pressure: 1013.25,
2354 ..BallisticInputs::default()
2355 };
2356 assert_eq!(omitted_inputs.cd_scale, 1.0);
2357
2358 let a_omitted = compute_derivatives(
2359 &[0.0, 0.0, 0.0, 700.0, 0.0, 0.0],
2360 &omitted_inputs,
2361 &WindSock::new(vec![]),
2362 FastAtmosphere::Standard {
2363 base_density: 1.225,
2364 },
2365 &omitted_inputs.bc_type,
2366 crate::transonic_drag::ProjectileShape::Spitzer,
2367 omitted_inputs.bc_value,
2368 false,
2369 false,
2370 None,
2371 None,
2372 None,
2373 )[3];
2374 let a_explicit = deck_accel_x(1.0);
2375 assert_eq!(
2376 a_omitted.to_bits(),
2377 a_explicit.to_bits(),
2378 "omitted cd_scale (Default) must be bit-identical to an explicit 1.0"
2379 );
2380 }
2381
2382 #[test]
2385 fn cd_scale_direction_on_fast_trajectory_kernel() {
2386 let baseline = deck_accel_x(1.0);
2387 let scaled_up = deck_accel_x(1.10);
2388 let scaled_down = deck_accel_x(0.90);
2389
2390 assert!(
2391 scaled_up < baseline,
2392 "cd_scale=1.10 must increase drag deceleration (more negative ax): \
2393 base={baseline} up={scaled_up}"
2394 );
2395 assert!(
2396 scaled_down > baseline,
2397 "cd_scale=0.90 must decrease drag deceleration (less negative ax): \
2398 base={baseline} down={scaled_down}"
2399 );
2400 }
2401
2402 #[test]
2404 fn cd_scale_is_inert_without_a_custom_drag_table() {
2405 let make = |cd_scale: f64| BallisticInputs {
2406 bc_value: 0.5,
2407 bc_type: DragModel::G1,
2408 temperature: 15.0,
2409 pressure: 1013.25,
2410 cd_scale,
2411 ..BallisticInputs::default()
2412 };
2413 let accel = |cd_scale: f64| {
2414 let inputs = make(cd_scale);
2415 compute_derivatives(
2416 &[0.0, 0.0, 0.0, 700.0, 0.0, 0.0],
2417 &inputs,
2418 &WindSock::new(vec![]),
2419 FastAtmosphere::Standard {
2420 base_density: 1.225,
2421 },
2422 &inputs.bc_type,
2423 crate::transonic_drag::ProjectileShape::Spitzer,
2424 inputs.bc_value,
2425 false,
2426 false,
2427 None,
2428 None,
2429 None,
2430 )[3]
2431 };
2432 assert_eq!(
2433 accel(1.0).to_bits(),
2434 accel(1.5).to_bits(),
2435 "cd_scale must not affect the G-model/BC drag path"
2436 );
2437 }
2438
2439 fn segmented_speed_at_1_5_s(inputs: &BallisticInputs) -> f64 {
2441 let v = inputs.muzzle_velocity;
2442 let elev = 0.012_f64;
2443 let solution = fast_integrate_with_segments(
2444 inputs,
2445 vec![],
2446 FastIntegrationParams {
2447 horiz: 1000.0,
2448 vert: 0.0,
2449 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
2450 t_span: (0.0, 5.0),
2451 atmo_params: (0.0, 15.0, 1013.25, 1.0),
2452 atmo_sock: None,
2453 },
2454 );
2455 assert!(solution.success);
2456 let s = solution.sol(&[1.5]);
2457 (s[3][0].powi(2) + s[4][0].powi(2) + s[5][0].powi(2)).sqrt()
2458 }
2459
2460 fn mba1602_308_inputs() -> BallisticInputs {
2461 BallisticInputs {
2462 muzzle_velocity: 792.48,
2463 bullet_mass: 0.01134,
2464 bullet_diameter: 0.0078232,
2465 bullet_length: 0.031496,
2466 bc_value: 0.505,
2467 bc_type: DragModel::G1,
2468 ..BallisticInputs::default()
2469 }
2470 }
2471
2472 #[test]
2473 fn segmented_fast_path_flies_caller_velocity_bc_ladder() {
2474 let base = mba1602_308_inputs();
2479 let mut ladder = base.clone();
2480 ladder.use_bc_segments = true;
2481 ladder.bc_segments_data = Some(vec![BCSegmentData {
2482 velocity_min: 0.0,
2483 velocity_max: 5000.0,
2484 bc_value: 0.300,
2485 }]);
2486 let scalar = BallisticInputs {
2487 bc_value: 0.300,
2488 ..base.clone()
2489 };
2490 let mut estimated = base.clone();
2491 estimated.use_bc_segments = true;
2492
2493 let flown = segmented_speed_at_1_5_s(&ladder);
2494 let expected = segmented_speed_at_1_5_s(&scalar);
2495 let auto = segmented_speed_at_1_5_s(&estimated);
2496 assert!(
2497 (flown - expected).abs() < 1e-9,
2498 "a one-band 0.300 ladder must fly as scalar BC 0.300: ladder={flown} scalar={expected}"
2499 );
2500 assert!(
2501 (flown - auto).abs() > 10.0,
2502 "the caller's ladder must not be replaced by the auto-estimate: ladder={flown} estimate={auto}"
2503 );
2504 }
2505
2506 #[test]
2507 fn segmented_fast_path_converts_army_standard_metro_caller_ladder() {
2508 let base = mba1602_308_inputs();
2511 let mut ladder = base.clone();
2512 ladder.bc_reference_standard = crate::cli_api::BcReferenceStandard::ArmyStandardMetro;
2513 ladder.use_bc_segments = true;
2514 ladder.bc_segments_data = Some(vec![BCSegmentData {
2515 velocity_min: 0.0,
2516 velocity_max: 5000.0,
2517 bc_value: 0.300,
2518 }]);
2519 let scalar = BallisticInputs {
2520 bc_value: 0.300,
2521 bc_reference_standard: crate::cli_api::BcReferenceStandard::ArmyStandardMetro,
2522 ..base
2523 };
2524 let flown = segmented_speed_at_1_5_s(&ladder);
2525 let expected = segmented_speed_at_1_5_s(&scalar);
2526 assert!(
2527 (flown - expected).abs() < 1e-9,
2528 "an ASM ladder must be converted like an ASM scalar: ladder={flown} scalar={expected}"
2529 );
2530 }
2531
2532 #[test]
2533 fn segmented_fast_path_empty_ladder_matches_absent_ladder() {
2534 let mut absent = mba1602_308_inputs();
2537 absent.use_bc_segments = true;
2538 let mut empty = absent.clone();
2539 empty.bc_segments_data = Some(Vec::new());
2540 assert_eq!(
2541 segmented_speed_at_1_5_s(&empty).to_bits(),
2542 segmented_speed_at_1_5_s(&absent).to_bits(),
2543 "Some(empty) must behave exactly like None"
2544 );
2545 }
2546
2547 fn reference_table_inputs(band_bc: f64) -> BallisticInputs {
2549 BallisticInputs {
2550 custom_drag_table: Some(crate::drag::DragTable::new(
2551 vec![0.5, 1.0, 2.0, 3.0],
2552 vec![0.23, 0.40, 0.30, 0.26],
2553 )),
2554 custom_drag_reference: true,
2555 use_bc_segments: true,
2556 bc_segments_data: Some(vec![BCSegmentData {
2557 velocity_min: 0.0,
2558 velocity_max: 5000.0,
2559 bc_value: band_bc,
2560 }]),
2561 ..mba1602_308_inputs()
2562 }
2563 }
2564
2565 fn own_curve_oracle(bc: f64) -> BallisticInputs {
2568 let base = mba1602_308_inputs();
2569 let mut normalized = base.clone();
2572 normalized.normalize_for_solve();
2573 let sd = normalized.sectional_density_lb_in2().expect("SD");
2574 BallisticInputs {
2575 custom_drag_table: reference_table_inputs(bc).custom_drag_table,
2576 cd_scale: sd / bc,
2577 ..base
2578 }
2579 }
2580
2581 #[test]
2586 fn segmented_fast_path_flies_a_reference_table_over_the_ladder_bc() {
2587 for band_bc in [0.30, 0.40] {
2588 let flown = segmented_speed_at_1_5_s(&reference_table_inputs(band_bc));
2589 let expected = segmented_speed_at_1_5_s(&own_curve_oracle(band_bc));
2590 assert!(
2591 (flown - expected).abs() < 1e-9 * expected,
2592 "band {band_bc}: flown {flown}, expected {expected}"
2593 );
2594 }
2595 let dropped = BallisticInputs {
2596 custom_drag_reference: false,
2597 ..reference_table_inputs(0.30)
2598 };
2599 let flown = segmented_speed_at_1_5_s(&reference_table_inputs(0.30));
2600 let own_curve = segmented_speed_at_1_5_s(&dropped);
2601 assert!(
2602 (own_curve - flown).abs() > 5.0,
2603 "the fixture must tell the readings apart: own curve {own_curve} vs reference {flown}"
2604 );
2605 }
2606
2607 #[test]
2609 fn fast_path_flies_a_reference_table_over_the_ladder_bc() {
2610 let final_speed = |inputs: &BallisticInputs| {
2611 let v = inputs.muzzle_velocity;
2612 let solution = fast_integrate(
2613 inputs,
2614 &WindSock::new(vec![]),
2615 FastIntegrationParams {
2616 horiz: 500.0,
2617 vert: 0.0,
2618 initial_state: [0.0, 0.0, 0.0, v, 0.0, 0.0],
2619 t_span: (0.0, 5.0),
2620 atmo_params: (0.0, 15.0, 1013.25, 1.0),
2621 atmo_sock: None,
2622 },
2623 );
2624 assert!(solution.success);
2625 let last = solution.t.len() - 1;
2626 (solution.y[3][last].powi(2)
2627 + solution.y[4][last].powi(2)
2628 + solution.y[5][last].powi(2))
2629 .sqrt()
2630 };
2631 for band_bc in [0.30, 0.40] {
2632 let flown = final_speed(&reference_table_inputs(band_bc));
2633 let expected = final_speed(&own_curve_oracle(band_bc));
2634 assert!(
2635 (flown - expected).abs() < 1e-9 * expected,
2636 "band {band_bc}: flown {flown}, expected {expected}"
2637 );
2638 }
2639 let dropped = BallisticInputs {
2640 custom_drag_reference: false,
2641 ..reference_table_inputs(0.30)
2642 };
2643 assert!(
2644 (final_speed(&dropped) - final_speed(&reference_table_inputs(0.30))).abs() > 5.0,
2645 "the fixture must tell the readings apart"
2646 );
2647 }
2648}