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 yaw_multiplier = 1.0 + yaw_rad.powi(2);
329
330 let density_scale = air_density / STANDARD_AIR_DENSITY;
332
333 let caliber_in = if inputs.caliber_inches > 0.0 {
346 inputs.caliber_inches
347 } else {
348 inputs.bullet_diameter / 0.0254 };
350 let weight_gr = if inputs.weight_grains > 0.0 {
351 inputs.weight_grains
352 } else {
353 inputs.bullet_mass / crate::constants::GRAINS_TO_KG };
355 let shape = crate::transonic_drag::resolve_projectile_shape(
358 inputs.bullet_model.as_deref(),
359 caliber_in,
360 weight_gr,
361 &inputs.bc_type.to_string(),
362 );
363 let drag_factor =
364 crate::transonic_drag::transonic_correction(mach, drag_factor, shape, false);
365
366 let (drag_factor, retard_denom) = match inputs.custom_drag_table {
381 Some(ref table) => (
382 table.interpolate(mach) * inputs.cd_scale,
387 inputs.custom_drag_denominator(bc_val),
388 ),
389 None => (drag_factor, bc_val),
390 };
391
392 let standard_factor = drag_factor * CD_TO_RETARD;
394 let a_drag_ft_s2 =
395 (v_rel_fps.powi(2) * standard_factor * yaw_multiplier * density_scale) / retard_denom;
396 let a_drag_m_s2 = a_drag_ft_s2 * FPS_TO_MPS;
397
398 accel_drag = -a_drag_m_s2 * (velocity_adjusted / speed_air);
400
401 if inputs.enable_magnus
409 && !(inputs.use_enhanced_spin_drift && inputs.enable_advanced_effects)
410 && inputs.bullet_diameter > 0.0
411 && inputs.twist_rate > 0.0
412 {
413 let diameter_m = inputs.bullet_diameter;
414 let (spin_rate_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
415 inputs.muzzle_velocity,
416 speed_air,
417 inputs.twist_rate,
418 diameter_m,
419 );
420
421 let c_np = calculate_magnus_moment_coefficient(mach);
422
423 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
425
426 let d_in = inputs.caliber_inches;
430 let m_gr = inputs.weight_grains;
431 let l_in = if inputs.bullet_length > 0.0 {
432 inputs.bullet_length / 0.0254 } else {
434 let est_m = crate::stability::estimate_bullet_length_m(
436 inputs.bullet_diameter,
437 inputs.bullet_mass,
438 );
439 if est_m > 0.0 {
440 est_m / 0.0254
441 } else {
442 4.5 * d_in.max(1e-9)
443 }
444 };
445 let sg = crate::spin_drift::calculate_dynamic_stability(
450 m_gr,
451 speed_air,
452 spin_rate_rad_s,
453 d_in,
454 l_in,
455 air_density,
456 );
457 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
458 sg,
459 speed_air,
460 spin_rate_rad_s,
461 0.0,
462 0.0,
463 air_density,
464 d_in,
465 l_in,
466 m_gr,
467 mach,
468 "match",
469 false,
470 );
471
472 let magnus_force_magnitude =
474 0.5 * air_density * speed_air.powi(2) * area * c_np * spin_param * yaw_rad.sin();
475
476 if magnus_force_magnitude > 1e-12 {
479 if let Some(magnus_direction) = yaw_of_repose_magnus_direction(
480 velocity_adjusted,
481 accel_gravity,
482 inputs.is_twist_right,
483 ) {
484 let bullet_mass_kg = inputs.bullet_mass; accel_magnus = (magnus_force_magnitude / bullet_mass_kg) * magnus_direction;
486 }
487 }
488 }
489 }
490
491 let mut accel = accel_gravity + accel_drag + accel_magnus;
493
494 if let Some(omega) = omega_vector {
499 let omega = level_vector_to_shot_frame(omega, theta);
500 let accel_coriolis = -2.0 * omega.cross(&vel);
501 accel += accel_coriolis;
502 }
503
504 [vel[0], vel[1], vel[2], accel[0], accel[1], accel[2]]
517}
518
519pub fn interpolated_bc(
525 mach: f64,
526 segments: &[(f64, f64)],
527 inputs: Option<&BallisticInputs>,
528) -> f64 {
529 if segments.is_empty() {
530 if let Some(inputs) = inputs {
531 return inputs.bc_value;
532 }
533 return crate::constants::BC_FALLBACK_CONSERVATIVE;
534 }
535
536 if segments.len() == 1 {
537 return segments[0].1;
538 }
539
540 let sorted_segments: std::borrow::Cow<[(f64, f64)]> =
544 if segments.windows(2).all(|w| w[0].0 <= w[1].0) {
545 std::borrow::Cow::Borrowed(segments)
546 } else {
547 let mut v = segments.to_vec();
548 v.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
549 std::borrow::Cow::Owned(v)
550 };
551
552 if mach <= sorted_segments[0].0 {
554 return sorted_segments[0].1;
555 }
556 if mach >= sorted_segments[sorted_segments.len() - 1].0 {
557 return sorted_segments[sorted_segments.len() - 1].1;
558 }
559
560 let idx = sorted_segments.partition_point(|(m, _)| *m <= mach);
562 if idx == 0 || idx >= sorted_segments.len() {
563 return sorted_segments[0].1;
565 }
566
567 let (mach1, bc1) = sorted_segments[idx - 1];
568 let (mach2, bc2) = sorted_segments[idx];
569
570 let denominator = mach2 - mach1;
572 if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
573 return bc1; }
575 let t = (mach - mach1) / denominator;
576 bc1 + t * (bc2 - bc1)
577}
578
579fn get_bc_for_velocity(velocity_fps: f64, inputs: &BallisticInputs, bc_used: f64) -> f64 {
581 if !inputs.use_bc_segments {
583 return bc_used;
584 }
585
586 if let Some(bc_segments_data) = inputs
588 .bc_segments_data
589 .as_ref()
590 .filter(|segments| !segments.is_empty())
591 {
592 return velocity_segment_bc(velocity_fps, bc_segments_data, bc_used);
595 }
596
597 if let Some(segments) = estimate_bc_segments_for(inputs, bc_used) {
601 return velocity_segment_bc(velocity_fps, &segments, bc_used);
602 }
603
604 bc_used
606}
607
608pub(crate) fn estimate_bc_segments_for(
615 inputs: &BallisticInputs,
616 bc_used: f64,
617) -> Option<Vec<crate::BCSegmentData>> {
618 if !(inputs.bullet_diameter > 0.0 && inputs.bullet_mass > 0.0 && bc_used > 0.0) {
619 return None;
620 }
621 let model = if let Some(ref bullet_id) = inputs.bullet_id {
623 bullet_id.clone()
624 } else {
625 format!("{}gr bullet", inputs.weight_grains as i32)
626 };
627 let bc_type_str = inputs.bc_type_str.as_deref().unwrap_or(match inputs.bc_type {
630 crate::DragModel::G7 => "G7",
631 _ => "G1",
632 });
633 Some(BCSegmentEstimator::estimate_bc_segments(
634 bc_used,
635 inputs.caliber_inches,
636 inputs.weight_grains,
637 &model,
638 bc_type_str,
639 ))
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645
646 fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
647 let (sin_angle, cos_angle) = angle.sin_cos();
648 Vector3::new(
649 level.x * cos_angle + level.y * sin_angle,
650 -level.x * sin_angle + level.y * cos_angle,
651 level.z,
652 )
653 }
654
655 #[test]
656 fn level_vector_projection_is_bit_exact_at_zero_incline() {
657 let level = Vector3::new(12.5, -0.0, -7.25);
658 let projected = level_vector_to_shot_frame(level, 0.0);
659
660 for component in 0..3 {
661 assert_eq!(projected[component].to_bits(), level[component].to_bits());
662 }
663 }
664
665 #[test]
666 fn dry_air_sound_speed_round_trips_to_local_celsius() {
667 for expected_temp_c in [-40.0_f64, 15.0, 40.0] {
668 let sound_speed = (1.4 * 287.05 * (expected_temp_c + 273.15)).sqrt();
669 let actual_temp_c = dry_air_temperature_c_from_sound_speed(sound_speed);
670 assert!(
671 (actual_temp_c - expected_temp_c).abs() < 1e-10,
672 "sound speed {sound_speed} m/s recovered {actual_temp_c} C, expected {expected_temp_c} C"
673 );
674 }
675
676 let direct_mode_temp_c = dry_air_temperature_c_from_sound_speed(340.0);
677 assert!(
678 direct_mode_temp_c > 10.0 && direct_mode_temp_c < 20.0,
679 "direct-mode 340 m/s must be interpreted as sound speed, not 340 C"
680 );
681 }
682
683 #[test]
684 fn tipoff_yaw_uses_documented_radians_for_drag_and_decay() {
685 let mut baseline_inputs = create_test_inputs();
686 baseline_inputs.tipoff_yaw = 0.0;
687 baseline_inputs.tipoff_decay_distance = 50.0;
688 let mut yawed_inputs = baseline_inputs.clone();
689 yawed_inputs.tipoff_yaw = 0.1;
690
691 let drag_x = |inputs: &BallisticInputs, downrange_m: f64| {
692 compute_derivatives(
693 Vector3::new(downrange_m, 0.0, 0.0),
694 Vector3::new(300.0, 0.0, 0.0),
695 inputs,
696 Vector3::zeros(),
697 (1.225, 340.0, 0.0, 0.0),
698 0.5,
699 None,
700 0.0,
701 None,
702 )[3]
703 };
704
705 for (downrange_m, expected_ratio) in
706 [(0.0_f64, 1.01), (50.0 * std::f64::consts::LN_2, 1.0025)]
707 {
708 let baseline_drag = drag_x(&baseline_inputs, downrange_m);
709 let yawed_drag = drag_x(&yawed_inputs, downrange_m);
710 let actual_ratio = yawed_drag / baseline_drag;
711
712 assert!(baseline_drag < 0.0, "baseline must be downrange drag");
713 assert!(
714 (actual_ratio - expected_ratio).abs() < 1e-12,
715 "tip-off yaw at x={downrange_m} m used the wrong angular unit or decay: \
716 ratio={actual_ratio}, expected={expected_ratio}"
717 );
718 }
719 }
720
721 fn create_test_inputs() -> BallisticInputs {
722 BallisticInputs {
726 muzzle_velocity: 800.0, bc_value: 0.5,
728 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,
732 weight_grains: 168.0,
733 altitude: 1000.0,
734 ..Default::default()
735 }
736 }
737
738 #[test]
739 fn measured_bc_drag_ignores_name_based_form_factor_flag() {
740 let derivatives_with_flag = |use_form_factor| {
741 let inputs = BallisticInputs {
742 bc_value: 0.462,
743 bc_type: crate::DragModel::G1,
744 bullet_model: Some("168gr SMK Match".to_string()),
745 use_form_factor,
746 ..create_test_inputs()
747 };
748
749 compute_derivatives(
750 Vector3::zeros(),
751 Vector3::new(600.0, 0.0, 0.0),
752 &inputs,
753 Vector3::zeros(),
754 (1.225, 340.0, 0.0, 0.0),
755 inputs.bc_value,
756 None,
757 0.0,
758 None,
759 )
760 };
761
762 let baseline = derivatives_with_flag(false);
763 let flagged = derivatives_with_flag(true);
764
765 for component in 3..6 {
766 assert_eq!(
767 flagged[component].to_bits(),
768 baseline[component].to_bits(),
769 "published BC already encodes form factor: component {component}, baseline={} flagged={}",
770 baseline[component],
771 flagged[component]
772 );
773 }
774 }
775
776 #[test]
777 fn inclined_positions_at_same_world_altitude_have_same_atmospheric_acceleration() {
778 let angle = std::f64::consts::FRAC_PI_6;
779 let mut inputs = create_test_inputs();
780 inputs.altitude = 100.0;
781 inputs.shooting_angle = angle;
782 let velocity = Vector3::new(600.0, 0.0, 0.0);
783 let along_slant = Vector3::new(1_000.0, 0.0, 0.0);
784 let across_slant = Vector3::new(0.0, 500.0 / angle.cos(), 0.0);
785 let atmo = (inputs.altitude, 15.0, 1013.25, 1.0);
786
787 let a = compute_derivatives(
788 along_slant,
789 velocity,
790 &inputs,
791 Vector3::zeros(),
792 atmo,
793 inputs.bc_value,
794 None,
795 0.0,
796 None,
797 );
798 let b = compute_derivatives(
799 across_slant,
800 velocity,
801 &inputs,
802 Vector3::zeros(),
803 atmo,
804 inputs.bc_value,
805 None,
806 0.0,
807 None,
808 );
809
810 for component in 3..6 {
811 assert!(
812 (a[component] - b[component]).abs() < 1e-10,
813 "derivative component {component} differs at equal world altitude: {} vs {}",
814 a[component],
815 b[component]
816 );
817 }
818 }
819
820 #[test]
821 fn inclined_headwind_is_rotated_into_solver_frame() {
822 let angle = std::f64::consts::FRAC_PI_6;
823 let mut inputs = create_test_inputs();
824 inputs.shooting_angle = angle;
825 let level_headwind = Vector3::new(-100.0, 0.0, 0.0);
826 let velocity = expected_shot_frame_vector(level_headwind, angle);
827 let actual = compute_derivatives(
828 Vector3::zeros(),
829 velocity,
830 &inputs,
831 level_headwind,
832 (1.225, 340.0, 0.0, 0.0),
833 inputs.bc_value,
834 None,
835 0.0,
836 None,
837 );
838 let expected = Vector3::new(
839 -G_ACCEL_MPS2 * angle.sin(),
840 -G_ACCEL_MPS2 * angle.cos(),
841 0.0,
842 );
843
844 assert!(
845 (Vector3::new(actual[3], actual[4], actual[5]) - expected).norm() < 1e-12,
846 "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
847 );
848 }
849
850 #[test]
851 fn inclined_coriolis_is_rotated_into_solver_frame() {
852 let angle = std::f64::consts::FRAC_PI_6;
853 let mut inputs = create_test_inputs();
854 inputs.shooting_angle = angle;
855 let velocity = Vector3::new(600.0, 20.0, 5.0);
856 let level_omega = Vector3::new(3.0e-5, 6.0e-5, -2.0e-5);
857 let run = |omega| {
858 compute_derivatives(
859 Vector3::zeros(),
860 velocity,
861 &inputs,
862 Vector3::zeros(),
863 (1.225, 340.0, 0.0, 0.0),
864 inputs.bc_value,
865 omega,
866 0.0,
867 None,
868 )
869 };
870 let baseline = run(None);
871 let with_coriolis = run(Some(level_omega));
872 let actual = Vector3::new(
873 with_coriolis[3] - baseline[3],
874 with_coriolis[4] - baseline[4],
875 with_coriolis[5] - baseline[5],
876 );
877 let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
878
879 assert!(
880 (actual - expected).norm() < 1e-12,
881 "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
882 );
883 }
884
885 #[test]
886 fn explicit_velocity_segment_gap_uses_scalar_bc_without_auto_estimation() {
887 let mut inputs = create_test_inputs();
888 inputs.bc_value = 0.91;
889 inputs.use_bc_segments = true;
890 inputs.bc_segments_data = Some(vec![
891 crate::BCSegmentData {
892 velocity_min: 0.0,
893 velocity_max: 999.0,
894 bc_value: 0.2,
895 },
896 crate::BCSegmentData {
897 velocity_min: 1000.0,
898 velocity_max: 2000.0,
899 bc_value: 0.3,
900 },
901 ]);
902
903 assert_eq!(
904 get_bc_for_velocity(999.5, &inputs, inputs.bc_value),
905 inputs.bc_value,
906 "an explicit table gap must not be replaced by an auto-estimated segment"
907 );
908 }
909
910 #[test]
911 fn test_mba955_bc_segments_prepopulate_byte_identical() {
912 let mut slow = create_test_inputs();
919 slow.use_bc_segments = true;
920 slow.bc_segments_data = None;
921 slow.bc_segments = None;
922
923 let bc_used = slow.bc_value;
924 let mut fast = slow.clone();
925 fast.bc_segments_data = estimate_bc_segments_for(&fast, bc_used);
926 assert!(
927 fast.bc_segments_data.is_some(),
928 "estimation should yield segments for a valid bullet"
929 );
930
931 for v in (200..=3500).step_by(50) {
932 let vf = v as f64;
933 let a = get_bc_for_velocity(vf, &slow, bc_used);
934 let b = get_bc_for_velocity(vf, &fast, bc_used);
935 assert_eq!(
936 a.to_bits(),
937 b.to_bits(),
938 "BC differs at {vf} fps: slow={a} fast={b}"
939 );
940 }
941 }
942
943 #[test]
944 fn estimated_segments_inherit_typed_g7_drag_model() {
945 let mut inputs = create_test_inputs();
946 inputs.bc_value = 0.243;
947 inputs.bc_type = crate::DragModel::G7;
948 inputs.bc_type_str = None;
949 inputs.bullet_mass = 175.0 * crate::constants::GRAINS_TO_KG;
950 inputs.weight_grains = 175.0;
951
952 let actual = estimate_bc_segments_for(&inputs, inputs.bc_value).unwrap();
953 let expected = BCSegmentEstimator::estimate_bc_segments(
954 inputs.bc_value,
955 inputs.caliber_inches,
956 inputs.weight_grains,
957 "175gr bullet",
958 "G7",
959 );
960
961 assert_eq!(actual.len(), expected.len());
962 for (actual, expected) in actual.iter().zip(&expected) {
963 assert_eq!(actual.velocity_min.to_bits(), expected.velocity_min.to_bits());
964 assert_eq!(actual.velocity_max.to_bits(), expected.velocity_max.to_bits());
965 assert_eq!(actual.bc_value.to_bits(), expected.bc_value.to_bits());
966 }
967
968 inputs.bc_type_str = Some("G1".to_string());
970 let legacy = estimate_bc_segments_for(&inputs, inputs.bc_value).unwrap();
971 let expected_g1 = BCSegmentEstimator::estimate_bc_segments(
972 inputs.bc_value,
973 inputs.caliber_inches,
974 inputs.weight_grains,
975 "175gr bullet",
976 "G1",
977 );
978 assert_eq!(legacy.len(), expected_g1.len());
979 for (legacy, expected) in legacy.iter().zip(&expected_g1) {
980 assert_eq!(legacy.bc_value.to_bits(), expected.bc_value.to_bits());
981 }
982 }
983
984 #[test]
985 fn test_compute_derivatives_basic() {
986 let pos = Vector3::new(0.0, 0.0, 0.0);
987 let vel = Vector3::new(800.0, 0.0, 0.0);
988 let inputs = create_test_inputs();
989 let wind_vector = Vector3::zeros();
990 let atmos_params = (1.225, 340.0, 0.0, 0.0); let bc_used = 0.5;
993
994 let result = compute_derivatives(
995 pos,
996 vel,
997 &inputs,
998 wind_vector,
999 atmos_params,
1000 bc_used,
1001 None,
1002 0.0,
1003 None,
1004 );
1005
1006 assert_eq!(result.len(), 6);
1008
1009 assert!((result[0] - vel[0]).abs() < 1e-10);
1011 assert!((result[1] - vel[1]).abs() < 1e-10);
1012 assert!((result[2] - vel[2]).abs() < 1e-10);
1013
1014 assert!(result[4] < 0.0); assert!(result[3] < 0.0); }
1020
1021 #[test]
1022 fn standard_atmosphere_zero_ratio_uses_sea_level_fallback() {
1023 let pos = Vector3::new(0.0, 0.0, 0.0);
1024 let vel = Vector3::new(800.0, 0.0, 0.0);
1025 let inputs = create_test_inputs();
1026 let run = |base_ratio| {
1027 compute_derivatives(
1028 pos,
1029 vel,
1030 &inputs,
1031 Vector3::zeros(),
1032 (inputs.altitude, 15.0, 1013.25, base_ratio),
1033 inputs.bc_value,
1034 None,
1035 0.0,
1036 None,
1037 )
1038 };
1039
1040 let missing_ratio = run(0.0);
1041 let explicit_sea_level = run(1.0);
1042 assert!(
1043 missing_ratio[3] < 0.0,
1044 "missing standard density ratio must not produce vacuum drag"
1045 );
1046 assert!((missing_ratio[3] - explicit_sea_level[3]).abs() < 1e-12);
1047 }
1048
1049 #[test]
1050 fn test_compute_derivatives_with_wind() {
1051 let pos = Vector3::new(0.0, 0.0, 0.0);
1052 let vel = Vector3::new(800.0, 0.0, 0.0);
1053 let inputs = create_test_inputs();
1054 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;
1057
1058 let result = compute_derivatives(
1059 pos,
1060 vel,
1061 &inputs,
1062 wind_vector,
1063 atmos_params,
1064 bc_used,
1065 None,
1066 0.0,
1067 None,
1068 );
1069
1070 assert!(result[3] < 0.0); }
1074
1075 #[test]
1076 fn test_compute_derivatives_with_coriolis() {
1077 let pos = Vector3::new(0.0, 0.0, 0.0);
1078 let vel = Vector3::new(800.0, 0.0, 0.0);
1079 let inputs = create_test_inputs();
1080 let wind_vector = Vector3::zeros();
1081 let atmos_params = (1.225, 340.0, 0.0, 0.0); let bc_used = 0.5;
1083 let omega = Vector3::new(0.0, 0.0, 7.2921e-5); let result = compute_derivatives(
1086 pos,
1087 vel,
1088 &inputs,
1089 wind_vector,
1090 atmos_params,
1091 bc_used,
1092 Some(omega),
1093 0.0,
1094 None,
1095 );
1096
1097 assert!(result[4].abs() > 1e-3); }
1100
1101 #[test]
1102 fn test_interpolated_bc() {
1103 let segments = vec![(0.5, 0.4), (1.0, 0.5), (1.5, 0.6), (2.0, 0.5)];
1104
1105 assert!((interpolated_bc(1.0, &segments, None) - 0.5).abs() < 1e-10);
1107
1108 let bc_075 = interpolated_bc(0.75, &segments, None);
1110 assert!(bc_075 > 0.4 && bc_075 < 0.5);
1111
1112 assert!((interpolated_bc(0.1, &segments, None) - 0.4).abs() < 1e-10);
1114 assert!((interpolated_bc(3.0, &segments, None) - 0.5).abs() < 1e-10);
1115 }
1116
1117 #[test]
1118 fn test_interpolated_bc_edge_cases() {
1119 assert!(
1121 (interpolated_bc(1.0, &[], None) - crate::constants::BC_FALLBACK_CONSERVATIVE).abs()
1122 < 1e-10
1123 );
1124
1125 let mut inputs = create_test_inputs();
1126 inputs.bc_type = crate::DragModel::G7;
1127 inputs.bc_value = 0.487;
1128 assert_eq!(
1129 interpolated_bc(1.0, &[], Some(&inputs)).to_bits(),
1130 inputs.bc_value.to_bits(),
1131 "an empty optional table must preserve the caller's scalar BC"
1132 );
1133
1134 let single = vec![(1.0, 0.7)];
1136 assert!((interpolated_bc(1.5, &single, None) - 0.7).abs() < 1e-10);
1137 }
1138
1139 #[test]
1140 fn empty_mach_segments_preserve_active_bc_used() {
1141 let mut inputs = create_test_inputs();
1142 inputs.bc_type = crate::DragModel::G7;
1143 inputs.bc_value = 0.123; let drag_acceleration = |inputs: &BallisticInputs| {
1146 compute_derivatives(
1147 Vector3::zeros(),
1148 Vector3::new(700.0, 0.0, 0.0),
1149 inputs,
1150 Vector3::zeros(),
1151 (1.225, 340.0, 0.0, 0.0),
1152 0.487,
1153 None,
1154 0.0,
1155 None,
1156 )[3]
1157 };
1158
1159 inputs.use_bc_segments = false;
1160 inputs.bc_segments = None;
1161 let no_table = drag_acceleration(&inputs);
1162
1163 for use_bc_segments in [false, true] {
1164 inputs.use_bc_segments = use_bc_segments;
1165 inputs.bc_segments = Some(Vec::new());
1166 let empty_table = drag_acceleration(&inputs);
1167
1168 assert_eq!(
1169 empty_table.to_bits(),
1170 no_table.to_bits(),
1171 "Some(empty) must preserve bc_used when use_bc_segments={use_bc_segments}"
1172 );
1173 }
1174 }
1175
1176 #[test]
1177 fn test_magnus_effect() {
1178 let pos = Vector3::new(0.0, 0.0, 0.0);
1179 let vel = Vector3::new(822.96, 0.0, 0.0); let wind_vector = Vector3::zeros();
1181 let atmos_params = (1.225, 340.0, 0.0, 0.0); let bc_used = 0.5;
1183 let acceleration = |enable_magnus, is_twist_right| {
1184 let mut inputs = create_test_inputs();
1185 inputs.twist_rate = 10.0; inputs.is_twist_right = is_twist_right;
1187 inputs.enable_magnus = enable_magnus; let result = compute_derivatives(
1190 pos,
1191 vel,
1192 &inputs,
1193 wind_vector,
1194 atmos_params,
1195 bc_used,
1196 None,
1197 0.0,
1198 None,
1199 );
1200 Vector3::new(result[3], result[4], result[5])
1201 };
1202
1203 let baseline = acceleration(false, true);
1204 let right_twist = acceleration(true, true) - baseline;
1205 let left_twist = acceleration(true, false) - baseline;
1206
1207 assert!(
1211 right_twist.y < 0.0,
1212 "right-hand Magnus must point down, got {right_twist:?}"
1213 );
1214 assert!(
1215 left_twist.y > 0.0,
1216 "left-hand Magnus must point up, got {left_twist:?}"
1217 );
1218 assert!((right_twist.y + left_twist.y).abs() < 1e-12);
1219 assert!(right_twist.x.abs() < 1e-12 && right_twist.z.abs() < 1e-12);
1220 assert!(left_twist.x.abs() < 1e-12 && left_twist.z.abs() < 1e-12);
1221 assert!(
1222 right_twist.y.abs() < 0.05,
1223 "Magnus must remain a small force"
1224 );
1225 }
1226
1227 #[test]
1228 fn magnus_uses_velocity_corrected_muzzle_stability_gate() {
1229 let muzzle_velocity = 1_400.0 / MPS_TO_FPS;
1230 let mut inputs = create_test_inputs();
1231 inputs.muzzle_velocity = muzzle_velocity;
1232 inputs.twist_rate = 15.0;
1233 inputs.enable_magnus = true;
1234
1235 let bare_sg = crate::spin_drift::miller_stability(0.308, 168.0, 15.0, 1.215);
1236 let canonical_sg = crate::spin_drift::effective_sg_from_inputs(&inputs, 15.0, 1013.25);
1237 assert!(bare_sg > 1.0, "test requires bare Sg above the Magnus gate");
1238 assert!(
1239 canonical_sg < 1.0,
1240 "velocity-corrected Sg must be below the gate, got {canonical_sg}"
1241 );
1242
1243 let acceleration = |inputs: &BallisticInputs| {
1244 let result = compute_derivatives(
1245 Vector3::zeros(),
1246 Vector3::new(muzzle_velocity, 0.0, 0.0),
1247 inputs,
1248 Vector3::zeros(),
1249 (1.225, 340.0, 0.0, 0.0),
1250 0.5,
1251 None,
1252 0.0,
1253 None,
1254 );
1255 Vector3::new(result[3], result[4], result[5])
1256 };
1257 let enabled = acceleration(&inputs);
1258 inputs.enable_magnus = false;
1259 let disabled = acceleration(&inputs);
1260
1261 assert_eq!(
1262 enabled, disabled,
1263 "canonical Sg below 1 must suppress every Magnus acceleration component"
1264 );
1265 }
1266
1267 #[test]
1268 fn magnus_force_grows_as_fixed_spin_projectile_slows() {
1269 let mut inputs = create_test_inputs();
1270 inputs.muzzle_velocity = 800.0;
1271 inputs.twist_rate = 12.0;
1272 inputs.enable_magnus = true;
1273
1274 let magnus_acceleration = |speed_mps| {
1275 let evaluate = |enable_magnus| {
1276 let mut run_inputs = inputs.clone();
1277 run_inputs.enable_magnus = enable_magnus;
1278 compute_derivatives(
1279 Vector3::zeros(),
1280 Vector3::new(speed_mps, 0.0, 0.0),
1281 &run_inputs,
1282 Vector3::zeros(),
1283 (1.225, 340.0, 0.0, 0.0),
1284 0.5,
1285 None,
1286 0.0,
1287 None,
1288 )[4]
1289 };
1290 (evaluate(true) - evaluate(false)).abs()
1291 };
1292
1293 let fast = magnus_acceleration(200.0);
1294 let slow = magnus_acceleration(100.0);
1295 let ratio = slow / fast;
1296 let expected_ratio = 2.0_f64.powf(5.0 / 3.0);
1297
1298 assert!(fast > 0.0 && slow > 0.0, "fast={fast}, slow={slow}");
1299 assert!(
1300 (ratio - expected_ratio).abs() < 1e-3,
1301 "fixed-spin Magnus acceleration must grow downrange; slow/fast={ratio}, \
1302 expected={expected_ratio}"
1303 );
1304 }
1305
1306 #[test]
1307 fn test_magnus_moment_coefficient() {
1308 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);
1314 }
1316
1317 fn deck_test_inputs(cd_scale: f64) -> BallisticInputs {
1319 BallisticInputs {
1320 custom_drag_table: Some(crate::drag::DragTable::new(
1321 vec![0.5, 1.0, 2.0, 3.0],
1322 vec![0.23, 0.40, 0.30, 0.26],
1323 )),
1324 cd_scale,
1325 ..create_test_inputs()
1326 }
1327 }
1328
1329 fn deck_accel_x(cd_scale: f64) -> f64 {
1330 let inputs = deck_test_inputs(cd_scale);
1331 compute_derivatives(
1332 Vector3::zeros(),
1333 Vector3::new(700.0, 0.0, 0.0),
1334 &inputs,
1335 Vector3::zeros(),
1336 (1.225, 340.0, 0.0, 0.0),
1337 inputs.bc_value,
1338 None,
1339 0.0,
1340 None,
1341 )[3]
1342 }
1343
1344 #[test]
1345 fn cd_scale_default_is_one_and_absent_matches_explicit() {
1346 assert_eq!(BallisticInputs::default().cd_scale, 1.0);
1347
1348 let omitted = BallisticInputs {
1349 custom_drag_table: Some(crate::drag::DragTable::new(
1350 vec![0.5, 1.0, 2.0, 3.0],
1351 vec![0.23, 0.40, 0.30, 0.26],
1352 )),
1353 ..create_test_inputs()
1354 };
1355 assert_eq!(omitted.cd_scale, 1.0);
1356
1357 let a_omitted = compute_derivatives(
1358 Vector3::zeros(),
1359 Vector3::new(700.0, 0.0, 0.0),
1360 &omitted,
1361 Vector3::zeros(),
1362 (1.225, 340.0, 0.0, 0.0),
1363 omitted.bc_value,
1364 None,
1365 0.0,
1366 None,
1367 );
1368 let a_explicit = deck_accel_x(1.0);
1369 assert_eq!(
1370 a_omitted[3].to_bits(),
1371 a_explicit.to_bits(),
1372 "omitted cd_scale (Default) must be bit-identical to an explicit 1.0"
1373 );
1374 }
1375
1376 #[test]
1379 fn cd_scale_direction_on_derivatives_kernel() {
1380 let baseline = deck_accel_x(1.0);
1381 let scaled_up = deck_accel_x(1.10);
1382 let scaled_down = deck_accel_x(0.90);
1383
1384 assert!(
1385 scaled_up < baseline,
1386 "cd_scale=1.10 must increase drag deceleration (more negative ax): \
1387 base={baseline} up={scaled_up}"
1388 );
1389 assert!(
1390 scaled_down > baseline,
1391 "cd_scale=0.90 must decrease drag deceleration (less negative ax): \
1392 base={baseline} down={scaled_down}"
1393 );
1394 }
1395
1396 #[test]
1398 fn cd_scale_is_inert_without_a_custom_drag_table() {
1399 let make = |cd_scale: f64| BallisticInputs {
1400 cd_scale,
1401 ..create_test_inputs()
1402 };
1403 let accel = |cd_scale: f64| {
1404 let inputs = make(cd_scale);
1405 compute_derivatives(
1406 Vector3::zeros(),
1407 Vector3::new(700.0, 0.0, 0.0),
1408 &inputs,
1409 Vector3::zeros(),
1410 (1.225, 340.0, 0.0, 0.0),
1411 inputs.bc_value,
1412 None,
1413 0.0,
1414 None,
1415 )[3]
1416 };
1417 assert_eq!(
1418 accel(1.0).to_bits(),
1419 accel(1.5).to_bits(),
1420 "cd_scale must not affect the G-model/BC drag path"
1421 );
1422 }
1423}