1use crate::atmosphere::{
2 calculate_air_density_cimp, get_direct_atmosphere, get_local_atmosphere_humid, AtmoSock,
3};
4use crate::bc_estimation::{velocity_segment_bc, BCSegmentEstimator};
5use crate::constants::*;
6use crate::drag::get_drag_coefficient_full;
7use crate::InternalBallisticInputs as BallisticInputs;
8use nalgebra::Vector3;
9
10const MAGNUS_COEFF_SUBSONIC: f64 = 0.030;
23
24const MAGNUS_COEFF_TRANSONIC_REDUCTION: f64 = 0.015;
31
32const MAGNUS_COEFF_SUPERSONIC_BASE: f64 = 0.015;
38
39const MAGNUS_COEFF_SUPERSONIC_SCALE: f64 = 0.0044;
45
46const MAGNUS_TRANSONIC_LOWER: f64 = 0.8; const MAGNUS_TRANSONIC_UPPER: f64 = 1.2; const MAGNUS_TRANSONIC_RANGE: f64 = 0.4; const MAGNUS_SUPERSONIC_RANGE: f64 = 1.8; const MAX_REALISTIC_DENSITY: f64 = 2.0; const MIN_REALISTIC_SPEED_OF_SOUND: f64 = 200.0; fn dry_air_temperature_c_from_sound_speed(speed_of_sound_mps: f64) -> f64 {
61 speed_of_sound_mps * speed_of_sound_mps / (1.4 * 287.05) - 273.15
62}
63
64pub(crate) fn calculate_magnus_moment_coefficient(mach: f64) -> f64 {
67 if mach < MAGNUS_TRANSONIC_LOWER {
71 MAGNUS_COEFF_SUBSONIC
73 } else if mach < MAGNUS_TRANSONIC_UPPER {
74 MAGNUS_COEFF_SUBSONIC
77 - MAGNUS_COEFF_TRANSONIC_REDUCTION * (mach - MAGNUS_TRANSONIC_LOWER)
78 / MAGNUS_TRANSONIC_RANGE
79 } else {
80 MAGNUS_COEFF_SUPERSONIC_BASE
82 + MAGNUS_COEFF_SUPERSONIC_SCALE
83 * ((mach - MAGNUS_TRANSONIC_UPPER) / MAGNUS_SUPERSONIC_RANGE).min(1.0)
84 }
85}
86
87#[inline]
92pub(crate) fn level_vector_to_shot_frame(
93 vector: Vector3<f64>,
94 shooting_angle: f64,
95) -> Vector3<f64> {
96 if shooting_angle == 0.0 {
97 return vector;
98 }
99
100 let (sin_angle, cos_angle) = shooting_angle.sin_cos();
101 Vector3::new(
102 vector.x * cos_angle + vector.y * sin_angle,
103 -vector.x * sin_angle + vector.y * cos_angle,
104 vector.z,
105 )
106}
107
108pub(crate) fn yaw_of_repose_magnus_direction(
116 air_velocity: Vector3<f64>,
117 gravity_acceleration: Vector3<f64>,
118 is_twist_right: bool,
119) -> Option<Vector3<f64>> {
120 let speed = air_velocity.norm();
121 if !speed.is_finite() || speed <= 1e-12 {
122 return None;
123 }
124
125 let velocity_unit = air_velocity / speed;
126 let gravity_normal =
127 gravity_acceleration - velocity_unit * gravity_acceleration.dot(&velocity_unit);
128 let normal_magnitude = gravity_normal.norm();
129 if !normal_magnitude.is_finite() || normal_magnitude <= 1e-12 {
130 return None;
131 }
132
133 let right_twist_direction = gravity_normal / normal_magnitude;
134 Some(if is_twist_right {
135 right_twist_direction
136 } else {
137 -right_twist_direction
138 })
139}
140
141#[allow(clippy::too_many_arguments)]
146pub fn compute_derivatives(
147 pos: Vector3<f64>,
148 vel: Vector3<f64>,
149 inputs: &BallisticInputs,
150 wind_vector: Vector3<f64>,
151 atmos_params: (f64, f64, f64, f64),
152 bc_used: f64,
153 omega_vector: Option<Vector3<f64>>,
154 _time: f64,
157 atmo_sock: Option<&AtmoSock>,
162) -> [f64; 6] {
163 let theta = inputs.shooting_angle;
166 let accel_gravity = Vector3::new(
167 -G_ACCEL_MPS2 * theta.sin(),
168 -G_ACCEL_MPS2 * theta.cos(),
169 0.0,
170 );
171
172 let wind_vector = level_vector_to_shot_frame(wind_vector, theta);
175 let velocity_adjusted = vel - wind_vector;
176 let speed_air = velocity_adjusted.norm();
177
178 let mut accel_drag = Vector3::zeros();
180 let mut accel_magnus = Vector3::zeros();
181
182 if speed_air > crate::constants::MIN_VELOCITY_THRESHOLD {
184 let v_rel_fps = speed_air * MPS_TO_FPS;
185
186 let altitude_at_pos = crate::atmosphere::shot_frame_altitude(
188 inputs.altitude,
189 pos[0],
190 pos[1],
191 inputs.shooting_angle,
192 );
193
194 let (air_density, speed_of_sound, temperature_c) = if atmos_params.0 < MAX_REALISTIC_DENSITY
200 && atmos_params.1 > MIN_REALISTIC_SPEED_OF_SOUND
201 && atmos_params.2 == 0.0
202 && atmos_params.3 == 0.0
203 {
204 let (rho, sound) = get_direct_atmosphere(atmos_params.0, atmos_params.1);
208 (rho, sound, dry_air_temperature_c_from_sound_speed(sound))
209 } else {
210 let (base_temp_c, base_press_hpa, base_ratio) = match atmo_sock {
226 Some(sock) => {
227 let (zt, zp, zh) = sock.atmo_for_range(pos[0]);
228 (zt, zp, calculate_air_density_cimp(zt, zp, zh) / 1.225)
229 }
230 None => {
231 let base_ratio = if atmos_params.3 > 0.0 {
235 atmos_params.3
236 } else {
237 1.0
238 };
239 (atmos_params.1, atmos_params.2, base_ratio)
240 }
241 };
242 let (rho, sound) = get_local_atmosphere_humid(
243 altitude_at_pos,
244 atmos_params.0, base_temp_c, base_press_hpa, base_ratio, 0.0, );
250 (rho, sound, dry_air_temperature_c_from_sound_speed(sound))
256 };
257
258 let mach = if speed_of_sound > 1e-9 {
260 speed_air / speed_of_sound
261 } else {
262 0.0 };
264
265 let drag_factor = get_drag_coefficient_full(
267 mach,
268 &inputs.bc_type,
269 false, false, None, if inputs.caliber_inches > 0.0 {
273 Some(inputs.caliber_inches)
274 } else {
275 Some(inputs.bullet_diameter / 0.0254) },
277 if inputs.weight_grains > 0.0 {
278 Some(inputs.weight_grains)
279 } else {
280 Some(inputs.bullet_mass / crate::constants::GRAINS_TO_KG) },
282 Some(speed_air),
283 Some(air_density),
284 Some(temperature_c),
285 );
286
287 let mut bc_val = bc_used;
289
290 if inputs.use_bc_segments {
291 if inputs.bc_segments_data.is_some() {
293 bc_val = get_bc_for_velocity(v_rel_fps, inputs, bc_used);
294 } else {
295 match inputs.bc_segments.as_deref() {
296 Some(segments) if !segments.is_empty() => {
298 bc_val = interpolated_bc(mach, segments, Some(inputs));
299 }
300 Some(_) => {}
302 None => bc_val = get_bc_for_velocity(v_rel_fps, inputs, bc_used),
304 }
305 }
306 } else if let Some(segments) = inputs
307 .bc_segments
308 .as_deref()
309 .filter(|segments| !segments.is_empty())
310 {
311 bc_val = interpolated_bc(mach, segments, Some(inputs));
313 }
314
315 let bc_val = bc_val.max(1e-6);
321
322 let yaw_rad = if inputs.tipoff_decay_distance.abs() > 1e-9 {
324 inputs.tipoff_yaw * (-pos[0] / inputs.tipoff_decay_distance).exp()
325 } else {
326 inputs.tipoff_yaw };
328 let density_scale = air_density / STANDARD_AIR_DENSITY;
338
339 let caliber_in = if inputs.caliber_inches > 0.0 {
352 inputs.caliber_inches
353 } else {
354 inputs.bullet_diameter / 0.0254 };
356 let weight_gr = if inputs.weight_grains > 0.0 {
357 inputs.weight_grains
358 } else {
359 inputs.bullet_mass / crate::constants::GRAINS_TO_KG };
361 let shape = crate::transonic_drag::resolve_projectile_shape(
364 inputs.bullet_model.as_deref(),
365 caliber_in,
366 weight_gr,
367 &inputs.bc_type.to_string(),
368 );
369 let drag_factor =
370 crate::transonic_drag::transonic_correction(mach, drag_factor, shape, false);
371
372 let (drag_factor, retard_denom) = match inputs.custom_drag_table {
387 Some(ref table) => (
388 table.interpolate(mach) * inputs.cd_scale,
393 inputs.custom_drag_denominator(bc_val),
394 ),
395 None => (drag_factor, bc_val),
396 };
397
398 let standard_factor = drag_factor * CD_TO_RETARD;
400 let mut a_drag_ft_s2 =
401 (v_rel_fps.powi(2) * standard_factor * density_scale) / retard_denom;
402 if yaw_rad != 0.0 {
407 if let Some(sd) = inputs.sectional_density_lb_in2() {
408 if sd > 0.0 {
409 a_drag_ft_s2 += v_rel_fps.powi(2)
410 * CD_TO_RETARD
411 * density_scale
412 * (inputs.cd_delta2 * yaw_rad.powi(2) / sd);
413 }
414 }
415 }
416 let a_drag_m_s2 = a_drag_ft_s2 * FPS_TO_MPS;
417
418 accel_drag = -a_drag_m_s2 * (velocity_adjusted / speed_air);
420
421 if inputs.enable_magnus
429 && !(inputs.use_enhanced_spin_drift && inputs.enable_advanced_effects)
430 && inputs.bullet_diameter > 0.0
431 && inputs.twist_rate > 0.0
432 {
433 let diameter_m = inputs.bullet_diameter;
434 let (spin_rate_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
435 inputs.muzzle_velocity,
436 speed_air,
437 inputs.twist_rate,
438 diameter_m,
439 );
440
441 let c_np = calculate_magnus_moment_coefficient(mach);
442
443 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
445
446 let d_in = inputs.caliber_inches;
450 let m_gr = inputs.weight_grains;
451 let l_in = if inputs.bullet_length > 0.0 {
452 inputs.bullet_length / 0.0254 } else {
454 let est_m = crate::stability::estimate_bullet_length_m(
456 inputs.bullet_diameter,
457 inputs.bullet_mass,
458 );
459 if est_m > 0.0 {
460 est_m / 0.0254
461 } else {
462 4.5 * d_in.max(1e-9)
463 }
464 };
465 let sg = crate::spin_drift::calculate_dynamic_stability(
470 m_gr,
471 speed_air,
472 spin_rate_rad_s,
473 d_in,
474 l_in,
475 air_density,
476 );
477 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
478 sg,
479 speed_air,
480 spin_rate_rad_s,
481 0.0,
482 0.0,
483 air_density,
484 d_in,
485 l_in,
486 m_gr,
487 mach,
488 "match",
489 false,
490 );
491
492 let magnus_force_magnitude =
494 0.5 * air_density * speed_air.powi(2) * area * c_np * spin_param * yaw_rad.sin();
495
496 if magnus_force_magnitude > 1e-12 {
499 if let Some(magnus_direction) = yaw_of_repose_magnus_direction(
500 velocity_adjusted,
501 accel_gravity,
502 inputs.is_twist_right,
503 ) {
504 let bullet_mass_kg = inputs.bullet_mass; accel_magnus = (magnus_force_magnitude / bullet_mass_kg) * magnus_direction;
506 }
507 }
508 }
509 }
510
511 let mut accel = accel_gravity + accel_drag + accel_magnus;
513
514 if let Some(omega) = omega_vector {
519 let omega = level_vector_to_shot_frame(omega, theta);
520 let accel_coriolis = -2.0 * omega.cross(&vel);
521 accel += accel_coriolis;
522 }
523
524 [vel[0], vel[1], vel[2], accel[0], accel[1], accel[2]]
537}
538
539pub fn interpolated_bc(
545 mach: f64,
546 segments: &[(f64, f64)],
547 inputs: Option<&BallisticInputs>,
548) -> f64 {
549 if segments.is_empty() {
550 if let Some(inputs) = inputs {
551 return inputs.bc_value;
552 }
553 return crate::constants::BC_FALLBACK_CONSERVATIVE;
554 }
555
556 if segments.len() == 1 {
557 return segments[0].1;
558 }
559
560 let sorted_segments: std::borrow::Cow<[(f64, f64)]> =
564 if segments.windows(2).all(|w| w[0].0 <= w[1].0) {
565 std::borrow::Cow::Borrowed(segments)
566 } else {
567 let mut v = segments.to_vec();
568 v.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
569 std::borrow::Cow::Owned(v)
570 };
571
572 if mach <= sorted_segments[0].0 {
574 return sorted_segments[0].1;
575 }
576 if mach >= sorted_segments[sorted_segments.len() - 1].0 {
577 return sorted_segments[sorted_segments.len() - 1].1;
578 }
579
580 let idx = sorted_segments.partition_point(|(m, _)| *m <= mach);
582 if idx == 0 || idx >= sorted_segments.len() {
583 return sorted_segments[0].1;
585 }
586
587 let (mach1, bc1) = sorted_segments[idx - 1];
588 let (mach2, bc2) = sorted_segments[idx];
589
590 let denominator = mach2 - mach1;
592 if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
593 return bc1; }
595 let t = (mach - mach1) / denominator;
596 bc1 + t * (bc2 - bc1)
597}
598
599fn get_bc_for_velocity(velocity_fps: f64, inputs: &BallisticInputs, bc_used: f64) -> f64 {
601 if !inputs.use_bc_segments {
603 return bc_used;
604 }
605
606 if let Some(bc_segments_data) = inputs
608 .bc_segments_data
609 .as_ref()
610 .filter(|segments| !segments.is_empty())
611 {
612 return velocity_segment_bc(velocity_fps, bc_segments_data, bc_used);
615 }
616
617 if let Some(segments) = estimate_bc_segments_for(inputs, bc_used) {
621 return velocity_segment_bc(velocity_fps, &segments, bc_used);
622 }
623
624 bc_used
626}
627
628pub(crate) fn estimate_bc_segments_for(
635 inputs: &BallisticInputs,
636 bc_used: f64,
637) -> Option<Vec<crate::BCSegmentData>> {
638 if !(inputs.bullet_diameter > 0.0 && inputs.bullet_mass > 0.0 && bc_used > 0.0) {
639 return None;
640 }
641 let model = if let Some(ref bullet_id) = inputs.bullet_id {
643 bullet_id.clone()
644 } else {
645 format!("{}gr bullet", inputs.weight_grains as i32)
646 };
647 let bc_type_str = inputs.bc_type_str.as_deref().unwrap_or(match inputs.bc_type {
650 crate::DragModel::G7 => "G7",
651 _ => "G1",
652 });
653 Some(BCSegmentEstimator::estimate_bc_segments(
654 bc_used,
655 inputs.caliber_inches,
656 inputs.weight_grains,
657 &model,
658 bc_type_str,
659 ))
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665
666 fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
667 let (sin_angle, cos_angle) = angle.sin_cos();
668 Vector3::new(
669 level.x * cos_angle + level.y * sin_angle,
670 -level.x * sin_angle + level.y * cos_angle,
671 level.z,
672 )
673 }
674
675 #[test]
676 fn level_vector_projection_is_bit_exact_at_zero_incline() {
677 let level = Vector3::new(12.5, -0.0, -7.25);
678 let projected = level_vector_to_shot_frame(level, 0.0);
679
680 for component in 0..3 {
681 assert_eq!(projected[component].to_bits(), level[component].to_bits());
682 }
683 }
684
685 #[test]
686 fn dry_air_sound_speed_round_trips_to_local_celsius() {
687 for expected_temp_c in [-40.0_f64, 15.0, 40.0] {
688 let sound_speed = (1.4 * 287.05 * (expected_temp_c + 273.15)).sqrt();
689 let actual_temp_c = dry_air_temperature_c_from_sound_speed(sound_speed);
690 assert!(
691 (actual_temp_c - expected_temp_c).abs() < 1e-10,
692 "sound speed {sound_speed} m/s recovered {actual_temp_c} C, expected {expected_temp_c} C"
693 );
694 }
695
696 let direct_mode_temp_c = dry_air_temperature_c_from_sound_speed(340.0);
697 assert!(
698 direct_mode_temp_c > 10.0 && direct_mode_temp_c < 20.0,
699 "direct-mode 340 m/s must be interpreted as sound speed, not 340 C"
700 );
701 }
702
703 #[test]
704 fn tipoff_yaw_uses_documented_radians_for_drag_and_decay() {
705 let mut baseline_inputs = create_test_inputs();
706 baseline_inputs.tipoff_yaw = 0.0;
707 baseline_inputs.tipoff_decay_distance = 50.0;
708 let mut yawed_inputs = baseline_inputs.clone();
709 yawed_inputs.tipoff_yaw = 0.1; let drag_x = |inputs: &BallisticInputs, downrange_m: f64| {
712 compute_derivatives(
713 Vector3::new(downrange_m, 0.0, 0.0),
714 Vector3::new(300.0, 0.0, 0.0),
715 inputs,
716 Vector3::zeros(),
717 (1.225, 340.0, 0.0, 0.0),
718 0.5,
719 None,
720 0.0,
721 None,
722 )[3]
723 };
724
725 let sd = baseline_inputs
732 .sectional_density_lb_in2()
733 .expect("test inputs carry mass/diameter");
734 let v_fps = 300.0 * MPS_TO_FPS; let density_scale = 1.225 / STANDARD_AIR_DENSITY;
736
737 for (downrange_m, decay_scale) in [(0.0_f64, 1.0_f64), (50.0 * std::f64::consts::LN_2, 0.5)]
738 {
739 let delta_eff = 0.1 * decay_scale;
740 let expected_extra_mps2 = v_fps.powi(2)
741 * CD_TO_RETARD
742 * density_scale
743 * (yawed_inputs.cd_delta2 * delta_eff.powi(2) / sd)
744 * FPS_TO_MPS;
745
746 let baseline_drag = drag_x(&baseline_inputs, downrange_m);
747 let yawed_drag = drag_x(&yawed_inputs, downrange_m);
748 assert!(baseline_drag < 0.0, "baseline must be downrange drag");
749 let measured_extra = baseline_drag - yawed_drag; assert!(
752 (measured_extra - expected_extra_mps2).abs() <= 1e-9 * expected_extra_mps2,
753 "additive yaw drag at x={downrange_m} m: measured extra {measured_extra:.9} \
754 != expected {expected_extra_mps2:.9}"
755 );
756 }
757 }
758
759 fn create_test_inputs() -> BallisticInputs {
760 BallisticInputs {
764 muzzle_velocity: 800.0, bc_value: 0.5,
766 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG, bullet_diameter: 0.308 * 0.0254, bullet_length: 1.215 * 0.0254, caliber_inches: 0.308,
770 weight_grains: 168.0,
771 altitude: 1000.0,
772 ..Default::default()
773 }
774 }
775
776 #[test]
777 fn measured_bc_drag_ignores_name_based_form_factor_flag() {
778 let derivatives_with_flag = |use_form_factor| {
779 let inputs = BallisticInputs {
780 bc_value: 0.462,
781 bc_type: crate::DragModel::G1,
782 bullet_model: Some("168gr SMK Match".to_string()),
783 use_form_factor,
784 ..create_test_inputs()
785 };
786
787 compute_derivatives(
788 Vector3::zeros(),
789 Vector3::new(600.0, 0.0, 0.0),
790 &inputs,
791 Vector3::zeros(),
792 (1.225, 340.0, 0.0, 0.0),
793 inputs.bc_value,
794 None,
795 0.0,
796 None,
797 )
798 };
799
800 let baseline = derivatives_with_flag(false);
801 let flagged = derivatives_with_flag(true);
802
803 for component in 3..6 {
804 assert_eq!(
805 flagged[component].to_bits(),
806 baseline[component].to_bits(),
807 "published BC already encodes form factor: component {component}, baseline={} flagged={}",
808 baseline[component],
809 flagged[component]
810 );
811 }
812 }
813
814 #[test]
815 fn inclined_positions_at_same_world_altitude_have_same_atmospheric_acceleration() {
816 let angle = std::f64::consts::FRAC_PI_6;
817 let mut inputs = create_test_inputs();
818 inputs.altitude = 100.0;
819 inputs.shooting_angle = angle;
820 let velocity = Vector3::new(600.0, 0.0, 0.0);
821 let along_slant = Vector3::new(1_000.0, 0.0, 0.0);
822 let across_slant = Vector3::new(0.0, 500.0 / angle.cos(), 0.0);
823 let atmo = (inputs.altitude, 15.0, 1013.25, 1.0);
824
825 let a = compute_derivatives(
826 along_slant,
827 velocity,
828 &inputs,
829 Vector3::zeros(),
830 atmo,
831 inputs.bc_value,
832 None,
833 0.0,
834 None,
835 );
836 let b = compute_derivatives(
837 across_slant,
838 velocity,
839 &inputs,
840 Vector3::zeros(),
841 atmo,
842 inputs.bc_value,
843 None,
844 0.0,
845 None,
846 );
847
848 for component in 3..6 {
849 assert!(
850 (a[component] - b[component]).abs() < 1e-10,
851 "derivative component {component} differs at equal world altitude: {} vs {}",
852 a[component],
853 b[component]
854 );
855 }
856 }
857
858 #[test]
859 fn inclined_headwind_is_rotated_into_solver_frame() {
860 let angle = std::f64::consts::FRAC_PI_6;
861 let mut inputs = create_test_inputs();
862 inputs.shooting_angle = angle;
863 let level_headwind = Vector3::new(-100.0, 0.0, 0.0);
864 let velocity = expected_shot_frame_vector(level_headwind, angle);
865 let actual = compute_derivatives(
866 Vector3::zeros(),
867 velocity,
868 &inputs,
869 level_headwind,
870 (1.225, 340.0, 0.0, 0.0),
871 inputs.bc_value,
872 None,
873 0.0,
874 None,
875 );
876 let expected = Vector3::new(
877 -G_ACCEL_MPS2 * angle.sin(),
878 -G_ACCEL_MPS2 * angle.cos(),
879 0.0,
880 );
881
882 assert!(
883 (Vector3::new(actual[3], actual[4], actual[5]) - expected).norm() < 1e-12,
884 "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
885 );
886 }
887
888 #[test]
889 fn inclined_coriolis_is_rotated_into_solver_frame() {
890 let angle = std::f64::consts::FRAC_PI_6;
891 let mut inputs = create_test_inputs();
892 inputs.shooting_angle = angle;
893 let velocity = Vector3::new(600.0, 20.0, 5.0);
894 let level_omega = Vector3::new(3.0e-5, 6.0e-5, -2.0e-5);
895 let run = |omega| {
896 compute_derivatives(
897 Vector3::zeros(),
898 velocity,
899 &inputs,
900 Vector3::zeros(),
901 (1.225, 340.0, 0.0, 0.0),
902 inputs.bc_value,
903 omega,
904 0.0,
905 None,
906 )
907 };
908 let baseline = run(None);
909 let with_coriolis = run(Some(level_omega));
910 let actual = Vector3::new(
911 with_coriolis[3] - baseline[3],
912 with_coriolis[4] - baseline[4],
913 with_coriolis[5] - baseline[5],
914 );
915 let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
916
917 assert!(
918 (actual - expected).norm() < 1e-12,
919 "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
920 );
921 }
922
923 #[test]
924 fn explicit_velocity_segment_gap_uses_scalar_bc_without_auto_estimation() {
925 let mut inputs = create_test_inputs();
926 inputs.bc_value = 0.91;
927 inputs.use_bc_segments = true;
928 inputs.bc_segments_data = Some(vec![
929 crate::BCSegmentData {
930 velocity_min: 0.0,
931 velocity_max: 999.0,
932 bc_value: 0.2,
933 },
934 crate::BCSegmentData {
935 velocity_min: 1000.0,
936 velocity_max: 2000.0,
937 bc_value: 0.3,
938 },
939 ]);
940
941 assert_eq!(
942 get_bc_for_velocity(999.5, &inputs, inputs.bc_value),
943 inputs.bc_value,
944 "an explicit table gap must not be replaced by an auto-estimated segment"
945 );
946 }
947
948 #[test]
949 fn test_mba955_bc_segments_prepopulate_byte_identical() {
950 let mut slow = create_test_inputs();
957 slow.use_bc_segments = true;
958 slow.bc_segments_data = None;
959 slow.bc_segments = None;
960
961 let bc_used = slow.bc_value;
962 let mut fast = slow.clone();
963 fast.bc_segments_data = estimate_bc_segments_for(&fast, bc_used);
964 assert!(
965 fast.bc_segments_data.is_some(),
966 "estimation should yield segments for a valid bullet"
967 );
968
969 for v in (200..=3500).step_by(50) {
970 let vf = v as f64;
971 let a = get_bc_for_velocity(vf, &slow, bc_used);
972 let b = get_bc_for_velocity(vf, &fast, bc_used);
973 assert_eq!(
974 a.to_bits(),
975 b.to_bits(),
976 "BC differs at {vf} fps: slow={a} fast={b}"
977 );
978 }
979 }
980
981 #[test]
982 fn estimated_segments_inherit_typed_g7_drag_model() {
983 let mut inputs = create_test_inputs();
984 inputs.bc_value = 0.243;
985 inputs.bc_type = crate::DragModel::G7;
986 inputs.bc_type_str = None;
987 inputs.bullet_mass = 175.0 * crate::constants::GRAINS_TO_KG;
988 inputs.weight_grains = 175.0;
989
990 let actual = estimate_bc_segments_for(&inputs, inputs.bc_value).unwrap();
991 let expected = BCSegmentEstimator::estimate_bc_segments(
992 inputs.bc_value,
993 inputs.caliber_inches,
994 inputs.weight_grains,
995 "175gr bullet",
996 "G7",
997 );
998
999 assert_eq!(actual.len(), expected.len());
1000 for (actual, expected) in actual.iter().zip(&expected) {
1001 assert_eq!(actual.velocity_min.to_bits(), expected.velocity_min.to_bits());
1002 assert_eq!(actual.velocity_max.to_bits(), expected.velocity_max.to_bits());
1003 assert_eq!(actual.bc_value.to_bits(), expected.bc_value.to_bits());
1004 }
1005
1006 inputs.bc_type_str = Some("G1".to_string());
1008 let legacy = estimate_bc_segments_for(&inputs, inputs.bc_value).unwrap();
1009 let expected_g1 = BCSegmentEstimator::estimate_bc_segments(
1010 inputs.bc_value,
1011 inputs.caliber_inches,
1012 inputs.weight_grains,
1013 "175gr bullet",
1014 "G1",
1015 );
1016 assert_eq!(legacy.len(), expected_g1.len());
1017 for (legacy, expected) in legacy.iter().zip(&expected_g1) {
1018 assert_eq!(legacy.bc_value.to_bits(), expected.bc_value.to_bits());
1019 }
1020 }
1021
1022 #[test]
1023 fn test_compute_derivatives_basic() {
1024 let pos = Vector3::new(0.0, 0.0, 0.0);
1025 let vel = Vector3::new(800.0, 0.0, 0.0);
1026 let inputs = create_test_inputs();
1027 let wind_vector = Vector3::zeros();
1028 let atmos_params = (1.225, 340.0, 0.0, 0.0); let bc_used = 0.5;
1031
1032 let result = compute_derivatives(
1033 pos,
1034 vel,
1035 &inputs,
1036 wind_vector,
1037 atmos_params,
1038 bc_used,
1039 None,
1040 0.0,
1041 None,
1042 );
1043
1044 assert_eq!(result.len(), 6);
1046
1047 assert!((result[0] - vel[0]).abs() < 1e-10);
1049 assert!((result[1] - vel[1]).abs() < 1e-10);
1050 assert!((result[2] - vel[2]).abs() < 1e-10);
1051
1052 assert!(result[4] < 0.0); assert!(result[3] < 0.0); }
1058
1059 #[test]
1060 fn standard_atmosphere_zero_ratio_uses_sea_level_fallback() {
1061 let pos = Vector3::new(0.0, 0.0, 0.0);
1062 let vel = Vector3::new(800.0, 0.0, 0.0);
1063 let inputs = create_test_inputs();
1064 let run = |base_ratio| {
1065 compute_derivatives(
1066 pos,
1067 vel,
1068 &inputs,
1069 Vector3::zeros(),
1070 (inputs.altitude, 15.0, 1013.25, base_ratio),
1071 inputs.bc_value,
1072 None,
1073 0.0,
1074 None,
1075 )
1076 };
1077
1078 let missing_ratio = run(0.0);
1079 let explicit_sea_level = run(1.0);
1080 assert!(
1081 missing_ratio[3] < 0.0,
1082 "missing standard density ratio must not produce vacuum drag"
1083 );
1084 assert!((missing_ratio[3] - explicit_sea_level[3]).abs() < 1e-12);
1085 }
1086
1087 #[test]
1088 fn test_compute_derivatives_with_wind() {
1089 let pos = Vector3::new(0.0, 0.0, 0.0);
1090 let vel = Vector3::new(800.0, 0.0, 0.0);
1091 let inputs = create_test_inputs();
1092 let wind_vector = Vector3::new(10.0, 0.0, 0.0); let atmos_params = (1.225, 340.0, 0.0, 0.0); let bc_used = 0.5;
1095
1096 let result = compute_derivatives(
1097 pos,
1098 vel,
1099 &inputs,
1100 wind_vector,
1101 atmos_params,
1102 bc_used,
1103 None,
1104 0.0,
1105 None,
1106 );
1107
1108 assert!(result[3] < 0.0); }
1112
1113 #[test]
1114 fn test_compute_derivatives_with_coriolis() {
1115 let pos = Vector3::new(0.0, 0.0, 0.0);
1116 let vel = Vector3::new(800.0, 0.0, 0.0);
1117 let inputs = create_test_inputs();
1118 let wind_vector = Vector3::zeros();
1119 let atmos_params = (1.225, 340.0, 0.0, 0.0); let bc_used = 0.5;
1121 let omega = Vector3::new(0.0, 0.0, 7.2921e-5); let result = compute_derivatives(
1124 pos,
1125 vel,
1126 &inputs,
1127 wind_vector,
1128 atmos_params,
1129 bc_used,
1130 Some(omega),
1131 0.0,
1132 None,
1133 );
1134
1135 assert!(result[4].abs() > 1e-3); }
1138
1139 #[test]
1140 fn test_interpolated_bc() {
1141 let segments = vec![(0.5, 0.4), (1.0, 0.5), (1.5, 0.6), (2.0, 0.5)];
1142
1143 assert!((interpolated_bc(1.0, &segments, None) - 0.5).abs() < 1e-10);
1145
1146 let bc_075 = interpolated_bc(0.75, &segments, None);
1148 assert!(bc_075 > 0.4 && bc_075 < 0.5);
1149
1150 assert!((interpolated_bc(0.1, &segments, None) - 0.4).abs() < 1e-10);
1152 assert!((interpolated_bc(3.0, &segments, None) - 0.5).abs() < 1e-10);
1153 }
1154
1155 #[test]
1156 fn test_interpolated_bc_edge_cases() {
1157 assert!(
1159 (interpolated_bc(1.0, &[], None) - crate::constants::BC_FALLBACK_CONSERVATIVE).abs()
1160 < 1e-10
1161 );
1162
1163 let mut inputs = create_test_inputs();
1164 inputs.bc_type = crate::DragModel::G7;
1165 inputs.bc_value = 0.487;
1166 assert_eq!(
1167 interpolated_bc(1.0, &[], Some(&inputs)).to_bits(),
1168 inputs.bc_value.to_bits(),
1169 "an empty optional table must preserve the caller's scalar BC"
1170 );
1171
1172 let single = vec![(1.0, 0.7)];
1174 assert!((interpolated_bc(1.5, &single, None) - 0.7).abs() < 1e-10);
1175 }
1176
1177 #[test]
1178 fn empty_mach_segments_preserve_active_bc_used() {
1179 let mut inputs = create_test_inputs();
1180 inputs.bc_type = crate::DragModel::G7;
1181 inputs.bc_value = 0.123; let drag_acceleration = |inputs: &BallisticInputs| {
1184 compute_derivatives(
1185 Vector3::zeros(),
1186 Vector3::new(700.0, 0.0, 0.0),
1187 inputs,
1188 Vector3::zeros(),
1189 (1.225, 340.0, 0.0, 0.0),
1190 0.487,
1191 None,
1192 0.0,
1193 None,
1194 )[3]
1195 };
1196
1197 inputs.use_bc_segments = false;
1198 inputs.bc_segments = None;
1199 let no_table = drag_acceleration(&inputs);
1200
1201 for use_bc_segments in [false, true] {
1202 inputs.use_bc_segments = use_bc_segments;
1203 inputs.bc_segments = Some(Vec::new());
1204 let empty_table = drag_acceleration(&inputs);
1205
1206 assert_eq!(
1207 empty_table.to_bits(),
1208 no_table.to_bits(),
1209 "Some(empty) must preserve bc_used when use_bc_segments={use_bc_segments}"
1210 );
1211 }
1212 }
1213
1214 #[test]
1215 fn test_magnus_effect() {
1216 let pos = Vector3::new(0.0, 0.0, 0.0);
1217 let vel = Vector3::new(822.96, 0.0, 0.0); let wind_vector = Vector3::zeros();
1219 let atmos_params = (1.225, 340.0, 0.0, 0.0); let bc_used = 0.5;
1221 let acceleration = |enable_magnus, is_twist_right| {
1222 let mut inputs = create_test_inputs();
1223 inputs.twist_rate = 10.0; inputs.is_twist_right = is_twist_right;
1225 inputs.enable_magnus = enable_magnus; let result = compute_derivatives(
1228 pos,
1229 vel,
1230 &inputs,
1231 wind_vector,
1232 atmos_params,
1233 bc_used,
1234 None,
1235 0.0,
1236 None,
1237 );
1238 Vector3::new(result[3], result[4], result[5])
1239 };
1240
1241 let baseline = acceleration(false, true);
1242 let right_twist = acceleration(true, true) - baseline;
1243 let left_twist = acceleration(true, false) - baseline;
1244
1245 assert!(
1249 right_twist.y < 0.0,
1250 "right-hand Magnus must point down, got {right_twist:?}"
1251 );
1252 assert!(
1253 left_twist.y > 0.0,
1254 "left-hand Magnus must point up, got {left_twist:?}"
1255 );
1256 assert!((right_twist.y + left_twist.y).abs() < 1e-12);
1257 assert!(right_twist.x.abs() < 1e-12 && right_twist.z.abs() < 1e-12);
1258 assert!(left_twist.x.abs() < 1e-12 && left_twist.z.abs() < 1e-12);
1259 assert!(
1260 right_twist.y.abs() < 0.05,
1261 "Magnus must remain a small force"
1262 );
1263 }
1264
1265 #[test]
1266 fn magnus_uses_velocity_corrected_muzzle_stability_gate() {
1267 let muzzle_velocity = 1_400.0 / MPS_TO_FPS;
1268 let mut inputs = create_test_inputs();
1269 inputs.muzzle_velocity = muzzle_velocity;
1270 inputs.twist_rate = 15.0;
1271 inputs.enable_magnus = true;
1272
1273 let bare_sg = crate::spin_drift::miller_stability(0.308, 168.0, 15.0, 1.215);
1274 let canonical_sg = crate::spin_drift::effective_sg_from_inputs(&inputs, 15.0, 1013.25);
1275 assert!(bare_sg > 1.0, "test requires bare Sg above the Magnus gate");
1276 assert!(
1277 canonical_sg < 1.0,
1278 "velocity-corrected Sg must be below the gate, got {canonical_sg}"
1279 );
1280
1281 let acceleration = |inputs: &BallisticInputs| {
1282 let result = compute_derivatives(
1283 Vector3::zeros(),
1284 Vector3::new(muzzle_velocity, 0.0, 0.0),
1285 inputs,
1286 Vector3::zeros(),
1287 (1.225, 340.0, 0.0, 0.0),
1288 0.5,
1289 None,
1290 0.0,
1291 None,
1292 );
1293 Vector3::new(result[3], result[4], result[5])
1294 };
1295 let enabled = acceleration(&inputs);
1296 inputs.enable_magnus = false;
1297 let disabled = acceleration(&inputs);
1298
1299 assert_eq!(
1300 enabled, disabled,
1301 "canonical Sg below 1 must suppress every Magnus acceleration component"
1302 );
1303 }
1304
1305 #[test]
1306 fn magnus_force_grows_as_fixed_spin_projectile_slows() {
1307 let mut inputs = create_test_inputs();
1308 inputs.muzzle_velocity = 800.0;
1309 inputs.twist_rate = 12.0;
1310 inputs.enable_magnus = true;
1311
1312 let magnus_acceleration = |speed_mps| {
1313 let evaluate = |enable_magnus| {
1314 let mut run_inputs = inputs.clone();
1315 run_inputs.enable_magnus = enable_magnus;
1316 compute_derivatives(
1317 Vector3::zeros(),
1318 Vector3::new(speed_mps, 0.0, 0.0),
1319 &run_inputs,
1320 Vector3::zeros(),
1321 (1.225, 340.0, 0.0, 0.0),
1322 0.5,
1323 None,
1324 0.0,
1325 None,
1326 )[4]
1327 };
1328 (evaluate(true) - evaluate(false)).abs()
1329 };
1330
1331 let fast = magnus_acceleration(200.0);
1332 let slow = magnus_acceleration(100.0);
1333 let ratio = slow / fast;
1334 let expected_ratio = 2.0_f64.powf(5.0 / 3.0);
1335
1336 assert!(fast > 0.0 && slow > 0.0, "fast={fast}, slow={slow}");
1337 assert!(
1338 (ratio - expected_ratio).abs() < 1e-3,
1339 "fixed-spin Magnus acceleration must grow downrange; slow/fast={ratio}, \
1340 expected={expected_ratio}"
1341 );
1342 }
1343
1344 #[test]
1345 fn test_magnus_moment_coefficient() {
1346 assert!((calculate_magnus_moment_coefficient(0.5) - 0.030).abs() < 0.001); assert!((calculate_magnus_moment_coefficient(0.8) - 0.030).abs() < 0.001); assert!((calculate_magnus_moment_coefficient(1.0) - 0.0225).abs() < 0.001); assert!((calculate_magnus_moment_coefficient(1.2) - 0.015).abs() < 0.001); assert!((calculate_magnus_moment_coefficient(2.0) - 0.01653).abs() < 0.001);
1352 }
1354
1355 fn deck_test_inputs(cd_scale: f64) -> BallisticInputs {
1357 BallisticInputs {
1358 custom_drag_table: Some(crate::drag::DragTable::new(
1359 vec![0.5, 1.0, 2.0, 3.0],
1360 vec![0.23, 0.40, 0.30, 0.26],
1361 )),
1362 cd_scale,
1363 ..create_test_inputs()
1364 }
1365 }
1366
1367 fn deck_accel_x(cd_scale: f64) -> f64 {
1368 let inputs = deck_test_inputs(cd_scale);
1369 compute_derivatives(
1370 Vector3::zeros(),
1371 Vector3::new(700.0, 0.0, 0.0),
1372 &inputs,
1373 Vector3::zeros(),
1374 (1.225, 340.0, 0.0, 0.0),
1375 inputs.bc_value,
1376 None,
1377 0.0,
1378 None,
1379 )[3]
1380 }
1381
1382 #[test]
1383 fn cd_scale_default_is_one_and_absent_matches_explicit() {
1384 assert_eq!(BallisticInputs::default().cd_scale, 1.0);
1385
1386 let omitted = BallisticInputs {
1387 custom_drag_table: Some(crate::drag::DragTable::new(
1388 vec![0.5, 1.0, 2.0, 3.0],
1389 vec![0.23, 0.40, 0.30, 0.26],
1390 )),
1391 ..create_test_inputs()
1392 };
1393 assert_eq!(omitted.cd_scale, 1.0);
1394
1395 let a_omitted = compute_derivatives(
1396 Vector3::zeros(),
1397 Vector3::new(700.0, 0.0, 0.0),
1398 &omitted,
1399 Vector3::zeros(),
1400 (1.225, 340.0, 0.0, 0.0),
1401 omitted.bc_value,
1402 None,
1403 0.0,
1404 None,
1405 );
1406 let a_explicit = deck_accel_x(1.0);
1407 assert_eq!(
1408 a_omitted[3].to_bits(),
1409 a_explicit.to_bits(),
1410 "omitted cd_scale (Default) must be bit-identical to an explicit 1.0"
1411 );
1412 }
1413
1414 #[test]
1417 fn cd_scale_direction_on_derivatives_kernel() {
1418 let baseline = deck_accel_x(1.0);
1419 let scaled_up = deck_accel_x(1.10);
1420 let scaled_down = deck_accel_x(0.90);
1421
1422 assert!(
1423 scaled_up < baseline,
1424 "cd_scale=1.10 must increase drag deceleration (more negative ax): \
1425 base={baseline} up={scaled_up}"
1426 );
1427 assert!(
1428 scaled_down > baseline,
1429 "cd_scale=0.90 must decrease drag deceleration (less negative ax): \
1430 base={baseline} down={scaled_down}"
1431 );
1432 }
1433
1434 #[test]
1436 fn cd_scale_is_inert_without_a_custom_drag_table() {
1437 let make = |cd_scale: f64| BallisticInputs {
1438 cd_scale,
1439 ..create_test_inputs()
1440 };
1441 let accel = |cd_scale: f64| {
1442 let inputs = make(cd_scale);
1443 compute_derivatives(
1444 Vector3::zeros(),
1445 Vector3::new(700.0, 0.0, 0.0),
1446 &inputs,
1447 Vector3::zeros(),
1448 (1.225, 340.0, 0.0, 0.0),
1449 inputs.bc_value,
1450 None,
1451 0.0,
1452 None,
1453 )[3]
1454 };
1455 assert_eq!(
1456 accel(1.0).to_bits(),
1457 accel(1.5).to_bits(),
1458 "cd_scale must not affect the G-model/BC drag path"
1459 );
1460 }
1461}