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