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
294 .bc_segments_data
295 .as_ref()
296 .is_some_and(|segments| !segments.is_empty())
297 {
298 bc_val = get_bc_for_velocity(v_rel_fps, inputs, bc_used);
299 } else {
300 match inputs.bc_segments.as_deref() {
301 Some(segments) if !segments.is_empty() => {
303 bc_val = interpolated_bc(mach, segments, Some(inputs));
304 }
305 Some(_) => {}
307 None => bc_val = get_bc_for_velocity(v_rel_fps, inputs, bc_used),
309 }
310 }
311 } else if let Some(segments) = inputs
312 .bc_segments
313 .as_deref()
314 .filter(|segments| !segments.is_empty())
315 {
316 bc_val = interpolated_bc(mach, segments, Some(inputs));
318 }
319
320 let bc_val = bc_val.max(1e-6);
326
327 let yaw_rad = if inputs.tipoff_decay_distance.abs() > 1e-9 {
329 inputs.tipoff_yaw * (-pos[0] / inputs.tipoff_decay_distance).exp()
330 } else {
331 inputs.tipoff_yaw };
333 let density_scale = air_density / STANDARD_AIR_DENSITY;
343
344 let caliber_in = if inputs.caliber_inches > 0.0 {
357 inputs.caliber_inches
358 } else {
359 inputs.bullet_diameter / 0.0254 };
361 let weight_gr = if inputs.weight_grains > 0.0 {
362 inputs.weight_grains
363 } else {
364 inputs.bullet_mass / crate::constants::GRAINS_TO_KG };
366 let shape = crate::transonic_drag::resolve_projectile_shape(
369 inputs.bullet_model.as_deref(),
370 caliber_in,
371 weight_gr,
372 &inputs.bc_type.to_string(),
373 );
374 let drag_factor =
375 crate::transonic_drag::transonic_correction(mach, drag_factor, shape, false);
376
377 let (drag_factor, retard_denom) = match inputs.custom_drag_table {
394 Some(ref table) => (
395 table.interpolate(mach) * inputs.cd_scale,
400 inputs.custom_drag_denominator(bc_val),
401 ),
402 None => (drag_factor, bc_val),
403 };
404
405 let standard_factor = drag_factor * CD_TO_RETARD;
407 let mut a_drag_ft_s2 = (v_rel_fps.powi(2) * standard_factor * density_scale) / retard_denom;
408 if yaw_rad != 0.0 {
413 if let Some(sd) = inputs.sectional_density_lb_in2() {
414 if sd > 0.0 {
415 a_drag_ft_s2 += v_rel_fps.powi(2)
416 * CD_TO_RETARD
417 * density_scale
418 * (inputs.cd_delta2 * yaw_rad.powi(2) / sd);
419 }
420 }
421 }
422 let a_drag_m_s2 = a_drag_ft_s2 * FPS_TO_MPS;
423
424 accel_drag = -a_drag_m_s2 * (velocity_adjusted / speed_air);
426
427 if inputs.enable_magnus
435 && !(inputs.use_enhanced_spin_drift && inputs.enable_advanced_effects)
436 && inputs.bullet_diameter > 0.0
437 && inputs.twist_rate > 0.0
438 {
439 let diameter_m = inputs.bullet_diameter;
440 let (spin_rate_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
441 inputs.muzzle_velocity,
442 speed_air,
443 inputs.twist_rate,
444 diameter_m,
445 );
446
447 let c_np = calculate_magnus_moment_coefficient(mach);
448
449 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
451
452 let d_in = inputs.caliber_inches;
456 let m_gr = inputs.weight_grains;
457 let l_in = if inputs.bullet_length > 0.0 {
458 inputs.bullet_length / 0.0254 } else {
460 let est_m = crate::stability::estimate_bullet_length_m(
462 inputs.bullet_diameter,
463 inputs.bullet_mass,
464 );
465 if est_m > 0.0 {
466 est_m / 0.0254
467 } else {
468 4.5 * d_in.max(1e-9)
469 }
470 };
471 let sg = crate::spin_drift::calculate_dynamic_stability(
476 m_gr,
477 speed_air,
478 spin_rate_rad_s,
479 d_in,
480 l_in,
481 air_density,
482 );
483 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
484 sg,
485 speed_air,
486 spin_rate_rad_s,
487 0.0,
488 0.0,
489 air_density,
490 d_in,
491 l_in,
492 m_gr,
493 mach,
494 "match",
495 false,
496 );
497
498 let magnus_force_magnitude =
500 0.5 * air_density * speed_air.powi(2) * area * c_np * spin_param * yaw_rad.sin();
501
502 if magnus_force_magnitude > 1e-12 {
505 if let Some(magnus_direction) = yaw_of_repose_magnus_direction(
506 velocity_adjusted,
507 accel_gravity,
508 inputs.is_twist_right,
509 ) {
510 let bullet_mass_kg = inputs.bullet_mass; accel_magnus = (magnus_force_magnitude / bullet_mass_kg) * magnus_direction;
512 }
513 }
514 }
515 }
516
517 let mut accel = accel_gravity + accel_drag + accel_magnus;
519
520 if let Some(omega) = omega_vector {
525 let omega = level_vector_to_shot_frame(omega, theta);
526 let accel_coriolis = -2.0 * omega.cross(&vel);
527 accel += accel_coriolis;
528 }
529
530 [vel[0], vel[1], vel[2], accel[0], accel[1], accel[2]]
543}
544
545pub fn interpolated_bc(
551 mach: f64,
552 segments: &[(f64, f64)],
553 inputs: Option<&BallisticInputs>,
554) -> f64 {
555 if segments.is_empty() {
556 if let Some(inputs) = inputs {
557 return inputs.bc_value;
558 }
559 return crate::constants::BC_FALLBACK_CONSERVATIVE;
560 }
561
562 if segments.len() == 1 {
563 return segments[0].1;
564 }
565
566 let sorted_segments: std::borrow::Cow<[(f64, f64)]> =
570 if segments.windows(2).all(|w| w[0].0 <= w[1].0) {
571 std::borrow::Cow::Borrowed(segments)
572 } else {
573 let mut v = segments.to_vec();
574 v.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
575 std::borrow::Cow::Owned(v)
576 };
577
578 if mach <= sorted_segments[0].0 {
580 return sorted_segments[0].1;
581 }
582 if mach >= sorted_segments[sorted_segments.len() - 1].0 {
583 return sorted_segments[sorted_segments.len() - 1].1;
584 }
585
586 let idx = sorted_segments.partition_point(|(m, _)| *m <= mach);
588 if idx == 0 || idx >= sorted_segments.len() {
589 return sorted_segments[0].1;
591 }
592
593 let (mach1, bc1) = sorted_segments[idx - 1];
594 let (mach2, bc2) = sorted_segments[idx];
595
596 let denominator = mach2 - mach1;
598 if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
599 return bc1; }
601 let t = (mach - mach1) / denominator;
602 bc1 + t * (bc2 - bc1)
603}
604
605fn get_bc_for_velocity(velocity_fps: f64, inputs: &BallisticInputs, bc_used: f64) -> f64 {
607 if !inputs.use_bc_segments {
609 return bc_used;
610 }
611
612 if let Some(bc_segments_data) = inputs
614 .bc_segments_data
615 .as_ref()
616 .filter(|segments| !segments.is_empty())
617 {
618 return velocity_segment_bc(velocity_fps, bc_segments_data, bc_used);
621 }
622
623 if let Some(segments) = estimate_bc_segments_for(inputs, bc_used) {
627 return velocity_segment_bc(velocity_fps, &segments, bc_used);
628 }
629
630 bc_used
632}
633
634pub(crate) fn estimate_bc_segments_for(
641 inputs: &BallisticInputs,
642 bc_used: f64,
643) -> Option<Vec<crate::BCSegmentData>> {
644 if !(inputs.bullet_diameter > 0.0 && inputs.bullet_mass > 0.0 && bc_used > 0.0) {
645 return None;
646 }
647 let model = if let Some(ref bullet_id) = inputs.bullet_id {
649 bullet_id.clone()
650 } else {
651 format!("{}gr bullet", inputs.weight_grains as i32)
652 };
653 let bc_type_str = inputs
656 .bc_type_str
657 .as_deref()
658 .unwrap_or(match inputs.bc_type {
659 crate::DragModel::G7 => "G7",
660 _ => "G1",
661 });
662 Some(BCSegmentEstimator::estimate_bc_segments(
663 bc_used,
664 inputs.caliber_inches,
665 inputs.weight_grains,
666 &model,
667 bc_type_str,
668 ))
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674
675 fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
676 let (sin_angle, cos_angle) = angle.sin_cos();
677 Vector3::new(
678 level.x * cos_angle + level.y * sin_angle,
679 -level.x * sin_angle + level.y * cos_angle,
680 level.z,
681 )
682 }
683
684 #[test]
685 fn level_vector_projection_is_bit_exact_at_zero_incline() {
686 let level = Vector3::new(12.5, -0.0, -7.25);
687 let projected = level_vector_to_shot_frame(level, 0.0);
688
689 for component in 0..3 {
690 assert_eq!(projected[component].to_bits(), level[component].to_bits());
691 }
692 }
693
694 #[test]
695 fn dry_air_sound_speed_round_trips_to_local_celsius() {
696 for expected_temp_c in [-40.0_f64, 15.0, 40.0] {
697 let sound_speed = (1.4 * 287.05 * (expected_temp_c + 273.15)).sqrt();
698 let actual_temp_c = dry_air_temperature_c_from_sound_speed(sound_speed);
699 assert!(
700 (actual_temp_c - expected_temp_c).abs() < 1e-10,
701 "sound speed {sound_speed} m/s recovered {actual_temp_c} C, expected {expected_temp_c} C"
702 );
703 }
704
705 let direct_mode_temp_c = dry_air_temperature_c_from_sound_speed(340.0);
706 assert!(
707 direct_mode_temp_c > 10.0 && direct_mode_temp_c < 20.0,
708 "direct-mode 340 m/s must be interpreted as sound speed, not 340 C"
709 );
710 }
711
712 #[test]
713 fn tipoff_yaw_uses_documented_radians_for_drag_and_decay() {
714 let mut baseline_inputs = create_test_inputs();
715 baseline_inputs.tipoff_yaw = 0.0;
716 baseline_inputs.tipoff_decay_distance = 50.0;
717 let mut yawed_inputs = baseline_inputs.clone();
718 yawed_inputs.tipoff_yaw = 0.1; let drag_x = |inputs: &BallisticInputs, downrange_m: f64| {
721 compute_derivatives(
722 Vector3::new(downrange_m, 0.0, 0.0),
723 Vector3::new(300.0, 0.0, 0.0),
724 inputs,
725 Vector3::zeros(),
726 (1.225, 340.0, 0.0, 0.0),
727 0.5,
728 None,
729 0.0,
730 None,
731 )[3]
732 };
733
734 let sd = baseline_inputs
741 .sectional_density_lb_in2()
742 .expect("test inputs carry mass/diameter");
743 let v_fps = 300.0 * MPS_TO_FPS; let density_scale = 1.225 / STANDARD_AIR_DENSITY;
745
746 for (downrange_m, decay_scale) in [(0.0_f64, 1.0_f64), (50.0 * std::f64::consts::LN_2, 0.5)]
747 {
748 let delta_eff = 0.1 * decay_scale;
749 let expected_extra_mps2 = v_fps.powi(2)
750 * CD_TO_RETARD
751 * density_scale
752 * (yawed_inputs.cd_delta2 * delta_eff.powi(2) / sd)
753 * FPS_TO_MPS;
754
755 let baseline_drag = drag_x(&baseline_inputs, downrange_m);
756 let yawed_drag = drag_x(&yawed_inputs, downrange_m);
757 assert!(baseline_drag < 0.0, "baseline must be downrange drag");
758 let measured_extra = baseline_drag - yawed_drag; assert!(
761 (measured_extra - expected_extra_mps2).abs() <= 1e-9 * expected_extra_mps2,
762 "additive yaw drag at x={downrange_m} m: measured extra {measured_extra:.9} \
763 != expected {expected_extra_mps2:.9}"
764 );
765 }
766 }
767
768 fn create_test_inputs() -> BallisticInputs {
769 BallisticInputs {
773 muzzle_velocity: 800.0, bc_value: 0.5,
775 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,
779 weight_grains: 168.0,
780 altitude: 1000.0,
781 ..Default::default()
782 }
783 }
784
785 #[test]
786 fn measured_bc_drag_ignores_name_based_form_factor_flag() {
787 let derivatives_with_flag = |use_form_factor| {
788 let inputs = BallisticInputs {
789 bc_value: 0.462,
790 bc_type: crate::DragModel::G1,
791 bullet_model: Some("168gr SMK Match".to_string()),
792 use_form_factor,
793 ..create_test_inputs()
794 };
795
796 compute_derivatives(
797 Vector3::zeros(),
798 Vector3::new(600.0, 0.0, 0.0),
799 &inputs,
800 Vector3::zeros(),
801 (1.225, 340.0, 0.0, 0.0),
802 inputs.bc_value,
803 None,
804 0.0,
805 None,
806 )
807 };
808
809 let baseline = derivatives_with_flag(false);
810 let flagged = derivatives_with_flag(true);
811
812 for component in 3..6 {
813 assert_eq!(
814 flagged[component].to_bits(),
815 baseline[component].to_bits(),
816 "published BC already encodes form factor: component {component}, baseline={} flagged={}",
817 baseline[component],
818 flagged[component]
819 );
820 }
821 }
822
823 #[test]
824 fn inclined_positions_at_same_world_altitude_have_same_atmospheric_acceleration() {
825 let angle = std::f64::consts::FRAC_PI_6;
826 let mut inputs = create_test_inputs();
827 inputs.altitude = 100.0;
828 inputs.shooting_angle = angle;
829 let velocity = Vector3::new(600.0, 0.0, 0.0);
830 let along_slant = Vector3::new(1_000.0, 0.0, 0.0);
831 let across_slant = Vector3::new(0.0, 500.0 / angle.cos(), 0.0);
832 let atmo = (inputs.altitude, 15.0, 1013.25, 1.0);
833
834 let a = compute_derivatives(
835 along_slant,
836 velocity,
837 &inputs,
838 Vector3::zeros(),
839 atmo,
840 inputs.bc_value,
841 None,
842 0.0,
843 None,
844 );
845 let b = compute_derivatives(
846 across_slant,
847 velocity,
848 &inputs,
849 Vector3::zeros(),
850 atmo,
851 inputs.bc_value,
852 None,
853 0.0,
854 None,
855 );
856
857 for component in 3..6 {
858 assert!(
859 (a[component] - b[component]).abs() < 1e-10,
860 "derivative component {component} differs at equal world altitude: {} vs {}",
861 a[component],
862 b[component]
863 );
864 }
865 }
866
867 #[test]
868 fn inclined_headwind_is_rotated_into_solver_frame() {
869 let angle = std::f64::consts::FRAC_PI_6;
870 let mut inputs = create_test_inputs();
871 inputs.shooting_angle = angle;
872 let level_headwind = Vector3::new(-100.0, 0.0, 0.0);
873 let velocity = expected_shot_frame_vector(level_headwind, angle);
874 let actual = compute_derivatives(
875 Vector3::zeros(),
876 velocity,
877 &inputs,
878 level_headwind,
879 (1.225, 340.0, 0.0, 0.0),
880 inputs.bc_value,
881 None,
882 0.0,
883 None,
884 );
885 let expected = Vector3::new(
886 -G_ACCEL_MPS2 * angle.sin(),
887 -G_ACCEL_MPS2 * angle.cos(),
888 0.0,
889 );
890
891 assert!(
892 (Vector3::new(actual[3], actual[4], actual[5]) - expected).norm() < 1e-12,
893 "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
894 );
895 }
896
897 #[test]
898 fn inclined_coriolis_is_rotated_into_solver_frame() {
899 let angle = std::f64::consts::FRAC_PI_6;
900 let mut inputs = create_test_inputs();
901 inputs.shooting_angle = angle;
902 let velocity = Vector3::new(600.0, 20.0, 5.0);
903 let level_omega = Vector3::new(3.0e-5, 6.0e-5, -2.0e-5);
904 let run = |omega| {
905 compute_derivatives(
906 Vector3::zeros(),
907 velocity,
908 &inputs,
909 Vector3::zeros(),
910 (1.225, 340.0, 0.0, 0.0),
911 inputs.bc_value,
912 omega,
913 0.0,
914 None,
915 )
916 };
917 let baseline = run(None);
918 let with_coriolis = run(Some(level_omega));
919 let actual = Vector3::new(
920 with_coriolis[3] - baseline[3],
921 with_coriolis[4] - baseline[4],
922 with_coriolis[5] - baseline[5],
923 );
924 let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
925
926 assert!(
927 (actual - expected).norm() < 1e-12,
928 "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
929 );
930 }
931
932 #[test]
933 fn explicit_velocity_segment_gap_uses_scalar_bc_without_auto_estimation() {
934 let mut inputs = create_test_inputs();
935 inputs.bc_value = 0.91;
936 inputs.use_bc_segments = true;
937 inputs.bc_segments_data = Some(vec![
938 crate::BCSegmentData {
939 velocity_min: 0.0,
940 velocity_max: 999.0,
941 bc_value: 0.2,
942 },
943 crate::BCSegmentData {
944 velocity_min: 1000.0,
945 velocity_max: 2000.0,
946 bc_value: 0.3,
947 },
948 ]);
949
950 assert_eq!(
951 get_bc_for_velocity(999.5, &inputs, inputs.bc_value),
952 inputs.bc_value,
953 "an explicit table gap must not be replaced by an auto-estimated segment"
954 );
955 }
956
957 #[test]
958 fn test_mba955_bc_segments_prepopulate_byte_identical() {
959 let mut slow = create_test_inputs();
966 slow.use_bc_segments = true;
967 slow.bc_segments_data = None;
968 slow.bc_segments = None;
969
970 let bc_used = slow.bc_value;
971 let mut fast = slow.clone();
972 fast.bc_segments_data = estimate_bc_segments_for(&fast, bc_used);
973 assert!(
974 fast.bc_segments_data.is_some(),
975 "estimation should yield segments for a valid bullet"
976 );
977
978 for v in (200..=3500).step_by(50) {
979 let vf = v as f64;
980 let a = get_bc_for_velocity(vf, &slow, bc_used);
981 let b = get_bc_for_velocity(vf, &fast, bc_used);
982 assert_eq!(
983 a.to_bits(),
984 b.to_bits(),
985 "BC differs at {vf} fps: slow={a} fast={b}"
986 );
987 }
988 }
989
990 #[test]
991 fn estimated_segments_inherit_typed_g7_drag_model() {
992 let mut inputs = create_test_inputs();
993 inputs.bc_value = 0.243;
994 inputs.bc_type = crate::DragModel::G7;
995 inputs.bc_type_str = None;
996 inputs.bullet_mass = 175.0 * crate::constants::GRAINS_TO_KG;
997 inputs.weight_grains = 175.0;
998
999 let actual = estimate_bc_segments_for(&inputs, inputs.bc_value).unwrap();
1000 let expected = BCSegmentEstimator::estimate_bc_segments(
1001 inputs.bc_value,
1002 inputs.caliber_inches,
1003 inputs.weight_grains,
1004 "175gr bullet",
1005 "G7",
1006 );
1007
1008 assert_eq!(actual.len(), expected.len());
1009 for (actual, expected) in actual.iter().zip(&expected) {
1010 assert_eq!(
1011 actual.velocity_min.to_bits(),
1012 expected.velocity_min.to_bits()
1013 );
1014 assert_eq!(
1015 actual.velocity_max.to_bits(),
1016 expected.velocity_max.to_bits()
1017 );
1018 assert_eq!(actual.bc_value.to_bits(), expected.bc_value.to_bits());
1019 }
1020
1021 inputs.bc_type_str = Some("G1".to_string());
1023 let legacy = estimate_bc_segments_for(&inputs, inputs.bc_value).unwrap();
1024 let expected_g1 = BCSegmentEstimator::estimate_bc_segments(
1025 inputs.bc_value,
1026 inputs.caliber_inches,
1027 inputs.weight_grains,
1028 "175gr bullet",
1029 "G1",
1030 );
1031 assert_eq!(legacy.len(), expected_g1.len());
1032 for (legacy, expected) in legacy.iter().zip(&expected_g1) {
1033 assert_eq!(legacy.bc_value.to_bits(), expected.bc_value.to_bits());
1034 }
1035 }
1036
1037 #[test]
1038 fn test_compute_derivatives_basic() {
1039 let pos = Vector3::new(0.0, 0.0, 0.0);
1040 let vel = Vector3::new(800.0, 0.0, 0.0);
1041 let inputs = create_test_inputs();
1042 let wind_vector = Vector3::zeros();
1043 let atmos_params = (1.225, 340.0, 0.0, 0.0); let bc_used = 0.5;
1046
1047 let result = compute_derivatives(
1048 pos,
1049 vel,
1050 &inputs,
1051 wind_vector,
1052 atmos_params,
1053 bc_used,
1054 None,
1055 0.0,
1056 None,
1057 );
1058
1059 assert_eq!(result.len(), 6);
1061
1062 assert!((result[0] - vel[0]).abs() < 1e-10);
1064 assert!((result[1] - vel[1]).abs() < 1e-10);
1065 assert!((result[2] - vel[2]).abs() < 1e-10);
1066
1067 assert!(result[4] < 0.0); assert!(result[3] < 0.0); }
1073
1074 #[test]
1075 fn standard_atmosphere_zero_ratio_uses_sea_level_fallback() {
1076 let pos = Vector3::new(0.0, 0.0, 0.0);
1077 let vel = Vector3::new(800.0, 0.0, 0.0);
1078 let inputs = create_test_inputs();
1079 let run = |base_ratio| {
1080 compute_derivatives(
1081 pos,
1082 vel,
1083 &inputs,
1084 Vector3::zeros(),
1085 (inputs.altitude, 15.0, 1013.25, base_ratio),
1086 inputs.bc_value,
1087 None,
1088 0.0,
1089 None,
1090 )
1091 };
1092
1093 let missing_ratio = run(0.0);
1094 let explicit_sea_level = run(1.0);
1095 assert!(
1096 missing_ratio[3] < 0.0,
1097 "missing standard density ratio must not produce vacuum drag"
1098 );
1099 assert!((missing_ratio[3] - explicit_sea_level[3]).abs() < 1e-12);
1100 }
1101
1102 #[test]
1103 fn test_compute_derivatives_with_wind() {
1104 let pos = Vector3::new(0.0, 0.0, 0.0);
1105 let vel = Vector3::new(800.0, 0.0, 0.0);
1106 let inputs = create_test_inputs();
1107 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;
1110
1111 let result = compute_derivatives(
1112 pos,
1113 vel,
1114 &inputs,
1115 wind_vector,
1116 atmos_params,
1117 bc_used,
1118 None,
1119 0.0,
1120 None,
1121 );
1122
1123 assert!(result[3] < 0.0); }
1127
1128 #[test]
1129 fn test_compute_derivatives_with_coriolis() {
1130 let pos = Vector3::new(0.0, 0.0, 0.0);
1131 let vel = Vector3::new(800.0, 0.0, 0.0);
1132 let inputs = create_test_inputs();
1133 let wind_vector = Vector3::zeros();
1134 let atmos_params = (1.225, 340.0, 0.0, 0.0); let bc_used = 0.5;
1136 let omega = Vector3::new(0.0, 0.0, 7.2921e-5); let result = compute_derivatives(
1139 pos,
1140 vel,
1141 &inputs,
1142 wind_vector,
1143 atmos_params,
1144 bc_used,
1145 Some(omega),
1146 0.0,
1147 None,
1148 );
1149
1150 assert!(result[4].abs() > 1e-3); }
1153
1154 #[test]
1155 fn test_interpolated_bc() {
1156 let segments = vec![(0.5, 0.4), (1.0, 0.5), (1.5, 0.6), (2.0, 0.5)];
1157
1158 assert!((interpolated_bc(1.0, &segments, None) - 0.5).abs() < 1e-10);
1160
1161 let bc_075 = interpolated_bc(0.75, &segments, None);
1163 assert!(bc_075 > 0.4 && bc_075 < 0.5);
1164
1165 assert!((interpolated_bc(0.1, &segments, None) - 0.4).abs() < 1e-10);
1167 assert!((interpolated_bc(3.0, &segments, None) - 0.5).abs() < 1e-10);
1168 }
1169
1170 #[test]
1171 fn test_interpolated_bc_edge_cases() {
1172 assert!(
1174 (interpolated_bc(1.0, &[], None) - crate::constants::BC_FALLBACK_CONSERVATIVE).abs()
1175 < 1e-10
1176 );
1177
1178 let mut inputs = create_test_inputs();
1179 inputs.bc_type = crate::DragModel::G7;
1180 inputs.bc_value = 0.487;
1181 assert_eq!(
1182 interpolated_bc(1.0, &[], Some(&inputs)).to_bits(),
1183 inputs.bc_value.to_bits(),
1184 "an empty optional table must preserve the caller's scalar BC"
1185 );
1186
1187 let single = vec![(1.0, 0.7)];
1189 assert!((interpolated_bc(1.5, &single, None) - 0.7).abs() < 1e-10);
1190 }
1191
1192 #[test]
1193 fn empty_velocity_table_does_not_mask_mach_table() {
1194 let mut inputs = create_test_inputs();
1199 inputs.use_bc_segments = true;
1200 inputs.bc_segments = Some(vec![(0.0, 0.3), (3.0, 0.3)]);
1201 let drag_acceleration = |inputs: &BallisticInputs| {
1202 compute_derivatives(
1203 Vector3::zeros(),
1204 Vector3::new(700.0, 0.0, 0.0),
1205 inputs,
1206 Vector3::zeros(),
1207 (1.225, 340.0, 0.0, 0.0),
1208 inputs.bc_value,
1209 None,
1210 0.0,
1211 None,
1212 )[3]
1213 };
1214 inputs.bc_segments_data = None;
1215 let absent = drag_acceleration(&inputs);
1216 inputs.bc_segments_data = Some(Vec::new());
1217 let empty = drag_acceleration(&inputs);
1218 assert_eq!(
1219 empty.to_bits(),
1220 absent.to_bits(),
1221 "Some(empty) velocity data must not mask the Mach table"
1222 );
1223 }
1224
1225 #[test]
1226 fn empty_velocity_and_mach_tables_fly_the_scalar_bc() {
1227 let mut inputs = create_test_inputs();
1231 inputs.use_bc_segments = true;
1232 inputs.bc_segments = Some(Vec::new());
1233 let drag_acceleration = |inputs: &BallisticInputs| {
1234 compute_derivatives(
1235 Vector3::zeros(),
1236 Vector3::new(700.0, 0.0, 0.0),
1237 inputs,
1238 Vector3::zeros(),
1239 (1.225, 340.0, 0.0, 0.0),
1240 inputs.bc_value,
1241 None,
1242 0.0,
1243 None,
1244 )[3]
1245 };
1246 inputs.bc_segments_data = None;
1247 let absent = drag_acceleration(&inputs);
1248 inputs.bc_segments_data = Some(Vec::new());
1249 let empty = drag_acceleration(&inputs);
1250 assert_eq!(
1251 empty.to_bits(),
1252 absent.to_bits(),
1253 "Some(empty) velocity data beside an empty Mach table must fly the scalar BC"
1254 );
1255 }
1256
1257 #[test]
1258 fn empty_mach_segments_preserve_active_bc_used() {
1259 let mut inputs = create_test_inputs();
1260 inputs.bc_type = crate::DragModel::G7;
1261 inputs.bc_value = 0.123; let drag_acceleration = |inputs: &BallisticInputs| {
1264 compute_derivatives(
1265 Vector3::zeros(),
1266 Vector3::new(700.0, 0.0, 0.0),
1267 inputs,
1268 Vector3::zeros(),
1269 (1.225, 340.0, 0.0, 0.0),
1270 0.487,
1271 None,
1272 0.0,
1273 None,
1274 )[3]
1275 };
1276
1277 inputs.use_bc_segments = false;
1278 inputs.bc_segments = None;
1279 let no_table = drag_acceleration(&inputs);
1280
1281 for use_bc_segments in [false, true] {
1282 inputs.use_bc_segments = use_bc_segments;
1283 inputs.bc_segments = Some(Vec::new());
1284 let empty_table = drag_acceleration(&inputs);
1285
1286 assert_eq!(
1287 empty_table.to_bits(),
1288 no_table.to_bits(),
1289 "Some(empty) must preserve bc_used when use_bc_segments={use_bc_segments}"
1290 );
1291 }
1292 }
1293
1294 #[test]
1295 fn test_magnus_effect() {
1296 let pos = Vector3::new(0.0, 0.0, 0.0);
1297 let vel = Vector3::new(822.96, 0.0, 0.0); let wind_vector = Vector3::zeros();
1299 let atmos_params = (1.225, 340.0, 0.0, 0.0); let bc_used = 0.5;
1301 let acceleration = |enable_magnus, is_twist_right| {
1302 let mut inputs = create_test_inputs();
1303 inputs.twist_rate = 10.0; inputs.is_twist_right = is_twist_right;
1305 inputs.enable_magnus = enable_magnus; let result = compute_derivatives(
1308 pos,
1309 vel,
1310 &inputs,
1311 wind_vector,
1312 atmos_params,
1313 bc_used,
1314 None,
1315 0.0,
1316 None,
1317 );
1318 Vector3::new(result[3], result[4], result[5])
1319 };
1320
1321 let baseline = acceleration(false, true);
1322 let right_twist = acceleration(true, true) - baseline;
1323 let left_twist = acceleration(true, false) - baseline;
1324
1325 assert!(
1329 right_twist.y < 0.0,
1330 "right-hand Magnus must point down, got {right_twist:?}"
1331 );
1332 assert!(
1333 left_twist.y > 0.0,
1334 "left-hand Magnus must point up, got {left_twist:?}"
1335 );
1336 assert!((right_twist.y + left_twist.y).abs() < 1e-12);
1337 assert!(right_twist.x.abs() < 1e-12 && right_twist.z.abs() < 1e-12);
1338 assert!(left_twist.x.abs() < 1e-12 && left_twist.z.abs() < 1e-12);
1339 assert!(
1340 right_twist.y.abs() < 0.05,
1341 "Magnus must remain a small force"
1342 );
1343 }
1344
1345 #[test]
1346 fn magnus_uses_velocity_corrected_muzzle_stability_gate() {
1347 let muzzle_velocity = 1_400.0 / MPS_TO_FPS;
1348 let mut inputs = create_test_inputs();
1349 inputs.muzzle_velocity = muzzle_velocity;
1350 inputs.twist_rate = 15.0;
1351 inputs.enable_magnus = true;
1352
1353 let bare_sg = crate::spin_drift::miller_stability(0.308, 168.0, 15.0, 1.215);
1354 let canonical_sg = crate::spin_drift::effective_sg_from_inputs(&inputs, 15.0, 1013.25);
1355 assert!(bare_sg > 1.0, "test requires bare Sg above the Magnus gate");
1356 assert!(
1357 canonical_sg < 1.0,
1358 "velocity-corrected Sg must be below the gate, got {canonical_sg}"
1359 );
1360
1361 let acceleration = |inputs: &BallisticInputs| {
1362 let result = compute_derivatives(
1363 Vector3::zeros(),
1364 Vector3::new(muzzle_velocity, 0.0, 0.0),
1365 inputs,
1366 Vector3::zeros(),
1367 (1.225, 340.0, 0.0, 0.0),
1368 0.5,
1369 None,
1370 0.0,
1371 None,
1372 );
1373 Vector3::new(result[3], result[4], result[5])
1374 };
1375 let enabled = acceleration(&inputs);
1376 inputs.enable_magnus = false;
1377 let disabled = acceleration(&inputs);
1378
1379 assert_eq!(
1380 enabled, disabled,
1381 "canonical Sg below 1 must suppress every Magnus acceleration component"
1382 );
1383 }
1384
1385 #[test]
1386 fn magnus_force_grows_as_fixed_spin_projectile_slows() {
1387 let mut inputs = create_test_inputs();
1388 inputs.muzzle_velocity = 800.0;
1389 inputs.twist_rate = 12.0;
1390 inputs.enable_magnus = true;
1391
1392 let magnus_acceleration = |speed_mps| {
1393 let evaluate = |enable_magnus| {
1394 let mut run_inputs = inputs.clone();
1395 run_inputs.enable_magnus = enable_magnus;
1396 compute_derivatives(
1397 Vector3::zeros(),
1398 Vector3::new(speed_mps, 0.0, 0.0),
1399 &run_inputs,
1400 Vector3::zeros(),
1401 (1.225, 340.0, 0.0, 0.0),
1402 0.5,
1403 None,
1404 0.0,
1405 None,
1406 )[4]
1407 };
1408 (evaluate(true) - evaluate(false)).abs()
1409 };
1410
1411 let fast = magnus_acceleration(200.0);
1412 let slow = magnus_acceleration(100.0);
1413 let ratio = slow / fast;
1414 let expected_ratio = 2.0_f64.powf(5.0 / 3.0);
1415
1416 assert!(fast > 0.0 && slow > 0.0, "fast={fast}, slow={slow}");
1417 assert!(
1418 (ratio - expected_ratio).abs() < 1e-3,
1419 "fixed-spin Magnus acceleration must grow downrange; slow/fast={ratio}, \
1420 expected={expected_ratio}"
1421 );
1422 }
1423
1424 #[test]
1425 fn test_magnus_moment_coefficient() {
1426 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);
1432 }
1434
1435 fn deck_test_inputs(cd_scale: f64) -> BallisticInputs {
1437 BallisticInputs {
1438 custom_drag_table: Some(crate::drag::DragTable::new(
1439 vec![0.5, 1.0, 2.0, 3.0],
1440 vec![0.23, 0.40, 0.30, 0.26],
1441 )),
1442 cd_scale,
1443 ..create_test_inputs()
1444 }
1445 }
1446
1447 fn deck_accel_x(cd_scale: f64) -> f64 {
1448 let inputs = deck_test_inputs(cd_scale);
1449 compute_derivatives(
1450 Vector3::zeros(),
1451 Vector3::new(700.0, 0.0, 0.0),
1452 &inputs,
1453 Vector3::zeros(),
1454 (1.225, 340.0, 0.0, 0.0),
1455 inputs.bc_value,
1456 None,
1457 0.0,
1458 None,
1459 )[3]
1460 }
1461
1462 #[test]
1463 fn cd_scale_default_is_one_and_absent_matches_explicit() {
1464 assert_eq!(BallisticInputs::default().cd_scale, 1.0);
1465
1466 let omitted = BallisticInputs {
1467 custom_drag_table: Some(crate::drag::DragTable::new(
1468 vec![0.5, 1.0, 2.0, 3.0],
1469 vec![0.23, 0.40, 0.30, 0.26],
1470 )),
1471 ..create_test_inputs()
1472 };
1473 assert_eq!(omitted.cd_scale, 1.0);
1474
1475 let a_omitted = compute_derivatives(
1476 Vector3::zeros(),
1477 Vector3::new(700.0, 0.0, 0.0),
1478 &omitted,
1479 Vector3::zeros(),
1480 (1.225, 340.0, 0.0, 0.0),
1481 omitted.bc_value,
1482 None,
1483 0.0,
1484 None,
1485 );
1486 let a_explicit = deck_accel_x(1.0);
1487 assert_eq!(
1488 a_omitted[3].to_bits(),
1489 a_explicit.to_bits(),
1490 "omitted cd_scale (Default) must be bit-identical to an explicit 1.0"
1491 );
1492 }
1493
1494 #[test]
1497 fn cd_scale_direction_on_derivatives_kernel() {
1498 let baseline = deck_accel_x(1.0);
1499 let scaled_up = deck_accel_x(1.10);
1500 let scaled_down = deck_accel_x(0.90);
1501
1502 assert!(
1503 scaled_up < baseline,
1504 "cd_scale=1.10 must increase drag deceleration (more negative ax): \
1505 base={baseline} up={scaled_up}"
1506 );
1507 assert!(
1508 scaled_down > baseline,
1509 "cd_scale=0.90 must decrease drag deceleration (less negative ax): \
1510 base={baseline} down={scaled_down}"
1511 );
1512 }
1513
1514 #[test]
1516 fn cd_scale_is_inert_without_a_custom_drag_table() {
1517 let make = |cd_scale: f64| BallisticInputs {
1518 cd_scale,
1519 ..create_test_inputs()
1520 };
1521 let accel = |cd_scale: f64| {
1522 let inputs = make(cd_scale);
1523 compute_derivatives(
1524 Vector3::zeros(),
1525 Vector3::new(700.0, 0.0, 0.0),
1526 &inputs,
1527 Vector3::zeros(),
1528 (1.225, 340.0, 0.0, 0.0),
1529 inputs.bc_value,
1530 None,
1531 0.0,
1532 None,
1533 )[3]
1534 };
1535 assert_eq!(
1536 accel(1.0).to_bits(),
1537 accel(1.5).to_bits(),
1538 "cd_scale must not affect the G-model/BC drag path"
1539 );
1540 }
1541
1542 #[test]
1548 fn a_reference_table_divides_by_the_resolved_band_bc() {
1549 let table = || {
1550 Some(crate::drag::DragTable::new(
1551 vec![0.5, 1.0, 2.0, 3.0],
1552 vec![0.23, 0.40, 0.30, 0.26],
1553 ))
1554 };
1555 let reference = BallisticInputs {
1556 custom_drag_table: table(),
1557 custom_drag_reference: true,
1558 use_bc_segments: true,
1559 bc_segments_data: Some(vec![
1560 crate::BCSegmentData {
1561 velocity_min: 2000.0,
1562 velocity_max: 4000.0,
1563 bc_value: 0.30,
1564 },
1565 crate::BCSegmentData {
1566 velocity_min: 0.0,
1567 velocity_max: 2000.0,
1568 bc_value: 0.20,
1569 },
1570 ]),
1571 ..create_test_inputs()
1572 };
1573 let sd = reference.sectional_density_lb_in2().expect("SD");
1574 let accel_x = |inputs: &BallisticInputs, speed: f64| {
1575 compute_derivatives(
1576 Vector3::zeros(),
1577 Vector3::new(speed, 0.0, 0.0),
1578 inputs,
1579 Vector3::zeros(),
1580 (1.225, 340.0, 0.0, 0.0),
1581 inputs.bc_value,
1582 None,
1583 0.0,
1584 None,
1585 )[3]
1586 };
1587
1588 for (speed, band_bc) in [(700.0, 0.30), (450.0, 0.20)] {
1591 let own_curve_oracle = BallisticInputs {
1592 custom_drag_table: table(),
1593 cd_scale: sd / band_bc,
1594 ..create_test_inputs()
1595 };
1596 let flown = accel_x(&reference, speed);
1597 let expected = accel_x(&own_curve_oracle, speed);
1598 assert!(
1599 (flown - expected).abs() <= 1e-12 * expected.abs(),
1600 "{speed} m/s must fly Cd / {band_bc}: flown {flown}, expected {expected}"
1601 );
1602
1603 let dropped = BallisticInputs {
1604 custom_drag_reference: false,
1605 ..reference.clone()
1606 };
1607 let own_curve = accel_x(&dropped, speed);
1608 assert!(
1609 (own_curve - flown).abs() > 0.1 * flown.abs(),
1610 "{speed} m/s: the fixture must tell the readings apart: {own_curve} vs {flown}"
1611 );
1612 }
1613 }
1614}