1use crate::{
7 atmosphere::{calculate_air_density_cimp, get_local_atmosphere_humid, AtmoSock},
8 bc_estimation::velocity_segment_bc,
9 constants::{G_ACCEL_MPS2, MPS_TO_FPS, STANDARD_AIR_DENSITY},
10 drag::get_drag_coefficient,
11 wind::WindSock,
12 DragModel, InternalBallisticInputs as BallisticInputs,
13};
14use nalgebra::Vector3;
15
16#[derive(Debug, Clone)]
18pub struct FastSolution {
19 pub t: Vec<f64>,
21 pub y: Vec<Vec<f64>>,
23 pub t_events: [Vec<f64>; 3],
25 pub success: bool,
27}
28
29impl FastSolution {
30 pub fn sol(&self, t_query: &[f64]) -> Vec<Vec<f64>> {
32 let mut result = vec![vec![0.0; t_query.len()]; 6];
33
34 for (i, &tq) in t_query.iter().enumerate() {
35 let idx = match self
38 .t
39 .binary_search_by(|&t| t.partial_cmp(&tq).unwrap_or(std::cmp::Ordering::Greater))
40 {
41 Ok(idx) => idx,
42 Err(idx) => idx,
43 };
44
45 if idx == 0 {
46 for (result_component, source_component) in result.iter_mut().zip(&self.y) {
48 result_component[i] = source_component[0];
49 }
50 } else if idx >= self.t.len() {
51 for (result_component, source_component) in result.iter_mut().zip(&self.y) {
53 result_component[i] = source_component[self.t.len() - 1];
54 }
55 } else {
56 let t0 = self.t[idx - 1];
58 let t1 = self.t[idx];
59 let span = t1 - t0;
60
61 for (result_component, source_component) in result.iter_mut().zip(&self.y) {
62 let y0 = source_component[idx - 1];
63 let y1 = source_component[idx];
64 result_component[i] = if span.abs() < f64::EPSILON {
65 y1
66 } else {
67 let frac = (tq - t0) / span;
68 y0 + frac * (y1 - y0)
69 };
70 }
71 }
72 }
73
74 result
75 }
76
77 pub fn from_trajectory_data(
79 times: Vec<f64>,
80 states: Vec<[f64; 6]>,
81 t_events: [Vec<f64>; 3],
82 ) -> Self {
83 let n_points = times.len();
84 let mut y = vec![vec![0.0; n_points]; 6];
85
86 for (i, state) in states.iter().enumerate() {
87 for j in 0..6 {
88 y[j][i] = state[j];
89 }
90 }
91
92 FastSolution {
93 t: times,
94 y,
95 t_events,
96 success: true,
97 }
98 }
99
100 fn degenerate(initial_state: &[f64; 6]) -> Self {
104 let mut y = vec![Vec::new(); 6];
105 for (j, slot) in y.iter_mut().enumerate() {
106 slot.push(initial_state[j]);
107 }
108 FastSolution {
109 t: vec![0.0],
110 y,
111 t_events: [Vec::new(), Vec::new(), Vec::new()],
112 success: false,
113 }
114 }
115}
116
117fn direct_atmosphere_values(
118 atmo_params: (f64, f64, f64, f64),
119) -> Option<(f64, f64)> {
120 let (a, b, c, d) = atmo_params;
121 (a.is_finite()
122 && b.is_finite()
123 && c == 0.0
124 && d == 0.0
125 && a > 0.0
126 && a < 2.0
127 && b > 200.0)
128 .then_some((a, b))
129}
130
131fn stability_atmosphere_params(atmo_params: (f64, f64, f64, f64)) -> (f64, f64, f64, f64) {
132 if let Some((air_density, _)) = direct_atmosphere_values(atmo_params) {
133 (0.0, 15.0, 1013.25 * air_density / STANDARD_AIR_DENSITY, 1.0)
137 } else {
138 atmo_params
139 }
140}
141
142const MAX_STANDARD_DENSITY_RATIO: f64 = 2.0;
143
144fn atmo_is_physical(atmo_params: (f64, f64, f64, f64)) -> bool {
157 let (a, b, c, d) = atmo_params;
158 if !(a.is_finite() && b.is_finite() && c.is_finite() && d.is_finite()) {
159 return false;
160 }
161 direct_atmosphere_values(atmo_params).is_some() || (c > 0.0 && d < MAX_STANDARD_DENSITY_RATIO)
166}
167
168#[derive(Debug, Clone, Copy)]
169enum FastAtmosphere {
170 Direct {
171 air_density: f64,
172 speed_of_sound: f64,
173 },
174 Standard {
175 base_density: f64,
176 },
177}
178
179pub struct FastIntegrationParams {
181 pub horiz: f64,
182 pub vert: f64,
183 pub initial_state: [f64; 6],
184 pub t_span: (f64, f64),
185 pub atmo_params: (f64, f64, f64, f64),
191 pub atmo_sock: Option<AtmoSock>,
196}
197
198pub fn aerodynamic_jump_launch_offset_rad(
206 inputs: &BallisticInputs,
207 atmo_params: (f64, f64, f64, f64),
208) -> f64 {
209 let crosswind_from_right_mps = inputs.wind_speed * inputs.wind_angle.sin();
210 aerodynamic_jump_launch_offset_for_crosswind_rad(
211 inputs,
212 atmo_params,
213 crosswind_from_right_mps,
214 )
215}
216
217fn aerodynamic_jump_launch_offset_for_crosswind_rad(
218 inputs: &BallisticInputs,
219 atmo_params: (f64, f64, f64, f64),
220 crosswind_from_right_mps: f64,
221) -> f64 {
222 if !inputs.enable_aerodynamic_jump {
223 return 0.0;
224 }
225 let diameter = inputs.bullet_diameter;
226 if !(inputs.twist_rate.is_finite()
227 && inputs.twist_rate != 0.0
228 && diameter.is_finite()
229 && diameter > 0.0
230 && inputs.bullet_length.is_finite()
231 && inputs.bullet_length > 0.0
232 && inputs.muzzle_velocity.is_finite())
233 {
234 return 0.0;
235 }
236 let stability_atmo = stability_atmosphere_params(atmo_params);
237 let sg = crate::stability::compute_stability_coefficient(inputs, stability_atmo);
238 if !(sg.is_finite() && sg > 0.0) {
239 return 0.0;
240 }
241 let length_cal = inputs.bullet_length / diameter;
242 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
243 let crosswind_from_right_mph = crosswind_from_right_mps * MS_TO_MPH;
244 let vertical_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
245 sg,
246 length_cal,
247 crosswind_from_right_mph,
248 inputs.is_twist_right,
249 );
250 if !vertical_moa.is_finite() {
251 return 0.0;
252 }
253 const MOA_PER_RAD: f64 = 3437.7467707849;
254 vertical_moa / MOA_PER_RAD
255}
256
257fn rotate_launch_velocity(state: &mut [f64; 6], theta_rad: f64) {
260 let (vx, vy, vz) = (state[3], state[4], state[5]);
261 let speed = (vx * vx + vy * vy + vz * vz).sqrt();
262 if speed <= 0.0 {
263 return;
264 }
265 let h = (vx * vx + vz * vz).sqrt(); let new_elev = vy.atan2(h) + theta_rad;
267 state[4] = speed * new_elev.sin();
268 let new_h = speed * new_elev.cos();
269 let scale = if h > 1e-12 { new_h / h } else { 0.0 };
270 state[3] = vx * scale;
271 state[5] = vz * scale;
272}
273
274fn launch_state_with_aerodynamic_jump(
275 inputs: &BallisticInputs,
276 atmo_params: (f64, f64, f64, f64),
277 segmented_crosswind_from_right_mps: Option<f64>,
278 mut initial_state: [f64; 6],
279) -> [f64; 6] {
280 let offset = match segmented_crosswind_from_right_mps {
281 Some(crosswind) => aerodynamic_jump_launch_offset_for_crosswind_rad(
282 inputs,
283 atmo_params,
284 crosswind,
285 ),
286 None => aerodynamic_jump_launch_offset_rad(inputs, atmo_params),
287 };
288 if offset != 0.0 {
289 rotate_launch_velocity(&mut initial_state, offset);
290 }
291 initial_state
292}
293
294pub fn fast_integrate(
296 inputs: &BallisticInputs,
297 wind_sock: &WindSock,
298 params: FastIntegrationParams,
299) -> FastSolution {
300 if wind_sock.validate_segments().is_err() || !atmo_is_physical(params.atmo_params) {
302 return FastSolution::degenerate(¶ms.initial_state);
303 }
304 let mut effective_inputs = inputs.clone();
305 effective_inputs.normalize_for_solve();
310 if params.atmo_params.2 > 0.0 {
311 effective_inputs.altitude = params.atmo_params.0;
312 effective_inputs.temperature = params.atmo_params.1;
313 effective_inputs.pressure = params.atmo_params.2;
314 effective_inputs.humidity = params.atmo_params.3;
315 }
316 let inputs = &effective_inputs;
317 let _mass_kg = inputs.bullet_mass; let bc = inputs.bc_value;
320 let drag_model = &inputs.bc_type;
321
322 let has_bc_segments =
324 inputs.bc_segments.is_some() && !inputs.bc_segments.as_ref().unwrap().is_empty();
325 let has_bc_segments_data =
326 inputs.bc_segments_data.is_some() && !inputs.bc_segments_data.as_ref().unwrap().is_empty();
327
328 let dt = if params.horiz > 200.0 {
330 0.001
331 } else if params.horiz > 100.0 {
332 0.0005
333 } else {
334 0.0001
335 };
336
337 let segmented_crosswind_from_right_mps = if inputs.enable_aerodynamic_jump {
340 wind_sock.muzzle_crosswind_from_right_mps()
341 } else {
342 None
343 };
344 let initial_state = launch_state_with_aerodynamic_jump(
345 inputs,
346 params.atmo_params,
347 segmented_crosswind_from_right_mps,
348 params.initial_state,
349 );
350 let vx = initial_state[3]; let n_steps = ((params.t_span.1 / dt) as usize) + 1;
364 let est_steps = if vx > 1e-6 && params.horiz > 0.0 {
365 (((4.0 * params.horiz / vx) / dt) as usize) + 1
366 } else {
367 n_steps
368 };
369 let cap = est_steps.min(n_steps);
370 let mut times = Vec::with_capacity(cap);
371 let mut states = Vec::with_capacity(cap);
372
373 times.push(0.0);
375 states.push(initial_state);
376
377 let atmosphere = if let Some((air_density, speed_of_sound)) =
382 direct_atmosphere_values(params.atmo_params)
383 {
384 FastAtmosphere::Direct {
385 air_density,
386 speed_of_sound,
387 }
388 } else {
389 let base_density = if params.atmo_params.3 > 0.0 {
390 params.atmo_params.3 * 1.225
391 } else {
392 1.225
393 };
394 FastAtmosphere::Standard { base_density }
395 };
396
397 let atmo_sock = params.atmo_sock.as_ref();
399
400 let drag_model_str: &str = match drag_model {
408 DragModel::G1 => "G1",
409 DragModel::G2 => "G2",
410 DragModel::G5 => "G5",
411 DragModel::G6 => "G6",
412 DragModel::G7 => "G7",
413 DragModel::G8 => "G8",
414 DragModel::GI => "GI",
415 DragModel::GS => "GS",
416 DragModel::RA4 => "RA4",
417 };
418
419 let caliber_in = if inputs.caliber_inches > 0.0 {
421 inputs.caliber_inches
422 } else {
423 inputs.bullet_diameter / 0.0254
424 };
425 let weight_gr = if inputs.weight_grains > 0.0 {
426 inputs.weight_grains
427 } else {
428 inputs.bullet_mass / crate::constants::GRAINS_TO_KG
429 };
430
431 let projectile_shape = crate::transonic_drag::resolve_projectile_shape(
434 inputs.bullet_model.as_deref(),
435 caliber_in,
436 weight_gr,
437 drag_model_str,
438 );
439
440 let omega_vector = match inputs.latitude {
446 Some(latitude) if inputs.enable_coriolis => {
447 let omega_earth = 7.2921159e-5_f64; let lat = latitude.to_radians();
449 let az = inputs.shot_azimuth; Some(Vector3::new(
451 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), ))
455 }
456 _ => None,
457 };
458 let wind_shear_model = if inputs.enable_wind_shear {
460 let model = crate::wind_shear::boundary_layer_model_from_name(&inputs.wind_shear_model);
461 (model != crate::wind_shear::WindShearModel::None).then_some(model)
462 } else {
463 None
464 };
465
466 let mut hit_target = false;
468 let mut hit_ground = false;
469 let mut max_ord_time = None;
470 let mut max_ord_y = 0.0;
471 let ground_threshold = inputs.ground_threshold;
472
473 for i in 0..n_steps - 1 {
475 let t = i as f64 * dt;
476 let state = states[i];
477
478 let pos = Vector3::new(state[0], state[1], state[2]);
479 let _vel = Vector3::new(state[3], state[4], state[5]);
480
481 if pos.x >= params.horiz {
483 hit_target = true;
484 break;
485 }
486
487 if pos.y <= ground_threshold {
488 hit_ground = true;
489 break;
490 }
491
492 if pos.y > max_ord_y {
494 max_ord_y = pos.y;
495 max_ord_time = Some(t);
496 }
497
498 let k1 = compute_derivatives(
500 &state,
501 inputs,
502 wind_sock,
503 atmosphere,
504 drag_model,
505 projectile_shape,
506 bc,
507 has_bc_segments,
508 has_bc_segments_data,
509 omega_vector,
510 wind_shear_model,
511 atmo_sock,
512 );
513
514 let mut state2 = state;
515 for j in 0..6 {
516 state2[j] = state[j] + 0.5 * dt * k1[j];
517 }
518 let k2 = compute_derivatives(
519 &state2,
520 inputs,
521 wind_sock,
522 atmosphere,
523 drag_model,
524 projectile_shape,
525 bc,
526 has_bc_segments,
527 has_bc_segments_data,
528 omega_vector,
529 wind_shear_model,
530 atmo_sock,
531 );
532
533 let mut state3 = state;
534 for j in 0..6 {
535 state3[j] = state[j] + 0.5 * dt * k2[j];
536 }
537 let k3 = compute_derivatives(
538 &state3,
539 inputs,
540 wind_sock,
541 atmosphere,
542 drag_model,
543 projectile_shape,
544 bc,
545 has_bc_segments,
546 has_bc_segments_data,
547 omega_vector,
548 wind_shear_model,
549 atmo_sock,
550 );
551
552 let mut state4 = state;
553 for j in 0..6 {
554 state4[j] = state[j] + dt * k3[j];
555 }
556 let k4 = compute_derivatives(
557 &state4,
558 inputs,
559 wind_sock,
560 atmosphere,
561 drag_model,
562 projectile_shape,
563 bc,
564 has_bc_segments,
565 has_bc_segments_data,
566 omega_vector,
567 wind_shear_model,
568 atmo_sock,
569 );
570
571 let mut new_state = state;
573 for j in 0..6 {
574 new_state[j] = state[j] + dt * (k1[j] + 2.0 * k2[j] + 2.0 * k3[j] + k4[j]) / 6.0;
575 }
576
577 if state[0] < params.horiz && new_state[0] >= params.horiz {
578 let alpha = (params.horiz - state[0]) / (new_state[0] - state[0]);
581 let mut target_state = state;
582 for j in 0..6 {
583 target_state[j] = state[j] + alpha * (new_state[j] - state[j]);
584 }
585 target_state[0] = params.horiz;
586 times.push(t + alpha * dt);
587 states.push(target_state);
588 hit_target = true;
589 break;
590 }
591
592 times.push(t + dt);
593 states.push(new_state);
594 }
595
596 let t_events = [
598 if hit_target {
599 vec![*times.last().unwrap()]
600 } else {
601 vec![]
602 },
603 if let Some(t) = max_ord_time {
604 vec![t]
605 } else {
606 vec![]
607 },
608 if hit_ground {
609 vec![*times.last().unwrap()]
610 } else {
611 vec![]
612 },
613 ];
614
615 if inputs.use_enhanced_spin_drift {
624 let (sd_temp_c, sd_press_hpa) = if params.atmo_params.2 > 0.0 {
628 (params.atmo_params.1, params.atmo_params.2)
629 } else {
630 (15.0, 1013.25)
631 };
632 let sg = crate::spin_drift::effective_sg_from_inputs(inputs, sd_temp_c, sd_press_hpa);
633 for (t, state) in times.iter().zip(states.iter_mut()) {
634 if *t > 0.0 {
635 state[2] += crate::spin_drift::litz_drift_meters(sg, *t, inputs.is_twist_right);
636 }
637 }
638 }
639
640 FastSolution::from_trajectory_data(times, states, t_events)
641}
642
643fn fast_magnus_acceleration(
644 inputs: &BallisticInputs,
645 air_velocity: Vector3<f64>,
646 air_density: f64,
647 mach: f64,
648 gravity_acceleration: Vector3<f64>,
649) -> Vector3<f64> {
650 if !inputs.enable_magnus
651 || inputs.use_enhanced_spin_drift
652 || inputs.bullet_diameter <= 0.0
653 || inputs.twist_rate <= 0.0
654 || inputs.bullet_mass <= 0.0
655 {
656 return Vector3::zeros();
657 }
658
659 let speed_air = air_velocity.norm();
660 let diameter_m = inputs.bullet_diameter;
661 let (spin_rate_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
662 inputs.muzzle_velocity,
663 speed_air,
664 inputs.twist_rate,
665 diameter_m,
666 );
667 let d_in = if inputs.caliber_inches > 0.0 {
668 inputs.caliber_inches
669 } else {
670 diameter_m / 0.0254
671 };
672 let m_gr = if inputs.weight_grains > 0.0 {
673 inputs.weight_grains
674 } else {
675 inputs.bullet_mass / crate::constants::GRAINS_TO_KG
676 };
677 let l_in = if inputs.bullet_length > 0.0 {
678 inputs.bullet_length / 0.0254
679 } else {
680 let estimated = crate::stability::estimate_bullet_length_m(diameter_m, inputs.bullet_mass);
681 if estimated > 0.0 {
682 estimated / 0.0254
683 } else {
684 4.5 * d_in.max(1e-9)
685 }
686 };
687 let sg = crate::spin_drift::calculate_dynamic_stability(
688 m_gr,
689 speed_air,
690 spin_rate_rad_s,
691 d_in,
692 l_in,
693 air_density,
694 );
695 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
696 sg,
697 speed_air,
698 spin_rate_rad_s,
699 0.0,
700 0.0,
701 air_density,
702 d_in,
703 l_in,
704 m_gr,
705 mach,
706 "match",
707 false,
708 );
709 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
710 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
711 let force = 0.5 * air_density * speed_air.powi(2) * area * c_np * spin_param * yaw_rad.sin();
712 if force <= 1e-12 {
713 return Vector3::zeros();
714 }
715
716 crate::derivatives::yaw_of_repose_magnus_direction(
717 air_velocity,
718 gravity_acceleration,
719 inputs.is_twist_right,
720 )
721 .map_or_else(Vector3::zeros, |direction| {
722 (force / inputs.bullet_mass) * direction
723 })
724}
725
726fn interpolated_vertical_apex(
727 previous_time: f64,
728 previous: &[f64; 6],
729 current_time: f64,
730 current: &[f64; 6],
731) -> Option<(f64, [f64; 6])> {
732 let dt = current_time - previous_time;
733 let previous_vy = previous[4];
734 let current_vy = current[4];
735 if !dt.is_finite()
736 || dt <= 0.0
737 || !previous_vy.is_finite()
738 || !current_vy.is_finite()
739 || previous_vy <= 0.0
740 || current_vy > 0.0
741 {
742 return None;
743 }
744
745 let denominator = previous_vy - current_vy;
746 if !denominator.is_finite() || denominator <= 0.0 {
747 return None;
748 }
749 let alpha = previous_vy / denominator;
750 if !alpha.is_finite() || !(0.0..1.0).contains(&alpha) {
753 return None;
754 }
755
756 let mut apex = [0.0; 6];
757 for component in 0..6 {
758 apex[component] = previous[component] + alpha * (current[component] - previous[component]);
759 }
760
761 let alpha2 = alpha * alpha;
764 let alpha3 = alpha2 * alpha;
765 let h00 = 2.0 * alpha3 - 3.0 * alpha2 + 1.0;
766 let h10 = alpha3 - 2.0 * alpha2 + alpha;
767 let h01 = -2.0 * alpha3 + 3.0 * alpha2;
768 let h11 = alpha3 - alpha2;
769 for axis in 0..3 {
770 apex[axis] = h00 * previous[axis]
771 + h10 * dt * previous[axis + 3]
772 + h01 * current[axis]
773 + h11 * dt * current[axis + 3];
774 }
775 apex[4] = 0.0;
776
777 apex.iter()
778 .all(|component| component.is_finite())
779 .then_some((previous_time + alpha * dt, apex))
780}
781
782#[allow(clippy::too_many_arguments)]
784fn compute_derivatives(
785 state: &[f64; 6],
786 inputs: &BallisticInputs,
787 wind_sock: &WindSock,
788 atmosphere: FastAtmosphere,
789 drag_model: &DragModel,
790 projectile_shape: crate::transonic_drag::ProjectileShape,
791 bc: f64,
792 has_bc_segments: bool,
793 has_bc_segments_data: bool,
794 omega: Option<Vector3<f64>>,
795 wind_shear_model: Option<crate::wind_shear::WindShearModel>,
796 atmo_sock: Option<&AtmoSock>,
798) -> [f64; 6] {
799 let pos = Vector3::new(state[0], state[1], state[2]);
800 let vel = Vector3::new(state[3], state[4], state[5]);
801
802 let level_wind = wind_sock.vector_for_range_stateless(pos.x);
805 let level_wind = if let Some(model) = wind_shear_model {
806 let height_rel_launch =
807 crate::atmosphere::shot_frame_altitude(0.0, pos.x, pos.y, inputs.shooting_angle);
808 crate::wind_shear::apply_boundary_layer_shear(level_wind, height_rel_launch, model)
809 } else {
810 level_wind
811 };
812 let wind_vector =
813 crate::derivatives::level_vector_to_shot_frame(level_wind, inputs.shooting_angle);
814
815 let vel_adjusted = vel - wind_vector;
817 let v_mag = vel_adjusted.norm();
818
819 let theta = inputs.shooting_angle;
822 let accel_gravity = Vector3::new(
823 -G_ACCEL_MPS2 * theta.sin(),
824 -G_ACCEL_MPS2 * theta.cos(),
825 0.0,
826 );
827
828 let mut accel = if v_mag < 1e-6 {
830 accel_gravity
831 } else {
832 let v_fps = v_mag * MPS_TO_FPS;
834
835 let (local_density, speed_of_sound) = match atmosphere {
857 FastAtmosphere::Direct {
858 air_density,
859 speed_of_sound,
860 } => (air_density, speed_of_sound),
861 FastAtmosphere::Standard { base_density } => {
862 let altitude = crate::atmosphere::shot_frame_altitude(
863 inputs.altitude,
864 pos.x,
865 pos.y,
866 inputs.shooting_angle,
867 );
868 let (base_temp_c, base_press_hpa, base_ratio) = match atmo_sock {
869 Some(sock) => {
870 let (zt, zp, zh) = sock.atmo_for_range(pos.x);
871 (zt, zp, calculate_air_density_cimp(zt, zp, zh) / 1.225)
872 }
873 None => (inputs.temperature, inputs.pressure, base_density / 1.225),
874 };
875 get_local_atmosphere_humid(
876 altitude,
877 inputs.altitude, base_temp_c,
879 base_press_hpa,
880 base_ratio,
881 0.0, )
883 }
884 };
885 let mach = v_mag / speed_of_sound;
886
887 let bc_current = match (
889 inputs.bc_segments_data.as_ref(),
890 inputs.bc_segments.as_ref(),
891 ) {
892 (Some(segments_data), _) if inputs.use_bc_segments && has_bc_segments_data => {
893 velocity_segment_bc(v_fps, segments_data, bc)
894 }
895 (_, Some(segments)) if has_bc_segments => {
896 crate::derivatives::interpolated_bc(mach, segments, Some(inputs))
897 }
898 _ => bc,
899 };
900 let bc_current = bc_current.max(1e-6);
903
904 let (drag_factor, retard_denom) = if let Some(ref table) = inputs.custom_drag_table {
915 (
916 table.interpolate(mach) * inputs.cd_scale,
921 inputs.custom_drag_denominator(bc_current),
922 )
923 } else {
924 let base_cd = get_drag_coefficient(mach, drag_model);
925 let cd =
926 crate::transonic_drag::transonic_correction(mach, base_cd, projectile_shape, false);
927 (cd, bc_current)
928 };
929
930 let cd_to_retard = crate::constants::CD_TO_RETARD;
932 let standard_factor = drag_factor * cd_to_retard;
933 let density_scale = local_density / 1.225;
936
937 let a_drag_ft_s2 = (v_fps * v_fps) * standard_factor * density_scale / retard_denom;
939
940 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let accel_drag = -a_drag_m_s2 * (vel_adjusted / v_mag);
943
944 let accel_magnus =
947 fast_magnus_acceleration(inputs, vel_adjusted, local_density, mach, accel_gravity);
948
949 accel_drag + accel_gravity + accel_magnus
951 };
952
953 if let Some(omega) = omega {
956 let omega = crate::derivatives::level_vector_to_shot_frame(omega, inputs.shooting_angle);
957 accel += -2.0 * omega.cross(&vel);
958 }
959
960 [vel.x, vel.y, vel.z, accel.x, accel.y, accel.z]
962}
963
964pub fn fast_integrate_with_segments(
967 inputs: &BallisticInputs,
968 wind_segments: Vec<crate::wind::WindSegment>,
969 params: FastIntegrationParams,
970) -> FastSolution {
971 use crate::trajectory_integration::{integrate_trajectory, TrajectoryParams};
973
974 if crate::wind::validate_wind_segments(&wind_segments).is_err()
976 || !atmo_is_physical(params.atmo_params)
977 {
978 return FastSolution::degenerate(¶ms.initial_state);
979 }
980
981 let mut normalized_inputs = inputs.clone();
987 normalized_inputs.normalize_for_solve();
988 let inputs = &normalized_inputs;
989
990 let segmented_crosswind_from_right_mps = if inputs.enable_aerodynamic_jump
993 && !wind_segments.is_empty()
994 {
995 WindSock::new(wind_segments.clone()).muzzle_crosswind_from_right_mps()
996 } else {
997 None
998 };
999 let initial_state = launch_state_with_aerodynamic_jump(
1000 inputs,
1001 params.atmo_params,
1002 segmented_crosswind_from_right_mps,
1003 params.initial_state,
1004 );
1005
1006 let mass_kg = inputs.bullet_mass; let bc = inputs.bc_value;
1009 let drag_model = inputs.bc_type;
1010
1011 let omega_vector = if inputs.enable_coriolis && inputs.latitude.is_some() {
1015 let omega_earth = 7.2921159e-5; let lat_rad = inputs.latitude.unwrap_or(0.0).to_radians();
1021 let azimuth = inputs.shot_azimuth; Some(Vector3::new(
1023 omega_earth * lat_rad.cos() * azimuth.cos(), omega_earth * lat_rad.sin(), -omega_earth * lat_rad.cos() * azimuth.sin(), ))
1027 } else {
1028 None
1029 };
1030
1031 let traj_params = TrajectoryParams {
1033 mass_kg,
1034 bc,
1035 drag_model,
1036 wind_segments,
1037 atmos_params: params.atmo_params,
1038 omega_vector,
1039 enable_spin_drift: inputs.use_enhanced_spin_drift,
1040 enable_magnus: inputs.enable_magnus,
1041 enable_coriolis: inputs.enable_coriolis,
1042 target_distance_m: params.horiz,
1043 enable_wind_shear: inputs.enable_wind_shear,
1044 wind_shear_model: inputs.wind_shear_model.clone(),
1045 shooter_altitude_m: inputs.altitude,
1046 is_twist_right: inputs.is_twist_right,
1047 shooting_angle: inputs.shooting_angle,
1048 bullet_diameter: inputs.bullet_diameter,
1050 bullet_length: inputs.bullet_length,
1051 twist_rate: inputs.twist_rate,
1052 cd_scale: inputs.cd_scale,
1053 custom_drag_table: inputs.custom_drag_table.clone(),
1054 bc_segments: inputs.bc_segments.clone(),
1055 use_bc_segments: inputs.use_bc_segments,
1056 ground_threshold: -1000.0,
1060 atmo_sock: params.atmo_sock,
1062 };
1063
1064 let trajectory = integrate_trajectory(
1066 initial_state,
1067 params.t_span,
1068 traj_params,
1069 "RK45", 1e-6, 0.01, );
1073
1074 let n_points = trajectory.len();
1076 let mut times = Vec::with_capacity(n_points + 1);
1077 let mut states = Vec::with_capacity(n_points + 1);
1078
1079 let mut target_hit_time: Option<f64> = None;
1080 let mut ground_hit_time: Option<f64> = None;
1081 let mut max_ord_time = None;
1082 let mut max_ord_y = 0.0;
1083
1084 for (t, state_vec) in trajectory {
1085 let state = [
1087 state_vec[0],
1088 state_vec[1],
1089 state_vec[2],
1090 state_vec[3],
1091 state_vec[4],
1092 state_vec[5],
1093 ];
1094
1095 if let Some((&previous_time, &previous_state)) = times.last().zip(states.last()) {
1103 if let Some((apex_time, apex_state)) =
1104 interpolated_vertical_apex(previous_time, &previous_state, t, &state)
1105 {
1106 if apex_state[1] > max_ord_y {
1107 max_ord_y = apex_state[1];
1108 max_ord_time = Some(apex_time);
1109 }
1110 times.push(apex_time);
1111 states.push(apex_state);
1112 }
1113 }
1114
1115 if target_hit_time.is_none() && state[0] >= params.horiz {
1117 target_hit_time = Some(t);
1118 }
1119
1120 if ground_hit_time.is_none() && state[1] <= inputs.ground_threshold {
1122 ground_hit_time = Some(t);
1123 }
1124
1125 if state[1] > max_ord_y {
1127 max_ord_y = state[1];
1128 max_ord_time = Some(t);
1129 }
1130
1131 times.push(t);
1132 states.push(state);
1133 }
1134
1135 let t_events = [
1137 if let Some(t) = target_hit_time {
1138 vec![t]
1139 } else {
1140 vec![]
1141 },
1142 if let Some(t) = max_ord_time {
1143 vec![t]
1144 } else {
1145 vec![]
1146 },
1147 if let Some(t) = ground_hit_time {
1148 vec![t]
1149 } else {
1150 vec![]
1151 },
1152 ];
1153
1154 if inputs.use_enhanced_spin_drift {
1159 let (sd_temp_c, sd_press_hpa) = if params.atmo_params.2 > 0.0 {
1163 (params.atmo_params.1, params.atmo_params.2)
1164 } else {
1165 (15.0, 1013.25)
1166 };
1167 let sg = crate::spin_drift::effective_sg_from_inputs(inputs, sd_temp_c, sd_press_hpa);
1168 for (t, state) in times.iter().zip(states.iter_mut()) {
1169 if *t > 0.0 {
1170 state[2] += crate::spin_drift::litz_drift_meters(sg, *t, inputs.is_twist_right);
1171 }
1172 }
1173 }
1174
1175 FastSolution::from_trajectory_data(times, states, t_events)
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180 use super::*;
1181 use crate::BCSegmentData;
1182
1183 fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
1184 let (sin_angle, cos_angle) = angle.sin_cos();
1185 Vector3::new(
1186 level.x * cos_angle + level.y * sin_angle,
1187 -level.x * sin_angle + level.y * cos_angle,
1188 level.z,
1189 )
1190 }
1191
1192 #[test]
1193 fn measured_bc_fast_drag_ignores_name_based_form_factor_flag() {
1194 let derivatives_with_flag = |use_form_factor| {
1195 let inputs = BallisticInputs {
1196 bc_value: 0.462,
1197 bc_type: DragModel::G1,
1198 bullet_model: Some("168gr SMK Match".to_string()),
1199 use_form_factor,
1200 temperature: 15.0,
1201 pressure: 1013.25,
1202 ..BallisticInputs::default()
1203 };
1204
1205 compute_derivatives(
1206 &[0.0, 0.0, 0.0, 600.0, 0.0, 0.0],
1207 &inputs,
1208 &WindSock::new(vec![]),
1209 FastAtmosphere::Standard {
1210 base_density: 1.225,
1211 },
1212 &inputs.bc_type,
1213 crate::transonic_drag::ProjectileShape::Spitzer,
1214 inputs.bc_value,
1215 false,
1216 false,
1217 None,
1218 None,
1219 None,
1220 )
1221 };
1222
1223 let baseline = derivatives_with_flag(false);
1224 let flagged = derivatives_with_flag(true);
1225
1226 for component in 3..6 {
1227 assert_eq!(
1228 flagged[component].to_bits(),
1229 baseline[component].to_bits(),
1230 "published BC already encodes form factor: component {component}, baseline={} flagged={}",
1231 baseline[component],
1232 flagged[component]
1233 );
1234 }
1235 }
1236
1237 #[test]
1238 fn velocity_bc_data_requires_opt_in_in_plain_fast_kernel() {
1239 let acceleration = |inputs: &BallisticInputs| {
1240 let has_mach_segments = inputs
1241 .bc_segments
1242 .as_ref()
1243 .is_some_and(|segments| !segments.is_empty());
1244 let has_velocity_segments = inputs
1245 .bc_segments_data
1246 .as_ref()
1247 .is_some_and(|segments| !segments.is_empty());
1248 compute_derivatives(
1249 &[0.0, 0.0, 0.0, 600.0, 0.0, 0.0],
1250 inputs,
1251 &WindSock::new(vec![]),
1252 FastAtmosphere::Standard {
1253 base_density: 1.225,
1254 },
1255 &inputs.bc_type,
1256 crate::transonic_drag::ProjectileShape::Spitzer,
1257 inputs.bc_value,
1258 has_mach_segments,
1259 has_velocity_segments,
1260 None,
1261 None,
1262 None,
1263 )
1264 };
1265
1266 let scalar_inputs = BallisticInputs {
1267 bc_value: 0.5,
1268 bc_type: DragModel::G7,
1269 temperature: 15.0,
1270 pressure: 1013.25,
1271 ..BallisticInputs::default()
1272 };
1273 let mut disabled_inputs = scalar_inputs.clone();
1274 disabled_inputs.bc_segments_data = Some(vec![BCSegmentData {
1275 velocity_min: 0.0,
1276 velocity_max: 4_000.0,
1277 bc_value: 0.46,
1278 }]);
1279 disabled_inputs.use_bc_segments = false;
1280 let mut enabled_inputs = disabled_inputs.clone();
1281 enabled_inputs.use_bc_segments = true;
1282 let mut mach_only_inputs = scalar_inputs.clone();
1283 mach_only_inputs.bc_segments = Some(vec![(0.0, 0.4), (3.0, 0.4)]);
1284 let mut disabled_with_both = mach_only_inputs.clone();
1285 disabled_with_both.bc_segments_data = disabled_inputs.bc_segments_data.clone();
1286
1287 let scalar = acceleration(&scalar_inputs);
1288 let disabled = acceleration(&disabled_inputs);
1289 let enabled = acceleration(&enabled_inputs);
1290 let mach_only = acceleration(&mach_only_inputs);
1291 let disabled_with_both = acceleration(&disabled_with_both);
1292
1293 assert_eq!(
1294 disabled[3].to_bits(),
1295 scalar[3].to_bits(),
1296 "a populated velocity table must not change drag while use_bc_segments is false"
1297 );
1298 assert!(
1299 enabled[3] < disabled[3] - 1.0,
1300 "enabling the lower BC table must increase drag: disabled ax={} enabled ax={}",
1301 disabled[3],
1302 enabled[3]
1303 );
1304 assert_eq!(
1305 disabled_with_both[3].to_bits(),
1306 mach_only[3].to_bits(),
1307 "disabling velocity data must fall through to an explicit Mach table"
1308 );
1309 }
1310
1311 #[test]
1312 fn inclined_positions_at_same_world_altitude_have_same_fast_acceleration() {
1313 let angle = std::f64::consts::FRAC_PI_6;
1314 let inputs = BallisticInputs {
1315 altitude: 100.0,
1316 temperature: 15.0,
1317 pressure: 1013.25,
1318 shooting_angle: angle,
1319 ..BallisticInputs::default()
1320 };
1321 let wind_sock = WindSock::new(vec![]);
1322 let atmosphere = FastAtmosphere::Standard {
1323 base_density: 1.225,
1324 };
1325 let state_along_slant = [1_000.0, 0.0, 0.0, 600.0, 0.0, 0.0];
1326 let state_across_slant = [0.0, 500.0 / angle.cos(), 0.0, 600.0, 0.0, 0.0];
1327
1328 let a = compute_derivatives(
1329 &state_along_slant,
1330 &inputs,
1331 &wind_sock,
1332 atmosphere,
1333 &inputs.bc_type,
1334 crate::transonic_drag::ProjectileShape::Spitzer,
1335 inputs.bc_value,
1336 false,
1337 false,
1338 None,
1339 None,
1340 None,
1341 );
1342 let b = compute_derivatives(
1343 &state_across_slant,
1344 &inputs,
1345 &wind_sock,
1346 atmosphere,
1347 &inputs.bc_type,
1348 crate::transonic_drag::ProjectileShape::Spitzer,
1349 inputs.bc_value,
1350 false,
1351 false,
1352 None,
1353 None,
1354 None,
1355 );
1356
1357 for component in 3..6 {
1358 assert!(
1359 (a[component] - b[component]).abs() < 1e-10,
1360 "fast derivative component {component} differs at equal world altitude: {} vs {}",
1361 a[component],
1362 b[component]
1363 );
1364 }
1365 }
1366
1367 #[test]
1368 fn inclined_headwind_is_rotated_into_solver_frame() {
1369 let angle = std::f64::consts::FRAC_PI_6;
1370 let inputs = BallisticInputs {
1371 shooting_angle: angle,
1372 ..BallisticInputs::default()
1373 };
1374 let speed_mps = 360.0 * (1000.0 / 3600.0);
1375 let level_headwind = Vector3::new(-speed_mps, 0.0, 0.0);
1376 let velocity = expected_shot_frame_vector(level_headwind, angle);
1377 let state = [0.0, 0.0, 0.0, velocity.x, velocity.y, velocity.z];
1378 let actual = compute_derivatives(
1379 &state,
1380 &inputs,
1381 &WindSock::new(vec![crate::wind::WindSegment::new(360.0, 0.0, 1000.0)]),
1382 FastAtmosphere::Direct {
1383 air_density: 1.225,
1384 speed_of_sound: 340.0,
1385 },
1386 &inputs.bc_type,
1387 crate::transonic_drag::ProjectileShape::Spitzer,
1388 inputs.bc_value,
1389 false,
1390 false,
1391 None,
1392 None,
1393 None,
1394 );
1395 let expected = Vector3::new(
1396 -G_ACCEL_MPS2 * angle.sin(),
1397 -G_ACCEL_MPS2 * angle.cos(),
1398 0.0,
1399 );
1400
1401 assert!(
1402 (Vector3::new(actual[3], actual[4], actual[5]) - expected).norm() < 1e-12,
1403 "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
1404 );
1405 }
1406
1407 #[test]
1408 fn plain_fast_kernel_applies_power_law_wind_shear() {
1409 let state = [500.0, 100.0, 0.0, 700.0, 0.0, 0.0];
1410 let run = |enable_wind_shear: bool, model: &str, wind_speed_kmh: f64| {
1411 let inputs = BallisticInputs {
1412 bc_value: 0.5,
1413 bc_type: DragModel::G7,
1414 enable_wind_shear,
1415 wind_shear_model: model.to_string(),
1416 ..BallisticInputs::default()
1417 };
1418 let wind_shear_model = enable_wind_shear
1419 .then(|| crate::wind_shear::boundary_layer_model_from_name(model))
1420 .filter(|model| *model != crate::wind_shear::WindShearModel::None);
1421 compute_derivatives(
1422 &state,
1423 &inputs,
1424 &WindSock::new(vec![crate::wind::WindSegment::new(wind_speed_kmh, 90.0, 2_000.0)]),
1425 FastAtmosphere::Direct {
1426 air_density: 1.225,
1427 speed_of_sound: 340.0,
1428 },
1429 &inputs.bc_type,
1430 crate::transonic_drag::ProjectileShape::Spitzer,
1431 inputs.bc_value,
1432 false,
1433 false,
1434 None,
1435 wind_shear_model,
1436 None,
1437 )
1438 };
1439
1440 let uniform = run(false, "power_law", 36.0);
1441 let model_none = run(true, "none", 36.0);
1442 assert_eq!(model_none, uniform, "model=none must preserve uniform wind");
1443
1444 let sheared = run(true, "power_law", 36.0);
1445 assert!(
1446 sheared[5] < uniform[5],
1447 "stronger aloft crosswind must increase leftward acceleration: uniform={}, shear={}",
1448 uniform[5],
1449 sheared[5]
1450 );
1451
1452 let ratio = crate::wind_shear::boundary_layer_speed_ratio(
1453 state[1],
1454 crate::wind_shear::WindShearModel::PowerLaw,
1455 );
1456 let equivalent_uniform = run(false, "none", 36.0 * ratio);
1457 for component in 3..6 {
1458 assert!(
1459 (sheared[component] - equivalent_uniform[component]).abs() < 1e-12,
1460 "shear component {component} must equal base wind scaled by {ratio}: shear={}, expected={}",
1461 sheared[component],
1462 equivalent_uniform[component]
1463 );
1464 }
1465 }
1466
1467 #[test]
1468 fn plain_fast_path_wind_shear_changes_high_arc_drift() {
1469 let run = |enable_wind_shear: bool, model: &str| {
1470 let inputs = BallisticInputs {
1471 muzzle_velocity: 800.0,
1472 bc_value: 0.5,
1473 bc_type: DragModel::G7,
1474 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
1475 bullet_diameter: 0.308 * 0.0254,
1476 enable_wind_shear,
1477 wind_shear_model: model.to_string(),
1478 ground_threshold: -100.0,
1479 ..BallisticInputs::default()
1480 };
1481 let elevation = 0.12_f64;
1482 let solution = fast_integrate(
1483 &inputs,
1484 &WindSock::new(vec![crate::wind::WindSegment::new(36.0, 90.0, 2_000.0)]),
1485 FastIntegrationParams {
1486 horiz: 1_000.0,
1487 vert: 0.0,
1488 initial_state: [
1489 0.0,
1490 0.0,
1491 0.0,
1492 inputs.muzzle_velocity * elevation.cos(),
1493 inputs.muzzle_velocity * elevation.sin(),
1494 0.0,
1495 ],
1496 t_span: (0.0, 5.0),
1497 atmo_params: (0.0, 15.0, 1013.25, 1.0),
1498 atmo_sock: None,
1499 },
1500 );
1501 let last = solution.t.len() - 1;
1502 assert_eq!(solution.y[0][last].to_bits(), 1_000.0_f64.to_bits());
1503 solution.y[2][last]
1504 };
1505
1506 let uniform = run(false, "power_law");
1507 let model_none = run(true, "none");
1508 assert_eq!(model_none.to_bits(), uniform.to_bits());
1509 let sheared = run(true, "power_law");
1510 assert!(
1511 sheared.abs() > uniform.abs() + 0.01,
1512 "aloft shear must increase drift magnitude: uniform={uniform}, shear={sheared}"
1513 );
1514 }
1515
1516 #[test]
1517 fn inclined_coriolis_is_rotated_into_solver_frame() {
1518 let angle = std::f64::consts::FRAC_PI_6;
1519 let inputs = BallisticInputs {
1520 shooting_angle: angle,
1521 ..BallisticInputs::default()
1522 };
1523 let velocity = Vector3::new(600.0, 20.0, 5.0);
1524 let state = [0.0, 0.0, 0.0, velocity.x, velocity.y, velocity.z];
1525 let level_omega = Vector3::new(3.0e-5, 6.0e-5, -2.0e-5);
1526 let run = |omega| {
1527 compute_derivatives(
1528 &state,
1529 &inputs,
1530 &WindSock::new(vec![]),
1531 FastAtmosphere::Direct {
1532 air_density: 1.225,
1533 speed_of_sound: 340.0,
1534 },
1535 &inputs.bc_type,
1536 crate::transonic_drag::ProjectileShape::Spitzer,
1537 inputs.bc_value,
1538 false,
1539 false,
1540 omega,
1541 None,
1542 None,
1543 )
1544 };
1545 let baseline = run(None);
1546 let with_coriolis = run(Some(level_omega));
1547 let actual = Vector3::new(
1548 with_coriolis[3] - baseline[3],
1549 with_coriolis[4] - baseline[4],
1550 with_coriolis[5] - baseline[5],
1551 );
1552 let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
1553
1554 assert!(
1555 (actual - expected).norm() < 1e-12,
1556 "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
1557 );
1558 }
1559
1560 #[test]
1561 fn test_fast_solution_interpolation() {
1562 let times = vec![0.0, 1.0, 2.0];
1563 let states = vec![
1564 [0.0, 0.0, 0.0, 100.0, 50.0, 0.0],
1565 [100.0, 45.0, 0.0, 99.0, 40.0, 0.0],
1566 [198.0, 80.0, 0.0, 98.0, 30.0, 0.0],
1567 ];
1568
1569 let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
1570
1571 let result = solution.sol(&[1.5]);
1573
1574 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); }
1578
1579 #[test]
1580 fn test_bc_from_velocity_segments() {
1581 let segments = vec![
1582 BCSegmentData {
1583 velocity_min: 0.0,
1584 velocity_max: 1000.0,
1585 bc_value: 0.5,
1586 },
1587 BCSegmentData {
1588 velocity_min: 1000.0,
1589 velocity_max: 2000.0,
1590 bc_value: 0.52,
1591 },
1592 BCSegmentData {
1593 velocity_min: 2000.0,
1594 velocity_max: 3000.0,
1595 bc_value: 0.55,
1596 },
1597 ];
1598
1599 assert_eq!(velocity_segment_bc(500.0, &segments, 0.5), 0.5);
1604 assert_eq!(velocity_segment_bc(1500.0, &segments, 0.5), 0.52);
1605 assert_eq!(velocity_segment_bc(2500.0, &segments, 0.5), 0.55);
1606
1607 assert_eq!(velocity_segment_bc(-100.0, &segments, 0.5), 0.5); assert_eq!(velocity_segment_bc(3500.0, &segments, 0.5), 0.55); }
1611
1612 #[test]
1613 fn test_fast_solution_interpolation_edge_cases() {
1614 let times = vec![0.0, 1.0, 2.0, 3.0];
1615 let states = vec![
1616 [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
1617 [800.0, 40.0, 100.0, 750.0, 30.0, 0.0],
1618 [1550.0, 60.0, 200.0, 700.0, 10.0, 0.0],
1619 [2250.0, 50.0, 300.0, 650.0, -10.0, 0.0],
1620 ];
1621
1622 let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
1623
1624 let result_before = solution.sol(&[-0.5]);
1626 assert!((result_before[0][0] - 0.0).abs() < 1e-10); let result_after = solution.sol(&[5.0]);
1630 assert!((result_after[0][0] - 2250.0).abs() < 1e-10); let result_exact = solution.sol(&[1.0]);
1634 assert!((result_exact[0][0] - 800.0).abs() < 1e-10);
1635
1636 let result_multi = solution.sol(&[0.5, 1.5, 2.5]);
1638 assert_eq!(result_multi[0].len(), 3);
1639 }
1640
1641 #[test]
1642 fn test_fast_solution_from_trajectory_data() {
1643 let times = vec![0.0, 0.5, 1.0];
1644 let states = vec![
1645 [0.0, 1.0, 2.0, 3.0, 4.0, 5.0],
1646 [10.0, 11.0, 12.0, 13.0, 14.0, 15.0],
1647 [20.0, 21.0, 22.0, 23.0, 24.0, 25.0],
1648 ];
1649 let t_events = [vec![1.0], vec![0.5], vec![]];
1650
1651 let solution = FastSolution::from_trajectory_data(times.clone(), states, t_events);
1652
1653 assert_eq!(solution.t, times);
1655 assert_eq!(solution.y.len(), 6); assert_eq!(solution.y[0].len(), 3); assert!(solution.success);
1658
1659 assert_eq!(solution.y[0][0], 0.0); assert_eq!(solution.y[1][0], 1.0); assert_eq!(solution.y[0][2], 20.0); }
1664
1665 #[test]
1666 fn test_bc_segments_boundary_conditions() {
1667 let single_segment = vec![BCSegmentData {
1671 velocity_min: 1000.0,
1672 velocity_max: 2000.0,
1673 bc_value: 0.5,
1674 }];
1675
1676 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![
1684 BCSegmentData {
1685 velocity_min: 0.0,
1686 velocity_max: 999.0, bc_value: 0.45,
1688 },
1689 BCSegmentData {
1690 velocity_min: 1000.0,
1691 velocity_max: 2000.0,
1692 bc_value: 0.50,
1693 },
1694 ];
1695
1696 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!(velocity_segment_bc(998.999, &segments, 0.7), 0.5735000320000354); assert_eq!(velocity_segment_bc(999.0, &segments, 0.7), 0.575); }
1715
1716 #[test]
1717 fn velocity_segment_gaps_and_clamps_do_not_depend_on_order() {
1718 let fallback_bc = 0.73;
1719 let ascending_with_gap = vec![
1720 BCSegmentData {
1721 velocity_min: 0.0,
1722 velocity_max: 999.0,
1723 bc_value: 0.6,
1724 },
1725 BCSegmentData {
1726 velocity_min: 1000.0,
1727 velocity_max: 2000.0,
1728 bc_value: 0.8,
1729 },
1730 ];
1731 assert_eq!(
1735 velocity_segment_bc(999.5, &ascending_with_gap, fallback_bc),
1736 fallback_bc,
1737 "coverage gaps must use the projectile's base BC"
1738 );
1739
1740 let mut descending = ascending_with_gap.clone();
1741 descending.reverse();
1742 assert_eq!(
1745 velocity_segment_bc(-100.0, &descending, fallback_bc),
1746 0.6,
1747 "below coverage must clamp to the lowest-velocity band"
1748 );
1749 assert_eq!(
1750 velocity_segment_bc(2500.0, &descending, fallback_bc),
1751 0.8,
1752 "above coverage must clamp to the highest-velocity band"
1753 );
1754 }
1755
1756 #[test]
1757 fn test_bc_segments_empty_fallback() {
1758 let empty_segments: Vec<BCSegmentData> = vec![];
1759
1760 let result = velocity_segment_bc(1500.0, &empty_segments, 0.73);
1764 assert_eq!(result, 0.73); }
1766
1767 #[test]
1768 fn test_fast_integration_params() {
1769 let params = FastIntegrationParams {
1771 horiz: 1000.0,
1772 vert: 0.0,
1773 initial_state: [0.0, 0.0, 0.0, 800.0, 50.0, 0.0], t_span: (0.0, 5.0),
1775 atmo_params: (0.0, 15.0, 1013.25, 1.0),
1776 atmo_sock: None,
1777 };
1778
1779 assert_eq!(params.horiz, 1000.0);
1780 assert_eq!(params.t_span.0, 0.0);
1781 assert_eq!(params.t_span.1, 5.0);
1782 assert_eq!(params.initial_state[3], 800.0); }
1784
1785 #[test]
1786 fn test_fast_solution_event_arrays() {
1787 let times = vec![0.0, 1.0, 2.0];
1788 let states = vec![
1789 [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
1790 [800.0, 40.0, 500.0, 750.0, 30.0, 0.0],
1791 [1500.0, 20.0, 1000.0, 700.0, 10.0, 0.0],
1792 ];
1793
1794 let t_events = [
1796 vec![2.0], vec![0.5], vec![], ];
1800
1801 let solution = FastSolution::from_trajectory_data(times, states, t_events);
1802
1803 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()); }
1807
1808 #[test]
1809 fn segmented_fast_path_interpolates_max_ordinate_between_saved_points() {
1810 let expected_apex_time = 5.105_f64;
1811 let downrange_velocity = 100.0_f64;
1812 let vertical_velocity = G_ACCEL_MPS2 * expected_apex_time;
1813 let inputs = BallisticInputs {
1814 muzzle_velocity: downrange_velocity.hypot(vertical_velocity),
1815 bc_value: 0.5,
1816 bc_type: DragModel::G7,
1817 use_enhanced_spin_drift: false,
1818 ..BallisticInputs::default()
1819 };
1820 let solution = fast_integrate_with_segments(
1821 &inputs,
1822 vec![],
1823 FastIntegrationParams {
1824 horiz: 1_000.0,
1825 vert: 0.0,
1826 initial_state: [0.0, 0.0, 0.0, downrange_velocity, vertical_velocity, 0.0],
1827 t_span: (0.0, 12.0),
1828 atmo_params: (1e-12, 340.0, 0.0, 0.0),
1831 atmo_sock: None,
1832 },
1833 );
1834
1835 assert!(solution.success);
1836 assert_eq!(solution.t_events[1].len(), 1);
1837 let reported_time = solution.t_events[1][0];
1838 assert!(
1839 (reported_time - expected_apex_time).abs() < 0.006,
1840 "max-ordinate time must be interpolated between coarse saves: reported={reported_time} expected={expected_apex_time}"
1841 );
1842 let event_index = solution
1843 .t
1844 .iter()
1845 .position(|time| time.to_bits() == reported_time.to_bits())
1846 .expect("the interpolated apex must be retained in the solution");
1847 assert_eq!(solution.y[4][event_index].to_bits(), 0.0_f64.to_bits());
1848
1849 let expected_height = vertical_velocity * expected_apex_time
1850 - 0.5 * G_ACCEL_MPS2 * expected_apex_time.powi(2);
1851 let event_state = solution.sol(&[reported_time]);
1852 assert!(
1853 (event_state[1][0] - expected_height).abs() < 2e-4,
1854 "max-ordinate state must preserve the interpolated apex height: reported={} expected={expected_height}",
1855 event_state[1][0]
1856 );
1857 }
1858
1859 #[test]
1860 fn plain_fast_path_interpolates_the_target_crossing() {
1861 let target = 500.123456789;
1862 let initial_state = [0.0, 0.0, 0.25, 800.0, 12.0, -2.5];
1863 let inputs = BallisticInputs {
1864 muzzle_velocity: 800.0,
1865 bc_value: 0.5,
1866 bc_type: DragModel::G7,
1867 ground_threshold: -100.0,
1868 use_enhanced_spin_drift: false,
1869 ..BallisticInputs::default()
1870 };
1871 let run = |horiz| {
1872 fast_integrate(
1873 &inputs,
1874 &WindSock::new(vec![]),
1875 FastIntegrationParams {
1876 horiz,
1877 vert: 0.0,
1878 initial_state,
1879 t_span: (0.0, 2.0),
1880 atmo_params: (0.0, 15.0, 1013.25, 1.0),
1881 atmo_sock: None,
1882 },
1883 )
1884 };
1885
1886 let reference = run(target + 2.0);
1888 let left = reference.y[0]
1889 .windows(2)
1890 .position(|x| x[0] < target && x[1] > target)
1891 .expect("reference trajectory must bracket target");
1892 let right = left + 1;
1893 let alpha =
1894 (target - reference.y[0][left]) / (reference.y[0][right] - reference.y[0][left]);
1895
1896 let solution = run(target);
1897 let last = solution.t.len() - 1;
1898 assert_eq!(solution.y[0][last].to_bits(), target.to_bits());
1899 let expected_time = reference.t[left] + alpha * (reference.t[right] - reference.t[left]);
1900 assert!((solution.t[last] - expected_time).abs() < 1e-12);
1901 assert_eq!(solution.t_events[0], vec![solution.t[last]]);
1902
1903 for component in 0..6 {
1904 assert_eq!(solution.y[component].len(), solution.t.len());
1905 let expected = reference.y[component][left]
1906 + alpha * (reference.y[component][right] - reference.y[component][left]);
1907 assert!(
1908 (solution.y[component][last] - expected).abs() < 1e-9,
1909 "component {component} is not at the crossing: actual={}, expected={expected}",
1910 solution.y[component][last]
1911 );
1912 }
1913 }
1914
1915 #[test]
1916 fn fast_path_coriolis_uses_shot_direction() {
1917 use std::f64::consts::FRAC_PI_2;
1922 fn final_xy(shot_az: f64) -> (f64, f64) {
1924 let inputs = BallisticInputs {
1925 muzzle_velocity: 800.0,
1926 bc_value: 0.5,
1927 bc_type: DragModel::G7,
1928 enable_advanced_effects: true, enable_coriolis: true,
1930 latitude: Some(45.0),
1931 shot_azimuth: shot_az,
1932 ..BallisticInputs::default()
1933 };
1934 let v = 800.0_f64;
1935 let elev = 0.02_f64;
1936 let params = FastIntegrationParams {
1937 horiz: 1000.0,
1938 vert: 0.0,
1939 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
1940 t_span: (0.0, 5.0),
1941 atmo_params: (0.0, 15.0, 1013.25, 1.0),
1942 atmo_sock: None,
1943 };
1944 let sol = fast_integrate_with_segments(&inputs, vec![], params);
1945 let n = sol.y[0].len();
1946 (sol.y[0][n - 1], sol.y[1][n - 1])
1947 }
1948 let (ex, ey) = final_xy(FRAC_PI_2); let (wx, wy) = final_xy(3.0 * FRAC_PI_2); assert!(
1953 (ex - wx).abs() < 0.5,
1954 "east/west downrange should be ~equal (ex={ex:.4}, wx={wx:.4})"
1955 );
1956 assert!(
1959 ey > wy,
1960 "fast-path east ({ey:.6}) must be higher than west ({wy:.6}) (Eotvos)"
1961 );
1962 assert!(
1963 (ey - wy) > 1e-5,
1964 "fast-path E-W vertical separation ({:.8} m) should be non-zero (the pre-fix bug was exact equality)",
1965 ey - wy
1966 );
1967 }
1968
1969 #[test]
1970 fn fast_path_coriolis_independent_of_advanced_effects() {
1971 use std::f64::consts::FRAC_PI_2;
1974 fn final_y(coriolis: bool, shot_az: f64) -> f64 {
1975 let inputs = BallisticInputs {
1976 muzzle_velocity: 800.0,
1977 bc_value: 0.5,
1978 bc_type: DragModel::G7,
1979 enable_coriolis: coriolis,
1980 enable_advanced_effects: false, latitude: Some(45.0),
1982 shot_azimuth: shot_az,
1983 ..BallisticInputs::default()
1984 };
1985 let v = 800.0_f64;
1986 let elev = 0.02_f64;
1987 let params = FastIntegrationParams {
1988 horiz: 1000.0,
1989 vert: 0.0,
1990 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
1991 t_span: (0.0, 5.0),
1992 atmo_params: (0.0, 15.0, 1013.25, 1.0),
1993 atmo_sock: None,
1994 };
1995 let sol = fast_integrate_with_segments(&inputs, vec![], params);
1996 let n = sol.y[0].len();
1997 sol.y[1][n - 1]
1998 }
1999 let e = final_y(true, FRAC_PI_2);
2001 let w = final_y(true, 3.0 * FRAC_PI_2);
2002 assert!(
2003 e > w && (e - w) > 1e-5,
2004 "Coriolis-only (no advanced effects) must still be directional: E={e} W={w}"
2005 );
2006 let e2 = final_y(false, FRAC_PI_2);
2008 let w2 = final_y(false, 3.0 * FRAC_PI_2);
2009 assert!(
2010 (e2 - w2).abs() < 1e-9,
2011 "with enable_coriolis=false, east/west must be identical: E={e2} W={w2}"
2012 );
2013 }
2014
2015 #[test]
2016 fn fast_path_rejects_degenerate_atmosphere() {
2017 let inputs = BallisticInputs {
2018 muzzle_velocity: 800.0,
2019 bc_value: 0.5,
2020 bc_type: DragModel::G7,
2021 ..BallisticInputs::default()
2022 };
2023 let v = 800.0_f64;
2024 let e = 0.02_f64;
2025 let mk = |atmo: (f64, f64, f64, f64)| FastIntegrationParams {
2026 horiz: 500.0,
2027 vert: 0.0,
2028 initial_state: [0.0, 0.0, 0.0, v * e.cos(), v * e.sin(), 0.0],
2029 t_span: (0.0, 5.0),
2030 atmo_params: atmo,
2031 atmo_sock: None,
2032 };
2033 let zero_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 0.0, 1.0)));
2035 assert!(
2036 !zero_p.success,
2037 "pressure=0 atmosphere must yield success=false"
2038 );
2039 let nan_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, f64::NAN, 1.0)));
2041 assert!(!nan_p.success, "NaN pressure must yield success=false");
2042 let segmented_too_dense =
2044 fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 1013.25, 50.0)));
2045 let plain_too_dense = fast_integrate(
2046 &inputs,
2047 &WindSock::new(vec![]),
2048 mk((0.0, 15.0, 1013.25, 50.0)),
2049 );
2050 assert!(
2051 !segmented_too_dense.success && !plain_too_dense.success,
2052 "ratio=50 atmosphere must fail in both wrappers: segmented={}, plain={}",
2053 segmented_too_dense.success,
2054 plain_too_dense.success
2055 );
2056 let good = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 1013.25, 1.0)));
2058 assert!(good.success, "realistic atmosphere must yield success=true");
2059 let direct = fast_integrate_with_segments(&inputs, vec![], mk((1.225, 340.0, 0.0, 0.0)));
2062 assert!(
2063 direct.success,
2064 "direct-atmosphere mode (pressure=0 sentinel) must yield success=true"
2065 );
2066 }
2067
2068 #[test]
2069 fn fast_paths_reject_invalid_wind_segments() {
2070 let inputs = BallisticInputs {
2071 muzzle_velocity: 800.0,
2072 bc_value: 0.5,
2073 bc_type: DragModel::G7,
2074 ..BallisticInputs::default()
2075 };
2076 let mk = || FastIntegrationParams {
2077 horiz: 100.0,
2078 vert: 0.0,
2079 initial_state: [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
2080 t_span: (0.0, 5.0),
2081 atmo_params: (0.0, 15.0, 1013.25, 1.0),
2082 atmo_sock: None,
2083 };
2084 let invalid = crate::wind::WindSegment::new(10.0, 90.0, f64::NAN);
2085
2086 let plain = fast_integrate(&inputs, &WindSock::new(vec![invalid]), mk());
2087 let segmented = fast_integrate_with_segments(&inputs, vec![invalid], mk());
2088
2089 assert!(
2090 !plain.success && !segmented.success,
2091 "invalid segmented wind must fail in both fast wrappers: plain={}, segmented={}",
2092 plain.success,
2093 segmented.success
2094 );
2095 }
2096
2097 #[test]
2098 fn plain_fast_path_honors_direct_atmosphere_values() {
2099 fn final_speed(muzzle_velocity: f64, atmo_params: (f64, f64, f64, f64)) -> f64 {
2100 let inputs = BallisticInputs {
2101 muzzle_velocity,
2102 bc_value: 0.5,
2103 bc_type: DragModel::G7,
2104 ground_threshold: -100.0,
2105 ..BallisticInputs::default()
2106 };
2107
2108 let wind_sock = WindSock::new(vec![]);
2109 let solution = fast_integrate(
2110 &inputs,
2111 &wind_sock,
2112 FastIntegrationParams {
2113 horiz: 10_000.0,
2114 vert: 0.0,
2115 initial_state: [0.0, 0.0, 0.0, muzzle_velocity, 0.0, 0.0],
2116 t_span: (0.0, 0.2),
2117 atmo_params,
2118 atmo_sock: None,
2119 },
2120 );
2121 assert!(solution.success);
2122
2123 let last = solution.y[0].len() - 1;
2124 (solution.y[3][last].powi(2)
2125 + solution.y[4][last].powi(2)
2126 + solution.y[5][last].powi(2))
2127 .sqrt()
2128 }
2129
2130 let thin_air = final_speed(800.0, (0.905, 340.0, 0.0, 0.0));
2131 let dense_air = final_speed(800.0, (1.225, 340.0, 0.0, 0.0));
2132 assert!(
2133 thin_air > dense_air,
2134 "lower supplied density must retain more velocity: thin={thin_air}, dense={dense_air}"
2135 );
2136
2137 let low_sound_speed = final_speed(340.0, (1.0, 300.0, 0.0, 0.0));
2138 let high_sound_speed = final_speed(340.0, (1.0, 400.0, 0.0, 0.0));
2139 assert!(
2140 (low_sound_speed - high_sound_speed).abs() > 1e-6,
2141 "supplied sound speed must affect Mach-dependent drag"
2142 );
2143 }
2144
2145 #[test]
2146 fn segmented_fast_path_nonpositive_density_ratio_uses_standard_fallback() {
2147 fn terminal_velocity(base_ratio: f64) -> f64 {
2148 let inputs = BallisticInputs {
2149 muzzle_velocity: 800.0,
2150 bc_value: 0.5,
2151 bc_type: DragModel::G7,
2152 ground_threshold: -100.0,
2153 ..BallisticInputs::default()
2154 };
2155
2156 let solution = fast_integrate_with_segments(
2157 &inputs,
2158 vec![],
2159 FastIntegrationParams {
2160 horiz: 500.0,
2161 vert: 0.0,
2162 initial_state: [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
2163 t_span: (0.0, 5.0),
2164 atmo_params: (0.0, 15.0, 1013.25, base_ratio),
2165 atmo_sock: None,
2166 },
2167 );
2168 assert!(solution.success);
2169
2170 let last = solution.y[3].len() - 1;
2171 solution.y[3][last]
2172 }
2173
2174 let explicit_sea_level = terminal_velocity(1.0);
2175 for base_ratio in [0.0, -1.0] {
2176 let missing_ratio = terminal_velocity(base_ratio);
2177 assert!(
2178 missing_ratio < 800.0,
2179 "missing density ratio must not create a vacuum trajectory: {missing_ratio}"
2180 );
2181 assert!((missing_ratio - explicit_sea_level).abs() < 1e-9);
2182 }
2183 }
2184
2185 #[test]
2186 fn fast_path_carries_real_bullet_geometry() {
2187 let run = |diameter: f64, twist: f64| {
2195 let inputs = BallisticInputs {
2196 muzzle_velocity: 800.0,
2197 bc_value: 0.5,
2198 bc_type: DragModel::G7,
2199 bullet_diameter: diameter,
2200 bullet_length: 0.0318,
2201 twist_rate: twist,
2202 enable_advanced_effects: true,
2203 enable_magnus: true,
2204 ..BallisticInputs::default()
2205 };
2206 let v = 800.0_f64;
2207 let elev = 0.02_f64;
2208 let params = FastIntegrationParams {
2209 horiz: 1000.0,
2210 vert: 0.0,
2211 initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
2212 t_span: (0.0, 5.0),
2213 atmo_params: (0.0, 15.0, 1013.25, 1.0),
2214 atmo_sock: None,
2215 };
2216 fast_integrate_with_segments(&inputs, vec![], params)
2217 };
2218 assert!(run(0.00569, 7.0).success, ".224 geometry must solve");
2221 assert!(run(0.00858, 10.0).success, ".338 geometry must solve");
2222 }
2223
2224 #[test]
2225 fn segmented_fast_spin_flags_do_not_depend_on_advanced_umbrella() {
2226 fn endpoint(
2227 enable_advanced_effects: bool,
2228 enable_magnus: bool,
2229 use_enhanced_spin_drift: bool,
2230 ) -> Vector3<f64> {
2231 let inputs = BallisticInputs {
2232 muzzle_velocity: 823.0,
2233 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
2234 bullet_diameter: 0.308 * 0.0254,
2235 bullet_length: 1.215 * 0.0254,
2236 caliber_inches: 0.308,
2237 weight_grains: 168.0,
2238 bc_value: 0.475,
2239 bc_type: DragModel::G1,
2240 twist_rate: 12.0,
2241 is_twist_right: true,
2242 enable_advanced_effects,
2243 enable_magnus,
2244 use_enhanced_spin_drift,
2245 ..BallisticInputs::default()
2246 };
2247 let elevation = 0.02_f64;
2248 let solution = fast_integrate_with_segments(
2249 &inputs,
2250 vec![],
2251 FastIntegrationParams {
2252 horiz: 1_000.0,
2253 vert: 0.0,
2254 initial_state: [
2255 0.0,
2256 0.0,
2257 0.0,
2258 inputs.muzzle_velocity * elevation.cos(),
2259 inputs.muzzle_velocity * elevation.sin(),
2260 0.0,
2261 ],
2262 t_span: (0.0, 5.0),
2263 atmo_params: (0.0, 15.0, 1013.25, 1.0),
2264 atmo_sock: None,
2265 },
2266 );
2267 assert!(solution.success);
2268 let last = solution.t.len() - 1;
2269 Vector3::new(
2270 solution.y[0][last],
2271 solution.y[1][last],
2272 solution.y[2][last],
2273 )
2274 }
2275
2276 let baseline = endpoint(false, false, false);
2277 let magnus_without_umbrella = endpoint(false, true, false);
2278 let magnus_with_umbrella = endpoint(true, true, false);
2279 assert!(
2280 (magnus_without_umbrella - baseline).norm() > 1e-5,
2281 "test shot must produce a measurable Magnus displacement"
2282 );
2283 assert!(
2284 (magnus_with_umbrella - magnus_without_umbrella).norm() < 1e-12,
2285 "the legacy umbrella must not suppress explicitly enabled Magnus: without={magnus_without_umbrella:?} with={magnus_with_umbrella:?}"
2286 );
2287
2288 let litz_only = endpoint(false, false, true);
2289 let litz_without_umbrella = endpoint(false, true, true);
2290 let litz_with_umbrella = endpoint(true, true, true);
2291 assert!(
2292 (litz_without_umbrella - litz_only).norm() < 1e-12,
2293 "Litz mode must suppress explicitly enabled Magnus: litz={litz_only:?} both={litz_without_umbrella:?}"
2294 );
2295 assert!(
2296 (litz_with_umbrella - litz_without_umbrella).norm() < 1e-12,
2297 "the legacy umbrella must not change Magnus suppression in Litz mode: without={litz_without_umbrella:?} with={litz_with_umbrella:?}"
2298 );
2299 }
2300
2301 fn deck_test_inputs(cd_scale: f64) -> BallisticInputs {
2303 BallisticInputs {
2304 bullet_mass: 0.0106,
2305 bullet_diameter: 0.00782,
2306 muzzle_velocity: 850.0,
2307 custom_drag_table: Some(crate::drag::DragTable::new(
2308 vec![0.5, 1.0, 2.0, 3.0],
2309 vec![0.23, 0.40, 0.30, 0.26],
2310 )),
2311 cd_scale,
2312 temperature: 15.0,
2313 pressure: 1013.25,
2314 ..BallisticInputs::default()
2315 }
2316 }
2317
2318 fn deck_accel_x(cd_scale: f64) -> f64 {
2319 let inputs = deck_test_inputs(cd_scale);
2320 compute_derivatives(
2321 &[0.0, 0.0, 0.0, 700.0, 0.0, 0.0],
2322 &inputs,
2323 &WindSock::new(vec![]),
2324 FastAtmosphere::Standard {
2325 base_density: 1.225,
2326 },
2327 &inputs.bc_type,
2328 crate::transonic_drag::ProjectileShape::Spitzer,
2329 inputs.bc_value,
2330 false,
2331 false,
2332 None,
2333 None,
2334 None,
2335 )[3]
2336 }
2337
2338 #[test]
2339 fn cd_scale_default_is_one_and_absent_matches_explicit() {
2340 assert_eq!(BallisticInputs::default().cd_scale, 1.0);
2341
2342 let omitted_inputs = BallisticInputs {
2343 bullet_mass: 0.0106,
2344 bullet_diameter: 0.00782,
2345 muzzle_velocity: 850.0,
2346 custom_drag_table: Some(crate::drag::DragTable::new(
2347 vec![0.5, 1.0, 2.0, 3.0],
2348 vec![0.23, 0.40, 0.30, 0.26],
2349 )),
2350 temperature: 15.0,
2351 pressure: 1013.25,
2352 ..BallisticInputs::default()
2353 };
2354 assert_eq!(omitted_inputs.cd_scale, 1.0);
2355
2356 let a_omitted = compute_derivatives(
2357 &[0.0, 0.0, 0.0, 700.0, 0.0, 0.0],
2358 &omitted_inputs,
2359 &WindSock::new(vec![]),
2360 FastAtmosphere::Standard {
2361 base_density: 1.225,
2362 },
2363 &omitted_inputs.bc_type,
2364 crate::transonic_drag::ProjectileShape::Spitzer,
2365 omitted_inputs.bc_value,
2366 false,
2367 false,
2368 None,
2369 None,
2370 None,
2371 )[3];
2372 let a_explicit = deck_accel_x(1.0);
2373 assert_eq!(
2374 a_omitted.to_bits(),
2375 a_explicit.to_bits(),
2376 "omitted cd_scale (Default) must be bit-identical to an explicit 1.0"
2377 );
2378 }
2379
2380 #[test]
2383 fn cd_scale_direction_on_fast_trajectory_kernel() {
2384 let baseline = deck_accel_x(1.0);
2385 let scaled_up = deck_accel_x(1.10);
2386 let scaled_down = deck_accel_x(0.90);
2387
2388 assert!(
2389 scaled_up < baseline,
2390 "cd_scale=1.10 must increase drag deceleration (more negative ax): \
2391 base={baseline} up={scaled_up}"
2392 );
2393 assert!(
2394 scaled_down > baseline,
2395 "cd_scale=0.90 must decrease drag deceleration (less negative ax): \
2396 base={baseline} down={scaled_down}"
2397 );
2398 }
2399
2400 #[test]
2402 fn cd_scale_is_inert_without_a_custom_drag_table() {
2403 let make = |cd_scale: f64| BallisticInputs {
2404 bc_value: 0.5,
2405 bc_type: DragModel::G1,
2406 temperature: 15.0,
2407 pressure: 1013.25,
2408 cd_scale,
2409 ..BallisticInputs::default()
2410 };
2411 let accel = |cd_scale: f64| {
2412 let inputs = make(cd_scale);
2413 compute_derivatives(
2414 &[0.0, 0.0, 0.0, 700.0, 0.0, 0.0],
2415 &inputs,
2416 &WindSock::new(vec![]),
2417 FastAtmosphere::Standard {
2418 base_density: 1.225,
2419 },
2420 &inputs.bc_type,
2421 crate::transonic_drag::ProjectileShape::Spitzer,
2422 inputs.bc_value,
2423 false,
2424 false,
2425 None,
2426 None,
2427 None,
2428 )[3]
2429 };
2430 assert_eq!(
2431 accel(1.0).to_bits(),
2432 accel(1.5).to_bits(),
2433 "cd_scale must not affect the G-model/BC drag path"
2434 );
2435 }
2436}