1use crate::cluster_bc::ClusterBCDegradation;
3use crate::mc_stats::{wilson_interval, BernoulliConfidenceSequence, ConfidenceLevel, Welford};
4use crate::pitch_damping::{calculate_pitch_damping_coefficient, PitchDampingCoefficients};
5use crate::precession_nutation::{
6 calculate_combined_angular_motion, projectile_moments_of_inertia, AngularState,
7 PrecessionNutationParams,
8};
9use crate::trajectory_observation::{bracket_param, Bracket, TrajectoryTermination};
10use crate::trajectory_sampling::{
11 projected_sample_count, sample_trajectory, TrajectoryData, TrajectoryOutputs, TrajectorySample,
12};
13use crate::wind_shear::WindShearModel;
14use crate::DragModel;
15use nalgebra::{Vector3, Vector6};
16use std::error::Error;
17use std::fmt;
18
19#[derive(Debug, Clone, Copy, PartialEq)]
29#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
30pub enum UnitSystem {
31 Metric,
33 Imperial,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq)]
39pub enum OutputFormat {
40 Table,
41 Json,
42 Csv,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
67pub enum BcReferenceStandard {
68 #[default]
71 Icao,
72 ArmyStandardMetro,
75}
76
77pub const BC_REFERENCE_STANDARD_INERT_WARNING: &str =
81 "warning: --bc-reference army-standard-metro has no effect together with a custom drag \
82 table (--drag-table): the deck's Cd is divided by sectional density, not a BC value, so \
83 no BC-reference conversion applies";
84
85#[derive(Debug)]
87pub struct BallisticsError {
88 message: String,
89}
90
91impl fmt::Display for BallisticsError {
92 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
93 write!(f, "{}", self.message)
94 }
95}
96
97impl Error for BallisticsError {}
98
99impl From<String> for BallisticsError {
100 fn from(msg: String) -> Self {
101 BallisticsError { message: msg }
102 }
103}
104
105impl From<&str> for BallisticsError {
106 fn from(msg: &str) -> Self {
107 BallisticsError {
108 message: msg.to_string(),
109 }
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
121pub enum DropsReference {
122 #[default]
124 Los,
125 Target,
128}
129
130#[derive(Debug, Clone)]
134pub struct BallisticInputs {
135 pub bc_value: f64, pub bc_type: DragModel, pub bc_reference_standard: BcReferenceStandard,
143 pub bullet_mass: f64, pub muzzle_velocity: f64, pub bullet_diameter: f64, pub bullet_length: f64, pub muzzle_angle: f64, pub target_distance: f64, pub azimuth_angle: f64, pub shot_azimuth: f64,
157 pub shooting_angle: f64, pub cant_angle: f64,
167 pub sight_height: f64, pub sight_offset_lateral_m: f64,
179 pub muzzle_height: f64, pub target_height: f64, pub zero_poi_vertical_m: f64,
190 pub zero_poi_horizontal_m: f64,
196 pub ground_threshold: f64, pub altitude: f64, pub temperature: f64, pub pressure: f64, pub humidity: f64,
207 pub latitude: Option<f64>, pub wind_speed: f64, pub wind_angle: f64, pub twist_rate: f64, pub is_twist_right: bool, pub caliber_inches: f64, pub weight_grains: f64, pub manufacturer: Option<String>, pub bullet_model: Option<String>, pub bullet_id: Option<String>, pub bullet_cluster: Option<usize>, pub use_rk4: bool, pub use_adaptive_rk45: bool, pub enable_advanced_effects: bool,
229 pub enable_magnus: bool, pub enable_coriolis: bool, pub use_powder_sensitivity: bool,
232 pub powder_temp_sensitivity: f64, pub powder_temp: f64, pub powder_temp_curve: Option<Vec<(f64, f64)>>,
241 pub powder_curve_temp_c: Option<f64>,
245 pub tipoff_yaw: f64, pub tipoff_decay_distance: f64, pub cd_delta2: f64,
253 pub use_bc_segments: bool,
263 pub bc_segments: Option<Vec<(f64, f64)>>, pub bc_segments_data: Option<Vec<crate::BCSegmentData>>, pub use_enhanced_spin_drift: bool,
266 pub use_form_factor: bool,
269 pub enable_wind_shear: bool,
270 pub wind_shear_model: String,
271 pub enable_trajectory_sampling: bool,
272 pub sample_interval: f64, pub drops_reference: DropsReference,
282 pub enable_pitch_damping: bool,
283 pub enable_precession_nutation: bool,
284 pub enable_aerodynamic_jump: bool,
287 pub use_cluster_bc: bool, pub custom_drag_table: Option<crate::drag::DragTable>,
304 pub cd_scale: f64,
318 pub custom_drag_reference: bool,
338
339 pub bc_type_str: Option<String>,
341}
342
343impl BallisticInputs {
344 pub fn humidity_percent(&self) -> f64 {
349 (self.humidity * 100.0).clamp(0.0, 100.0)
350 }
351
352 pub fn windage_zero_bias_rad(&self, zero_distance_m: f64) -> f64 {
367 if zero_distance_m > 0.0 {
368 (self.zero_poi_horizontal_m + self.sight_offset_lateral_m) / zero_distance_m
369 } else {
370 0.0
371 }
372 }
373
374 pub fn sectional_density_lb_in2(&self) -> Option<f64> {
380 let weight_gr = if self.weight_grains > 0.0 {
381 self.weight_grains
382 } else {
383 self.bullet_mass / crate::constants::GRAINS_TO_KG };
385 let diameter_in = if self.caliber_inches > 0.0 {
386 self.caliber_inches
387 } else {
388 self.bullet_diameter / 0.0254 };
390 if weight_gr > 0.0 && diameter_in > 0.0 {
391 Some(weight_gr / 7000.0 / (diameter_in * diameter_in))
392 } else {
393 None
394 }
395 }
396
397 pub fn custom_drag_denominator(&self, resolved_bc: f64) -> f64 {
415 if self.custom_drag_reference {
416 return resolved_bc;
417 }
418 match self.sectional_density_lb_in2() {
419 Some(sd) => sd,
420 None => {
421 static WARN_ONCE: std::sync::Once = std::sync::Once::new();
422 WARN_ONCE.call_once(|| {
423 eprintln!(
424 "Warning: custom drag table active but bullet mass/diameter are \
425 unavailable; falling back to bc_value for the retardation denominator"
426 );
427 });
428 resolved_bc
429 }
430 }
431 }
432
433 pub fn bc_reference_standard_inert_warning(&self) -> Option<&'static str> {
446 if self.custom_drag_table.is_some()
447 && !self.custom_drag_reference
448 && matches!(
449 self.bc_reference_standard,
450 BcReferenceStandard::ArmyStandardMetro
451 )
452 {
453 Some(BC_REFERENCE_STANDARD_INERT_WARNING)
454 } else {
455 None
456 }
457 }
458
459 pub fn normalize_for_solve(&mut self) {
478 if matches!(
491 self.bc_reference_standard,
492 BcReferenceStandard::ArmyStandardMetro
493 ) {
494 self.bc_value *= crate::constants::ASM_TO_ICAO_BC;
495 if let Some(segments) = self.bc_segments.as_mut() {
496 for (_mach, bc) in segments.iter_mut() {
497 *bc *= crate::constants::ASM_TO_ICAO_BC;
498 }
499 }
500 if let Some(segments) = self.bc_segments_data.as_mut() {
501 for segment in segments.iter_mut() {
502 segment.bc_value *= crate::constants::ASM_TO_ICAO_BC;
503 }
504 }
505 self.bc_reference_standard = BcReferenceStandard::Icao;
508 }
509
510 self.caliber_inches = self.bullet_diameter / 0.0254;
515 self.weight_grains = self.bullet_mass / crate::constants::GRAINS_TO_KG;
516
517 self.muzzle_velocity = resolve_powder_adjusted_velocity(
528 self.muzzle_velocity,
529 self.temperature,
530 self.use_powder_sensitivity,
531 self.powder_temp_sensitivity,
532 self.powder_temp,
533 self.powder_temp_curve.as_deref(),
534 self.powder_curve_temp_c,
535 );
536 }
537}
538
539impl Default for BallisticInputs {
540 fn default() -> Self {
541 let mass_kg = 0.01;
542 let diameter_m = 0.00762;
543 let bc = 0.5;
544 let muzzle_angle_rad = 0.0;
545 let bc_type = DragModel::G1;
546
547 Self {
548 bc_value: bc,
550 bc_type,
551 bc_reference_standard: BcReferenceStandard::Icao,
552 bullet_mass: mass_kg,
553 muzzle_velocity: 800.0,
554 bullet_diameter: diameter_m,
555 bullet_length: crate::stability::estimate_bullet_length_m(diameter_m, mass_kg),
559
560 muzzle_angle: muzzle_angle_rad,
562 target_distance: 100.0,
563 azimuth_angle: 0.0,
564 shot_azimuth: 0.0,
565 shooting_angle: 0.0,
566 cant_angle: 0.0,
567 sight_height: 0.05,
568 sight_offset_lateral_m: 0.0, muzzle_height: 0.0, target_height: 0.0, zero_poi_vertical_m: 0.0, zero_poi_horizontal_m: 0.0,
573 ground_threshold: -100.0, altitude: 0.0,
577 temperature: 15.0,
578 pressure: 1013.25, humidity: 0.5, latitude: None,
581
582 wind_speed: 0.0,
584 wind_angle: 0.0,
585
586 twist_rate: 12.0, is_twist_right: true,
589 caliber_inches: diameter_m / 0.0254, weight_grains: mass_kg / crate::constants::GRAINS_TO_KG, manufacturer: None,
592 bullet_model: None,
593 bullet_id: None,
594 bullet_cluster: None,
595
596 use_rk4: true, use_adaptive_rk45: true, enable_advanced_effects: false,
602 enable_magnus: false,
603 enable_coriolis: false,
604 use_powder_sensitivity: false,
605 powder_temp_sensitivity: 0.0,
606 powder_temp: 15.0,
607 powder_temp_curve: None,
608 powder_curve_temp_c: None,
609 tipoff_yaw: 0.0,
610 tipoff_decay_distance: 50.0,
611 cd_delta2: 7.5,
612 use_bc_segments: false,
613 bc_segments: None,
614 bc_segments_data: None,
615 use_enhanced_spin_drift: false,
616 use_form_factor: false,
617 enable_wind_shear: false,
618 wind_shear_model: "none".to_string(),
619 enable_trajectory_sampling: false,
620 sample_interval: 10.0, drops_reference: DropsReference::Los, enable_pitch_damping: false,
623 enable_precession_nutation: false,
624 enable_aerodynamic_jump: false,
625 use_cluster_bc: false, custom_drag_table: None,
629 cd_scale: 1.0,
630 custom_drag_reference: false,
631
632 bc_type_str: None,
634 }
635 }
636}
637
638pub fn interpolate_powder_temp_curve(curve: &[(f64, f64)], temp_c: f64) -> f64 {
644 debug_assert!(!curve.is_empty());
645 if curve.is_empty() {
646 return 0.0;
647 }
648 let mut sorted;
651 let pts: &[(f64, f64)] = if curve.windows(2).all(|w| w[0].0 <= w[1].0) {
652 curve
653 } else {
654 sorted = curve.to_vec();
655 sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
656 &sorted
657 };
658 let n = pts.len();
659 if temp_c <= pts[0].0 {
660 return pts[0].1; }
662 if temp_c >= pts[n - 1].0 {
663 return pts[n - 1].1; }
665 for i in 1..n {
666 let (t0, v0) = pts[i - 1];
667 let (t1, v1) = pts[i];
668 if temp_c <= t1 {
669 let span = t1 - t0;
670 if span.abs() < f64::EPSILON {
671 return v1; }
673 let f = (temp_c - t0) / span;
674 return v0 + f * (v1 - v0);
675 }
676 }
677 pts[n - 1].1
678}
679
680pub fn parse_powder_sweep(s: &str) -> Result<Vec<f64>, String> {
686 const MAX_SWEEP_ROWS: usize = 500;
687 let parts: Vec<&str> = s.split(':').collect();
688 if parts.len() != 3 {
689 return Err(format!(
690 "Invalid --sweep '{}': expected START:END:STEP (e.g. \"20:110:10\")",
691 s
692 ));
693 }
694 let parse = |p: &str, name: &str| -> Result<f64, String> {
695 p.trim()
696 .parse::<f64>()
697 .map_err(|_| format!("Invalid --sweep {}: '{}' is not a number", name, p.trim()))
698 };
699 let start = parse(parts[0], "START")?;
700 let end = parse(parts[1], "END")?;
701 let step = parse(parts[2], "STEP")?;
702 if !step.is_finite() || step <= 0.0 {
703 return Err(format!("Invalid --sweep STEP {}: must be positive", step));
704 }
705 if !start.is_finite() || !end.is_finite() || end < start {
706 return Err(format!(
707 "Invalid --sweep range {}:{}: END must be >= START",
708 start, end
709 ));
710 }
711 let n_f = ((end - start) / step + 1e-9).floor();
717 if !n_f.is_finite() || n_f + 1.0 > MAX_SWEEP_ROWS as f64 {
718 return Err(format!(
719 "--sweep would produce more than {} rows; use a larger STEP",
720 MAX_SWEEP_ROWS
721 ));
722 }
723 let n = n_f as usize + 1;
724 Ok((0..n).map(|i| start + step * i as f64).collect())
726}
727
728pub fn resolve_powder_adjusted_velocity(
737 nominal_velocity_mps: f64,
738 ambient_temperature_c: f64,
739 use_powder_sensitivity: bool,
740 powder_temp_sensitivity_mps_per_c: f64,
741 powder_reference_temp_c: f64,
742 powder_temp_curve: Option<&[(f64, f64)]>,
743 powder_curve_temp_c: Option<f64>,
744) -> f64 {
745 if let Some(curve) = powder_temp_curve {
746 if !curve.is_empty() {
747 let lookup_c = powder_curve_temp_c.unwrap_or(ambient_temperature_c);
748 return interpolate_powder_temp_curve(curve, lookup_c);
749 }
750 return nominal_velocity_mps;
753 }
754 if use_powder_sensitivity {
755 let temp_delta_c = ambient_temperature_c - powder_reference_temp_c;
756 return nominal_velocity_mps + powder_temp_sensitivity_mps_per_c * temp_delta_c;
757 }
758 nominal_velocity_mps
759}
760
761#[derive(Debug, Clone)]
763pub struct WindConditions {
764 pub speed: f64, pub direction: f64,
768 pub vertical_speed: f64,
776}
777
778impl Default for WindConditions {
779 fn default() -> Self {
780 Self {
781 speed: 0.0,
782 direction: 0.0,
783 vertical_speed: 0.0,
784 }
785 }
786}
787
788#[derive(Debug, Clone)]
790pub struct AtmosphericConditions {
791 pub temperature: f64, pub pressure: f64, pub humidity: f64,
797 pub altitude: f64, }
799
800impl Default for AtmosphericConditions {
801 fn default() -> Self {
802 Self {
803 temperature: 15.0,
804 pressure: 1013.25,
805 humidity: 50.0,
806 altitude: 0.0,
807 }
808 }
809}
810
811#[derive(Debug, Clone)]
813pub struct TrajectoryPoint {
814 pub time: f64,
815 pub position: Vector3<f64>,
816 pub velocity_magnitude: f64,
817 pub kinetic_energy: f64,
818 pub drag_coefficient: Option<f64>,
825}
826
827impl TrajectoryPoint {
828 pub fn drag_coefficient_json_value(&self, with_drag_coefficient: bool) -> Option<f64> {
836 if with_drag_coefficient {
837 self.drag_coefficient
838 } else {
839 None
840 }
841 }
842}
843
844#[derive(Debug, Clone)]
846pub struct TrajectoryResult {
847 pub max_range: f64,
848 pub max_height: f64,
849 pub time_of_flight: f64,
850 pub impact_velocity: f64,
851 pub impact_energy: f64,
852 pub projectile_mass_kg: f64,
854 pub line_of_sight_height_m: f64,
856 pub station_speed_of_sound_mps: f64,
858 pub termination: TrajectoryTermination,
860 pub points: Vec<TrajectoryPoint>,
861 pub sampled_points: Option<Vec<TrajectorySample>>, pub min_pitch_damping: Option<f64>, pub transonic_mach: Option<f64>, pub angular_state: Option<AngularState>, pub max_yaw_angle: Option<f64>, pub max_precession_angle: Option<f64>, pub aerodynamic_jump: Option<crate::aerodynamic_jump::AerodynamicJumpComponents>,
870 pub mach_1_2_distance_m: Option<f64>,
875 pub mach_1_0_distance_m: Option<f64>,
879 pub mach_0_9_distance_m: Option<f64>,
887}
888
889const RK45_TOLERANCE: f64 = 1e-6;
890const RK45_SAFETY_FACTOR: f64 = 0.9;
891const RK45_MAX_DT: f64 = 0.01;
892const RK45_MIN_DT: f64 = 1e-6;
893const TRAJECTORY_TIME_LIMIT_S: f64 = 100.0;
894
895pub const MAX_TRAJECTORY_POINTS: usize = 250_000;
901
902fn cli_rk45_error_norm(
904 position: &Vector3<f64>,
905 velocity: &Vector3<f64>,
906 fifth_position: &Vector3<f64>,
907 fifth_velocity: &Vector3<f64>,
908 fourth_position: &Vector3<f64>,
909 fourth_velocity: &Vector3<f64>,
910) -> f64 {
911 let pack_state = |position: &Vector3<f64>, velocity: &Vector3<f64>| {
912 Vector6::new(
913 position.x, position.y, position.z, velocity.x, velocity.y, velocity.z,
914 )
915 };
916 let state = pack_state(position, velocity);
917 let fifth_order = pack_state(fifth_position, fifth_velocity);
918 let fourth_order = pack_state(fourth_position, fourth_velocity);
919
920 crate::trajectory_integration::rk45_error_norm(&state, &fifth_order, &fourth_order)
921}
922
923struct Rk45Trial {
924 position: Vector3<f64>,
925 velocity: Vector3<f64>,
926 suggested_dt: f64,
927 error: f64,
928}
929
930struct Rk45AcceptedStep {
931 position: Vector3<f64>,
932 velocity: Vector3<f64>,
933 used_dt: f64,
934 next_dt: f64,
935 error: f64,
936}
937
938#[derive(Default)]
952struct MachTransitionTracker {
953 previous_mach: Option<f64>,
954 crossed_transonic: bool,
955 crossed_subsonic: bool,
956 crossed_narrow: bool,
957 mach_1_2_distance_m: Option<f64>,
960 mach_1_0_distance_m: Option<f64>,
963 mach_0_9_distance_m: Option<f64>,
966}
967
968impl MachTransitionTracker {
969 fn record_downward_crossings(&mut self, mach: f64, downrange_m: f64, distances: &mut Vec<f64>) {
970 if !mach.is_finite() {
971 self.previous_mach = None;
972 return;
973 }
974
975 if let Some(previous_mach) = self.previous_mach {
976 if !self.crossed_transonic && previous_mach >= 1.2 && mach < 1.2 {
977 self.crossed_transonic = true;
978 distances.push(downrange_m);
979 self.mach_1_2_distance_m = Some(downrange_m);
980 }
981 if !self.crossed_subsonic && previous_mach >= 1.0 && mach < 1.0 {
982 self.crossed_subsonic = true;
983 distances.push(downrange_m);
984 self.mach_1_0_distance_m = Some(downrange_m);
985 }
986 if !self.crossed_narrow && previous_mach >= 0.9 && mach < 0.9 {
987 self.crossed_narrow = true;
988 self.mach_0_9_distance_m = Some(downrange_m);
990 }
991 }
992 self.previous_mach = Some(mach);
993 }
994}
995
996impl TrajectoryResult {
997 pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
1001 if self.points.is_empty() {
1002 return None;
1003 }
1004
1005 for i in 0..self.points.len() - 1 {
1007 let p1 = &self.points[i];
1008 let p2 = &self.points[i + 1];
1009
1010 if p1.position.x <= target_range && p2.position.x >= target_range {
1012 let dx = p2.position.x - p1.position.x;
1014 if dx.abs() < 1e-10 {
1015 return Some(p1.position);
1016 }
1017 let t = (target_range - p1.position.x) / dx;
1018
1019 return Some(Vector3::new(
1021 target_range,
1022 p1.position.y + t * (p2.position.y - p1.position.y),
1023 p1.position.z + t * (p2.position.z - p1.position.z),
1024 ));
1025 }
1026 }
1027
1028 self.points.last().map(|p| p.position)
1030 }
1031}
1032
1033#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1035enum StationAtmosphereResolution {
1036 LegacyDefaultSentinels,
1039 Authoritative,
1042}
1043
1044#[derive(Clone)]
1045pub struct TrajectorySolver {
1046 inputs: BallisticInputs,
1047 wind: WindConditions,
1048 atmosphere: AtmosphericConditions,
1049 station_atmosphere_resolution: StationAtmosphereResolution,
1050 max_range: f64,
1051 time_step: f64,
1052 max_trajectory_points: usize,
1053 cluster_bc: Option<ClusterBCDegradation>,
1054 precession_nutation_inertias: (f64, f64),
1056 wind_sock: Option<crate::wind::WindSock>,
1061 atmo_sock: Option<crate::atmosphere::AtmoSock>,
1068}
1069
1070#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1082pub(crate) enum ZeroTargetFrame {
1083 SightLine,
1084 WorldVertical,
1085}
1086
1087#[derive(Debug, Clone, Copy, PartialEq)]
1100pub struct ZeroCrossings {
1101 pub near_m: Option<f64>,
1104 pub far_m: Option<f64>,
1107}
1108
1109impl TrajectorySolver {
1110 pub fn new(
1111 inputs: BallisticInputs,
1112 wind: WindConditions,
1113 atmosphere: AtmosphericConditions,
1114 ) -> Self {
1115 Self::new_with_station_atmosphere_resolution(
1116 inputs,
1117 wind,
1118 atmosphere,
1119 StationAtmosphereResolution::LegacyDefaultSentinels,
1120 )
1121 }
1122
1123 pub fn new_with_resolved_station_atmosphere(
1134 inputs: BallisticInputs,
1135 wind: WindConditions,
1136 atmosphere: AtmosphericConditions,
1137 ) -> Self {
1138 Self::new_with_station_atmosphere_resolution(
1139 inputs,
1140 wind,
1141 atmosphere,
1142 StationAtmosphereResolution::Authoritative,
1143 )
1144 }
1145
1146 fn new_with_station_atmosphere_resolution(
1147 mut inputs: BallisticInputs,
1148 wind: WindConditions,
1149 atmosphere: AtmosphericConditions,
1150 station_atmosphere_resolution: StationAtmosphereResolution,
1151 ) -> Self {
1152 inputs.normalize_for_solve();
1157
1158 let cluster_bc = if inputs.use_cluster_bc {
1160 Some(ClusterBCDegradation::new())
1161 } else {
1162 None
1163 };
1164 let precession_nutation_inertias = projectile_moments_of_inertia(
1165 inputs.bullet_mass,
1166 inputs.bullet_diameter,
1167 inputs.bullet_length,
1168 );
1169
1170 Self {
1171 inputs,
1172 wind,
1173 atmosphere,
1174 station_atmosphere_resolution,
1175 max_range: 1000.0,
1176 time_step: 0.001,
1177 max_trajectory_points: MAX_TRAJECTORY_POINTS,
1178 cluster_bc,
1179 precession_nutation_inertias,
1180 wind_sock: None,
1181 atmo_sock: None,
1182 }
1183 }
1184
1185 pub fn set_max_range(&mut self, range: f64) {
1186 self.max_range = range;
1187 }
1188
1189 pub fn set_time_step(&mut self, step: f64) {
1190 self.time_step = step;
1191 }
1192
1193 pub(crate) fn calculate_and_set_zero_angle(
1197 &mut self,
1198 target_distance_m: f64,
1199 target_height_m: f64,
1200 frame: ZeroTargetFrame,
1201 ) -> Result<f64, BallisticsError> {
1202 let angle = self.find_zero_angle(target_distance_m, target_height_m, frame)?;
1203 let angle = if target_distance_m > 0.0 {
1212 angle + self.inputs.zero_poi_vertical_m / target_distance_m
1213 } else {
1214 angle
1215 };
1216 self.inputs.muzzle_angle = angle;
1217 self.apply_windage_zero_bias(target_distance_m);
1218 Ok(angle)
1219 }
1220
1221 pub(crate) fn apply_windage_zero_bias(&mut self, target_distance_m: f64) {
1235 self.inputs.azimuth_angle += self.inputs.windage_zero_bias_rad(target_distance_m);
1236 }
1237
1238 fn find_zero_angle(
1239 &self,
1240 target_distance_m: f64,
1241 target_height_m: f64,
1242 frame: ZeroTargetFrame,
1243 ) -> Result<f64, BallisticsError> {
1244 let mut low_angle = 0.0;
1248 let mut high_angle = 0.2; let tolerance = 1e-7;
1250 let max_iterations = 60;
1251
1252 let low_height = self.zero_trial_height_at(low_angle, target_distance_m, frame)?;
1254 let high_height = self.zero_trial_height_at(high_angle, target_distance_m, frame)?;
1255
1256 match (low_height, high_height) {
1257 (Some(low_height), Some(high_height)) => {
1258 let low_error = low_height - target_height_m;
1259 let high_error = high_height - target_height_m;
1260
1261 if low_error > 0.0 && high_error > 0.0 {
1262 let mut expanded = false;
1269 for multiplier in [1.0, 2.0, 3.0, 4.0] {
1270 let new_low = (-0.2_f64 * multiplier).max(-0.785);
1271 match self.zero_trial_height_at(new_low, target_distance_m, frame) {
1272 Ok(Some(height)) if height - target_height_m > 0.0 => {}
1273 Ok(_) => {
1274 high_angle = low_angle;
1275 low_angle = new_low;
1276 expanded = true;
1277 break;
1278 }
1279 Err(_) => {}
1280 }
1281 }
1282 if !expanded {
1283 return Err("Cannot find zero angle: target is below the trajectory even at the steepest downward angle".into());
1284 }
1285 } else if low_error < 0.0 && high_error < 0.0 {
1286 let mut expanded = false;
1288 for multiplier in [2.0, 3.0, 4.0] {
1289 let new_high = (high_angle * multiplier).min(0.785);
1290 if let Ok(Some(height)) =
1291 self.zero_trial_height_at(new_high, target_distance_m, frame)
1292 {
1293 if height - target_height_m > 0.0 {
1294 high_angle = new_high;
1295 expanded = true;
1296 break;
1297 }
1298 }
1299 if new_high >= 0.785 {
1300 break;
1301 }
1302 }
1303 if !expanded {
1304 return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
1305 }
1306 }
1307 }
1308 (None, Some(_)) => {
1309 }
1312 (Some(_), None) => {
1313 return Err(
1314 "Cannot find zero angle: high angle trajectory doesn't reach target distance"
1315 .into(),
1316 );
1317 }
1318 (None, None) => {
1319 return Err(
1320 "Cannot find zero angle: trajectory cannot reach target distance at any angle"
1321 .into(),
1322 );
1323 }
1324 }
1325
1326 for _ in 0..max_iterations {
1327 let mid_angle = (low_angle + high_angle) / 2.0;
1328 match self.zero_trial_height_at(mid_angle, target_distance_m, frame)? {
1329 Some(height) => {
1330 let error = height - target_height_m;
1331 if error.abs() < 0.0001 {
1334 return Ok(mid_angle);
1335 }
1336
1337 if (high_angle - low_angle).abs() < tolerance {
1340 if error.abs() < 0.01 {
1341 return Ok(mid_angle);
1342 }
1343 return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
1344 }
1345
1346 if error > 0.0 {
1347 high_angle = mid_angle;
1348 } else {
1349 low_angle = mid_angle;
1350 }
1351 }
1352 None => {
1353 low_angle = mid_angle;
1354 if (high_angle - low_angle).abs() < tolerance {
1355 return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
1356 }
1357 }
1358 }
1359 }
1360
1361 Err("Failed to find zero angle".into())
1362 }
1363
1364 fn zero_trial_height_at(
1367 &self,
1368 angle_rad: f64,
1369 target_distance_m: f64,
1370 frame: ZeroTargetFrame,
1371 ) -> Result<Option<f64>, BallisticsError> {
1372 let mut trial = self.clone();
1373 trial.inputs.muzzle_angle = angle_rad;
1374 trial.inputs.enable_aerodynamic_jump = false;
1377 trial.inputs.cant_angle = 0.0;
1380 if frame == ZeroTargetFrame::SightLine {
1387 trial.inputs.shooting_angle = 0.0;
1388 }
1389 trial.set_max_range(target_distance_m * 2.0);
1390 let result = trial.solve()?;
1391
1392 for (index, point) in result.points.iter().enumerate() {
1393 if point.position.x >= target_distance_m {
1394 let shot_y_m = if index == 0 {
1395 point.position.y
1396 } else {
1397 let previous = &result.points[index - 1];
1398 let span = point.position.x - previous.position.x;
1399 let fraction = (target_distance_m - previous.position.x) / span;
1400 previous.position.y + fraction * (point.position.y - previous.position.y)
1401 };
1402 return Ok(Some(crate::atmosphere::shot_frame_altitude(
1403 0.0,
1404 target_distance_m,
1405 shot_y_m,
1406 trial.inputs.shooting_angle,
1407 )));
1408 }
1409 }
1410 Ok(None)
1411 }
1412
1413 fn find_zero_range(
1440 &self,
1441 angle_rad: f64,
1442 target_height_m: f64,
1443 frame: ZeroTargetFrame,
1444 ) -> Result<ZeroCrossings, BallisticsError> {
1445 let mut trial = self.clone();
1446 trial.inputs.muzzle_angle = angle_rad;
1447 trial.inputs.enable_aerodynamic_jump = false;
1450 trial.inputs.cant_angle = 0.0;
1451 if frame == ZeroTargetFrame::SightLine {
1452 trial.inputs.shooting_angle = 0.0;
1453 }
1454 let result = trial.solve()?;
1455
1456 let mut near_crossing: Option<f64> = None;
1464 let mut far_crossing: Option<f64> = None;
1465 let mut previous: Option<(f64, f64)> = None; for point in &result.points {
1467 let height = crate::atmosphere::shot_frame_altitude(
1468 0.0,
1469 point.position.x,
1470 point.position.y,
1471 trial.inputs.shooting_angle,
1472 );
1473 let error = height - target_height_m;
1474 if let Some((prev_x, prev_error)) = previous {
1475 if prev_error == 0.0 {
1476 if near_crossing.is_none() {
1479 near_crossing = Some(prev_x);
1480 } else {
1481 far_crossing = Some(prev_x);
1482 }
1483 }
1484 if prev_error * error < 0.0 {
1485 let fraction = prev_error / (prev_error - error);
1486 let crossing = prev_x + fraction * (point.position.x - prev_x);
1487 if prev_error < 0.0 && error > 0.0 {
1488 if near_crossing.is_none() {
1490 near_crossing = Some(crossing);
1491 }
1492 } else {
1493 far_crossing = Some(crossing);
1495 }
1496 }
1497 }
1498 previous = Some((point.position.x, error));
1499 }
1500 if let Some((last_x, last_error)) = previous {
1503 if last_error == 0.0 {
1504 if near_crossing.is_none() {
1505 near_crossing = Some(last_x);
1506 } else {
1507 far_crossing = Some(last_x);
1508 }
1509 }
1510 }
1511
1512 if near_crossing.is_none() && far_crossing.is_none() {
1513 return Err(BallisticsError::from(
1514 "Cannot find zero range: this angle never crosses the target height within the \
1515 solved range (angle too shallow to reach it, or both crossings lie beyond \
1516 the solver's max range)."
1517 .to_string(),
1518 ));
1519 }
1520
1521 Ok(ZeroCrossings {
1522 near_m: near_crossing,
1523 far_m: far_crossing,
1524 })
1525 }
1526
1527 pub fn equivalent_horizontal_range(
1551 &self,
1552 target_range_m: f64,
1553 zero_distance_m: f64,
1554 ) -> Option<f64> {
1555 if !target_range_m.is_finite() || !zero_distance_m.is_finite() {
1556 return None;
1557 }
1558 if target_range_m <= zero_distance_m || target_range_m <= 0.0 {
1559 return None;
1560 }
1561
1562 fn path_y_at(points: &[TrajectoryPoint], distance_m: f64) -> Option<f64> {
1567 match bracket_param(points.len(), |i| points[i].position.x, distance_m) {
1568 Bracket::Below => Some(points[0].position.y),
1573 Bracket::Above | Bracket::Degenerate => None,
1574 Bracket::Inside { lo, t } => {
1575 let hi = lo + 1;
1576 Some(
1577 points[lo].position.y + t * (points[hi].position.y - points[lo].position.y),
1578 )
1579 }
1580 }
1581 }
1582
1583 let mut inclined = self.clone();
1587 inclined.inputs.enable_trajectory_sampling = false;
1588 let inclined_result = inclined.solve().ok()?;
1589 let los_height = inclined_result.line_of_sight_height_m;
1590 let inclined_drop = los_height - path_y_at(&inclined_result.points, target_range_m)?;
1591 let correction = inclined_drop / target_range_m;
1592 if correction <= 0.0 {
1593 return None;
1594 }
1595
1596 let mut flat = self.clone();
1598 flat.inputs.enable_trajectory_sampling = false;
1599 flat.inputs.shooting_angle = 0.0;
1600 let flat_result = flat.solve().ok()?;
1601 let flat_correction_at = |range_m: f64| -> Option<f64> {
1602 Some((los_height - path_y_at(&flat_result.points, range_m)?) / range_m)
1603 };
1604
1605 let flat_terminal_m = flat_result.points.last().map(|p| p.position.x)?;
1613 let mut low = zero_distance_m.max(1.0);
1614 let mut high = target_range_m.min(flat_terminal_m);
1615 if high <= low {
1616 return None;
1617 }
1618 if flat_correction_at(low)? - correction > 0.0 {
1619 return None; }
1621 if flat_correction_at(high)? - correction < 0.0 {
1622 return None; }
1624 for _ in 0..60 {
1625 let mid = 0.5 * (low + high);
1626 let error = flat_correction_at(mid)? - correction;
1627 if error.abs() == 0.0 {
1628 return Some(mid);
1629 }
1630 if error < 0.0 {
1631 low = mid;
1632 } else {
1633 high = mid;
1634 }
1635 if high - low < 0.01 {
1636 break;
1637 }
1638 }
1639 Some(0.5 * (low + high))
1640 }
1641
1642 fn validate_for_solve(&self) -> Result<(), BallisticsError> {
1648 let require_finite = |name: &str, value: f64| {
1649 if value.is_finite() {
1650 Ok(())
1651 } else {
1652 Err(BallisticsError::from(format!("{name} must be finite")))
1653 }
1654 };
1655 let require_positive = |name: &str, value: f64| {
1656 if value.is_finite() && value > 0.0 {
1657 Ok(())
1658 } else {
1659 Err(BallisticsError::from(format!(
1660 "{name} must be finite and greater than zero"
1661 )))
1662 }
1663 };
1664
1665 if self.inputs.custom_drag_table.is_none() || self.inputs.custom_drag_reference {
1674 require_positive("bc_value", self.inputs.bc_value)?;
1675 }
1676 require_positive("bullet_mass", self.inputs.bullet_mass)?;
1677 require_positive("bullet_diameter", self.inputs.bullet_diameter)?;
1678 require_positive("muzzle_velocity", self.inputs.muzzle_velocity)?;
1679 require_positive("cd_scale", self.inputs.cd_scale)?;
1685
1686 require_finite("muzzle_angle", self.inputs.muzzle_angle)?;
1687 require_finite("azimuth_angle", self.inputs.azimuth_angle)?;
1688 require_finite("shooting_angle", self.inputs.shooting_angle)?;
1689 require_finite("cant_angle", self.inputs.cant_angle)?;
1690 require_finite("muzzle_height", self.inputs.muzzle_height)?;
1691
1692 for (name, value) in [
1696 ("zero_poi_vertical_m", self.inputs.zero_poi_vertical_m),
1697 ("zero_poi_horizontal_m", self.inputs.zero_poi_horizontal_m),
1698 ] {
1699 require_finite(name, value)?;
1700 if value.abs() >= 1.0 {
1701 return Err(BallisticsError::from(format!(
1702 "{name} must be smaller than 1.0 m in magnitude (it is a linear POI \
1703 offset at the zero range, in meters)"
1704 )));
1705 }
1706 }
1707
1708 require_finite("sight_offset_lateral_m", self.inputs.sight_offset_lateral_m)?;
1712 if self.inputs.sight_offset_lateral_m.abs() >= 0.5 {
1713 return Err(BallisticsError::from(
1714 "sight_offset_lateral_m must be smaller than 0.5 m in magnitude (it is \
1715 the lateral sight-to-bore mount offset, in meters)",
1716 ));
1717 }
1718
1719 if !(self.inputs.ground_threshold.is_finite()
1722 || self.inputs.ground_threshold == f64::NEG_INFINITY)
1723 {
1724 return Err(BallisticsError::from(
1725 "ground_threshold must be finite or negative infinity",
1726 ));
1727 }
1728
1729 match &self.wind_sock {
1730 Some(wind_sock) => wind_sock
1731 .validate_segments()
1732 .map_err(BallisticsError::from)?,
1733 None => {
1734 require_finite("wind.speed", self.wind.speed)?;
1735 require_finite("wind.direction", self.wind.direction)?;
1736 require_finite("wind.vertical_speed", self.wind.vertical_speed)?;
1737 }
1738 }
1739
1740 require_finite("atmosphere.temperature", self.atmosphere.temperature)?;
1741 require_finite("atmosphere.pressure", self.atmosphere.pressure)?;
1742 require_finite("atmosphere.humidity", self.atmosphere.humidity)?;
1743 require_finite("atmosphere.altitude", self.atmosphere.altitude)?;
1744
1745 require_positive("max_range", self.max_range)?;
1746 if !self.inputs.use_rk4 || !self.inputs.use_adaptive_rk45 {
1749 require_positive("time_step", self.time_step)?;
1750 }
1751
1752 if self.inputs.enable_trajectory_sampling {
1753 require_finite("sight_height", self.inputs.sight_height)?;
1754 require_positive("sample_interval", self.inputs.sample_interval)?;
1755 projected_sample_count(self.max_range, self.inputs.sample_interval)?;
1756 }
1757
1758 if self.inputs.drops_reference == DropsReference::Target {
1762 require_finite("target_height", self.inputs.target_height)?;
1763 if self.inputs.shooting_angle.cos() <= 1e-9 {
1764 return Err(BallisticsError::from(
1765 "drops reference 'target' is undefined for shooting angles at or beyond 90 degrees",
1766 ));
1767 }
1768 }
1769
1770 if self.inputs.enable_coriolis {
1771 require_finite("shot_azimuth", self.inputs.shot_azimuth)?;
1772 if let Some(latitude) = self.inputs.latitude {
1773 require_finite("latitude", latitude)?;
1774 }
1775 }
1776
1777 Ok(())
1778 }
1779
1780 fn validate_result_sanity(&self, result: &TrajectoryResult) -> Result<(), BallisticsError> {
1787 let require_finite = |name: &str, value: f64| {
1788 if value.is_finite() {
1789 Ok(())
1790 } else {
1791 Err(BallisticsError::from(format!(
1792 "trajectory result contains non-finite {name}"
1793 )))
1794 }
1795 };
1796 let require_non_negative = |name: &str, value: f64| {
1797 if value >= 0.0 {
1798 Ok(())
1799 } else {
1800 Err(BallisticsError::from(format!(
1801 "trajectory result contains non-physical negative {name} ({value})"
1802 )))
1803 }
1804 };
1805 let require_indexed_finite = |collection: &str, index: usize, field: &str, value: f64| {
1806 if value.is_finite() {
1807 Ok(())
1808 } else {
1809 Err(BallisticsError::from(format!(
1810 "trajectory result contains non-finite {collection}[{index}].{field}"
1811 )))
1812 }
1813 };
1814 let require_indexed_non_negative = |collection: &str,
1815 index: usize,
1816 field: &str,
1817 value: f64| {
1818 if value >= 0.0 {
1819 Ok(())
1820 } else {
1821 Err(BallisticsError::from(format!(
1822 "trajectory result contains non-physical negative {collection}[{index}].{field} ({value})"
1823 )))
1824 }
1825 };
1826
1827 require_finite("max_range", result.max_range)?;
1828 require_finite("max_height", result.max_height)?;
1829 require_finite("time_of_flight", result.time_of_flight)?;
1830 require_finite("impact_velocity", result.impact_velocity)?;
1831 require_finite("impact_energy", result.impact_energy)?;
1832 require_finite("projectile_mass_kg", result.projectile_mass_kg)?;
1833 require_finite("line_of_sight_height_m", result.line_of_sight_height_m)?;
1834 require_finite(
1835 "station_speed_of_sound_mps",
1836 result.station_speed_of_sound_mps,
1837 )?;
1838
1839 require_non_negative("max_range", result.max_range)?;
1843 require_non_negative("time_of_flight", result.time_of_flight)?;
1844 require_non_negative("impact_velocity", result.impact_velocity)?;
1845 require_non_negative("impact_energy", result.impact_energy)?;
1846 require_non_negative("projectile_mass_kg", result.projectile_mass_kg)?;
1847 require_non_negative(
1848 "station_speed_of_sound_mps",
1849 result.station_speed_of_sound_mps,
1850 )?;
1851
1852 for (index, point) in result.points.iter().enumerate() {
1853 require_indexed_finite("points", index, "time", point.time)?;
1854 require_indexed_finite("points", index, "position.x", point.position.x)?;
1855 require_indexed_finite("points", index, "position.y", point.position.y)?;
1856 require_indexed_finite("points", index, "position.z", point.position.z)?;
1857 require_indexed_finite(
1858 "points",
1859 index,
1860 "velocity_magnitude",
1861 point.velocity_magnitude,
1862 )?;
1863 require_indexed_finite("points", index, "kinetic_energy", point.kinetic_energy)?;
1864 require_indexed_non_negative("points", index, "time", point.time)?;
1865 require_indexed_non_negative(
1866 "points",
1867 index,
1868 "velocity_magnitude",
1869 point.velocity_magnitude,
1870 )?;
1871 require_indexed_non_negative("points", index, "kinetic_energy", point.kinetic_energy)?;
1872 }
1873
1874 if let Some(samples) = &result.sampled_points {
1875 for (index, sample) in samples.iter().enumerate() {
1876 require_indexed_finite("sampled_points", index, "distance_m", sample.distance_m)?;
1877 require_indexed_finite("sampled_points", index, "drop_m", sample.drop_m)?;
1878 require_indexed_finite(
1879 "sampled_points",
1880 index,
1881 "wind_drift_m",
1882 sample.wind_drift_m,
1883 )?;
1884 require_indexed_finite(
1885 "sampled_points",
1886 index,
1887 "velocity_mps",
1888 sample.velocity_mps,
1889 )?;
1890 require_indexed_finite("sampled_points", index, "energy_j", sample.energy_j)?;
1891 require_indexed_finite("sampled_points", index, "time_s", sample.time_s)?;
1892 }
1893 }
1894
1895 for (name, value) in [
1896 ("min_pitch_damping", result.min_pitch_damping),
1897 ("transonic_mach", result.transonic_mach),
1898 ("max_yaw_angle", result.max_yaw_angle),
1899 ("max_precession_angle", result.max_precession_angle),
1900 ] {
1901 if let Some(value) = value {
1902 require_finite(name, value)?;
1903 }
1904 }
1905
1906 if let Some(state) = result.angular_state {
1907 for (name, value) in [
1908 ("angular_state.pitch_angle", state.pitch_angle),
1909 ("angular_state.yaw_angle", state.yaw_angle),
1910 ("angular_state.pitch_rate", state.pitch_rate),
1911 ("angular_state.yaw_rate", state.yaw_rate),
1912 ("angular_state.precession_angle", state.precession_angle),
1913 ("angular_state.nutation_phase", state.nutation_phase),
1914 ] {
1915 require_finite(name, value)?;
1916 }
1917 }
1918
1919 if let Some(jump) = result.aerodynamic_jump {
1920 for (name, value) in [
1921 ("aerodynamic_jump.vertical_jump_moa", jump.vertical_jump_moa),
1922 (
1923 "aerodynamic_jump.horizontal_jump_moa",
1924 jump.horizontal_jump_moa,
1925 ),
1926 ("aerodynamic_jump.jump_angle_rad", jump.jump_angle_rad),
1927 (
1928 "aerodynamic_jump.magnus_component_moa",
1929 jump.magnus_component_moa,
1930 ),
1931 ("aerodynamic_jump.yaw_component_moa", jump.yaw_component_moa),
1932 (
1933 "aerodynamic_jump.stabilization_factor",
1934 jump.stabilization_factor,
1935 ),
1936 ] {
1937 require_finite(name, value)?;
1938 }
1939 }
1940
1941 Ok(())
1942 }
1943
1944 fn validate_integration_state(
1957 &self,
1958 position: &Vector3<f64>,
1959 velocity: &Vector3<f64>,
1960 time: f64,
1961 ) -> Result<(), BallisticsError> {
1962 if !(position.iter().all(|value| value.is_finite())
1963 && velocity.iter().all(|value| value.is_finite())
1964 && time.is_finite())
1965 {
1966 return Err(BallisticsError::from(
1967 "trajectory integration produced a non-finite state (often from physically \
1968 extreme inputs — e.g. an absurd bore/muzzle height placing the launch far \
1969 from sea level, or a degenerate atmosphere; check those inputs, or set \
1970 --altitude explicitly)",
1971 ));
1972 }
1973
1974 let speed = velocity.magnitude();
1975 let budget = self.speed_budget(time);
1976 if speed > budget {
1977 return Err(BallisticsError::from(format!(
1978 "trajectory integration diverged: speed {speed:.3e} m/s at t={time:.6}s exceeds \
1979 the physical budget of {budget:.3e} m/s"
1980 )));
1981 }
1982 Ok(())
1983 }
1984
1985 fn speed_budget(&self, time: f64) -> f64 {
1990 let scalar_wind = self.wind.speed.abs() + self.wind.vertical_speed.abs();
1991 let wind_bound = match &self.wind_sock {
1992 Some(sock) => scalar_wind.max(sock.max_speed_mps()),
1993 None => scalar_wind,
1994 };
1995 2.0 * (self.inputs.muzzle_velocity + wind_bound + 10.0)
1996 + crate::constants::G_ACCEL_MPS2 * time
1997 }
1998
1999 fn push_trajectory_point(
2003 &self,
2004 points: &mut Vec<TrajectoryPoint>,
2005 point: TrajectoryPoint,
2006 collapsed_steps: &mut usize,
2007 ) -> Result<(), BallisticsError> {
2008 if points.len() + *collapsed_steps >= self.max_trajectory_points {
2013 return Err(BallisticsError::from(format!(
2014 "trajectory point limit of {} exceeded",
2015 self.max_trajectory_points
2016 )));
2017 }
2018 if let Some(last) = points.last_mut() {
2029 if point.position.x == last.position.x {
2030 *last = point;
2031 *collapsed_steps += 1;
2032 return Ok(());
2033 }
2034 }
2035 points.push(point);
2036 Ok(())
2037 }
2038
2039 pub fn set_wind_segments(&mut self, segments: Vec<crate::wind::WindSegment>) {
2046 self.wind_sock = if segments.is_empty() {
2047 None
2048 } else {
2049 Some(crate::wind::WindSock::new(segments))
2050 };
2051 }
2052
2053 pub fn set_atmo_segments(&mut self, segments: Vec<crate::atmosphere::AtmoSegment>) {
2062 self.atmo_sock = if segments.is_empty() {
2063 None
2064 } else {
2065 Some(crate::atmosphere::AtmoSock::new(segments))
2066 };
2067 }
2068
2069 fn launch_angles_from(
2078 &self,
2079 aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
2080 ) -> (f64, f64) {
2081 let (mut elev, mut azim) = (self.inputs.muzzle_angle, self.inputs.azimuth_angle);
2082 if self.inputs.cant_angle != 0.0 {
2089 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
2090 let (e0, a0) = (elev, azim);
2091 elev = e0 * cos_c - a0 * sin_c;
2092 azim = a0 * cos_c + e0 * sin_c;
2093 }
2094 match aj {
2095 Some(c) => {
2096 const MOA_PER_RAD: f64 = 3437.7467707849;
2098 (
2099 elev + c.vertical_jump_moa / MOA_PER_RAD,
2100 azim + c.horizontal_jump_moa / MOA_PER_RAD,
2101 )
2102 }
2103 None => (elev, azim),
2104 }
2105 }
2106
2107 fn aerodynamic_jump_components(
2115 &self,
2116 ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
2117 if !self.inputs.enable_aerodynamic_jump {
2118 return None;
2119 }
2120 let diameter_m = self.inputs.bullet_diameter;
2124 if !(self.inputs.twist_rate.is_finite()
2125 && self.inputs.twist_rate != 0.0
2126 && diameter_m.is_finite()
2127 && diameter_m > 0.0
2128 && self.inputs.bullet_length.is_finite()
2129 && self.inputs.bullet_length > 0.0
2130 && self.inputs.muzzle_velocity.is_finite())
2131 {
2132 return None;
2133 }
2134
2135 let (_, _, temp_c, pressure_hpa) = self.resolved_atmosphere();
2137 let sg = crate::stability::compute_stability_coefficient(
2138 &self.inputs,
2139 (self.atmosphere.altitude, temp_c, pressure_hpa, 0.0),
2140 );
2141 if !(sg.is_finite() && sg > 0.0) {
2142 return None;
2143 }
2144 let length_calibers = self.inputs.bullet_length / diameter_m;
2145
2146 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
2152 let crosswind_from_right_mps = if let Some(sock) = &self.wind_sock {
2153 -sock.vector_for_range_stateless(0.0)[2]
2154 } else {
2155 self.wind.speed * self.wind.direction.sin()
2156 };
2157 let crosswind_from_right_mph = crosswind_from_right_mps * MS_TO_MPH;
2158
2159 let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
2160 sg,
2161 length_calibers,
2162 crosswind_from_right_mph,
2163 self.inputs.is_twist_right,
2164 );
2165 if !vertical_jump_moa.is_finite() {
2166 return None;
2167 }
2168
2169 const MOA_PER_RAD: f64 = 3437.7467707849;
2170 Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
2171 vertical_jump_moa,
2172 horizontal_jump_moa: 0.0,
2174 jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
2175 magnus_component_moa: 0.0,
2176 yaw_component_moa: 0.0,
2177 stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
2178 })
2179 }
2180
2181 fn resolved_atmosphere(&self) -> (f64, f64, f64, f64) {
2182 let (temp_c, pressure_hpa) = match self.station_atmosphere_resolution {
2183 StationAtmosphereResolution::LegacyDefaultSentinels => {
2184 crate::atmosphere::resolve_station_conditions(
2185 self.atmosphere.temperature,
2186 self.atmosphere.pressure,
2187 self.atmosphere.altitude,
2188 )
2189 }
2190 StationAtmosphereResolution::Authoritative => {
2191 (self.atmosphere.temperature, self.atmosphere.pressure)
2192 }
2193 };
2194 let (density, speed_of_sound) = crate::atmosphere::calculate_atmosphere(
2195 self.atmosphere.altitude,
2196 Some(temp_c),
2197 Some(pressure_hpa),
2198 self.atmosphere.humidity,
2199 );
2200 (density, speed_of_sound, temp_c, pressure_hpa)
2201 }
2202
2203 fn precession_nutation_params(
2204 &self,
2205 velocity_mps: f64,
2206 air_density_kg_m3: f64,
2207 speed_of_sound_mps: f64,
2208 ) -> PrecessionNutationParams {
2209 let (spin_inertia, transverse_inertia) = self.precession_nutation_inertias;
2210 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
2211 let velocity_fps = velocity_mps * 3.28084;
2212 let twist_rate_ft = self.inputs.twist_rate / 12.0;
2213 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
2214 } else {
2215 0.0
2216 };
2217
2218 PrecessionNutationParams {
2219 mass_kg: self.inputs.bullet_mass,
2220 caliber_m: self.inputs.bullet_diameter,
2221 length_m: self.inputs.bullet_length,
2222 spin_rate_rad_s,
2223 spin_inertia,
2224 transverse_inertia,
2225 velocity_mps,
2226 air_density_kg_m3,
2227 mach: velocity_mps / speed_of_sound_mps,
2228 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
2229 nutation_damping_factor: 0.05,
2230 }
2231 }
2232
2233 fn append_terminal_endpoint(
2242 &self,
2243 points: &mut Vec<TrajectoryPoint>,
2244 post_position: Vector3<f64>,
2245 post_velocity: Vector3<f64>,
2246 post_time: f64,
2247 max_height: &mut f64,
2248 collapsed_steps: &mut usize,
2249 ) -> Result<TrajectoryTermination, BallisticsError> {
2250 let previous = points
2251 .last()
2252 .cloned()
2253 .ok_or_else(|| BallisticsError::from("No trajectory points generated"))?;
2254
2255 let mut crossings = Vec::with_capacity(3);
2256 if previous.position.x < self.max_range && post_position.x >= self.max_range {
2257 let span = post_position.x - previous.position.x;
2258 if span.is_finite() && span > 0.0 {
2259 crossings.push((
2260 (self.max_range - previous.position.x) / span,
2261 TrajectoryTermination::MaxRange,
2262 ));
2263 }
2264 }
2265 if self.inputs.ground_threshold.is_finite()
2266 && previous.position.y > self.inputs.ground_threshold
2267 && post_position.y <= self.inputs.ground_threshold
2268 {
2269 let span = post_position.y - previous.position.y;
2270 if span.is_finite() && span < 0.0 {
2271 crossings.push((
2272 (self.inputs.ground_threshold - previous.position.y) / span,
2273 TrajectoryTermination::GroundThreshold,
2274 ));
2275 }
2276 }
2277 if previous.time < TRAJECTORY_TIME_LIMIT_S && post_time >= TRAJECTORY_TIME_LIMIT_S {
2278 let span = post_time - previous.time;
2279 if span.is_finite() && span > 0.0 {
2280 crossings.push((
2281 (TRAJECTORY_TIME_LIMIT_S - previous.time) / span,
2282 TrajectoryTermination::TimeLimit,
2283 ));
2284 }
2285 }
2286
2287 let (fraction, termination) = crossings
2288 .into_iter()
2289 .filter(|(fraction, _)| fraction.is_finite() && (0.0..=1.0).contains(fraction))
2290 .min_by(|left, right| {
2291 let priority = |termination: TrajectoryTermination| match termination {
2292 TrajectoryTermination::GroundThreshold => 0,
2293 TrajectoryTermination::MaxRange => 1,
2294 TrajectoryTermination::TimeLimit => 2,
2295 TrajectoryTermination::VelocityFloor => 3,
2296 };
2297 left.0
2298 .total_cmp(&right.0)
2299 .then_with(|| priority(left.1).cmp(&priority(right.1)))
2300 })
2301 .ok_or_else(|| {
2302 BallisticsError::from(
2303 "trajectory integration stopped without crossing a supported boundary",
2304 )
2305 })?;
2306
2307 let mut position = previous.position + (post_position - previous.position) * fraction;
2308 match termination {
2309 TrajectoryTermination::MaxRange => position.x = self.max_range,
2310 TrajectoryTermination::GroundThreshold => {
2311 position.y = self.inputs.ground_threshold;
2312 }
2313 TrajectoryTermination::TimeLimit | TrajectoryTermination::VelocityFloor => {}
2314 }
2315 let velocity_magnitude = previous.velocity_magnitude
2316 + (post_velocity.magnitude() - previous.velocity_magnitude) * fraction;
2317 let mut time = previous.time + (post_time - previous.time) * fraction;
2318 if termination == TrajectoryTermination::TimeLimit {
2319 time = TRAJECTORY_TIME_LIMIT_S;
2320 }
2321 let kinetic_energy =
2322 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
2323
2324 if position.y > *max_height {
2325 *max_height = position.y;
2326 }
2327 let terminal_point = TrajectoryPoint {
2328 time,
2329 position,
2330 velocity_magnitude,
2331 kinetic_energy,
2332 drag_coefficient: None,
2333 };
2334 if terminal_point.position.x < previous.position.x {
2335 return Err(BallisticsError::from(
2336 "trajectory terminal state reversed downrange before the crossed boundary",
2337 ));
2338 }
2339 if terminal_point.position.x == previous.position.x {
2340 let last = points.last_mut().ok_or_else(|| {
2345 BallisticsError::from("trajectory points disappeared during terminal finalization")
2346 })?;
2347 *last = terminal_point;
2348 } else {
2349 self.push_trajectory_point(points, terminal_point, collapsed_steps)?;
2350 }
2351 Ok(termination)
2352 }
2353
2354 fn gravity_acceleration(&self) -> Vector3<f64> {
2355 let theta = self.inputs.shooting_angle;
2356 Vector3::new(
2357 -crate::constants::G_ACCEL_MPS2 * theta.sin(),
2358 -crate::constants::G_ACCEL_MPS2 * theta.cos(),
2359 0.0,
2360 )
2361 }
2362
2363 fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
2364 let model = match self.inputs.wind_shear_model.as_str() {
2379 "logarithmic" => WindShearModel::Logarithmic,
2380 "power_law" | "powerlaw" | "exponential" => WindShearModel::PowerLaw,
2381 "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
2382 "custom_layers" | "custom" => WindShearModel::CustomLayers,
2383 _ => WindShearModel::PowerLaw,
2384 };
2385 let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
2386
2387 crate::wind::wind_vector(self.wind.speed, self.wind.direction, 0.0) * speed_ratio
2394 + Vector3::new(0.0, self.wind.vertical_speed, 0.0)
2395 }
2396
2397 pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
2398 self.validate_for_solve()?;
2399 let mut result = if self.inputs.use_rk4 {
2400 if self.inputs.use_adaptive_rk45 {
2401 self.solve_rk45()?
2402 } else {
2403 self.solve_rk4()?
2404 }
2405 } else {
2406 self.solve_euler()?
2407 };
2408 self.apply_spin_drift(&mut result);
2409 self.validate_result_sanity(&result)?;
2410 Ok(result)
2411 }
2412
2413 fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
2419 if !self.inputs.use_enhanced_spin_drift {
2420 return;
2421 }
2422 let d_in = self.inputs.bullet_diameter / 0.0254; let m_gr = self.inputs.bullet_mass / crate::constants::GRAINS_TO_KG; let twist_in = self.inputs.twist_rate; if d_in <= 0.0 || m_gr <= 0.0 || twist_in <= 0.0 {
2426 return;
2427 }
2428
2429 let sg = self.effective_spin_drift_sg();
2436
2437 for p in result.points.iter_mut() {
2438 if p.time <= 0.0 {
2439 continue;
2440 }
2441 p.position.z +=
2443 crate::spin_drift::litz_drift_meters(sg, p.time, self.inputs.is_twist_right);
2444 }
2445
2446 if let Some(samples) = result.sampled_points.as_mut() {
2450 for s in samples.iter_mut() {
2451 if s.time_s <= 0.0 {
2452 continue;
2453 }
2454 s.wind_drift_m +=
2455 crate::spin_drift::litz_drift_meters(sg, s.time_s, self.inputs.is_twist_right);
2456 }
2457 }
2458 }
2459
2460 fn effective_spin_drift_sg(&self) -> f64 {
2465 let (_, _, temp_c, press_hpa) = self.resolved_atmosphere();
2466 crate::spin_drift::effective_sg_from_inputs(&self.inputs, temp_c, press_hpa)
2467 }
2468
2469 fn initial_position(&self) -> Vector3<f64> {
2487 if self.inputs.cant_angle == 0.0 && self.inputs.sight_offset_lateral_m == 0.0 {
2488 return Vector3::new(0.0, self.inputs.muzzle_height, 0.0);
2489 }
2490 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
2491 let sh = self.inputs.sight_height;
2492 let off = self.inputs.sight_offset_lateral_m;
2493 Vector3::new(
2494 0.0,
2495 self.inputs.muzzle_height + sh * (1.0 - cos_c) + off * sin_c,
2496 -sh * sin_c - off * cos_c,
2497 )
2498 }
2499
2500 fn build_sampled_points(
2507 &self,
2508 points: &[TrajectoryPoint],
2509 max_height: f64,
2510 transonic_distances: Vec<f64>,
2511 mach_transitions: &MachTransitionTracker,
2512 ) -> Result<Option<Vec<TrajectorySample>>, BallisticsError> {
2513 if !self.inputs.enable_trajectory_sampling {
2514 return Ok(None);
2515 }
2516
2517 let last_point = points.last().ok_or("No trajectory points generated")?;
2518 let trajectory_data = TrajectoryData {
2519 times: points.iter().map(|p| p.time).collect(),
2520 positions: points.iter().map(|p| p.position).collect(),
2521 velocities: points
2522 .iter()
2523 .map(|p| {
2524 Vector3::new(0.0, 0.0, p.velocity_magnitude)
2526 })
2527 .collect(),
2528 transonic_distances, mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2530 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2531 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2532 };
2533
2534 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
2539 let target_reference = self.inputs.drops_reference == DropsReference::Target;
2540 let target_vertical_height_m = if target_reference && self.inputs.target_height != 0.0 {
2547 self.inputs.target_height
2548 } else {
2549 sight_position_m
2550 };
2551 let outputs = TrajectoryOutputs {
2552 target_distance_horiz_m: last_point.position.x, target_vertical_height_m,
2554 time_of_flight_s: last_point.time,
2555 max_ord_dist_horiz_m: max_height,
2556 sight_height_m: sight_position_m,
2557 };
2558
2559 let mut samples = sample_trajectory(
2561 &trajectory_data,
2562 &outputs,
2563 self.inputs.sample_interval,
2564 self.inputs.bullet_mass,
2565 )?;
2566
2567 if target_reference {
2574 let cos_theta = self.inputs.shooting_angle.cos();
2575 for sample in &mut samples {
2576 sample.drop_m /= cos_theta;
2577 }
2578 }
2579 Ok(Some(samples))
2580 }
2581
2582 fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
2583 let mut time = 0.0;
2585 let mut position = self.initial_position();
2589 let aj_components = self.aerodynamic_jump_components();
2595 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
2596 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
2597 let mut velocity = Vector3::new(
2598 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
2602
2603 let mut points = Vec::new();
2604 let mut collapsed_steps = 0;
2607 let mut max_height = position.y;
2608 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
2614 let mut mach_transitions = MachTransitionTracker::default();
2615
2616 let mut angular_state = if self.inputs.enable_precession_nutation {
2618 Some(AngularState {
2619 pitch_angle: 0.001, yaw_angle: 0.001,
2621 pitch_rate: 0.0,
2622 yaw_rate: 0.0,
2623 precession_angle: 0.0,
2624 nutation_phase: 0.0,
2625 })
2626 } else {
2627 None
2628 };
2629 let mut max_yaw_angle = 0.0;
2630 let mut max_precession_angle = 0.0;
2631
2632 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2634 self.resolved_atmosphere();
2635 let base_ratio = air_density / 1.225;
2640
2641 let wind_vector = crate::wind::wind_vector(
2647 self.wind.speed,
2648 self.wind.direction,
2649 self.wind.vertical_speed,
2650 );
2651
2652 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2655 self.inputs.bullet_model.as_deref().unwrap_or("default"),
2656 );
2657
2658 while position.x < self.max_range
2660 && position.y > self.inputs.ground_threshold
2661 && time < TRAJECTORY_TIME_LIMIT_S
2662 {
2663 let velocity_magnitude = velocity.magnitude();
2665 let kinetic_energy =
2666 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
2667
2668 self.push_trajectory_point(
2669 &mut points,
2670 TrajectoryPoint {
2671 time,
2672 position,
2673 velocity_magnitude,
2674 kinetic_energy,
2675 drag_coefficient: None,
2676 },
2677 &mut collapsed_steps,
2678 )?;
2679
2680 {
2683 let mach_here = if speed_of_sound > 0.0 {
2684 velocity_magnitude / speed_of_sound
2685 } else {
2686 0.0
2687 };
2688 mach_transitions.record_downward_crossings(
2689 mach_here,
2690 position.x,
2691 &mut transonic_distances,
2692 );
2693 }
2694
2695 if position.y > max_height {
2697 max_height = position.y;
2698 }
2699
2700 if self.inputs.enable_pitch_damping {
2702 let mach = velocity_magnitude / speed_of_sound;
2703
2704 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2706 transonic_mach = Some(mach);
2707 }
2708
2709 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2711
2712 if pitch_damping < min_pitch_damping {
2714 min_pitch_damping = pitch_damping;
2715 }
2716 }
2717
2718 if self.inputs.enable_precession_nutation {
2720 if let Some(ref mut state) = angular_state {
2721 let velocity_magnitude = velocity.magnitude();
2722 let params = self.precession_nutation_params(
2723 velocity_magnitude,
2724 air_density,
2725 speed_of_sound,
2726 );
2727
2728 *state = calculate_combined_angular_motion(
2730 ¶ms,
2731 state,
2732 time,
2733 self.time_step,
2734 0.001, );
2736
2737 if state.yaw_angle.abs() > max_yaw_angle {
2739 max_yaw_angle = state.yaw_angle.abs();
2740 }
2741 if state.precession_angle.abs() > max_precession_angle {
2742 max_precession_angle = state.precession_angle.abs();
2743 }
2744 }
2745 }
2746
2747 let acceleration = self.calculate_acceleration(
2754 &position,
2755 &velocity,
2756 &wind_vector,
2757 (resolved_temp_c, resolved_press_hpa, base_ratio),
2758 );
2759
2760 velocity += acceleration * self.time_step;
2762 position += velocity * self.time_step;
2763 time += self.time_step;
2764 self.validate_integration_state(&position, &velocity, time)?;
2765 }
2766
2767 let termination = self.append_terminal_endpoint(
2768 &mut points,
2769 position,
2770 velocity,
2771 time,
2772 &mut max_height,
2773 &mut collapsed_steps,
2774 )?;
2775
2776 self.annotate_drag_coefficients(&mut points, speed_of_sound);
2781
2782 let last_point = points.last().ok_or("No trajectory points generated")?;
2783
2784 let sampled_points =
2786 self.build_sampled_points(&points, max_height, transonic_distances, &mach_transitions)?;
2787
2788 Ok(TrajectoryResult {
2789 max_range: last_point.position.x, max_height,
2791 time_of_flight: last_point.time,
2792 impact_velocity: last_point.velocity_magnitude,
2793 impact_energy: last_point.kinetic_energy,
2794 projectile_mass_kg: self.inputs.bullet_mass,
2795 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
2796 station_speed_of_sound_mps: speed_of_sound,
2797 termination,
2798 points,
2799 sampled_points,
2800 min_pitch_damping: if self.inputs.enable_pitch_damping {
2801 Some(min_pitch_damping)
2802 } else {
2803 None
2804 },
2805 transonic_mach,
2806 angular_state,
2807 max_yaw_angle: if self.inputs.enable_precession_nutation {
2808 Some(max_yaw_angle)
2809 } else {
2810 None
2811 },
2812 max_precession_angle: if self.inputs.enable_precession_nutation {
2813 Some(max_precession_angle)
2814 } else {
2815 None
2816 },
2817 aerodynamic_jump: aj_components,
2818 mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2819 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2820 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2821 })
2822 }
2823
2824 fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
2825 let mut time = 0.0;
2827 let mut position = self.initial_position();
2832
2833 let aj_components = self.aerodynamic_jump_components();
2839 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
2840 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
2841 let mut velocity = Vector3::new(
2842 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
2846
2847 let mut points = Vec::new();
2848 let mut collapsed_steps = 0;
2851 let mut max_height = position.y;
2852 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
2858 let mut mach_transitions = MachTransitionTracker::default();
2859
2860 let mut angular_state = if self.inputs.enable_precession_nutation {
2862 Some(AngularState {
2863 pitch_angle: 0.001, yaw_angle: 0.001,
2865 pitch_rate: 0.0,
2866 yaw_rate: 0.0,
2867 precession_angle: 0.0,
2868 nutation_phase: 0.0,
2869 })
2870 } else {
2871 None
2872 };
2873 let mut max_yaw_angle = 0.0;
2874 let mut max_precession_angle = 0.0;
2875
2876 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2878 self.resolved_atmosphere();
2879 let base_ratio = air_density / 1.225;
2884
2885 let wind_vector = crate::wind::wind_vector(
2891 self.wind.speed,
2892 self.wind.direction,
2893 self.wind.vertical_speed,
2894 );
2895
2896 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2899 self.inputs.bullet_model.as_deref().unwrap_or("default"),
2900 );
2901
2902 while position.x < self.max_range
2904 && position.y > self.inputs.ground_threshold
2905 && time < TRAJECTORY_TIME_LIMIT_S
2906 {
2907 let velocity_magnitude = velocity.magnitude();
2909 let kinetic_energy =
2910 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
2911
2912 self.push_trajectory_point(
2913 &mut points,
2914 TrajectoryPoint {
2915 time,
2916 position,
2917 velocity_magnitude,
2918 kinetic_energy,
2919 drag_coefficient: None,
2920 },
2921 &mut collapsed_steps,
2922 )?;
2923
2924 {
2927 let mach_here = if speed_of_sound > 0.0 {
2928 velocity_magnitude / speed_of_sound
2929 } else {
2930 0.0
2931 };
2932 mach_transitions.record_downward_crossings(
2933 mach_here,
2934 position.x,
2935 &mut transonic_distances,
2936 );
2937 }
2938
2939 if position.y > max_height {
2940 max_height = position.y;
2941 }
2942
2943 if self.inputs.enable_pitch_damping {
2945 let mach = velocity_magnitude / speed_of_sound;
2946
2947 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2949 transonic_mach = Some(mach);
2950 }
2951
2952 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2954
2955 if pitch_damping < min_pitch_damping {
2957 min_pitch_damping = pitch_damping;
2958 }
2959 }
2960
2961 if self.inputs.enable_precession_nutation {
2963 if let Some(ref mut state) = angular_state {
2964 let velocity_magnitude = velocity.magnitude();
2965 let params = self.precession_nutation_params(
2966 velocity_magnitude,
2967 air_density,
2968 speed_of_sound,
2969 );
2970
2971 *state = calculate_combined_angular_motion(
2973 ¶ms,
2974 state,
2975 time,
2976 self.time_step,
2977 0.001, );
2979
2980 if state.yaw_angle.abs() > max_yaw_angle {
2982 max_yaw_angle = state.yaw_angle.abs();
2983 }
2984 if state.precession_angle.abs() > max_precession_angle {
2985 max_precession_angle = state.precession_angle.abs();
2986 }
2987 }
2988 }
2989
2990 let dt = self.time_step;
2992
2993 let acc1 = self.calculate_acceleration(
2995 &position,
2996 &velocity,
2997 &wind_vector,
2998 (resolved_temp_c, resolved_press_hpa, base_ratio),
2999 );
3000
3001 let pos2 = position + velocity * (dt * 0.5);
3003 let vel2 = velocity + acc1 * (dt * 0.5);
3004 let acc2 = self.calculate_acceleration(
3005 &pos2,
3006 &vel2,
3007 &wind_vector,
3008 (resolved_temp_c, resolved_press_hpa, base_ratio),
3009 );
3010
3011 let pos3 = position + vel2 * (dt * 0.5);
3013 let vel3 = velocity + acc2 * (dt * 0.5);
3014 let acc3 = self.calculate_acceleration(
3015 &pos3,
3016 &vel3,
3017 &wind_vector,
3018 (resolved_temp_c, resolved_press_hpa, base_ratio),
3019 );
3020
3021 let pos4 = position + vel3 * dt;
3023 let vel4 = velocity + acc3 * dt;
3024 let acc4 = self.calculate_acceleration(
3025 &pos4,
3026 &vel4,
3027 &wind_vector,
3028 (resolved_temp_c, resolved_press_hpa, base_ratio),
3029 );
3030
3031 position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
3033 velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
3034 time += dt;
3035 self.validate_integration_state(&position, &velocity, time)?;
3036 }
3037
3038 let termination = self.append_terminal_endpoint(
3039 &mut points,
3040 position,
3041 velocity,
3042 time,
3043 &mut max_height,
3044 &mut collapsed_steps,
3045 )?;
3046
3047 self.annotate_drag_coefficients(&mut points, speed_of_sound);
3052
3053 let last_point = points.last().ok_or("No trajectory points generated")?;
3054
3055 let sampled_points =
3057 self.build_sampled_points(&points, max_height, transonic_distances, &mach_transitions)?;
3058
3059 Ok(TrajectoryResult {
3060 max_range: last_point.position.x, max_height,
3062 time_of_flight: last_point.time,
3063 impact_velocity: last_point.velocity_magnitude,
3064 impact_energy: last_point.kinetic_energy,
3065 projectile_mass_kg: self.inputs.bullet_mass,
3066 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
3067 station_speed_of_sound_mps: speed_of_sound,
3068 termination,
3069 points,
3070 sampled_points,
3071 min_pitch_damping: if self.inputs.enable_pitch_damping {
3072 Some(min_pitch_damping)
3073 } else {
3074 None
3075 },
3076 transonic_mach,
3077 angular_state,
3078 max_yaw_angle: if self.inputs.enable_precession_nutation {
3079 Some(max_yaw_angle)
3080 } else {
3081 None
3082 },
3083 max_precession_angle: if self.inputs.enable_precession_nutation {
3084 Some(max_precession_angle)
3085 } else {
3086 None
3087 },
3088 aerodynamic_jump: aj_components,
3089 mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
3090 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
3091 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
3092 })
3093 }
3094
3095 fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
3096 let mut time = 0.0;
3098 let mut position = self.initial_position();
3102
3103 let aj_components = self.aerodynamic_jump_components();
3109 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
3110 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
3111 let mut velocity = Vector3::new(
3112 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
3116
3117 let mut points = Vec::new();
3118 let mut collapsed_steps = 0;
3121 let mut max_height = position.y;
3122 let mut dt = 0.001; let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
3127 self.resolved_atmosphere();
3128 let base_ratio = air_density / 1.225;
3133 let wind_vector = crate::wind::wind_vector(
3138 self.wind.speed,
3139 self.wind.direction,
3140 self.wind.vertical_speed,
3141 );
3142
3143 let mut transonic_distances: Vec<f64> = Vec::new();
3145 let mut mach_transitions = MachTransitionTracker::default();
3146
3147 let mut min_pitch_damping = f64::INFINITY;
3152 let mut transonic_mach: Option<f64> = None;
3153 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
3154 self.inputs.bullet_model.as_deref().unwrap_or("default"),
3155 );
3156 let mut angular_state = if self.inputs.enable_precession_nutation {
3157 Some(AngularState {
3158 pitch_angle: 0.001,
3159 yaw_angle: 0.001,
3160 pitch_rate: 0.0,
3161 yaw_rate: 0.0,
3162 precession_angle: 0.0,
3163 nutation_phase: 0.0,
3164 })
3165 } else {
3166 None
3167 };
3168 let mut max_yaw_angle = 0.0;
3169 let mut max_precession_angle = 0.0;
3170
3171 while position.x < self.max_range
3172 && position.y > self.inputs.ground_threshold
3173 && time < TRAJECTORY_TIME_LIMIT_S
3174 {
3175 let velocity_magnitude = velocity.magnitude();
3177 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
3178
3179 self.push_trajectory_point(
3180 &mut points,
3181 TrajectoryPoint {
3182 time,
3183 position,
3184 velocity_magnitude,
3185 kinetic_energy,
3186 drag_coefficient: None,
3187 },
3188 &mut collapsed_steps,
3189 )?;
3190
3191 {
3194 let mach_here = if speed_of_sound > 0.0 {
3195 velocity_magnitude / speed_of_sound
3196 } else {
3197 0.0
3198 };
3199 mach_transitions.record_downward_crossings(
3200 mach_here,
3201 position.x,
3202 &mut transonic_distances,
3203 );
3204 }
3205
3206 if position.y > max_height {
3207 max_height = position.y;
3208 }
3209
3210 if self.inputs.enable_pitch_damping {
3213 let mach = velocity_magnitude / speed_of_sound;
3214 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
3215 transonic_mach = Some(mach);
3216 }
3217 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
3218 if pitch_damping < min_pitch_damping {
3219 min_pitch_damping = pitch_damping;
3220 }
3221 }
3222
3223 let accepted_step = self.adaptive_rk45_step(
3226 &position,
3227 &velocity,
3228 dt,
3229 &wind_vector,
3230 (resolved_temp_c, resolved_press_hpa, base_ratio),
3231 );
3232 debug_assert!(
3233 accepted_step.error <= RK45_TOLERANCE || accepted_step.used_dt <= RK45_MIN_DT
3234 );
3235
3236 if self.inputs.enable_precession_nutation {
3240 if let Some(ref mut state) = angular_state {
3241 let params = self.precession_nutation_params(
3242 velocity_magnitude,
3243 air_density,
3244 speed_of_sound,
3245 );
3246
3247 *state = calculate_combined_angular_motion(
3248 ¶ms,
3249 state,
3250 time,
3251 accepted_step.used_dt,
3252 0.001,
3253 );
3254
3255 if state.yaw_angle.abs() > max_yaw_angle {
3256 max_yaw_angle = state.yaw_angle.abs();
3257 }
3258 if state.precession_angle.abs() > max_precession_angle {
3259 max_precession_angle = state.precession_angle.abs();
3260 }
3261 }
3262 }
3263
3264 position = accepted_step.position;
3265 velocity = accepted_step.velocity;
3266 time += accepted_step.used_dt;
3267 self.validate_integration_state(&position, &velocity, time)?;
3268
3269 dt = accepted_step.next_dt;
3271 }
3272
3273 if points.is_empty() {
3275 return Err(BallisticsError::from("No trajectory points calculated"));
3276 }
3277
3278 let termination = self.append_terminal_endpoint(
3280 &mut points,
3281 position,
3282 velocity,
3283 time,
3284 &mut max_height,
3285 &mut collapsed_steps,
3286 )?;
3287
3288 self.annotate_drag_coefficients(&mut points, speed_of_sound);
3290
3291 let last_point = points.last().unwrap();
3292
3293 let sampled_points =
3295 self.build_sampled_points(&points, max_height, transonic_distances, &mach_transitions)?;
3296
3297 Ok(TrajectoryResult {
3298 max_range: last_point.position.x, max_height,
3300 time_of_flight: last_point.time,
3301 impact_velocity: last_point.velocity_magnitude,
3302 impact_energy: last_point.kinetic_energy,
3303 projectile_mass_kg: self.inputs.bullet_mass,
3304 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
3305 station_speed_of_sound_mps: speed_of_sound,
3306 termination,
3307 points,
3308 sampled_points,
3309 min_pitch_damping: if self.inputs.enable_pitch_damping {
3310 Some(min_pitch_damping)
3311 } else {
3312 None
3313 },
3314 transonic_mach,
3315 angular_state,
3316 max_yaw_angle: if self.inputs.enable_precession_nutation {
3317 Some(max_yaw_angle)
3318 } else {
3319 None
3320 },
3321 max_precession_angle: if self.inputs.enable_precession_nutation {
3322 Some(max_precession_angle)
3323 } else {
3324 None
3325 },
3326 aerodynamic_jump: aj_components,
3327 mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
3328 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
3329 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
3330 })
3331 }
3332
3333 fn adaptive_rk45_step(
3334 &self,
3335 position: &Vector3<f64>,
3336 velocity: &Vector3<f64>,
3337 initial_dt: f64,
3338 wind_vector: &Vector3<f64>,
3339 resolved_atmo: (f64, f64, f64),
3340 ) -> Rk45AcceptedStep {
3341 let mut trial_dt = initial_dt;
3342
3343 loop {
3344 let trial = self.rk45_step(
3345 position,
3346 velocity,
3347 trial_dt,
3348 wind_vector,
3349 RK45_TOLERANCE,
3350 resolved_atmo,
3351 );
3352 let next_dt = if trial.suggested_dt.is_finite() {
3357 (RK45_SAFETY_FACTOR * trial.suggested_dt).clamp(RK45_MIN_DT, RK45_MAX_DT)
3358 } else {
3359 RK45_MIN_DT
3360 };
3361
3362 if trial.error <= RK45_TOLERANCE || trial_dt <= RK45_MIN_DT {
3363 return Rk45AcceptedStep {
3364 position: trial.position,
3365 velocity: trial.velocity,
3366 used_dt: trial_dt,
3367 next_dt,
3368 error: trial.error,
3369 };
3370 }
3371
3372 trial_dt = next_dt;
3373 }
3374 }
3375
3376 fn rk45_step(
3377 &self,
3378 position: &Vector3<f64>,
3379 velocity: &Vector3<f64>,
3380 dt: f64,
3381 wind_vector: &Vector3<f64>,
3382 tolerance: f64,
3383 resolved_atmo: (f64, f64, f64), ) -> Rk45Trial {
3385 const A21: f64 = 1.0 / 5.0;
3387 const A31: f64 = 3.0 / 40.0;
3388 const A32: f64 = 9.0 / 40.0;
3389 const A41: f64 = 44.0 / 45.0;
3390 const A42: f64 = -56.0 / 15.0;
3391 const A43: f64 = 32.0 / 9.0;
3392 const A51: f64 = 19372.0 / 6561.0;
3393 const A52: f64 = -25360.0 / 2187.0;
3394 const A53: f64 = 64448.0 / 6561.0;
3395 const A54: f64 = -212.0 / 729.0;
3396 const A61: f64 = 9017.0 / 3168.0;
3397 const A62: f64 = -355.0 / 33.0;
3398 const A63: f64 = 46732.0 / 5247.0;
3399 const A64: f64 = 49.0 / 176.0;
3400 const A65: f64 = -5103.0 / 18656.0;
3401 const A71: f64 = 35.0 / 384.0;
3402 const A73: f64 = 500.0 / 1113.0;
3403 const A74: f64 = 125.0 / 192.0;
3404 const A75: f64 = -2187.0 / 6784.0;
3405 const A76: f64 = 11.0 / 84.0;
3406
3407 const B1: f64 = 35.0 / 384.0;
3409 const B3: f64 = 500.0 / 1113.0;
3410 const B4: f64 = 125.0 / 192.0;
3411 const B5: f64 = -2187.0 / 6784.0;
3412 const B6: f64 = 11.0 / 84.0;
3413
3414 const B1_ERR: f64 = 5179.0 / 57600.0;
3416 const B3_ERR: f64 = 7571.0 / 16695.0;
3417 const B4_ERR: f64 = 393.0 / 640.0;
3418 const B5_ERR: f64 = -92097.0 / 339200.0;
3419 const B6_ERR: f64 = 187.0 / 2100.0;
3420 const B7_ERR: f64 = 1.0 / 40.0;
3421
3422 let k1_v = self.calculate_acceleration(position, velocity, wind_vector, resolved_atmo);
3424 let k1_p = *velocity;
3425
3426 let p2 = position + dt * A21 * k1_p;
3427 let v2 = velocity + dt * A21 * k1_v;
3428 let k2_v = self.calculate_acceleration(&p2, &v2, wind_vector, resolved_atmo);
3429 let k2_p = v2;
3430
3431 let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
3432 let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
3433 let k3_v = self.calculate_acceleration(&p3, &v3, wind_vector, resolved_atmo);
3434 let k3_p = v3;
3435
3436 let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
3437 let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
3438 let k4_v = self.calculate_acceleration(&p4, &v4, wind_vector, resolved_atmo);
3439 let k4_p = v4;
3440
3441 let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
3442 let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
3443 let k5_v = self.calculate_acceleration(&p5, &v5, wind_vector, resolved_atmo);
3444 let k5_p = v5;
3445
3446 let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
3447 let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
3448 let k6_v = self.calculate_acceleration(&p6, &v6, wind_vector, resolved_atmo);
3449 let k6_p = v6;
3450
3451 let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
3452 let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
3453 let k7_v = self.calculate_acceleration(&p7, &v7, wind_vector, resolved_atmo);
3454 let k7_p = v7;
3455
3456 let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
3458 let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
3459
3460 let pos_err = position
3462 + dt * (B1_ERR * k1_p
3463 + B3_ERR * k3_p
3464 + B4_ERR * k4_p
3465 + B5_ERR * k5_p
3466 + B6_ERR * k6_p
3467 + B7_ERR * k7_p);
3468 let vel_err = velocity
3469 + dt * (B1_ERR * k1_v
3470 + B3_ERR * k3_v
3471 + B4_ERR * k4_v
3472 + B5_ERR * k5_v
3473 + B6_ERR * k6_v
3474 + B7_ERR * k7_v);
3475
3476 let error = cli_rk45_error_norm(position, velocity, &new_pos, &new_vel, &pos_err, &vel_err);
3478
3479 let dt_new = if error < tolerance {
3481 dt * (tolerance / error).powf(0.2).min(2.0)
3482 } else {
3483 dt * (tolerance / error).powf(0.25).max(0.1)
3484 };
3485
3486 Rk45Trial {
3487 position: new_pos,
3488 velocity: new_vel,
3489 suggested_dt: dt_new,
3490 error,
3491 }
3492 }
3493
3494 fn apply_cluster_bc_correction(&self, base_bc: f64, velocity_fps: f64) -> f64 {
3495 if let Some(ref cluster_bc) = self.cluster_bc {
3496 cluster_bc.apply_correction_for_drag_model(
3497 base_bc,
3498 self.inputs.caliber_inches,
3499 self.inputs.weight_grains,
3500 velocity_fps,
3501 self.inputs.bc_type,
3502 )
3503 } else {
3504 base_bc
3505 }
3506 }
3507
3508 fn calculate_acceleration(
3509 &self,
3510 position: &Vector3<f64>,
3511 velocity: &Vector3<f64>,
3512 wind_vector: &Vector3<f64>,
3513 resolved_atmo: (f64, f64, f64), ) -> Vector3<f64> {
3515 let actual_wind = if let Some(ref sock) = self.wind_sock {
3521 sock.vector_for_range_stateless(position.x)
3522 } else if self.inputs.enable_wind_shear {
3523 self.get_wind_at_altitude(position.y)
3524 } else {
3525 *wind_vector
3526 };
3527 let actual_wind =
3528 crate::derivatives::level_vector_to_shot_frame(actual_wind, self.inputs.shooting_angle);
3529
3530 let relative_velocity = velocity - actual_wind;
3531 let velocity_magnitude = relative_velocity.magnitude();
3532
3533 if velocity_magnitude < 0.001 {
3534 return self.gravity_acceleration();
3535 }
3536
3537 let (base_temp_c, base_press_hpa, station_ratio) = resolved_atmo;
3548
3549 let (drag_base_temp_c, drag_base_press_hpa, drag_base_ratio, drag_humidity_percent) =
3557 if let Some(ref sock) = self.atmo_sock {
3558 let (zone_temp_c, zone_press_hpa, zone_humidity) = sock.atmo_for_range(position.x);
3559 let zone_base_ratio = crate::atmosphere::calculate_air_density_cimp(
3560 zone_temp_c,
3561 zone_press_hpa,
3562 zone_humidity,
3563 ) / 1.225;
3564 (zone_temp_c, zone_press_hpa, zone_base_ratio, zone_humidity)
3565 } else {
3566 (
3567 base_temp_c,
3568 base_press_hpa,
3569 station_ratio,
3570 self.atmosphere.humidity,
3571 )
3572 };
3573 let local_alt = crate::atmosphere::shot_frame_altitude(
3574 self.atmosphere.altitude,
3575 position.x,
3576 position.y,
3577 self.inputs.shooting_angle,
3578 );
3579 let (air_density, speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
3580 local_alt,
3581 self.atmosphere.altitude,
3582 drag_base_temp_c,
3583 drag_base_press_hpa,
3584 drag_base_ratio,
3585 drag_humidity_percent,
3586 );
3587
3588 let (cd, retard_denom) = self.drag_terms(velocity_magnitude, speed_of_sound);
3592
3593 let velocity_fps = velocity_magnitude * 3.28084;
3595
3596 let cd_to_retard = crate::constants::CD_TO_RETARD;
3601 let standard_factor = cd * cd_to_retard;
3602 let density_scale = air_density / 1.225; let a_drag_ft_s2 =
3606 (velocity_fps * velocity_fps) * standard_factor * density_scale / retard_denom;
3607 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
3611
3612 let mut accel = drag_acceleration + self.gravity_acceleration();
3615
3616 if self.inputs.enable_coriolis {
3619 if let Some(lat_deg) = self.inputs.latitude {
3620 let omega_earth = 7.2921159e-5_f64; let lat = lat_deg.to_radians();
3622 let az = self.inputs.shot_azimuth; let omega = Vector3::new(
3629 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), );
3633 let omega = crate::derivatives::level_vector_to_shot_frame(
3634 omega,
3635 self.inputs.shooting_angle,
3636 );
3637 accel += -2.0 * omega.cross(velocity);
3642 }
3643 }
3644
3645 if self.inputs.enable_magnus
3652 && !self.inputs.use_enhanced_spin_drift
3653 && self.inputs.bullet_diameter > 0.0
3654 && self.inputs.twist_rate > 0.0
3655 {
3656 let diameter_m = self.inputs.bullet_diameter;
3657 let (spin_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
3658 self.inputs.muzzle_velocity,
3659 velocity_magnitude,
3660 self.inputs.twist_rate,
3661 diameter_m,
3662 );
3663 let mach = velocity_magnitude / speed_of_sound;
3665
3666 let d_in = self.inputs.bullet_diameter / 0.0254;
3668 let m_gr = self.inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
3669 let l_in = if self.inputs.bullet_length > 0.0 {
3670 self.inputs.bullet_length / 0.0254
3671 } else {
3672 let est_m = crate::stability::estimate_bullet_length_m(
3674 self.inputs.bullet_diameter,
3675 self.inputs.bullet_mass,
3676 );
3677 if est_m > 0.0 {
3678 est_m / 0.0254
3679 } else {
3680 4.5 * d_in
3681 }
3682 };
3683 let sg = crate::spin_drift::calculate_dynamic_stability(
3687 m_gr,
3688 velocity_magnitude,
3689 spin_rad_s,
3690 d_in,
3691 l_in,
3692 air_density,
3693 );
3694
3695 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
3697 sg,
3698 velocity_magnitude,
3699 spin_rad_s,
3700 0.0, 0.0, air_density,
3703 d_in,
3704 l_in,
3705 m_gr,
3706 mach,
3707 "match",
3708 false,
3709 );
3710
3711 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
3713 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
3714 let magnus_force = 0.5
3715 * air_density
3716 * velocity_magnitude.powi(2)
3717 * area
3718 * c_np
3719 * spin_param
3720 * yaw_rad.sin();
3721
3722 if magnus_force.abs() > 1e-12 {
3726 if let Some(dir) = crate::derivatives::yaw_of_repose_magnus_direction(
3727 relative_velocity,
3728 self.gravity_acceleration(),
3729 self.inputs.is_twist_right,
3730 ) {
3731 accel += (magnus_force / self.inputs.bullet_mass) * dir;
3732 }
3733 }
3734 }
3735
3736 accel
3737 }
3738
3739 fn drag_terms(&self, velocity_magnitude: f64, speed_of_sound: f64) -> (f64, f64) {
3753 let cd = self.calculate_drag_coefficient(velocity_magnitude, speed_of_sound);
3754
3755 let velocity_fps = velocity_magnitude * 3.28084;
3756
3757 let (base_bc, bc_from_segments) = if let Some(segments) = self
3762 .inputs
3763 .bc_segments_data
3764 .as_ref()
3765 .filter(|segments| self.inputs.use_bc_segments && !segments.is_empty())
3766 {
3767 (
3769 crate::bc_estimation::velocity_segment_bc(
3770 velocity_fps,
3771 segments,
3772 self.inputs.bc_value,
3773 ),
3774 true,
3775 )
3776 } else if let Some(segments) = self
3777 .inputs
3778 .bc_segments
3779 .as_ref()
3780 .filter(|segments| !segments.is_empty())
3781 {
3782 (
3783 crate::derivatives::interpolated_bc(
3784 velocity_magnitude / speed_of_sound,
3785 segments,
3786 Some(&self.inputs),
3787 ),
3788 true,
3789 )
3790 } else {
3791 (self.inputs.bc_value, false)
3792 };
3793
3794 let effective_bc = if bc_from_segments {
3799 base_bc
3800 } else {
3801 self.apply_cluster_bc_correction(base_bc, velocity_fps)
3802 };
3803 let effective_bc = effective_bc.max(1e-6);
3806
3807 let retard_denom = if self.inputs.custom_drag_table.is_some() {
3813 self.inputs.custom_drag_denominator(effective_bc)
3814 } else {
3815 effective_bc
3816 };
3817
3818 (cd, retard_denom)
3819 }
3820
3821 pub fn effective_drag_coefficient(
3844 &self,
3845 velocity_magnitude: f64,
3846 speed_of_sound: f64,
3847 ) -> Option<f64> {
3848 if !velocity_magnitude.is_finite() || speed_of_sound <= 1e-9 {
3849 return None;
3850 }
3851 let sectional_density = self.inputs.sectional_density_lb_in2()?;
3852 let (cd, retard_denom) = self.drag_terms(velocity_magnitude, speed_of_sound);
3853 if retard_denom <= 0.0 {
3854 return None;
3855 }
3856 let effective = cd * sectional_density / retard_denom;
3857 effective.is_finite().then_some(effective)
3858 }
3859
3860 fn annotate_drag_coefficients(&self, points: &mut [TrajectoryPoint], speed_of_sound: f64) {
3870 for point in points.iter_mut() {
3871 point.drag_coefficient =
3872 self.effective_drag_coefficient(point.velocity_magnitude, speed_of_sound);
3873 }
3874 }
3875
3876 fn calculate_drag_coefficient(&self, velocity: f64, speed_of_sound: f64) -> f64 {
3877 let mach = velocity / speed_of_sound;
3878
3879 if let Some(ref table) = self.inputs.custom_drag_table {
3883 return table.interpolate(mach) * self.inputs.cd_scale;
3888 }
3889
3890 crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type)
3893 }
3894}
3895
3896#[derive(Debug, Clone)]
3898pub struct MonteCarloParams {
3899 pub num_simulations: usize,
3900 pub velocity_std_dev: f64,
3901 pub angle_std_dev: f64,
3902 pub bc_std_dev: f64,
3903 pub wind_speed_std_dev: f64,
3904 pub target_distance: Option<f64>,
3905 pub base_wind_speed: f64,
3906 pub base_wind_direction: f64,
3907 pub azimuth_std_dev: f64, }
3909
3910impl Default for MonteCarloParams {
3911 fn default() -> Self {
3912 Self {
3913 num_simulations: 1000,
3914 velocity_std_dev: 1.0,
3915 angle_std_dev: 0.001,
3916 bc_std_dev: 0.01,
3917 wind_speed_std_dev: 1.0,
3918 target_distance: None,
3919 base_wind_speed: 0.0,
3920 base_wind_direction: 0.0,
3921 azimuth_std_dev: 0.001, }
3923 }
3924}
3925
3926#[derive(Debug, Clone)]
3928pub struct MonteCarloResults {
3929 pub ranges: Vec<f64>,
3930 pub impact_velocities: Vec<f64>,
3931 pub impact_positions: Vec<Vector3<f64>>,
3937}
3938
3939pub const DEFAULT_HIT_RADIUS_M: f64 = 0.3;
3942
3943pub const TARGET_NOT_REACHED_SENTINEL_M: f64 = -1.0e9;
3949
3950impl MonteCarloResults {
3951 pub fn position_reached_target(position: &Vector3<f64>) -> bool {
3953 position.iter().all(|component| component.is_finite())
3954 && position.y != TARGET_NOT_REACHED_SENTINEL_M
3955 }
3956
3957 pub fn target_arrival_count(&self) -> usize {
3959 self.impact_positions
3960 .iter()
3961 .filter(|position| Self::position_reached_target(position))
3962 .count()
3963 }
3964
3965 pub fn target_shortfall_fraction(&self) -> f64 {
3968 if self.impact_positions.is_empty() {
3969 return 0.0;
3970 }
3971 (self.impact_positions.len() - self.target_arrival_count()) as f64
3972 / self.impact_positions.len() as f64
3973 }
3974
3975 pub fn target_plane_cep(&self) -> Option<f64> {
3981 let mut radial_misses: Vec<f64> = self
3982 .impact_positions
3983 .iter()
3984 .filter(|position| Self::position_reached_target(position))
3985 .map(Vector3::norm)
3986 .filter(|miss| miss.is_finite())
3987 .collect();
3988 radial_misses.sort_by(f64::total_cmp);
3989 if radial_misses.is_empty() {
3990 None
3991 } else {
3992 Some(radial_misses[radial_misses.len() / 2])
3993 }
3994 }
3995
3996 pub fn hit_probability(&self, hit_radius_m: f64) -> f64 {
4005 if self.impact_positions.is_empty() {
4006 return 0.0;
4007 }
4008 let hits = self
4009 .impact_positions
4010 .iter()
4011 .filter(|position| Self::position_is_hit(position, hit_radius_m))
4012 .count();
4013 hits as f64 / self.impact_positions.len() as f64
4014 }
4015
4016 pub fn position_is_hit(position: &Vector3<f64>, hit_radius_m: f64) -> bool {
4028 Self::position_reached_target(position) && position.norm() < hit_radius_m
4029 }
4030
4031 pub fn hit_probability_wilson(
4053 &self,
4054 hit_radius_m: f64,
4055 level: ConfidenceLevel,
4056 ) -> (f64, (f64, f64), u64) {
4057 let trials = self.impact_positions.len() as u64;
4058 let hits = self
4059 .impact_positions
4060 .iter()
4061 .filter(|position| Self::position_is_hit(position, hit_radius_m))
4062 .count() as u64;
4063 (
4064 self.hit_probability(hit_radius_m),
4065 wilson_interval(hits, trials, level),
4066 trials,
4067 )
4068 }
4069
4070 pub fn rect_hit_probability(&self, width_m: f64, height_m: f64) -> f64 {
4082 let dimensions_invalid =
4083 width_m.is_nan() || width_m <= 0.0 || height_m.is_nan() || height_m <= 0.0;
4084 if self.impact_positions.is_empty() || dimensions_invalid {
4085 return 0.0;
4086 }
4087 let half_width = width_m / 2.0;
4088 let half_height = height_m / 2.0;
4089 let hits = self
4090 .impact_positions
4091 .iter()
4092 .filter(|position| {
4093 Self::position_reached_target(position)
4094 && position.z.abs() <= half_width
4095 && position.y.abs() <= half_height
4096 })
4097 .count();
4098 hits as f64 / self.impact_positions.len() as f64
4099 }
4100}
4101
4102fn wind_from_signed_speed_sample(
4103 signed_speed: f64,
4104 sampled_direction: f64,
4105 vertical_speed: f64,
4106) -> WindConditions {
4107 if signed_speed < 0.0 {
4112 WindConditions {
4113 speed: -signed_speed,
4114 direction: sampled_direction + std::f64::consts::PI,
4115 vertical_speed,
4116 }
4117 } else {
4118 WindConditions {
4119 speed: signed_speed,
4120 direction: sampled_direction,
4121 vertical_speed,
4122 }
4123 }
4124}
4125
4126struct MonteCarloWindSampler {
4127 speed: rand_distr::Normal<f64>,
4128 direction: rand_distr::Normal<f64>,
4129 vertical_speed: f64,
4131}
4132
4133impl MonteCarloWindSampler {
4134 fn new(
4135 base_wind: &WindConditions,
4136 wind_speed_std_dev: f64,
4137 wind_direction_std_dev: f64,
4138 ) -> Result<Self, BallisticsError> {
4139 use rand_distr::Normal;
4140
4141 if !wind_direction_std_dev.is_finite() || wind_direction_std_dev < 0.0 {
4142 return Err("Wind direction standard deviation must be finite and non-negative".into());
4143 }
4144
4145 let speed = Normal::new(base_wind.speed, wind_speed_std_dev)
4146 .map_err(|e| format!("Invalid wind speed distribution: {e}"))?;
4147 let direction = Normal::new(base_wind.direction, wind_direction_std_dev)
4148 .map_err(|e| format!("Invalid wind direction distribution: {e}"))?;
4149 Ok(Self {
4150 speed,
4151 direction,
4152 vertical_speed: base_wind.vertical_speed,
4153 })
4154 }
4155
4156 fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> WindConditions {
4157 use rand_distr::Distribution;
4158
4159 wind_from_signed_speed_sample(
4160 self.speed.sample(rng),
4161 self.direction.sample(rng),
4162 self.vertical_speed,
4163 )
4164 }
4165}
4166
4167#[derive(Debug, Clone, Copy)]
4170struct TrialOutcome {
4171 range: f64,
4174 impact_velocity: f64,
4176 impact_position: Vector3<f64>,
4179}
4180
4181struct MonteCarloTrialSampler {
4201 base_inputs: BallisticInputs,
4202 atmosphere: AtmosphericConditions,
4203 solver_max_range: f64,
4204 target_distance: f64,
4207 baseline_at_target: Vector3<f64>,
4210 velocity_delta_dist: rand_distr::Normal<f64>,
4211 angle_dist: rand_distr::Normal<f64>,
4212 bc_dist: rand_distr::Normal<f64>,
4213 wind_sampler: MonteCarloWindSampler,
4214 azimuth_dist: rand_distr::Normal<f64>,
4215}
4216
4217impl MonteCarloTrialSampler {
4218 fn new(
4222 base_inputs: BallisticInputs,
4223 base_wind: &WindConditions,
4224 params: &MonteCarloParams,
4225 wind_direction_std_dev: f64,
4226 ) -> Result<Self, BallisticsError> {
4227 use rand_distr::Normal;
4228
4229 let atmosphere = AtmosphericConditions {
4230 temperature: base_inputs.temperature,
4231 pressure: base_inputs.pressure,
4232 humidity: base_inputs.humidity_percent(),
4233 altitude: base_inputs.altitude,
4234 };
4235 let target_hint = params
4236 .target_distance
4237 .unwrap_or(base_inputs.target_distance);
4238 let solver_max_range = target_hint.max(1000.0) * 2.0;
4239
4240 let mut baseline_solver =
4242 TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), atmosphere.clone());
4243 baseline_solver.set_max_range(solver_max_range);
4244 let baseline_result = baseline_solver.solve()?;
4245
4246 let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
4248
4249 let baseline_at_target = baseline_result
4251 .position_at_range(target_distance)
4252 .ok_or("Could not interpolate baseline at target distance")?;
4253
4254 let velocity_delta_dist = Normal::new(0.0, params.velocity_std_dev)
4259 .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
4260 let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
4261 .map_err(|e| format!("Invalid angle distribution: {}", e))?;
4262 let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
4263 .map_err(|e| format!("Invalid BC distribution: {}", e))?;
4264 let wind_sampler = MonteCarloWindSampler::new(
4267 base_wind,
4268 params.wind_speed_std_dev,
4269 wind_direction_std_dev,
4270 )?;
4271 let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
4272 .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
4273
4274 Ok(Self {
4275 base_inputs,
4276 atmosphere,
4277 solver_max_range,
4278 target_distance,
4279 baseline_at_target,
4280 velocity_delta_dist,
4281 angle_dist,
4282 bc_dist,
4283 wind_sampler,
4284 azimuth_dist,
4285 })
4286 }
4287
4288 fn sample_one_trial<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> Option<TrialOutcome> {
4298 use rand_distr::Distribution;
4299
4300 let mut inputs = self.base_inputs.clone();
4302 let muzzle_velocity_delta = self.velocity_delta_dist.sample(&mut *rng);
4303 inputs.muzzle_angle = self.angle_dist.sample(&mut *rng);
4304 inputs.bc_value = self.bc_dist.sample(&mut *rng).max(0.01);
4305 inputs.azimuth_angle = self.azimuth_dist.sample(&mut *rng); let wind = self.wind_sampler.sample(&mut *rng);
4309
4310 let mut solver = TrajectorySolver::new(inputs, wind, self.atmosphere.clone());
4314 solver.inputs.muzzle_velocity =
4315 (solver.inputs.muzzle_velocity + muzzle_velocity_delta).max(0.0);
4316 solver.set_max_range(self.solver_max_range);
4317 let result = solver.solve().ok()?;
4319
4320 let impact_position = if result.max_range < self.target_distance {
4326 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)
4329 } else {
4330 let pos_at_target = result.position_at_range(self.target_distance)?;
4332 Vector3::new(
4337 0.0,
4338 pos_at_target.y - self.baseline_at_target.y,
4339 pos_at_target.z - self.baseline_at_target.z,
4340 )
4341 };
4342
4343 Some(TrialOutcome {
4344 range: result.max_range,
4345 impact_velocity: result.impact_velocity,
4346 impact_position,
4347 })
4348 }
4349}
4350
4351pub fn run_monte_carlo(
4353 base_inputs: BallisticInputs,
4354 params: MonteCarloParams,
4355) -> Result<MonteCarloResults, BallisticsError> {
4356 run_monte_carlo_with_direction_std_dev(base_inputs, params, 0.0)
4357}
4358
4359pub fn run_monte_carlo_with_direction_std_dev(
4364 base_inputs: BallisticInputs,
4365 params: MonteCarloParams,
4366 wind_direction_std_dev: f64,
4367) -> Result<MonteCarloResults, BallisticsError> {
4368 let base_wind = WindConditions {
4369 speed: params.base_wind_speed,
4370 direction: params.base_wind_direction,
4371 vertical_speed: 0.0,
4372 };
4373 run_monte_carlo_with_wind_and_direction_std_dev(
4374 base_inputs,
4375 base_wind,
4376 params,
4377 wind_direction_std_dev,
4378 )
4379}
4380
4381pub fn run_monte_carlo_with_wind(
4383 base_inputs: BallisticInputs,
4384 base_wind: WindConditions,
4385 params: MonteCarloParams,
4386) -> Result<MonteCarloResults, BallisticsError> {
4387 run_monte_carlo_with_wind_and_direction_std_dev(base_inputs, base_wind, params, 0.0)
4388}
4389
4390pub fn run_monte_carlo_with_wind_and_direction_std_dev(
4395 base_inputs: BallisticInputs,
4396 base_wind: WindConditions,
4397 params: MonteCarloParams,
4398 wind_direction_std_dev: f64,
4399) -> Result<MonteCarloResults, BallisticsError> {
4400 let mut rng = rand::rng();
4401 run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
4402 base_inputs,
4403 base_wind,
4404 params,
4405 wind_direction_std_dev,
4406 &mut rng,
4407 )
4408}
4409
4410pub fn run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4417 base_inputs: BallisticInputs,
4418 base_wind: WindConditions,
4419 params: MonteCarloParams,
4420 wind_direction_std_dev: f64,
4421 seed: u64,
4422) -> Result<MonteCarloResults, BallisticsError> {
4423 use rand::{rngs::StdRng, SeedableRng};
4424 let mut rng = StdRng::seed_from_u64(seed);
4425 run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
4426 base_inputs,
4427 base_wind,
4428 params,
4429 wind_direction_std_dev,
4430 &mut rng,
4431 )
4432}
4433
4434fn run_monte_carlo_with_wind_and_direction_std_dev_using_rng<R: rand::Rng + ?Sized>(
4435 base_inputs: BallisticInputs,
4436 base_wind: WindConditions,
4437 params: MonteCarloParams,
4438 wind_direction_std_dev: f64,
4439 rng: &mut R,
4440) -> Result<MonteCarloResults, BallisticsError> {
4441 let mut ranges = Vec::new();
4442 let mut impact_velocities = Vec::new();
4443 let mut impact_positions = Vec::new();
4444
4445 let sampler =
4446 MonteCarloTrialSampler::new(base_inputs, &base_wind, ¶ms, wind_direction_std_dev)?;
4447
4448 for _ in 0..params.num_simulations {
4449 if let Some(outcome) = sampler.sample_one_trial(rng) {
4453 ranges.push(outcome.range);
4454 impact_velocities.push(outcome.impact_velocity);
4455 impact_positions.push(outcome.impact_position);
4456 }
4457 }
4458
4459 if ranges.is_empty() {
4460 return Err("No successful simulations".into());
4461 }
4462
4463 Ok(MonteCarloResults {
4464 ranges,
4465 impact_velocities,
4466 impact_positions,
4467 })
4468}
4469
4470pub const MC_ADAPTIVE_SCHEMA_VERSION_V1: u32 = 1;
4472
4473pub const MC_ADAPTIVE_METHOD_V1: &str = "anytime_beta_binomial_mixture_cs_v1";
4480
4481pub const MC_ADAPTIVE_ASSUMPTIONS_V1: [&str; 4] = [
4495 "Sampling uncertainty only: intervals cover Monte Carlo sampling error, not model error in the trajectory solver or its inputs.",
4496 "Anytime-valid stopping: the beta-binomial mixture confidence sequence keeps its coverage guarantee despite stopping the moment the target half-width is met.",
4497 "Input dispersions are the independent normal distributions declared in MonteCarloParams; correlations between inputs are not modeled.",
4498 "Continuous statistics are streaming Welford moments over trials that reached the target plane, reported with sample (n-1) standard deviations; hit probability's denominator includes all trials.",
4499];
4500
4501#[derive(Debug, Clone)]
4508pub struct McConvergence {
4509 pub level: ConfidenceLevel,
4511 pub target_half_width: f64,
4514 pub min_samples: u64,
4518 pub max_samples: u64,
4521 pub batch_size: u64,
4524}
4525
4526impl Default for McConvergence {
4527 fn default() -> Self {
4528 Self {
4529 level: ConfidenceLevel::P95,
4530 target_half_width: 0.02,
4531 min_samples: 1_000,
4532 max_samples: 100_000,
4533 batch_size: 500,
4534 }
4535 }
4536}
4537
4538impl McConvergence {
4539 pub fn validate(&self) -> Result<(), String> {
4548 if !self.target_half_width.is_finite() || self.target_half_width <= 0.0 {
4549 return Err(format!(
4550 "McConvergence.target_half_width must be a finite value greater than zero (got {})",
4551 self.target_half_width
4552 ));
4553 }
4554 if self.batch_size == 0 {
4555 return Err("McConvergence.batch_size must be greater than zero".to_string());
4556 }
4557 if self.max_samples == 0 {
4558 return Err("McConvergence.max_samples must be greater than zero".to_string());
4559 }
4560 if self.max_samples < self.min_samples {
4561 return Err(format!(
4562 "McConvergence.max_samples ({}) must be at least McConvergence.min_samples ({})",
4563 self.max_samples, self.min_samples
4564 ));
4565 }
4566 Ok(())
4567 }
4568}
4569
4570#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
4572#[serde(rename_all = "snake_case")]
4573pub enum McStopReason {
4574 TargetHalfWidthMet,
4577 MaxSamplesReached,
4580}
4581
4582#[derive(Debug, Clone, serde::Serialize)]
4589pub struct AdaptiveMcReportV1 {
4590 pub schema_version: u32,
4592 pub method: String,
4594 pub assumptions: Vec<String>,
4596 pub confidence_percent: u32,
4598 pub hit_probability: f64,
4601 pub ci_low: f64,
4603 pub ci_high: f64,
4605 pub samples: u64,
4607 pub attempts: u64,
4618 pub arrivals: u64,
4633 pub stop_reason: McStopReason,
4635 pub hit_radius_m: f64,
4637 pub target_distance_m: f64,
4639 pub mean_impact_velocity_mps: f64,
4644 pub std_impact_velocity_mps: f64,
4646 pub mean_drop_at_target_m: f64,
4654 pub std_drop_at_target_m: f64,
4657 pub mean_wind_drift_at_target_m: f64,
4660 pub std_wind_drift_at_target_m: f64,
4663}
4664
4665pub fn run_monte_carlo_adaptive_seeded(
4740 base_inputs: &BallisticInputs,
4741 base_wind: &WindConditions,
4742 params: &MonteCarloParams,
4743 convergence: &McConvergence,
4744 hit_radius_m: f64,
4745 seed: u64,
4746) -> Result<AdaptiveMcReportV1, String> {
4747 use rand::{rngs::StdRng, SeedableRng};
4748
4749 convergence.validate()?;
4750
4751 let sampler = MonteCarloTrialSampler::new(base_inputs.clone(), base_wind, params, 0.0)
4754 .map_err(|e| e.to_string())?;
4755
4756 let mut rng = StdRng::seed_from_u64(seed);
4757 let mut hits_cs = BernoulliConfidenceSequence::new(convergence.level);
4758 let mut impact_velocity = Welford::new();
4759 let mut drop_at_target = Welford::new();
4760 let mut drift_at_target = Welford::new();
4761
4762 let mut attempts: u64 = 0;
4763 let mut stop_reason = McStopReason::MaxSamplesReached;
4764
4765 while attempts < convergence.max_samples {
4766 let batch = convergence
4769 .batch_size
4770 .min(convergence.max_samples - attempts);
4771 let mut batch_hits: u64 = 0;
4772 let mut batch_trials: u64 = 0;
4773
4774 for _ in 0..batch {
4775 attempts += 1;
4776 let Some(outcome) = sampler.sample_one_trial(&mut rng) else {
4777 continue; };
4779 batch_trials += 1;
4780 if MonteCarloResults::position_is_hit(&outcome.impact_position, hit_radius_m) {
4781 batch_hits += 1;
4782 }
4783 if MonteCarloResults::position_reached_target(&outcome.impact_position) {
4787 impact_velocity.push(outcome.impact_velocity);
4788 drop_at_target.push(outcome.impact_position.y);
4789 drift_at_target.push(outcome.impact_position.z);
4790 }
4791 }
4792
4793 hits_cs.update_batch(batch_hits, batch_trials);
4794
4795 if hits_cs.trials() >= convergence.min_samples
4796 && hits_cs.half_width() <= convergence.target_half_width
4797 {
4798 stop_reason = McStopReason::TargetHalfWidthMet;
4799 break;
4800 }
4801 }
4802
4803 let samples = hits_cs.trials();
4804 if samples == 0 {
4805 return Err("No successful simulations".to_string());
4806 }
4807 let (ci_low, ci_high) = hits_cs.bounds();
4808
4809 Ok(AdaptiveMcReportV1 {
4810 schema_version: MC_ADAPTIVE_SCHEMA_VERSION_V1,
4811 method: MC_ADAPTIVE_METHOD_V1.to_string(),
4812 assumptions: MC_ADAPTIVE_ASSUMPTIONS_V1
4813 .iter()
4814 .map(|s| s.to_string())
4815 .collect(),
4816 confidence_percent: convergence.level.as_percent(),
4817 hit_probability: hits_cs.successes() as f64 / samples as f64,
4818 ci_low,
4819 ci_high,
4820 samples,
4821 attempts,
4822 arrivals: drop_at_target.count(),
4826 stop_reason,
4827 hit_radius_m,
4828 target_distance_m: sampler.target_distance,
4829 mean_impact_velocity_mps: impact_velocity.mean(),
4830 std_impact_velocity_mps: impact_velocity.sample_std(),
4831 mean_drop_at_target_m: drop_at_target.mean(),
4832 std_drop_at_target_m: drop_at_target.sample_std(),
4833 mean_wind_drift_at_target_m: drift_at_target.mean(),
4834 std_wind_drift_at_target_m: drift_at_target.sample_std(),
4835 })
4836}
4837
4838pub fn calculate_zero_angle(
4840 inputs: BallisticInputs,
4841 target_distance: f64,
4842 target_height: f64,
4843) -> Result<f64, BallisticsError> {
4844 calculate_zero_angle_with_conditions(
4845 inputs,
4846 target_distance,
4847 target_height,
4848 WindConditions::default(),
4849 AtmosphericConditions::default(),
4850 )
4851}
4852
4853pub fn calculate_zero_angle_with_conditions(
4854 inputs: BallisticInputs,
4855 target_distance: f64,
4856 target_height: f64,
4857 wind: WindConditions,
4858 atmosphere: AtmosphericConditions,
4859) -> Result<f64, BallisticsError> {
4860 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
4861 solver.calculate_and_set_zero_angle(target_distance, target_height, ZeroTargetFrame::SightLine)
4862}
4863
4864pub fn calculate_zero_angle_with_resolved_conditions(
4870 inputs: BallisticInputs,
4871 target_distance: f64,
4872 target_height: f64,
4873 wind: WindConditions,
4874 atmosphere: AtmosphericConditions,
4875) -> Result<f64, BallisticsError> {
4876 let mut solver =
4877 TrajectorySolver::new_with_resolved_station_atmosphere(inputs, wind, atmosphere);
4878 solver.calculate_and_set_zero_angle(target_distance, target_height, ZeroTargetFrame::SightLine)
4879}
4880
4881pub const ZERO_RANGE_FROM_ANGLE_MAX_RANGE_M: f64 = 2000.0;
4889
4890pub fn calculate_zero_range_from_angle_with_conditions(
4905 inputs: BallisticInputs,
4906 zero_angle_rad: f64,
4907 target_height: f64,
4908 wind: WindConditions,
4909 atmosphere: AtmosphericConditions,
4910) -> Result<ZeroCrossings, BallisticsError> {
4911 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
4912 solver.set_max_range(ZERO_RANGE_FROM_ANGLE_MAX_RANGE_M);
4913 solver.find_zero_range(zero_angle_rad, target_height, ZeroTargetFrame::SightLine)
4914}
4915
4916pub fn calculate_zero_range_from_angle_with_resolved_conditions(
4922 inputs: BallisticInputs,
4923 zero_angle_rad: f64,
4924 target_height: f64,
4925 wind: WindConditions,
4926 atmosphere: AtmosphericConditions,
4927) -> Result<ZeroCrossings, BallisticsError> {
4928 let mut solver =
4929 TrajectorySolver::new_with_resolved_station_atmosphere(inputs, wind, atmosphere);
4930 solver.set_max_range(ZERO_RANGE_FROM_ANGLE_MAX_RANGE_M);
4931 solver.find_zero_range(zero_angle_rad, target_height, ZeroTargetFrame::SightLine)
4932}
4933
4934#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4936pub enum BcFitMode {
4937 Drop,
4939 Velocity,
4942}
4943
4944#[derive(Debug, Clone, Copy)]
4946pub struct BcEstimate {
4947 pub bc: f64,
4949 pub rms_error: f64,
4951 pub drag_model: DragModel,
4953 pub mode: BcFitMode,
4955 pub at_bound: bool,
4959}
4960
4961fn fit_value_at(
4969 points: &[TrajectoryPoint],
4970 target_dist: f64,
4971 mode: BcFitMode,
4972 drop_offset: f64,
4973) -> Option<f64> {
4974 let val = |p: &TrajectoryPoint| match mode {
4975 BcFitMode::Drop => drop_offset - p.position.y,
4976 BcFitMode::Velocity => p.velocity_magnitude,
4977 };
4978 for i in 0..points.len() {
4979 if points[i].position.x >= target_dist {
4980 if i == 0 {
4981 return Some(val(&points[0]));
4982 }
4983 let p1 = &points[i - 1];
4984 let p2 = &points[i];
4985 let dx = p2.position.x - p1.position.x;
4986 if dx.abs() < 1e-9 {
4987 return Some(val(p2));
4988 }
4989 let t = (target_dist - p1.position.x) / dx;
4990 return Some(val(p1) + t * (val(p2) - val(p1)));
4991 }
4992 }
4993 None
4994}
4995
4996fn fit_residual_sse(
4997 trajectory: &[TrajectoryPoint],
4998 observations: &[(f64, f64)],
4999 mode: BcFitMode,
5000 drop_offset: f64,
5001) -> Option<f64> {
5002 if observations.is_empty() {
5003 return None;
5004 }
5005 let mut total = 0.0;
5006 for (target_dist, target_val) in observations {
5007 let value = fit_value_at(trajectory, *target_dist, mode, drop_offset)?;
5010 let error = value - target_val;
5011 total += error * error;
5012 }
5013 Some(total)
5014}
5015
5016#[allow(clippy::too_many_arguments)] pub fn estimate_bc_fit(
5034 velocity: f64,
5035 mass: f64,
5036 diameter: f64,
5037 points: &[(f64, f64)],
5038 drag_model: DragModel,
5039 mode: BcFitMode,
5040 atmosphere: AtmosphericConditions,
5041 zero_range: Option<f64>,
5042 sight_height: f64,
5043) -> Result<BcEstimate, BallisticsError> {
5044 if points.is_empty() {
5045 return Err(BallisticsError::from(
5046 "No data points provided for BC estimation.".to_string(),
5047 ));
5048 }
5049 let max_dist = points.iter().map(|(d, _)| *d).fold(0.0_f64, f64::max);
5050 let drop_offset = if zero_range.is_some() {
5053 sight_height
5054 } else {
5055 0.0
5056 };
5057
5058 let sse = |bc_value: f64| -> Option<f64> {
5060 let mut inputs = BallisticInputs {
5061 muzzle_velocity: velocity,
5062 bc_value,
5063 bc_type: drag_model,
5064 bullet_mass: mass,
5065 bullet_diameter: diameter,
5066 sight_height,
5067 ..Default::default()
5068 };
5069 if let Some(zr) = zero_range {
5072 let za = calculate_zero_angle_with_conditions(
5078 inputs.clone(),
5079 zr,
5080 sight_height,
5081 WindConditions::default(),
5082 atmosphere.clone(),
5083 )
5084 .ok()?;
5085 inputs.muzzle_angle = za;
5086 }
5087 let mut solver =
5088 TrajectorySolver::new(inputs, WindConditions::default(), atmosphere.clone());
5089 solver.set_max_range(max_dist * 1.5);
5090 let result = solver.solve().ok()?;
5091 fit_residual_sse(&result.points, points, mode, drop_offset)
5092 };
5093
5094 let (bc_min, bc_max) = match drag_model {
5098 DragModel::G7 => (0.05, 0.70),
5099 _ => (0.10, 1.20),
5100 };
5101
5102 let mut best_bc = f64::NAN;
5104 let mut best_sse = f64::MAX;
5105 let mut bc = bc_min;
5106 while bc <= bc_max + 1e-9 {
5107 if let Some(s) = sse(bc) {
5108 if s < best_sse {
5109 best_sse = s;
5110 best_bc = bc;
5111 }
5112 }
5113 bc += 0.01;
5114 }
5115 if !best_bc.is_finite() {
5116 return Err(BallisticsError::from(
5117 "Unable to estimate BC from provided data. Check that the values and units are correct."
5118 .to_string(),
5119 ));
5120 }
5121
5122 let lo = (best_bc - 0.01).max(bc_min);
5124 let hi = (best_bc + 0.01).min(bc_max);
5125 let mut bc = lo;
5126 while bc <= hi + 1e-9 {
5127 if let Some(s) = sse(bc) {
5128 if s < best_sse {
5129 best_sse = s;
5130 best_bc = bc;
5131 }
5132 }
5133 bc += 0.001;
5134 }
5135
5136 let at_bound = best_bc <= bc_min + 0.011 || best_bc >= bc_max - 0.011;
5139 let rms_error = (best_sse / points.len() as f64).sqrt();
5142 Ok(BcEstimate {
5143 bc: best_bc,
5144 rms_error,
5145 drag_model,
5146 mode,
5147 at_bound,
5148 })
5149}
5150
5151pub fn estimate_bc_from_trajectory(
5154 velocity: f64,
5155 mass: f64,
5156 diameter: f64,
5157 points: &[(f64, f64)], ) -> Result<f64, BallisticsError> {
5159 estimate_bc_fit(
5160 velocity,
5161 mass,
5162 diameter,
5163 points,
5164 DragModel::G1,
5165 BcFitMode::Drop,
5166 AtmosphericConditions::default(),
5167 None,
5168 0.05,
5169 )
5170 .map(|e| e.bc)
5171}
5172
5173use rand;
5175use rand_distr;
5176
5177#[cfg(test)]
5178mod mba737_powder_resolution_tests {
5179 use super::*;
5180
5181 #[test]
5182 fn linear_model_cold_powder_subtracts() {
5183 let v = resolve_powder_adjusted_velocity(823.0, 11.1, true, 0.5486, 21.1, None, None);
5185 assert!((v - (823.0 + 0.5486 * (11.1 - 21.1))).abs() < 1e-12);
5186 assert!(v < 823.0);
5187 }
5188
5189 #[test]
5190 fn linear_model_hot_powder_adds() {
5191 let v = resolve_powder_adjusted_velocity(823.0, 31.1, true, 0.5486, 21.1, None, None);
5192 assert!((v - (823.0 + 0.5486 * 10.0)).abs() < 1e-12);
5193 }
5194
5195 #[test]
5196 fn disabled_flag_is_passthrough() {
5197 let v = resolve_powder_adjusted_velocity(823.0, 40.0, false, 0.5486, 21.1, None, None);
5198 assert_eq!(v, 823.0);
5199 }
5200
5201 #[test]
5202 fn curve_overrides_linear_and_interpolates_at_powder_temp() {
5203 let curve = [(4.4, 798.6), (21.1, 823.0), (37.8, 841.2)];
5204 let v = resolve_powder_adjusted_velocity(
5206 823.0,
5207 30.0,
5208 true,
5209 99.0,
5210 21.1,
5211 Some(&curve),
5212 Some(4.4),
5213 );
5214 assert!((v - 798.6).abs() < 1e-9);
5215 }
5216
5217 #[test]
5218 fn curve_falls_back_to_ambient_and_clamps() {
5219 let curve = [(4.4, 798.6), (37.8, 841.2)];
5220 let v = resolve_powder_adjusted_velocity(823.0, -40.0, true, 1.0, 21.1, Some(&curve), None);
5222 assert!((v - 798.6).abs() < 1e-9);
5223 let v_hot =
5224 resolve_powder_adjusted_velocity(823.0, 60.0, true, 1.0, 21.1, Some(&curve), None);
5225 assert!((v_hot - 841.2).abs() < 1e-9);
5226 }
5227
5228 #[test]
5229 fn empty_curve_suppresses_linear_fallback() {
5230 let v = resolve_powder_adjusted_velocity(823.0, 40.0, true, 0.5486, 21.1, Some(&[]), None);
5232 assert_eq!(v, 823.0);
5233 }
5234
5235 #[test]
5236 fn sweep_huge_range_errors_instead_of_overflowing() {
5237 assert!(parse_powder_sweep("0:1e20:1").is_err());
5240 assert!(parse_powder_sweep("0:1e308:1e-3").is_err());
5241 }
5242
5243 #[test]
5244 fn sweep_fractional_step_keeps_end_row() {
5245 let rows = parse_powder_sweep("0:0.3:0.1").unwrap();
5247 assert_eq!(rows.len(), 4);
5248 assert!((rows[3] - 0.3).abs() < 1e-9);
5249 }
5250
5251 #[test]
5252 fn solver_and_helper_agree_on_linear_model() {
5253 let inputs = BallisticInputs {
5255 use_powder_sensitivity: true,
5256 powder_temp_sensitivity: 0.5486,
5257 powder_temp: 21.1,
5258 temperature: 4.4,
5259 ..Default::default()
5260 };
5261 let expected = resolve_powder_adjusted_velocity(
5262 inputs.muzzle_velocity,
5263 inputs.temperature,
5264 true,
5265 0.5486,
5266 21.1,
5267 None,
5268 None,
5269 );
5270 let solver = TrajectorySolver::new(
5271 inputs,
5272 WindConditions::default(),
5273 AtmosphericConditions::default(),
5274 );
5275 assert!((solver.inputs.muzzle_velocity - expected).abs() < 1e-12);
5276 }
5277}
5278
5279#[cfg(test)]
5280mod mba1302_solver_seam_tests {
5281 use super::*;
5282 use crate::wind::WindSegment;
5283
5284 #[test]
5285 fn authoritative_station_atmosphere_preserves_explicit_standard_values_at_altitude() {
5286 let atmosphere = AtmosphericConditions {
5287 temperature: 15.0,
5288 pressure: 1013.25,
5289 humidity: 50.0,
5290 altitude: 2_000.0,
5291 };
5292 let legacy = TrajectorySolver::new(
5293 BallisticInputs::default(),
5294 WindConditions::default(),
5295 atmosphere.clone(),
5296 );
5297 let authoritative = TrajectorySolver::new_with_resolved_station_atmosphere(
5298 BallisticInputs::default(),
5299 WindConditions::default(),
5300 atmosphere,
5301 );
5302
5303 let (legacy_density, _, legacy_temp_c, legacy_pressure_hpa) = legacy.resolved_atmosphere();
5304 let (authoritative_density, _, authoritative_temp_c, authoritative_pressure_hpa) =
5305 authoritative.resolved_atmosphere();
5306 let (icao_temp_k, icao_pressure_pa) =
5307 crate::atmosphere::calculate_icao_standard_atmosphere(2_000.0);
5308 let (expected_authoritative_density, _) =
5309 crate::atmosphere::calculate_atmosphere(2_000.0, Some(15.0), Some(1013.25), 50.0);
5310
5311 assert!((legacy_temp_c - (icao_temp_k - 273.15)).abs() < 1e-12);
5312 assert!((legacy_pressure_hpa - icao_pressure_pa / 100.0).abs() < 1e-12);
5313 assert_eq!(authoritative_temp_c.to_bits(), 15.0_f64.to_bits());
5314 assert_eq!(authoritative_pressure_hpa.to_bits(), 1013.25_f64.to_bits());
5315 assert_eq!(
5316 authoritative_density.to_bits(),
5317 expected_authoritative_density.to_bits()
5318 );
5319 assert!(
5320 (authoritative_density - legacy_density).abs() > 0.1,
5321 "explicit standard values at altitude must differ from ICAO-at-altitude: explicit={authoritative_density}, ICAO={legacy_density}"
5322 );
5323 }
5324
5325 #[test]
5334 fn precomputed_absolute_resolution_via_authoritative_matches_legacy_new() {
5335 for (temperature, pressure, altitude) in [
5336 (15.0, 1013.25, 0.0), (15.0, 1013.25, 2000.0), (-5.0, 850.0, 2000.0), (22.0, 950.0, 500.0),
5340 ] {
5341 let atmosphere = AtmosphericConditions {
5342 temperature,
5343 pressure,
5344 humidity: 50.0,
5345 altitude,
5346 };
5347 let legacy = TrajectorySolver::new(
5348 BallisticInputs::default(),
5349 WindConditions::default(),
5350 atmosphere.clone(),
5351 );
5352
5353 let (resolved_temp_c, resolved_pressure_hpa) =
5354 crate::atmosphere::resolve_station_conditions_with_pressure_mode(
5355 temperature,
5356 pressure,
5357 altitude,
5358 crate::atmosphere::PressureReferenceMode::Absolute,
5359 );
5360 let precomputed_atmosphere = AtmosphericConditions {
5361 temperature: resolved_temp_c,
5362 pressure: resolved_pressure_hpa,
5363 humidity: 50.0,
5364 altitude,
5365 };
5366 let precomputed = TrajectorySolver::new_with_resolved_station_atmosphere(
5367 BallisticInputs::default(),
5368 WindConditions::default(),
5369 precomputed_atmosphere,
5370 );
5371
5372 let (legacy_density, legacy_sos, legacy_temp_c, legacy_pressure_hpa) =
5373 legacy.resolved_atmosphere();
5374 let (pre_density, pre_sos, pre_temp_c, pre_pressure_hpa) =
5375 precomputed.resolved_atmosphere();
5376
5377 assert_eq!(
5378 legacy_temp_c.to_bits(),
5379 pre_temp_c.to_bits(),
5380 "temperature=({temperature}, {pressure}, {altitude})"
5381 );
5382 assert_eq!(
5383 legacy_pressure_hpa.to_bits(),
5384 pre_pressure_hpa.to_bits(),
5385 "pressure=({temperature}, {pressure}, {altitude})"
5386 );
5387 assert_eq!(legacy_density.to_bits(), pre_density.to_bits());
5388 assert_eq!(legacy_sos.to_bits(), pre_sos.to_bits());
5389 }
5390 }
5391
5392 fn configured_euler_zero(vertical_wind_mps: f64, time_step_s: f64) -> TrajectorySolver {
5393 let inputs = BallisticInputs {
5394 muzzle_velocity: 800.0,
5395 bc_value: 0.5,
5396 bc_type: DragModel::G7,
5397 bullet_mass: 0.0109,
5398 bullet_diameter: 0.00782,
5399 bullet_length: 0.0309,
5400 sight_height: 0.05,
5401 ground_threshold: -100.0,
5402 use_rk4: false,
5403 use_adaptive_rk45: false,
5404 ..BallisticInputs::default()
5405 };
5406 let mut solver = TrajectorySolver::new_with_resolved_station_atmosphere(
5407 inputs,
5408 WindConditions::default(),
5409 AtmosphericConditions::default(),
5410 );
5411 solver.set_max_range(300.0);
5412 solver.set_time_step(time_step_s);
5413 if vertical_wind_mps != 0.0 {
5414 solver.set_wind_segments(vec![WindSegment {
5415 speed_kmh: 0.0,
5416 angle_deg: 0.0,
5417 until_m: 400.0,
5418 vertical_mps: vertical_wind_mps,
5419 }]);
5420 }
5421 solver
5422 }
5423
5424 #[test]
5425 fn inclined_shot_zeroes_like_a_level_rifle() {
5426 const ZERO_DISTANCE_M: f64 = 91.44; const SIGHT_HEIGHT_M: f64 = 0.0381; let inputs = BallisticInputs {
5435 bc_value: 0.5,
5436 bullet_mass: 150.0 * 0.06479891 / 1000.0,
5437 muzzle_velocity: 2700.0 * 0.3048,
5438 sight_height: SIGHT_HEIGHT_M,
5439 ..Default::default()
5440 };
5441
5442 let mut level = inputs.clone();
5443 level.shooting_angle = 0.0;
5444 let level_angle = TrajectorySolver::new(level, Default::default(), Default::default())
5445 .find_zero_angle(ZERO_DISTANCE_M, SIGHT_HEIGHT_M, ZeroTargetFrame::SightLine)
5446 .expect("level zero must solve");
5447
5448 let mut inclined = inputs;
5449 inclined.shooting_angle = 5.71_f64.to_radians();
5450 let inclined_angle =
5451 TrajectorySolver::new(inclined, Default::default(), Default::default())
5452 .find_zero_angle(ZERO_DISTANCE_M, SIGHT_HEIGHT_M, ZeroTargetFrame::SightLine)
5453 .expect("MBA-1412: a 5.71 deg incline at a 100 yd zero must be solvable");
5454
5455 assert!(
5456 (inclined_angle - level_angle).abs() < 1e-9,
5457 "zeroing is level-rifle sight geometry; incline must not move the solved zero: \
5458 level={level_angle}, inclined={inclined_angle}"
5459 );
5460 }
5461
5462 #[test]
5463 fn target_below_the_level_bore_impact_zeroes_with_a_downward_bore() {
5464 const TARGET_DISTANCE_M: f64 = 457.2; let inputs = BallisticInputs {
5469 bc_value: 0.462,
5470 bullet_mass: 168.0 * 0.06479891 / 1000.0,
5471 bullet_diameter: 0.308 * 0.0254,
5472 muzzle_velocity: 2700.0 * 0.3048,
5473 sight_height: 0.05,
5474 ..Default::default()
5475 };
5476 let solver = TrajectorySolver::new(inputs, Default::default(), Default::default());
5477 let level_impact_m = solver
5478 .zero_trial_height_at(0.0, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
5479 .expect("level trial")
5480 .expect("level trial reaches the target distance");
5481
5482 for depth_m in [0.005, 1.0, 4.572, 91.44] {
5485 let target_m = level_impact_m - depth_m;
5486 let angle = solver
5487 .find_zero_angle(TARGET_DISTANCE_M, target_m, ZeroTargetFrame::SightLine)
5488 .unwrap_or_else(|e| panic!("target {depth_m} m below the level impact: {e}"));
5489 assert!(
5490 angle < 0.0,
5491 "a target below the level impact needs a downward bore: depth={depth_m} m angle={angle}"
5492 );
5493 let hit_m = solver
5494 .zero_trial_height_at(angle, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
5495 .expect("verify trial")
5496 .expect("zeroed trial reaches the target distance");
5497 assert!(
5498 (hit_m - target_m).abs() < 0.0001,
5499 "zero missed a target {depth_m} m below the level impact: hit={hit_m} target={target_m}"
5500 );
5501 }
5502 }
5503
5504 #[test]
5505 fn configured_zero_keeps_segments_method_and_time_step_then_sets_base_angle() {
5506 const TARGET_DISTANCE_M: f64 = 150.0;
5507 const TARGET_HEIGHT_M: f64 = 0.05;
5508
5509 let mut segmented = configured_euler_zero(-10.0, 0.02);
5512 let coarse_height = segmented
5513 .zero_trial_height_at(0.0, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
5514 .expect("coarse configured trial")
5515 .expect("coarse trial reaches target");
5516 let mut fine = segmented.clone();
5517 fine.set_time_step(0.001);
5518 let fine_height = fine
5519 .zero_trial_height_at(0.0, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
5520 .expect("fine configured trial")
5521 .expect("fine trial reaches target");
5522 assert!(
5523 (coarse_height - fine_height).abs() > 1e-5,
5524 "configured Euler step must affect zero trials: coarse={coarse_height}, fine={fine_height}"
5525 );
5526
5527 let segmented_angle = segmented
5528 .calculate_and_set_zero_angle(
5529 TARGET_DISTANCE_M,
5530 TARGET_HEIGHT_M,
5531 ZeroTargetFrame::SightLine,
5532 )
5533 .expect("segmented zero");
5534 assert_eq!(
5535 segmented.inputs.muzzle_angle.to_bits(),
5536 segmented_angle.to_bits(),
5537 "successful zero must install its angle on the configured solver"
5538 );
5539 assert_eq!(segmented.time_step.to_bits(), 0.02_f64.to_bits());
5540 assert_eq!(segmented.max_range.to_bits(), 300.0_f64.to_bits());
5541 assert!(segmented.wind_sock.is_some());
5542 assert_eq!(
5543 segmented.station_atmosphere_resolution,
5544 StationAtmosphereResolution::Authoritative
5545 );
5546 let zero_height = segmented
5547 .zero_trial_height_at(
5548 segmented_angle,
5549 TARGET_DISTANCE_M,
5550 ZeroTargetFrame::SightLine,
5551 )
5552 .expect("verify segmented zero")
5553 .expect("zeroed trial reaches target");
5554 assert!(
5555 (zero_height - TARGET_HEIGHT_M).abs() < 0.0001,
5556 "configured zero missed target: height={zero_height}"
5557 );
5558
5559 let mut calm = configured_euler_zero(0.0, 0.02);
5560 let calm_angle = calm
5561 .calculate_and_set_zero_angle(
5562 TARGET_DISTANCE_M,
5563 TARGET_HEIGHT_M,
5564 ZeroTargetFrame::SightLine,
5565 )
5566 .expect("calm zero");
5567 assert!(
5568 (segmented_angle - calm_angle).abs() > 1e-5,
5569 "segmented vertical wind must participate in zero trials: segmented={segmented_angle}, calm={calm_angle}"
5570 );
5571 }
5572}
5573
5574#[cfg(test)]
5575mod result_sanity_tests {
5576 use super::*;
5577
5578 fn default_solver() -> TrajectorySolver {
5579 TrajectorySolver::new(
5580 BallisticInputs::default(),
5581 WindConditions::default(),
5582 AtmosphericConditions::default(),
5583 )
5584 }
5585
5586 fn minimal_result() -> TrajectoryResult {
5587 TrajectoryResult {
5588 max_range: 100.0,
5589 max_height: 1.0,
5590 time_of_flight: 0.5,
5591 impact_velocity: 700.0,
5592 impact_energy: 2450.0,
5593 projectile_mass_kg: 0.01,
5594 line_of_sight_height_m: 1.5,
5595 station_speed_of_sound_mps: 340.0,
5596 termination: TrajectoryTermination::MaxRange,
5597 points: vec![],
5598 sampled_points: None,
5599 min_pitch_damping: None,
5600 transonic_mach: None,
5601 angular_state: None,
5602 max_yaw_angle: None,
5603 max_precession_angle: None,
5604 aerodynamic_jump: None,
5605 mach_1_2_distance_m: None,
5606 mach_1_0_distance_m: None,
5607 mach_0_9_distance_m: None,
5608 }
5609 }
5610
5611 #[test]
5612 fn mba1293_negative_scalars_fail_the_result_postcondition() {
5613 let solver = default_solver();
5614 solver
5615 .validate_result_sanity(&minimal_result())
5616 .expect("a sane result must pass");
5617
5618 for (name, mutate) in [
5619 (
5620 "max_range",
5621 (|r| r.max_range = -50.588) as fn(&mut TrajectoryResult),
5622 ),
5623 ("time_of_flight", |r| r.time_of_flight = -1.0),
5624 ("impact_velocity", |r| r.impact_velocity = -700.0),
5625 ("impact_energy", |r| r.impact_energy = -1.0),
5626 ] {
5627 let mut result = minimal_result();
5628 mutate(&mut result);
5629 let error = solver
5630 .validate_result_sanity(&result)
5631 .expect_err("negative scalar must fail");
5632 assert!(
5633 error.to_string().contains(name),
5634 "error for {name} did not name the field: {error}"
5635 );
5636 }
5637 }
5638
5639 #[test]
5640 fn mba1293_speed_budget_bounds_legitimate_states_and_rejects_divergence() {
5641 let solver = default_solver();
5642 let mv = solver.inputs.muzzle_velocity;
5643
5644 let position = Vector3::new(10.0, 0.0, 0.0);
5646 solver
5647 .validate_integration_state(&position, &Vector3::new(mv, 0.0, 0.0), 0.01)
5648 .expect("muzzle-speed state must pass");
5649
5650 let error = solver
5652 .validate_integration_state(&position, &Vector3::new(-13.0 * mv, 0.0, 0.0), 0.01)
5653 .expect_err("13x muzzle speed must fail the budget");
5654 assert!(error.to_string().contains("diverged"), "{error}");
5655
5656 let after_fall = mv + crate::constants::G_ACCEL_MPS2 * 60.0;
5658 solver
5659 .validate_integration_state(&position, &Vector3::new(0.0, -after_fall, 0.0), 60.0)
5660 .expect("gravity-accelerated speed within g*t must pass");
5661 }
5662}
5663
5664#[cfg(test)]
5665mod trajectory_point_budget_tests {
5666 use super::*;
5667 use crate::MAX_TRAJECTORY_SAMPLES;
5668
5669 fn solver_with_budget(
5670 use_rk4: bool,
5671 use_adaptive_rk45: bool,
5672 point_budget: usize,
5673 max_range: f64,
5674 ) -> TrajectorySolver {
5675 let inputs = BallisticInputs {
5676 use_rk4,
5677 use_adaptive_rk45,
5678 ground_threshold: f64::NEG_INFINITY,
5679 ..BallisticInputs::default()
5680 };
5681 let mut solver = TrajectorySolver::new(
5682 inputs,
5683 WindConditions::default(),
5684 AtmosphericConditions::default(),
5685 );
5686 solver.max_trajectory_points = point_budget;
5687 solver.set_max_range(max_range);
5688 solver.set_time_step(0.001);
5689 solver
5690 }
5691
5692 #[test]
5693 fn mba1283_every_solver_errors_instead_of_exceeding_point_budget() {
5694 for (mode, use_rk4, use_adaptive_rk45) in [
5695 ("Euler", false, false),
5696 ("RK4", true, false),
5697 ("RK45", true, true),
5698 ] {
5699 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 3, 10.0)
5700 .solve()
5701 .expect_err("a solve requiring more than three points must fail");
5702 assert!(
5703 error.to_string().contains("point limit of 3"),
5704 "unexpected {mode} point-budget error: {error}"
5705 );
5706 }
5707 }
5708
5709 #[test]
5710 fn mba1283_interpolated_endpoint_counts_toward_point_budget() {
5711 for (mode, use_rk4, use_adaptive_rk45) in [
5712 ("Euler", false, false),
5713 ("RK4", true, false),
5714 ("RK45", true, true),
5715 ] {
5716 let result = solver_with_budget(use_rk4, use_adaptive_rk45, 2, 0.1)
5717 .solve()
5718 .expect("the initial point plus exact endpoint fit a two-point budget");
5719 assert_eq!(result.points.len(), 2, "unexpected {mode} point count");
5720
5721 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 1, 0.1)
5722 .solve()
5723 .expect_err("the exact endpoint must not exceed a one-point budget");
5724 assert!(
5725 error.to_string().contains("point limit of 1"),
5726 "unexpected {mode} endpoint-budget error: {error}"
5727 );
5728 }
5729 }
5730
5731 #[test]
5739 fn mba1599_collapsed_stall_steps_count_toward_point_budget() {
5740 for (mode, use_rk4, use_adaptive_rk45, bc_value) in [
5741 ("Euler", false, false, 2.0e-4),
5742 ("RK4", true, false, 1.0e-4),
5743 ("RK45", true, true, 1.0e-4),
5744 ] {
5745 let stalled = |point_budget: usize| {
5746 let inputs = BallisticInputs {
5747 bc_value,
5748 bc_type: DragModel::G7,
5749 bullet_mass: 0.01134,
5750 bullet_diameter: 0.00782,
5751 muzzle_velocity: 823.0,
5752 use_rk4,
5753 use_adaptive_rk45,
5754 ground_threshold: -100.0,
5755 ..BallisticInputs::default()
5756 };
5757 let mut solver = TrajectorySolver::new(
5758 inputs,
5759 WindConditions::default(),
5760 AtmosphericConditions::default(),
5761 );
5762 solver.max_trajectory_points = point_budget;
5763 solver.set_max_range(300.0);
5764 solver.set_time_step(0.001);
5765 solver.solve()
5766 };
5767
5768 let flown = stalled(MAX_TRAJECTORY_POINTS)
5769 .unwrap_or_else(|error| panic!("{mode}: the stall ends inside the cap: {error}"));
5770 assert_eq!(
5771 flown.termination,
5772 TrajectoryTermination::GroundThreshold,
5773 "{mode}"
5774 );
5775 let stored = flown.points.len();
5776 let budget = stored + 1;
5777 let Err(error) = stalled(budget) else {
5778 panic!("{mode}: the flight fit a {budget}-point budget with {stored} stored points, so the collapsed steps were not counted");
5779 };
5780 assert!(
5781 error
5782 .to_string()
5783 .contains(&format!("trajectory point limit of {budget} exceeded")),
5784 "unexpected {mode} stall-budget error: {error}"
5785 );
5786 }
5787 }
5788
5789 #[test]
5793 fn mba1599_point_budget_fails_on_the_same_call_with_or_without_collapsing() {
5794 let solver = solver_with_budget(true, false, 4, 10.0);
5795 let point = |x: f64| TrajectoryPoint {
5796 time: x,
5797 position: Vector3::new(x, 0.0, 0.0),
5798 velocity_magnitude: 1.0,
5799 kinetic_energy: 0.0,
5800 drag_coefficient: None,
5801 };
5802 let assert_over_budget = |error: BallisticsError| {
5803 assert!(
5804 error
5805 .to_string()
5806 .contains("trajectory point limit of 4 exceeded"),
5807 "unexpected point-budget error: {error}"
5808 );
5809 };
5810 for distances in [
5811 [0.0, 1.0, 2.0, 3.0],
5812 [0.0, 1.0, 1.0, 2.0],
5813 [0.0, 0.0, 0.0, 0.0],
5814 ] {
5815 let mut points = Vec::new();
5816 let mut collapsed_steps = 0;
5817 for x in distances {
5818 solver
5819 .push_trajectory_point(&mut points, point(x), &mut collapsed_steps)
5820 .expect("four calls fit a four-point budget");
5821 }
5822 assert_eq!(points.len() + collapsed_steps, 4, "{distances:?}");
5823
5824 for x in [distances[3], 5.0] {
5826 let error = solver
5827 .push_trajectory_point(
5828 &mut points.clone(),
5829 point(x),
5830 &mut collapsed_steps.clone(),
5831 )
5832 .expect_err("a fifth call exceeds a four-point budget");
5833 assert_over_budget(error);
5834 }
5835 let error = solver
5837 .append_terminal_endpoint(
5838 &mut points.clone(),
5839 Vector3::new(20.0, 0.0, 0.0),
5840 Vector3::new(1.0, 0.0, 0.0),
5841 10.0,
5842 &mut 0.0,
5843 &mut collapsed_steps.clone(),
5844 )
5845 .expect_err("the terminal point is a fifth call");
5846 assert_over_budget(error);
5847 }
5848 }
5849
5850 #[test]
5851 fn mba1299_every_solver_preflights_the_sample_budget() {
5852 for (mode, use_rk4, use_adaptive_rk45) in [
5853 ("Euler", false, false),
5854 ("RK4", true, false),
5855 ("RK45", true, true),
5856 ] {
5857 let inputs = BallisticInputs {
5858 use_rk4,
5859 use_adaptive_rk45,
5860 enable_trajectory_sampling: true,
5861 sample_interval: 1.0,
5862 ground_threshold: f64::NEG_INFINITY,
5863 ..BallisticInputs::default()
5864 };
5865 let mut solver = TrajectorySolver::new(
5866 inputs,
5867 WindConditions::default(),
5868 AtmosphericConditions::default(),
5869 );
5870 solver.set_max_range(MAX_TRAJECTORY_SAMPLES as f64);
5871 solver.max_trajectory_points = 0;
5874
5875 let error = solver
5876 .solve()
5877 .expect_err("an over-limit sample grid must fail before integration");
5878 assert!(
5879 error
5880 .to_string()
5881 .contains("trajectory sample limit of 250000 exceeded"),
5882 "unexpected {mode} sample-budget error: {error}"
5883 );
5884 }
5885 }
5886
5887 #[test]
5888 fn mba1299_normal_sampling_does_not_change_solver_results() {
5889 for (mode, use_rk4, use_adaptive_rk45) in [
5890 ("Euler", false, false),
5891 ("RK4", true, false),
5892 ("RK45", true, true),
5893 ] {
5894 let solve = |enable_trajectory_sampling| {
5895 let inputs = BallisticInputs {
5896 use_rk4,
5897 use_adaptive_rk45,
5898 enable_trajectory_sampling,
5899 sample_interval: 0.5,
5900 ground_threshold: f64::NEG_INFINITY,
5901 ..BallisticInputs::default()
5902 };
5903 let mut solver = TrajectorySolver::new(
5904 inputs,
5905 WindConditions::default(),
5906 AtmosphericConditions::default(),
5907 );
5908 solver.set_max_range(2.0);
5909 solver.solve().expect("normal short-range solve")
5910 };
5911
5912 let baseline = solve(false);
5913 let sampled = solve(true);
5914 for (field, left, right) in [
5915 ("max_range", baseline.max_range, sampled.max_range),
5916 ("max_height", baseline.max_height, sampled.max_height),
5917 (
5918 "time_of_flight",
5919 baseline.time_of_flight,
5920 sampled.time_of_flight,
5921 ),
5922 (
5923 "impact_velocity",
5924 baseline.impact_velocity,
5925 sampled.impact_velocity,
5926 ),
5927 (
5928 "impact_energy",
5929 baseline.impact_energy,
5930 sampled.impact_energy,
5931 ),
5932 ] {
5933 assert_eq!(
5934 left.to_bits(),
5935 right.to_bits(),
5936 "{mode} sampling changed {field}"
5937 );
5938 }
5939 assert_eq!(baseline.points.len(), sampled.points.len());
5940 for (index, (left, right)) in baseline.points.iter().zip(&sampled.points).enumerate() {
5941 assert_eq!(
5942 left.time.to_bits(),
5943 right.time.to_bits(),
5944 "{mode} point {index}"
5945 );
5946 assert_eq!(
5947 left.position.map(f64::to_bits),
5948 right.position.map(f64::to_bits),
5949 "{mode} point {index} position"
5950 );
5951 assert_eq!(
5952 left.velocity_magnitude.to_bits(),
5953 right.velocity_magnitude.to_bits(),
5954 "{mode} point {index} velocity"
5955 );
5956 assert_eq!(
5957 left.kinetic_energy.to_bits(),
5958 right.kinetic_energy.to_bits(),
5959 "{mode} point {index} energy"
5960 );
5961 }
5962 assert!(baseline.sampled_points.is_none());
5963 let samples = sampled
5964 .sampled_points
5965 .expect("sampling-enabled solve should return observations");
5966 assert_eq!(
5967 samples
5968 .iter()
5969 .map(|sample| sample.distance_m)
5970 .collect::<Vec<_>>(),
5971 vec![0.0, 0.5, 1.0, 1.5, 2.0],
5972 "{mode} normal sampling grid changed"
5973 );
5974 }
5975 }
5976}
5977
5978#[cfg(test)]
5979mod monte_carlo_result_tests {
5980 use super::*;
5981
5982 fn make_results(impact_positions: Vec<Vector3<f64>>) -> MonteCarloResults {
5983 let count = impact_positions.len();
5984 MonteCarloResults {
5985 ranges: vec![500.0; count],
5986 impact_velocities: vec![300.0; count],
5987 impact_positions,
5988 }
5989 }
5990
5991 #[test]
5992 fn target_plane_cep_excludes_shortfall_markers() {
5993 let mut positions: Vec<Vector3<f64>> = (1..=5)
5994 .map(|radius| Vector3::new(0.0, radius as f64, 0.0))
5995 .collect();
5996 positions.extend((0..5).map(|_| Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)));
5997 let results = make_results(positions);
5998
5999 assert_eq!(results.target_arrival_count(), 5);
6000 assert_eq!(results.target_shortfall_fraction(), 0.5);
6001 assert_eq!(results.target_plane_cep(), Some(3.0));
6002
6003 let one_shortfall = make_results(vec![
6004 Vector3::new(0.0, 1.0, 0.0),
6005 Vector3::new(0.0, 2.0, 0.0),
6006 Vector3::new(0.0, 3.0, 0.0),
6007 Vector3::new(0.0, 4.0, 0.0),
6008 Vector3::new(0.0, 5.0, 0.0),
6009 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
6010 ]);
6011 assert_eq!(one_shortfall.target_plane_cep(), Some(3.0));
6012 }
6013
6014 #[test]
6015 fn all_shortfalls_have_no_cep_but_still_count_as_misses() {
6016 let all_shortfalls = make_results(vec![
6017 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
6018 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
6019 ]);
6020 assert_eq!(all_shortfalls.target_arrival_count(), 0);
6021 assert_eq!(all_shortfalls.target_shortfall_fraction(), 1.0);
6022 assert_eq!(all_shortfalls.target_plane_cep(), None);
6023 assert_eq!(all_shortfalls.hit_probability(0.3), 0.0);
6024
6025 let one_hit_one_shortfall = make_results(vec![
6026 Vector3::new(0.0, 0.1, 0.0),
6027 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
6028 ]);
6029 assert_eq!(one_hit_one_shortfall.hit_probability(0.3), 0.5);
6030 }
6031
6032 #[test]
6034 fn rect_hit_probability_checks_independent_axis_halves() {
6035 let results = make_results(vec![
6036 Vector3::new(0.0, 0.1, 0.1),
6038 Vector3::new(0.0, 0.0, 0.2),
6040 Vector3::new(0.0, 0.0, 0.201),
6042 Vector3::new(0.0, 0.301, 0.0),
6044 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
6046 ]);
6047 assert!((results.rect_hit_probability(0.4, 0.6) - 0.4).abs() < 1e-12);
6049 }
6050
6051 #[test]
6052 fn rect_hit_probability_matches_circular_hit_probability_for_a_centered_hit() {
6053 let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
6054 assert_eq!(results.rect_hit_probability(0.5, 0.5), 1.0);
6055 assert_eq!(results.hit_probability(0.3), 1.0);
6056 }
6057
6058 #[test]
6059 fn rect_hit_probability_is_zero_for_empty_or_nonpositive_dimensions() {
6060 let empty = make_results(vec![]);
6061 assert_eq!(empty.rect_hit_probability(1.0, 1.0), 0.0);
6062
6063 let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
6064 assert_eq!(results.rect_hit_probability(0.0, 1.0), 0.0);
6065 assert_eq!(results.rect_hit_probability(1.0, 0.0), 0.0);
6066 assert_eq!(results.rect_hit_probability(-1.0, 1.0), 0.0);
6067 }
6068}
6069
6070#[cfg(test)]
6071mod monte_carlo_seeded_tests {
6072 use super::*;
6073
6074 fn seeded_test_fixture() -> (BallisticInputs, WindConditions) {
6083 (
6084 BallisticInputs {
6085 muzzle_velocity: 800.0,
6086 ..BallisticInputs::default()
6087 },
6088 WindConditions::default(),
6089 )
6090 }
6091
6092 fn loose_params() -> MonteCarloParams {
6102 MonteCarloParams {
6103 num_simulations: 1, velocity_std_dev: 3.0,
6105 angle_std_dev: 3.5e-4,
6106 bc_std_dev: 0.01,
6107 wind_speed_std_dev: 1.0,
6108 target_distance: Some(300.0),
6109 base_wind_speed: 0.0,
6110 base_wind_direction: 0.0,
6111 azimuth_std_dev: 3.5e-4,
6112 }
6113 }
6114
6115 fn mixed_arrival_params() -> MonteCarloParams {
6123 MonteCarloParams {
6124 num_simulations: 1,
6125 target_distance: Some(1920.0),
6126 ..MonteCarloParams::default()
6127 }
6128 }
6129
6130 #[test]
6146 fn legacy_seeded_estimates_are_pinned_bit_for_bit() {
6147 let (inputs, wind) = seeded_test_fixture();
6148 let params = MonteCarloParams {
6149 num_simulations: 200,
6150 target_distance: Some(500.0),
6151 ..MonteCarloParams::default()
6152 };
6153
6154 let results = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
6155 inputs,
6156 wind,
6157 params,
6158 0.01,
6159 0x1352_5EED,
6160 )
6161 .expect("seeded legacy run");
6162
6163 assert_eq!(results.ranges.len(), 200, "ranges length");
6168 assert_eq!(
6169 results.impact_velocities.len(),
6170 200,
6171 "impact_velocities length"
6172 );
6173 assert_eq!(
6174 results.impact_positions.len(),
6175 200,
6176 "impact_positions length"
6177 );
6178
6179 assert_eq!(
6181 results.hit_probability(DEFAULT_HIT_RADIUS_M).to_bits(),
6182 0.14_f64.to_bits(),
6183 "hit_probability = {:?}",
6184 results.hit_probability(DEFAULT_HIT_RADIUS_M)
6185 );
6186
6187 let expected_ranges: [f64; 3] = [1907.972891143359, 1936.408435469319, 1912.8150447617645];
6188 let expected_velocities: [f64; 3] =
6189 [238.6187151542299, 239.91923651600106, 243.14112455427164];
6190 let expected_positions: [(f64, f64, f64); 3] = [
6191 (0.0, -0.0643556039548101, 0.7344970252014579),
6192 (0.0, 0.5769422971539162, 0.27227201756386726),
6193 (0.0, -0.7440425792472842, 0.1541804446282822),
6194 ];
6195
6196 for (i, expected) in expected_ranges.iter().enumerate() {
6197 assert_eq!(
6198 results.ranges[i].to_bits(),
6199 expected.to_bits(),
6200 "ranges[{i}] = {:?}, pinned {expected:?}",
6201 results.ranges[i]
6202 );
6203 }
6204 for (i, expected) in expected_velocities.iter().enumerate() {
6205 assert_eq!(
6206 results.impact_velocities[i].to_bits(),
6207 expected.to_bits(),
6208 "impact_velocities[{i}] = {:?}, pinned {expected:?}",
6209 results.impact_velocities[i]
6210 );
6211 }
6212 for (i, (x, y, z)) in expected_positions.iter().enumerate() {
6213 let actual = results.impact_positions[i];
6214 assert_eq!(
6215 actual.x.to_bits(),
6216 x.to_bits(),
6217 "impact_positions[{i}].x = {:?}",
6218 actual.x
6219 );
6220 assert_eq!(
6221 actual.y.to_bits(),
6222 y.to_bits(),
6223 "impact_positions[{i}].y = {:?}",
6224 actual.y
6225 );
6226 assert_eq!(
6227 actual.z.to_bits(),
6228 z.to_bits(),
6229 "impact_positions[{i}].z = {:?}",
6230 actual.z
6231 );
6232 }
6233 }
6234
6235 #[test]
6236 fn seeded_runs_are_deterministic_and_match_the_using_rng_path() {
6237 let inputs = BallisticInputs {
6238 muzzle_velocity: 800.0,
6239 ..BallisticInputs::default()
6240 };
6241 let params = MonteCarloParams {
6242 num_simulations: 64,
6243 target_distance: Some(200.0),
6244 ..MonteCarloParams::default()
6245 };
6246
6247 let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
6248 inputs.clone(),
6249 WindConditions::default(),
6250 params.clone(),
6251 0.01,
6252 42,
6253 )
6254 .expect("seeded run a");
6255 let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
6256 inputs,
6257 WindConditions::default(),
6258 params,
6259 0.01,
6260 42,
6261 )
6262 .expect("seeded run b");
6263
6264 assert_eq!(a.ranges.len(), b.ranges.len());
6265 for (ra, rb) in a.ranges.iter().zip(b.ranges.iter()) {
6266 assert_eq!(ra.to_bits(), rb.to_bits());
6267 }
6268 for (pa, pb) in a.impact_positions.iter().zip(b.impact_positions.iter()) {
6269 assert_eq!(pa.x.to_bits(), pb.x.to_bits());
6270 assert_eq!(pa.y.to_bits(), pb.y.to_bits());
6271 assert_eq!(pa.z.to_bits(), pb.z.to_bits());
6272 }
6273 }
6274
6275 #[test]
6276 fn different_seeds_generally_produce_different_draws() {
6277 let inputs = BallisticInputs {
6278 muzzle_velocity: 800.0,
6279 ..BallisticInputs::default()
6280 };
6281 let params = MonteCarloParams {
6282 num_simulations: 32,
6283 velocity_std_dev: 5.0,
6284 target_distance: Some(200.0),
6285 ..MonteCarloParams::default()
6286 };
6287
6288 let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
6289 inputs.clone(),
6290 WindConditions::default(),
6291 params.clone(),
6292 0.0,
6293 1,
6294 )
6295 .expect("seeded run a");
6296 let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
6297 inputs,
6298 WindConditions::default(),
6299 params,
6300 0.0,
6301 2,
6302 )
6303 .expect("seeded run b");
6304
6305 assert_ne!(a.impact_velocities, b.impact_velocities);
6306 }
6307
6308 #[test]
6309 fn adaptive_stops_at_target_half_width_on_an_easy_case() {
6310 let (inputs, wind) = seeded_test_fixture();
6311 let conv = McConvergence {
6312 target_half_width: 0.05,
6313 ..Default::default()
6314 };
6315 let r = run_monte_carlo_adaptive_seeded(
6316 &inputs,
6317 &wind,
6318 &loose_params(),
6319 &conv,
6320 DEFAULT_HIT_RADIUS_M,
6321 0x1352_ADA9,
6322 )
6323 .unwrap();
6324
6325 assert_eq!(r.stop_reason, McStopReason::TargetHalfWidthMet);
6326 assert!(
6327 (r.ci_high - r.ci_low) / 2.0 <= 0.05 + 1e-12,
6328 "half-width {} exceeds the requested 0.05",
6329 (r.ci_high - r.ci_low) / 2.0
6330 );
6331 assert!(r.samples >= conv.min_samples, "stopped below min_samples");
6332 assert!(r.samples < conv.max_samples, "did not actually stop early");
6333 assert!(r.samples.is_multiple_of(conv.batch_size) || r.samples == conv.min_samples);
6334 assert!(r.ci_low <= r.hit_probability && r.hit_probability <= r.ci_high);
6335
6336 assert_eq!(r.hit_radius_m, DEFAULT_HIT_RADIUS_M);
6338 assert_eq!(r.target_distance_m, 300.0);
6339 assert_eq!(r.confidence_percent, 95);
6340 assert!(
6342 r.samples > 1,
6343 "params.num_simulations must be ignored by the adaptive driver"
6344 );
6345 assert!(
6348 r.mean_impact_velocity_mps > 0.0,
6349 "no impact velocity accumulated"
6350 );
6351 assert!(
6352 r.std_drop_at_target_m > 0.0 && r.std_wind_drift_at_target_m > 0.0,
6353 "dispersion collapsed: drop sd {} drift sd {}",
6354 r.std_drop_at_target_m,
6355 r.std_wind_drift_at_target_m
6356 );
6357 }
6358
6359 #[test]
6360 fn adaptive_caps_at_max_samples_on_an_impossible_target() {
6361 let (inputs, wind) = seeded_test_fixture();
6362 let conv = McConvergence {
6363 target_half_width: 1e-6,
6364 max_samples: 3_000,
6365 batch_size: 500,
6366 min_samples: 1_000,
6367 level: ConfidenceLevel::P95,
6368 };
6369 let r = run_monte_carlo_adaptive_seeded(
6370 &inputs,
6371 &wind,
6372 &loose_params(),
6373 &conv,
6374 DEFAULT_HIT_RADIUS_M,
6375 7,
6376 )
6377 .unwrap();
6378
6379 assert_eq!(r.stop_reason, McStopReason::MaxSamplesReached);
6380 assert_eq!(r.samples, 3_000);
6381 }
6382
6383 #[test]
6392 fn adaptive_stops_between_the_floor_and_the_ceiling() {
6393 let (inputs, wind) = seeded_test_fixture();
6394 let conv = McConvergence {
6395 level: ConfidenceLevel::P95,
6396 target_half_width: 0.03,
6397 min_samples: 0,
6398 max_samples: 10_000,
6399 batch_size: 100,
6400 };
6401 let r = run_monte_carlo_adaptive_seeded(
6402 &inputs,
6403 &wind,
6404 &loose_params(),
6405 &conv,
6406 DEFAULT_HIT_RADIUS_M,
6407 0x1352_5A1D,
6408 )
6409 .unwrap();
6410
6411 assert_eq!(r.stop_reason, McStopReason::TargetHalfWidthMet);
6412 assert!(r.samples > 0);
6413 assert!(
6414 r.samples.is_multiple_of(conv.batch_size),
6415 "samples {} is not a whole number of batches",
6416 r.samples
6417 );
6418 assert!(
6419 r.samples > conv.min_samples,
6420 "stopped on the floor, not on the data"
6421 );
6422 assert!(
6423 r.samples < conv.max_samples,
6424 "ran to the ceiling, so nothing adaptive was exercised"
6425 );
6426 assert!(
6427 r.samples > conv.batch_size,
6428 "stopped on the very first look ({} samples); the multi-batch path is untested",
6429 r.samples
6430 );
6431 assert!((r.ci_high - r.ci_low) / 2.0 <= 0.03 + 1e-12);
6432 assert!(r.ci_low <= r.hit_probability && r.hit_probability <= r.ci_high);
6433 assert_eq!(r.attempts, r.samples, "no trial should have been dropped");
6434 }
6435
6436 #[test]
6444 fn adaptive_runs_a_truncated_final_batch_up_to_max_samples() {
6445 let (inputs, wind) = seeded_test_fixture();
6446 let conv = McConvergence {
6447 level: ConfidenceLevel::P95,
6448 target_half_width: 1e-6,
6449 min_samples: 0,
6450 max_samples: 750,
6451 batch_size: 500,
6452 };
6453 let r = run_monte_carlo_adaptive_seeded(
6454 &inputs,
6455 &wind,
6456 &loose_params(),
6457 &conv,
6458 DEFAULT_HIT_RADIUS_M,
6459 0x1352_7B10,
6460 )
6461 .unwrap();
6462
6463 assert_eq!(r.stop_reason, McStopReason::MaxSamplesReached);
6464 assert_eq!(
6465 r.samples, 750,
6466 "the 250-trial final batch did not run, or was not truncated"
6467 );
6468 assert_eq!(r.attempts, 750);
6469 assert!(!r.samples.is_multiple_of(conv.batch_size));
6470 }
6471
6472 #[test]
6473 fn adaptive_is_deterministic_for_a_seed() {
6474 let (inputs, wind) = seeded_test_fixture();
6475 let conv = McConvergence::default();
6476 let a = run_monte_carlo_adaptive_seeded(
6477 &inputs,
6478 &wind,
6479 &loose_params(),
6480 &conv,
6481 DEFAULT_HIT_RADIUS_M,
6482 99,
6483 )
6484 .unwrap();
6485 let b = run_monte_carlo_adaptive_seeded(
6486 &inputs,
6487 &wind,
6488 &loose_params(),
6489 &conv,
6490 DEFAULT_HIT_RADIUS_M,
6491 99,
6492 )
6493 .unwrap();
6494
6495 assert_eq!(a.hit_probability.to_bits(), b.hit_probability.to_bits());
6496 assert_eq!(a.samples, b.samples);
6497 assert_eq!(a.ci_low.to_bits(), b.ci_low.to_bits());
6498 assert_eq!(a.ci_high.to_bits(), b.ci_high.to_bits());
6499 assert_eq!(
6502 a.mean_impact_velocity_mps.to_bits(),
6503 b.mean_impact_velocity_mps.to_bits()
6504 );
6505 assert_eq!(
6506 a.std_drop_at_target_m.to_bits(),
6507 b.std_drop_at_target_m.to_bits()
6508 );
6509 }
6510
6511 #[test]
6512 fn adaptive_report_carries_schema_method_and_all_four_assumptions() {
6513 let (inputs, wind) = seeded_test_fixture();
6514 let conv = McConvergence {
6518 min_samples: 0,
6519 max_samples: 50,
6520 batch_size: 50,
6521 target_half_width: 1.0,
6522 level: ConfidenceLevel::P90,
6523 };
6524 let r = run_monte_carlo_adaptive_seeded(
6525 &inputs,
6526 &wind,
6527 &mixed_arrival_params(),
6528 &conv,
6529 DEFAULT_HIT_RADIUS_M,
6530 0x1352_D0C5,
6531 )
6532 .unwrap();
6533
6534 assert_eq!(r.schema_version, MC_ADAPTIVE_SCHEMA_VERSION_V1);
6535 assert_eq!(r.schema_version, 1);
6536 assert_eq!(r.method, "anytime_beta_binomial_mixture_cs_v1");
6537 assert_eq!(r.confidence_percent, 90);
6538
6539 assert_eq!(r.attempts, 50, "one full batch was drawn");
6545 assert_eq!(r.samples, 50, "no trial was dropped by the solver");
6546 assert!(
6547 r.arrivals > 0 && r.arrivals < r.samples,
6548 "fixture must split the run: arrivals {} of samples {}",
6549 r.arrivals,
6550 r.samples
6551 );
6552 assert!(
6555 r.arrivals >= 2,
6556 "arrivals {} too few for a sample sd",
6557 r.arrivals
6558 );
6559 assert!(r.std_drop_at_target_m > 0.0 && r.std_impact_velocity_mps > 0.0);
6560 assert!(r.attempts >= r.samples && r.samples >= r.arrivals);
6562
6563 assert_eq!(r.assumptions.len(), 4, "exactly four assumptions expected");
6569 assert_eq!(
6570 r.assumptions[0],
6571 "Sampling uncertainty only: intervals cover Monte Carlo sampling error, not model error in the trajectory solver or its inputs."
6572 );
6573 assert_eq!(
6574 r.assumptions[1],
6575 "Anytime-valid stopping: the beta-binomial mixture confidence sequence keeps its coverage guarantee despite stopping the moment the target half-width is met."
6576 );
6577 assert_eq!(
6578 r.assumptions[2],
6579 "Input dispersions are the independent normal distributions declared in MonteCarloParams; correlations between inputs are not modeled."
6580 );
6581 assert_eq!(
6582 r.assumptions[3],
6583 "Continuous statistics are streaming Welford moments over trials that reached the target plane, reported with sample (n-1) standard deviations; hit probability's denominator includes all trials."
6584 );
6585
6586 assert_eq!(
6588 serde_json::to_string(&McStopReason::TargetHalfWidthMet).unwrap(),
6589 "\"target_half_width_met\""
6590 );
6591 assert_eq!(
6592 serde_json::to_string(&McStopReason::MaxSamplesReached).unwrap(),
6593 "\"max_samples_reached\""
6594 );
6595 }
6596
6597 #[test]
6598 fn adaptive_rejects_nonsense_convergence() {
6599 let (inputs, wind) = seeded_test_fixture();
6600 let run = |conv: McConvergence| {
6601 run_monte_carlo_adaptive_seeded(
6602 &inputs,
6603 &wind,
6604 &loose_params(),
6605 &conv,
6606 DEFAULT_HIT_RADIUS_M,
6607 1,
6608 )
6609 .unwrap_err()
6610 };
6611
6612 for bad_width in [0.0, -0.01, f64::NAN] {
6613 let err = run(McConvergence {
6614 target_half_width: bad_width,
6615 ..Default::default()
6616 });
6617 assert!(
6618 err.contains("target_half_width"),
6619 "error must name the field, got: {err}"
6620 );
6621 }
6622
6623 let err = run(McConvergence {
6624 batch_size: 0,
6625 ..Default::default()
6626 });
6627 assert!(err.contains("batch_size"), "got: {err}");
6628
6629 let err = run(McConvergence {
6630 min_samples: 5_000,
6631 max_samples: 1_000,
6632 ..Default::default()
6633 });
6634 assert!(err.contains("max_samples"), "got: {err}");
6635 assert!(err.contains("min_samples"), "got: {err}");
6636
6637 let err = run(McConvergence {
6638 max_samples: 0,
6639 min_samples: 0,
6640 ..Default::default()
6641 });
6642 assert!(err.contains("max_samples"), "got: {err}");
6643
6644 let err = McConvergence {
6646 batch_size: 0,
6647 ..Default::default()
6648 }
6649 .validate()
6650 .unwrap_err();
6651 assert!(err.contains("batch_size"));
6652 }
6653
6654 #[test]
6655 fn wilson_companion_matches_hit_probability_and_wilson_interval() {
6656 let (inputs, wind) = seeded_test_fixture();
6657 let params = MonteCarloParams {
6658 num_simulations: 128,
6659 target_distance: Some(500.0),
6660 ..MonteCarloParams::default()
6661 };
6662 let results = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
6663 inputs,
6664 wind,
6665 params,
6666 0.01,
6667 0x1352_C0DE,
6668 )
6669 .expect("seeded legacy run");
6670
6671 for level in [
6672 ConfidenceLevel::P90,
6673 ConfidenceLevel::P95,
6674 ConfidenceLevel::P99,
6675 ] {
6676 let (p_hat, (lo, hi), n) = results.hit_probability_wilson(DEFAULT_HIT_RADIUS_M, level);
6677
6678 assert_eq!(
6681 p_hat.to_bits(),
6682 results.hit_probability(DEFAULT_HIT_RADIUS_M).to_bits()
6683 );
6684 assert_eq!(n, results.impact_positions.len() as u64);
6685
6686 let hits = results
6690 .impact_positions
6691 .iter()
6692 .filter(|p| MonteCarloResults::position_is_hit(p, DEFAULT_HIT_RADIUS_M))
6693 .count() as u64;
6694 let (want_lo, want_hi) = wilson_interval(hits, n, level);
6695 assert_eq!(lo.to_bits(), want_lo.to_bits());
6696 assert_eq!(hi.to_bits(), want_hi.to_bits());
6697 assert!(lo <= p_hat && p_hat <= hi, "interval excludes p_hat");
6698 }
6699
6700 let empty = MonteCarloResults {
6702 ranges: Vec::new(),
6703 impact_velocities: Vec::new(),
6704 impact_positions: Vec::new(),
6705 };
6706 assert_eq!(
6707 empty.hit_probability_wilson(DEFAULT_HIT_RADIUS_M, ConfidenceLevel::P95),
6708 (0.0, (0.0, 1.0), 0)
6709 );
6710 }
6711}
6712
6713#[cfg(test)]
6714mod monte_carlo_powder_curve_tests {
6715 use super::*;
6716 use rand::{rngs::StdRng, SeedableRng};
6717
6718 #[test]
6719 fn powder_curve_preserves_sampled_muzzle_velocity_dispersion() {
6720 let inputs = BallisticInputs {
6721 muzzle_velocity: 700.0,
6722 powder_temp_curve: Some(vec![(15.0, 800.0)]),
6723 powder_curve_temp_c: Some(15.0),
6724 ..BallisticInputs::default()
6725 };
6726 let params = MonteCarloParams {
6727 num_simulations: 16,
6728 velocity_std_dev: 20.0,
6729 angle_std_dev: 1e-12,
6730 bc_std_dev: 1e-12,
6731 wind_speed_std_dev: 1e-12,
6732 target_distance: Some(100.0),
6733 azimuth_std_dev: 1e-12,
6734 ..MonteCarloParams::default()
6735 };
6736
6737 let mut rng = StdRng::seed_from_u64(0x5EED_1176);
6738 let results = run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
6739 inputs,
6740 WindConditions::default(),
6741 params,
6742 0.0,
6743 &mut rng,
6744 )
6745 .expect("Monte Carlo solve");
6746 let min_velocity = results
6747 .impact_velocities
6748 .iter()
6749 .copied()
6750 .fold(f64::INFINITY, f64::min);
6751 let max_velocity = results
6752 .impact_velocities
6753 .iter()
6754 .copied()
6755 .fold(f64::NEG_INFINITY, f64::max);
6756
6757 assert!(
6758 max_velocity - min_velocity > 1.0,
6759 "20 m/s muzzle spread collapsed after curve resolution: impact-velocity span={} m/s",
6760 max_velocity - min_velocity
6761 );
6762 }
6763}
6764
6765#[cfg(test)]
6766mod monte_carlo_wind_sampling_tests {
6767 use super::*;
6768 use rand::{rngs::StdRng, SeedableRng};
6769
6770 #[test]
6771 fn wind_speed_sigma_does_not_change_seeded_direction_draws() {
6772 let base_wind = WindConditions {
6773 speed: 100.0,
6774 direction: 0.37,
6775 vertical_speed: 0.0,
6776 };
6777 let narrow_speed = MonteCarloWindSampler::new(&base_wind, 0.5, 0.2).unwrap();
6778 let wide_speed = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
6779 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
6780 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
6781 let mut speed_changed = false;
6782
6783 for _ in 0..32 {
6784 let narrow = narrow_speed.sample(&mut narrow_rng);
6785 let wide = wide_speed.sample(&mut wide_rng);
6786 assert!(narrow.speed > 0.0 && wide.speed > 0.0);
6787 assert_eq!(narrow.direction.to_bits(), wide.direction.to_bits());
6788 speed_changed |= narrow.speed.to_bits() != wide.speed.to_bits();
6789 }
6790 assert!(
6791 speed_changed,
6792 "different speed sigmas must still vary speed draws"
6793 );
6794 }
6795
6796 #[test]
6797 fn zero_direction_sigma_has_no_angular_jitter() {
6798 let base_wind = WindConditions {
6799 speed: 100.0,
6800 direction: 0.37,
6801 vertical_speed: 0.0,
6802 };
6803 let sampler = MonteCarloWindSampler::new(&base_wind, 4.0, 0.0).unwrap();
6804 let mut rng = StdRng::seed_from_u64(0x5EED_1223);
6805 let mut speed_changed = false;
6806
6807 for _ in 0..32 {
6808 let wind = sampler.sample(&mut rng);
6809 speed_changed |= wind.speed.to_bits() != base_wind.speed.to_bits();
6810 assert_eq!(wind.direction.to_bits(), base_wind.direction.to_bits());
6811 }
6812 assert!(speed_changed, "speed uncertainty should remain active");
6813 }
6814
6815 #[test]
6816 fn direction_sigma_controls_seeded_angular_spread_in_radians() {
6817 let base_wind = WindConditions {
6818 speed: 100.0,
6819 direction: 0.37,
6820 vertical_speed: 0.0,
6821 };
6822 let narrow = MonteCarloWindSampler::new(&base_wind, 4.0, 0.1).unwrap();
6823 let wide = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
6824 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
6825 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
6826 let mut nonzero_direction_draw = false;
6827
6828 for _ in 0..32 {
6829 let narrow_wind = narrow.sample(&mut narrow_rng);
6830 let wide_wind = wide.sample(&mut wide_rng);
6831 assert_eq!(narrow_wind.speed.to_bits(), wide_wind.speed.to_bits());
6832
6833 let narrow_delta = narrow_wind.direction - base_wind.direction;
6834 let wide_delta = wide_wind.direction - base_wind.direction;
6835 assert!((wide_delta - 2.0 * narrow_delta).abs() < 1e-12);
6836 nonzero_direction_draw |= narrow_delta.abs() > 1e-6;
6837 }
6838 assert!(
6839 nonzero_direction_draw,
6840 "positive radians sigma must vary direction"
6841 );
6842 }
6843
6844 #[test]
6845 fn direction_sigma_rejects_negative_or_nonfinite_values() {
6846 let base_wind = WindConditions::default();
6847 for sigma in [-0.1, f64::NAN, f64::INFINITY] {
6848 assert!(MonteCarloWindSampler::new(&base_wind, 1.0, sigma).is_err());
6849 }
6850 }
6851
6852 #[test]
6853 fn base_vertical_wind_rides_into_every_mc_sample() {
6854 use rand::SeedableRng;
6858 let base_wind = WindConditions {
6859 vertical_speed: 4.2,
6860 ..Default::default()
6861 };
6862 let sampler = MonteCarloWindSampler::new(&base_wind, 1.0, 0.2).unwrap();
6863 let mut rng = rand::rngs::StdRng::seed_from_u64(7);
6864 for _ in 0..32 {
6865 let w = sampler.sample(&mut rng);
6866 assert_eq!(w.vertical_speed, 4.2);
6867 }
6868 }
6869
6870 #[test]
6871 fn negative_speed_sample_reverses_wind_direction() {
6872 let direction = 0.25;
6873 let signed_speed = -2.5;
6874 let wind = wind_from_signed_speed_sample(signed_speed, direction, 0.0);
6875 let positive_wind = wind_from_signed_speed_sample(2.5, direction, 0.0);
6876
6877 assert_eq!(wind.speed, 2.5);
6878 assert!(
6879 (wind.direction - (direction + std::f64::consts::PI)).abs() < f64::EPSILON,
6880 "negative speed must reverse direction by pi: got {}",
6881 wind.direction
6882 );
6883 assert_eq!(positive_wind.speed, 2.5);
6884 assert_eq!(positive_wind.direction, direction);
6885
6886 let normalized_x = -wind.speed * wind.direction.cos();
6887 let normalized_z = -wind.speed * wind.direction.sin();
6888 let signed_x = -signed_speed * direction.cos();
6889 let signed_z = -signed_speed * direction.sin();
6890 assert!((normalized_x - signed_x).abs() < 1e-12);
6891 assert!((normalized_z - signed_z).abs() < 1e-12);
6892 }
6893}
6894
6895#[cfg(test)]
6896mod bc_fit_objective_tests {
6897 use super::*;
6898
6899 fn velocity_point(range_m: f64, velocity_mps: f64) -> TrajectoryPoint {
6900 TrajectoryPoint {
6901 time: 0.0,
6902 position: Vector3::new(range_m, 0.0, 0.0),
6903 velocity_magnitude: velocity_mps,
6904 kinetic_energy: 0.0,
6905 drag_coefficient: None,
6906 }
6907 }
6908
6909 #[test]
6910 fn candidate_that_misses_an_observation_has_no_score() {
6911 let trajectory = vec![velocity_point(0.0, 800.0), velocity_point(100.0, 700.0)];
6912 let observations = vec![(50.0, 750.0), (150.0, 600.0)];
6913
6914 assert!(
6915 fit_residual_sse(&trajectory, &observations, BcFitMode::Velocity, 0.0).is_none(),
6916 "a candidate that reaches only one of two observations must not compete on partial SSE"
6917 );
6918
6919 let complete_observations = vec![(50.0, 740.0), (100.0, 680.0)];
6920 assert_eq!(
6921 fit_residual_sse(
6922 &trajectory,
6923 &complete_observations,
6924 BcFitMode::Velocity,
6925 0.0,
6926 ),
6927 Some(500.0)
6928 );
6929 }
6930}
6931
6932#[cfg(test)]
6933mod cluster_bc_reference_space_tests {
6934 use super::*;
6935
6936 fn acceleration_at_1100_fps(inputs: BallisticInputs) -> Vector3<f64> {
6937 let solver = TrajectorySolver::new(
6938 inputs,
6939 WindConditions::default(),
6940 AtmosphericConditions::default(),
6941 );
6942 let position = Vector3::zeros();
6943 let velocity = Vector3::new(1100.0 / 3.28084, 0.0, 0.0);
6944 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
6945 solver.calculate_acceleration(
6946 &position,
6947 &velocity,
6948 &Vector3::zeros(),
6949 (temp_c, pressure_hpa, density / 1.225),
6950 )
6951 }
6952
6953 #[test]
6954 fn solver_passes_g7_reference_model_to_cluster_classifier() {
6955 let inputs = BallisticInputs {
6956 bc_value: 0.190,
6957 bc_type: DragModel::G7,
6958 bullet_mass: 77.0 * crate::constants::GRAINS_TO_KG,
6959 bullet_diameter: 0.224 * 0.0254,
6960 use_cluster_bc: true,
6961 ..BallisticInputs::default()
6962 };
6963
6964 let solver = TrajectorySolver::new(
6965 inputs,
6966 WindConditions::default(),
6967 AtmosphericConditions::default(),
6968 );
6969 let corrected = solver.apply_cluster_bc_correction(0.190, 2800.0);
6970
6971 assert!(
6972 (corrected / 0.190 - 1.004).abs() < 1e-12,
6973 "solver selected the wrong G7 cluster multiplier: {}",
6974 corrected / 0.190
6975 );
6976 }
6977
6978 #[test]
6979 fn velocity_bc_segments_are_not_cluster_corrected_twice() {
6980 let segmented_clustered = BallisticInputs {
6981 bc_value: 0.5,
6982 bc_type: DragModel::G7,
6983 use_bc_segments: true,
6984 bc_segments_data: Some(vec![
6985 crate::BCSegmentData {
6986 velocity_min: 0.0,
6987 velocity_max: 1_600.0,
6988 bc_value: 0.4,
6989 },
6990 crate::BCSegmentData {
6991 velocity_min: 1_600.0,
6992 velocity_max: 5_000.0,
6993 bc_value: 0.45,
6994 },
6995 ]),
6996 use_cluster_bc: true,
6997 ..BallisticInputs::default()
6998 };
6999 let mut segmented_only = segmented_clustered.clone();
7000 segmented_only.use_cluster_bc = false;
7001 let mut constant_clustered = segmented_clustered.clone();
7002 constant_clustered.bc_value = 0.4;
7003 constant_clustered.bc_segments_data = None;
7004
7005 let stacked = acceleration_at_1100_fps(segmented_clustered);
7006 let segment_only = acceleration_at_1100_fps(segmented_only);
7007 let cluster_only = acceleration_at_1100_fps(constant_clustered);
7008
7009 assert!(
7010 (stacked.x - segment_only.x).abs() < 1e-12,
7011 "segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
7012 stacked.x,
7013 segment_only.x
7014 );
7015 assert!(
7016 (cluster_only.x - segment_only.x).abs() > 1e-6,
7017 "cluster correction must remain active for a constant BC"
7018 );
7019 }
7020
7021 #[test]
7022 fn mach_bc_segments_are_not_cluster_corrected_twice() {
7023 let mach_segmented_clustered = BallisticInputs {
7024 bc_value: 0.5,
7025 bc_type: DragModel::G7,
7026 use_bc_segments: false,
7027 bc_segments: Some(vec![(0.5, 0.3), (1.5, 0.5)]),
7028 use_cluster_bc: true,
7029 ..BallisticInputs::default()
7030 };
7031 let mut mach_segmented_only = mach_segmented_clustered.clone();
7032 mach_segmented_only.use_cluster_bc = false;
7033
7034 let stacked = acceleration_at_1100_fps(mach_segmented_clustered);
7035 let segment_only = acceleration_at_1100_fps(mach_segmented_only);
7036
7037 assert!(
7038 (stacked.x - segment_only.x).abs() < 1e-12,
7039 "Mach segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
7040 stacked.x,
7041 segment_only.x
7042 );
7043 }
7044}
7045
7046#[cfg(test)]
7047mod velocity_bc_flag_tests {
7048 use super::*;
7049
7050 fn acceleration_at_600_mps(inputs: BallisticInputs) -> Vector3<f64> {
7051 let solver = TrajectorySolver::new(
7052 inputs,
7053 WindConditions::default(),
7054 AtmosphericConditions::default(),
7055 );
7056 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
7057 solver.calculate_acceleration(
7058 &Vector3::zeros(),
7059 &Vector3::new(600.0, 0.0, 0.0),
7060 &Vector3::zeros(),
7061 (temp_c, pressure_hpa, density / 1.225),
7062 )
7063 }
7064
7065 #[test]
7066 fn velocity_bc_data_requires_opt_in_in_trajectory_solver() {
7067 let scalar_inputs = BallisticInputs {
7068 bc_value: 0.5,
7069 bc_type: DragModel::G7,
7070 ..BallisticInputs::default()
7071 };
7072 let mut disabled_inputs = scalar_inputs.clone();
7073 disabled_inputs.bc_segments_data = Some(vec![crate::BCSegmentData {
7074 velocity_min: 0.0,
7075 velocity_max: 4_000.0,
7076 bc_value: 0.46,
7077 }]);
7078 disabled_inputs.use_bc_segments = false;
7079 let mut enabled_inputs = disabled_inputs.clone();
7080 enabled_inputs.use_bc_segments = true;
7081 let mut mach_only_inputs = scalar_inputs.clone();
7082 mach_only_inputs.bc_segments = Some(vec![(0.0, 0.4), (3.0, 0.4)]);
7083 let mut disabled_with_both = mach_only_inputs.clone();
7084 disabled_with_both.bc_segments_data = disabled_inputs.bc_segments_data.clone();
7085
7086 let scalar = acceleration_at_600_mps(scalar_inputs);
7087 let disabled = acceleration_at_600_mps(disabled_inputs);
7088 let enabled = acceleration_at_600_mps(enabled_inputs);
7089 let mach_only = acceleration_at_600_mps(mach_only_inputs);
7090 let disabled_with_both = acceleration_at_600_mps(disabled_with_both);
7091
7092 assert_eq!(
7093 disabled.x.to_bits(),
7094 scalar.x.to_bits(),
7095 "a populated velocity table must not change drag while use_bc_segments is false"
7096 );
7097 assert!(
7098 enabled.x < disabled.x - 1.0,
7099 "enabling the lower BC table must increase drag: disabled ax={} enabled ax={}",
7100 disabled.x,
7101 enabled.x
7102 );
7103 assert_eq!(
7104 disabled_with_both.x.to_bits(),
7105 mach_only.x.to_bits(),
7106 "disabling velocity data must fall through to an explicit Mach table"
7107 );
7108 }
7109}
7110
7111#[cfg(test)]
7112mod mach_bc_segment_tests {
7113 use super::*;
7114
7115 #[test]
7116 fn trajectory_solver_interpolates_explicit_mach_bc_segments() {
7117 let segmented_inputs = BallisticInputs {
7118 bc_value: 0.8,
7119 use_bc_segments: false,
7120 bc_segments: Some(vec![(1.0, 0.2), (2.0, 0.4)]),
7121 bc_segments_data: None,
7122 ..BallisticInputs::default()
7123 };
7124
7125 let mut expected_inputs = segmented_inputs.clone();
7126 expected_inputs.bc_value = 0.3;
7127 expected_inputs.bc_segments = None;
7128
7129 let atmosphere = AtmosphericConditions::default();
7130 let segmented_solver = TrajectorySolver::new(
7131 segmented_inputs,
7132 WindConditions::default(),
7133 atmosphere.clone(),
7134 );
7135 let expected_solver =
7136 TrajectorySolver::new(expected_inputs, WindConditions::default(), atmosphere);
7137 let position = Vector3::zeros();
7138 let (density, _, temp_c, pressure_hpa) = segmented_solver.resolved_atmosphere();
7139 let (_, local_speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
7140 segmented_solver.atmosphere.altitude,
7141 segmented_solver.atmosphere.altitude,
7142 temp_c,
7143 pressure_hpa,
7144 density / 1.225,
7145 segmented_solver.atmosphere.humidity,
7146 );
7147 let velocity = Vector3::new(1.5 * local_speed_of_sound, 0.0, 0.0);
7148 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
7149
7150 let segmented_acceleration = segmented_solver.calculate_acceleration(
7151 &position,
7152 &velocity,
7153 &Vector3::zeros(),
7154 resolved_atmo,
7155 );
7156 let expected_acceleration = expected_solver.calculate_acceleration(
7157 &position,
7158 &velocity,
7159 &Vector3::zeros(),
7160 resolved_atmo,
7161 );
7162
7163 assert!(
7164 (segmented_acceleration.x - expected_acceleration.x).abs() < 1e-12,
7165 "Mach 1.5 must interpolate BC 0.3: segmented ax={} expected ax={}",
7166 segmented_acceleration.x,
7167 expected_acceleration.x
7168 );
7169 }
7170}
7171
7172#[cfg(test)]
7173mod custom_drag_table_validation_tests {
7174 use super::*;
7175
7176 #[test]
7177 fn solve_accepts_zero_bc_when_custom_table_present() {
7178 let inputs = BallisticInputs {
7179 bc_value: 0.0, bullet_mass: 0.0106,
7181 bullet_diameter: 0.00782,
7182 muzzle_velocity: 850.0,
7183 custom_drag_table: Some(crate::drag::DragTable::new(
7184 vec![0.5, 1.0, 2.0, 3.0],
7185 vec![0.23, 0.40, 0.30, 0.26],
7186 )),
7187 ..BallisticInputs::default()
7188 };
7189 let solver = TrajectorySolver::new(
7190 inputs,
7191 WindConditions::default(),
7192 AtmosphericConditions::default(),
7193 );
7194 assert!(solver.solve().is_ok());
7196 }
7197
7198 #[test]
7199 fn solve_still_requires_bc_without_table() {
7200 let inputs = BallisticInputs {
7201 bc_value: 0.0,
7202 bullet_mass: 0.0106,
7203 bullet_diameter: 0.00782,
7204 muzzle_velocity: 850.0,
7205 ..BallisticInputs::default()
7206 };
7207 let solver = TrajectorySolver::new(
7208 inputs,
7209 WindConditions::default(),
7210 AtmosphericConditions::default(),
7211 );
7212 assert!(solver.solve().is_err());
7213 }
7214}
7215
7216#[cfg(test)]
7218mod cd_scale_tests {
7219 use super::*;
7220
7221 fn deck() -> crate::drag::DragTable {
7222 crate::drag::DragTable::new(vec![0.5, 1.0, 2.0, 3.0], vec![0.23, 0.40, 0.30, 0.26])
7223 }
7224
7225 fn deck_inputs(cd_scale: f64) -> BallisticInputs {
7226 BallisticInputs {
7227 bullet_mass: 0.0106,
7228 bullet_diameter: 0.00782,
7229 muzzle_velocity: 850.0,
7230 custom_drag_table: Some(deck()),
7231 cd_scale,
7232 ..BallisticInputs::default()
7233 }
7234 }
7235
7236 #[test]
7237 fn default_cd_scale_is_one() {
7238 assert_eq!(BallisticInputs::default().cd_scale, 1.0);
7239 }
7240
7241 #[test]
7244 fn cd_scale_absent_is_byte_identical_to_explicit_one() {
7245 let omitted = BallisticInputs {
7246 bullet_mass: 0.0106,
7247 bullet_diameter: 0.00782,
7248 muzzle_velocity: 850.0,
7249 custom_drag_table: Some(deck()),
7250 ..BallisticInputs::default()
7251 };
7252 let explicit = BallisticInputs {
7253 cd_scale: 1.0,
7254 ..omitted.clone()
7255 };
7256
7257 let solver_omitted = TrajectorySolver::new(
7258 omitted,
7259 WindConditions::default(),
7260 AtmosphericConditions::default(),
7261 );
7262 let solver_explicit = TrajectorySolver::new(
7263 explicit,
7264 WindConditions::default(),
7265 AtmosphericConditions::default(),
7266 );
7267
7268 let cd_omitted = solver_omitted.calculate_drag_coefficient(700.0, 340.0);
7269 let cd_explicit = solver_explicit.calculate_drag_coefficient(700.0, 340.0);
7270 assert_eq!(
7271 cd_omitted.to_bits(),
7272 cd_explicit.to_bits(),
7273 "default cd_scale must be bit-identical to an explicit 1.0"
7274 );
7275
7276 let result = solver_omitted.solve();
7279 assert!(
7280 result.is_ok(),
7281 "existing custom-deck solves must pass unchanged"
7282 );
7283 }
7284
7285 #[test]
7287 fn cd_scale_multiplies_the_interpolated_cd_exactly() {
7288 let velocity = 700.0;
7289 let speed_of_sound = 340.0;
7290 let mach = velocity / speed_of_sound;
7291 let expected_unscaled = deck().interpolate(mach);
7292
7293 for &scale in &[0.90, 1.0, 1.10, 1.5] {
7294 let solver = TrajectorySolver::new(
7295 deck_inputs(scale),
7296 WindConditions::default(),
7297 AtmosphericConditions::default(),
7298 );
7299 let cd = solver.calculate_drag_coefficient(velocity, speed_of_sound);
7300 assert!(
7301 (cd - expected_unscaled * scale).abs() < 1e-12,
7302 "scale={scale}: cd={cd} expected={}",
7303 expected_unscaled * scale
7304 );
7305 }
7306 }
7307
7308 #[test]
7312 fn cd_scale_direction_on_cli_api_solver() {
7313 let solve = |scale: f64| {
7314 TrajectorySolver::new(
7315 deck_inputs(scale),
7316 WindConditions::default(),
7317 AtmosphericConditions::default(),
7318 )
7319 .solve()
7320 .expect("custom-deck solve should succeed")
7321 };
7322
7323 let baseline = solve(1.0);
7324 let scaled_up = solve(1.10);
7325 let scaled_down = solve(0.90);
7326
7327 assert!(
7328 scaled_up.impact_velocity < baseline.impact_velocity,
7329 "cd_scale=1.10 must increase drag -> lower impact velocity: base={} up={}",
7330 baseline.impact_velocity,
7331 scaled_up.impact_velocity
7332 );
7333 assert!(
7334 scaled_down.impact_velocity > baseline.impact_velocity,
7335 "cd_scale=0.90 must decrease drag -> higher impact velocity: base={} down={}",
7336 baseline.impact_velocity,
7337 scaled_down.impact_velocity
7338 );
7339 }
7340
7341 #[test]
7343 fn validate_for_solve_rejects_invalid_cd_scale() {
7344 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
7345 let solver = TrajectorySolver::new(
7346 deck_inputs(bad),
7347 WindConditions::default(),
7348 AtmosphericConditions::default(),
7349 );
7350 assert!(
7351 solver.solve().is_err(),
7352 "cd_scale={bad} must be rejected by validate_for_solve"
7353 );
7354 }
7355 }
7356
7357 #[test]
7364 fn validate_for_solve_rejects_invalid_cd_scale_without_a_custom_drag_table() {
7365 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
7366 let inputs = BallisticInputs {
7367 bc_value: 0.5,
7368 bc_type: crate::DragModel::G1,
7369 bullet_mass: 0.0106,
7370 bullet_diameter: 0.00782,
7371 muzzle_velocity: 850.0,
7372 cd_scale: bad,
7373 ..BallisticInputs::default()
7374 };
7375 assert!(
7376 inputs.custom_drag_table.is_none(),
7377 "precondition: no custom deck"
7378 );
7379 let solver = TrajectorySolver::new(
7380 inputs,
7381 WindConditions::default(),
7382 AtmosphericConditions::default(),
7383 );
7384 assert!(
7385 solver.solve().is_err(),
7386 "cd_scale={bad} must be rejected by validate_for_solve even without a custom \
7387 drag table"
7388 );
7389 }
7390 }
7391
7392 #[test]
7396 fn cd_scale_is_inert_without_a_custom_drag_table() {
7397 let make = |cd_scale: f64| BallisticInputs {
7398 bc_value: 0.5,
7399 bc_type: crate::DragModel::G1,
7400 bullet_mass: 0.0106,
7401 bullet_diameter: 0.00782,
7402 muzzle_velocity: 850.0,
7403 cd_scale,
7404 ..BallisticInputs::default()
7405 };
7406 let solver_neutral = TrajectorySolver::new(
7407 make(1.0),
7408 WindConditions::default(),
7409 AtmosphericConditions::default(),
7410 );
7411 let solver_far = TrajectorySolver::new(
7412 make(1.5),
7413 WindConditions::default(),
7414 AtmosphericConditions::default(),
7415 );
7416 let cd_neutral = solver_neutral.calculate_drag_coefficient(700.0, 340.0);
7417 let cd_far = solver_far.calculate_drag_coefficient(700.0, 340.0);
7418 assert_eq!(
7419 cd_neutral.to_bits(),
7420 cd_far.to_bits(),
7421 "cd_scale must not affect the G-model/BC drag path"
7422 );
7423 }
7424
7425 #[test]
7428 fn cd_scale_shifts_all_three_solver_paths_in_the_same_direction() {
7429 let cli_solve = |scale: f64| {
7431 TrajectorySolver::new(
7432 deck_inputs(scale),
7433 WindConditions::default(),
7434 AtmosphericConditions::default(),
7435 )
7436 .solve()
7437 .expect("cli_api custom-deck solve should succeed")
7438 };
7439 let cli_baseline = cli_solve(1.0);
7440 let cli_scaled = cli_solve(1.10);
7441 assert!(
7442 cli_scaled.impact_velocity < cli_baseline.impact_velocity,
7443 "cli_api: cd_scale=1.10 must lower impact velocity"
7444 );
7445
7446 let derivatives_accel_x = |scale: f64| {
7448 let inputs = deck_inputs(scale);
7449 crate::derivatives::compute_derivatives(
7450 nalgebra::Vector3::zeros(),
7451 nalgebra::Vector3::new(700.0, 0.0, 0.0),
7452 &inputs,
7453 nalgebra::Vector3::zeros(),
7454 (1.225, 340.0, 0.0, 0.0),
7455 inputs.bc_value,
7456 None,
7457 0.0,
7458 None,
7459 )[3]
7460 };
7461 let deriv_baseline = derivatives_accel_x(1.0);
7462 let deriv_scaled = derivatives_accel_x(1.10);
7463 assert!(
7464 deriv_scaled < deriv_baseline,
7465 "derivatives: cd_scale=1.10 must make x-acceleration more negative (more drag): \
7466 base={deriv_baseline} scaled={deriv_scaled}"
7467 );
7468
7469 let fast_final_speed = |scale: f64| {
7471 let inputs = deck_inputs(scale);
7472 let wind_sock = crate::wind::WindSock::new(vec![]);
7473 let params = crate::fast_trajectory::FastIntegrationParams {
7474 horiz: 500.0,
7475 vert: 0.0,
7476 initial_state: [0.0, 0.0, 0.0, 850.0, 0.0, 0.0],
7477 t_span: (0.0, 5.0),
7478 atmo_params: (0.0, 15.0, 1013.25, 1.0),
7479 atmo_sock: None,
7480 };
7481 let solution = crate::fast_trajectory::fast_integrate(&inputs, &wind_sock, params);
7482 assert!(
7483 solution.success,
7484 "fast_integrate must succeed for scale={scale}"
7485 );
7486 let last = solution.t.len() - 1;
7487 let (vx, vy, vz) = (
7488 solution.y[3][last],
7489 solution.y[4][last],
7490 solution.y[5][last],
7491 );
7492 (vx * vx + vy * vy + vz * vz).sqrt()
7493 };
7494 let fast_baseline = fast_final_speed(1.0);
7495 let fast_scaled = fast_final_speed(1.10);
7496 assert!(
7497 fast_scaled < fast_baseline,
7498 "fast_trajectory: cd_scale=1.10 must lower final speed: base={fast_baseline} scaled={fast_scaled}"
7499 );
7500 }
7501}
7502
7503#[cfg(test)]
7507mod custom_drag_reference_tests {
7508 use super::*;
7509
7510 fn g7_table() -> crate::drag::DragTable {
7512 crate::drag::DragTable::from_csv_str(include_str!("../data/g7.csv"))
7513 .expect("bundled table parses")
7514 }
7515
7516 fn g1_table() -> crate::drag::DragTable {
7518 crate::drag::DragTable::from_csv_str(include_str!("../data/g1.csv"))
7519 .expect("bundled table parses")
7520 }
7521
7522 fn laddered_inputs() -> BallisticInputs {
7526 BallisticInputs {
7527 bc_value: 0.243,
7528 bc_type: DragModel::G7,
7529 bullet_mass: 175.0 * crate::constants::GRAINS_TO_KG,
7530 bullet_diameter: 0.308 * 0.0254,
7531 weight_grains: 175.0,
7532 caliber_inches: 0.308,
7533 muzzle_velocity: 823.0,
7534 use_bc_segments: true,
7535 bc_segments_data: Some(vec![
7536 crate::BCSegmentData {
7537 velocity_min: 2400.0,
7538 velocity_max: 4000.0,
7539 bc_value: 0.260,
7540 },
7541 crate::BCSegmentData {
7542 velocity_min: 0.0,
7543 velocity_max: 2400.0,
7544 bc_value: 0.220,
7545 },
7546 ]),
7547 ..BallisticInputs::default()
7548 }
7549 }
7550
7551 fn reference(inputs: BallisticInputs) -> BallisticInputs {
7552 BallisticInputs {
7553 custom_drag_table: Some(g7_table()),
7554 custom_drag_reference: true,
7555 ..inputs
7556 }
7557 }
7558
7559 fn solve(inputs: BallisticInputs) -> TrajectoryResult {
7560 let mut solver = TrajectorySolver::new(
7561 inputs,
7562 WindConditions::default(),
7563 AtmosphericConditions::default(),
7564 );
7565 solver.set_max_range(800.0);
7566 solver.solve().expect("solve")
7567 }
7568
7569 #[test]
7570 fn default_is_the_projectiles_own_curve() {
7571 assert!(!BallisticInputs::default().custom_drag_reference);
7572 }
7573
7574 #[test]
7575 fn the_denominator_is_the_resolved_bc_for_a_reference_curve() {
7576 let own = BallisticInputs {
7577 custom_drag_table: Some(g7_table()),
7578 ..laddered_inputs()
7579 };
7580 let sd = own.sectional_density_lb_in2().expect("SD");
7581 assert_eq!(own.custom_drag_denominator(0.220), sd);
7582 let reference = reference(laddered_inputs());
7583 assert_eq!(reference.custom_drag_denominator(0.220), 0.220);
7584 assert_eq!(reference.custom_drag_denominator(0.260), 0.260);
7585 }
7586
7587 #[test]
7591 fn a_reference_curve_flies_a_ladder_exactly_as_the_builtin_model() {
7592 let builtin = solve(laddered_inputs());
7593 let table = solve(reference(laddered_inputs()));
7594 assert_eq!(table.points.len(), builtin.points.len());
7595 for (t, b) in table.points.iter().zip(&builtin.points) {
7596 assert_eq!(t.position, b.position);
7597 assert_eq!(
7598 t.velocity_magnitude.to_bits(),
7599 b.velocity_magnitude.to_bits()
7600 );
7601 }
7602
7603 let own_curve = solve(BallisticInputs {
7604 custom_drag_reference: false,
7605 ..reference(laddered_inputs())
7606 });
7607 assert!(
7608 (own_curve.impact_velocity - builtin.impact_velocity).abs() > 10.0,
7609 "the fixture must tell the readings apart: {} vs {}",
7610 own_curve.impact_velocity,
7611 builtin.impact_velocity
7612 );
7613 }
7614
7615 #[test]
7620 fn a_reference_curve_flies_the_table_not_bc_types_curve() {
7621 let table = solve(BallisticInputs {
7622 custom_drag_table: Some(g1_table()),
7623 custom_drag_reference: true,
7624 bc_type: DragModel::G7,
7625 ..laddered_inputs()
7626 });
7627 let builtin_g1 = solve(BallisticInputs {
7628 bc_type: DragModel::G1,
7629 ..laddered_inputs()
7630 });
7631 assert_eq!(table.points.len(), builtin_g1.points.len());
7632 for (t, b) in table.points.iter().zip(&builtin_g1.points) {
7633 assert_eq!(t.position, b.position);
7634 assert_eq!(
7635 t.velocity_magnitude.to_bits(),
7636 b.velocity_magnitude.to_bits()
7637 );
7638 }
7639
7640 let builtin_g7 = solve(laddered_inputs());
7641 assert!(
7642 (builtin_g1.impact_velocity - builtin_g7.impact_velocity).abs() > 50.0,
7643 "the fixture must tell the curves apart: G1 {} vs G7 {}",
7644 builtin_g1.impact_velocity,
7645 builtin_g7.impact_velocity
7646 );
7647 }
7648
7649 #[test]
7652 fn validate_for_solve_requires_a_bc_for_a_reference_curve() {
7653 for bad in [0.0, -0.1, f64::NAN, f64::INFINITY] {
7654 let inputs = BallisticInputs {
7655 bc_value: bad,
7656 ..reference(laddered_inputs())
7657 };
7658 let error = TrajectorySolver::new(
7659 inputs.clone(),
7660 WindConditions::default(),
7661 AtmosphericConditions::default(),
7662 )
7663 .solve()
7664 .expect_err("a reference curve needs a finite positive BC");
7665 assert!(error.to_string().contains("bc_value"), "{error}");
7666
7667 let own_curve = BallisticInputs {
7668 custom_drag_reference: false,
7669 use_bc_segments: false,
7670 bc_segments_data: None,
7671 ..inputs
7672 };
7673 assert!(
7674 TrajectorySolver::new(
7675 own_curve,
7676 WindConditions::default(),
7677 AtmosphericConditions::default(),
7678 )
7679 .solve()
7680 .is_ok(),
7681 "bc_value={bad} is still ignored by the own-curve reading"
7682 );
7683 }
7684 }
7685
7686 #[test]
7690 fn army_standard_metro_converts_a_reference_curves_bcs() {
7691 let asm = BallisticInputs {
7692 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
7693 ..reference(laddered_inputs())
7694 };
7695 assert!(asm.bc_reference_standard_inert_warning().is_none());
7696 assert!(BallisticInputs {
7697 custom_drag_reference: false,
7698 ..asm.clone()
7699 }
7700 .bc_reference_standard_inert_warning()
7701 .is_some());
7702
7703 let converted = solve(asm);
7704 let builtin_asm = solve(BallisticInputs {
7705 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
7706 ..laddered_inputs()
7707 });
7708 let icao = solve(reference(laddered_inputs()));
7709 assert_eq!(
7710 converted.impact_velocity.to_bits(),
7711 builtin_asm.impact_velocity.to_bits()
7712 );
7713 assert!(
7714 converted.impact_velocity < icao.impact_velocity,
7715 "the ASM BCs are smaller once converted, so the bullet must slow more: {} vs {}",
7716 converted.impact_velocity,
7717 icao.impact_velocity
7718 );
7719 }
7720
7721 #[test]
7724 fn the_reported_cd_is_the_projectiles_own() {
7725 let inputs = reference(laddered_inputs());
7726 let sd = inputs.sectional_density_lb_in2().expect("SD");
7727 let solver = TrajectorySolver::new(
7728 inputs,
7729 WindConditions::default(),
7730 AtmosphericConditions::default(),
7731 );
7732 let sos = 340.0;
7733 for (fps, band_bc) in [(2600.0, 0.260), (2200.0, 0.220)] {
7734 let velocity = fps / 3.28084;
7735 let expected = g7_table().interpolate(velocity / sos) * sd / band_bc;
7736 let reported = solver
7737 .effective_drag_coefficient(velocity, sos)
7738 .expect("mass and diameter are set");
7739 assert!(
7740 (reported - expected).abs() < 1e-12,
7741 "{fps} fps: reported {reported}, expected {expected}"
7742 );
7743 }
7744 }
7745}
7746
7747#[cfg(test)]
7748mod humid_local_mach_tests {
7749 use super::*;
7750
7751 fn solver_with_station_humidity(humidity_percent: f64) -> TrajectorySolver {
7752 let inputs = BallisticInputs {
7753 custom_drag_table: Some(crate::drag::DragTable::new(vec![0.5, 1.5], vec![0.1, 1.1])),
7754 ..BallisticInputs::default()
7755 };
7756 TrajectorySolver::new(
7757 inputs,
7758 WindConditions::default(),
7759 AtmosphericConditions {
7760 temperature: 30.0,
7761 pressure: 1013.25,
7762 humidity: humidity_percent,
7763 altitude: 0.0,
7764 },
7765 )
7766 }
7767
7768 fn acceleration(solver: &TrajectorySolver, base_ratio: f64) -> Vector3<f64> {
7769 solver.calculate_acceleration(
7770 &Vector3::zeros(),
7771 &Vector3::new(350.0, 0.0, 0.0),
7772 &Vector3::zeros(),
7773 (30.0, 1013.25, base_ratio),
7774 )
7775 }
7776
7777 #[test]
7778 fn local_mach_uses_station_humidity_when_density_is_held_constant() {
7779 let dry = acceleration(&solver_with_station_humidity(0.0), 1.0);
7780 let humid = acceleration(&solver_with_station_humidity(100.0), 1.0);
7781
7782 assert!(
7783 humid.x > dry.x,
7784 "humid sound speed should lower Mach and drag on the rising test curve: dry ax={} humid ax={}",
7785 dry.x,
7786 humid.x
7787 );
7788 }
7789
7790 #[test]
7791 fn active_atmosphere_zone_uses_zone_humidity_instead_of_station_humidity() {
7792 let zone_humidity = 80.0;
7793 let zone_ratio =
7794 crate::atmosphere::calculate_air_density_cimp(30.0, 1013.25, zone_humidity) / 1.225;
7795 let station_solver = solver_with_station_humidity(zone_humidity);
7796 let mut zoned_solver = solver_with_station_humidity(0.0);
7797 zoned_solver.set_atmo_segments(vec![(30.0, 1013.25, zone_humidity, 1_000.0)]);
7798
7799 let station = acceleration(&station_solver, zone_ratio);
7800 let zoned = acceleration(&zoned_solver, zone_ratio);
7801
7802 assert!(
7803 (zoned - station).norm() < 1e-12,
7804 "active zone T/P/RH should override the station atmosphere: station={station:?} zoned={zoned:?}"
7805 );
7806 }
7807}
7808
7809#[cfg(test)]
7810mod inclined_atmosphere_frame_tests {
7811 use super::*;
7812
7813 fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
7814 let (sin_angle, cos_angle) = angle.sin_cos();
7815 Vector3::new(
7816 level.x * cos_angle + level.y * sin_angle,
7817 -level.x * sin_angle + level.y * cos_angle,
7818 level.z,
7819 )
7820 }
7821
7822 #[test]
7823 fn inclined_positions_at_same_world_altitude_have_same_solver_acceleration() {
7824 let angle = std::f64::consts::FRAC_PI_6;
7825 let inputs = BallisticInputs {
7826 shooting_angle: angle,
7827 ..BallisticInputs::default()
7828 };
7829 let atmosphere = AtmosphericConditions {
7830 altitude: 100.0,
7831 ..AtmosphericConditions::default()
7832 };
7833 let solver = TrajectorySolver::new(inputs, WindConditions::default(), atmosphere);
7834 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
7835 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
7836 let velocity = Vector3::new(600.0, 0.0, 0.0);
7837 let along_slant = Vector3::new(1_000.0, 0.0, 0.0);
7838 let across_slant = Vector3::new(0.0, 500.0 / angle.cos(), 0.0);
7839
7840 let a = solver.calculate_acceleration(
7841 &along_slant,
7842 &velocity,
7843 &Vector3::zeros(),
7844 resolved_atmo,
7845 );
7846 let b = solver.calculate_acceleration(
7847 &across_slant,
7848 &velocity,
7849 &Vector3::zeros(),
7850 resolved_atmo,
7851 );
7852
7853 assert!(
7854 (a - b).norm() < 1e-10,
7855 "solver acceleration differs at equal world altitude: {a:?} vs {b:?}"
7856 );
7857 }
7858
7859 #[test]
7860 fn inclined_headwind_is_rotated_into_solver_frame() {
7861 let angle = std::f64::consts::FRAC_PI_6;
7862 let inputs = BallisticInputs {
7863 shooting_angle: angle,
7864 ..BallisticInputs::default()
7865 };
7866 let solver = TrajectorySolver::new(
7867 inputs,
7868 WindConditions::default(),
7869 AtmosphericConditions::default(),
7870 );
7871 let level_headwind = Vector3::new(-100.0, 0.0, 0.0);
7872 let velocity = expected_shot_frame_vector(level_headwind, angle);
7873 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
7874 let actual = solver.calculate_acceleration(
7875 &Vector3::zeros(),
7876 &velocity,
7877 &level_headwind,
7878 (temp_c, pressure_hpa, density / 1.225),
7879 );
7880
7881 assert!(
7882 (actual - solver.gravity_acceleration()).norm() < 1e-12,
7883 "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
7884 );
7885 }
7886
7887 #[test]
7888 fn inclined_coriolis_is_rotated_into_solver_frame() {
7889 let angle = std::f64::consts::FRAC_PI_6;
7890 let latitude_deg = 45.0_f64;
7891 let shot_azimuth = 0.4_f64;
7892 let velocity = Vector3::new(600.0, 20.0, 5.0);
7893 let base_inputs = BallisticInputs {
7894 shooting_angle: angle,
7895 latitude: Some(latitude_deg),
7896 shot_azimuth,
7897 ..BallisticInputs::default()
7898 };
7899 let acceleration = |enable_coriolis| {
7900 let mut inputs = base_inputs.clone();
7901 inputs.enable_coriolis = enable_coriolis;
7902 let solver = TrajectorySolver::new(
7903 inputs,
7904 WindConditions::default(),
7905 AtmosphericConditions::default(),
7906 );
7907 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
7908 solver.calculate_acceleration(
7909 &Vector3::zeros(),
7910 &velocity,
7911 &Vector3::zeros(),
7912 (temp_c, pressure_hpa, density / 1.225),
7913 )
7914 };
7915
7916 let omega_earth = 7.2921159e-5_f64;
7917 let latitude = latitude_deg.to_radians();
7918 let level_omega = Vector3::new(
7919 omega_earth * latitude.cos() * shot_azimuth.cos(),
7920 omega_earth * latitude.sin(),
7921 -omega_earth * latitude.cos() * shot_azimuth.sin(),
7922 );
7923 let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
7924 let actual = acceleration(true) - acceleration(false);
7925
7926 assert!(
7927 (actual - expected).norm() < 1e-12,
7928 "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
7929 );
7930 }
7931}
7932
7933#[cfg(test)]
7934mod terminal_range_interpolation_tests {
7935 use super::*;
7936
7937 #[test]
7938 fn terminal_finalizer_selects_the_earliest_crossed_boundary() {
7939 let inputs = BallisticInputs {
7940 ground_threshold: 0.0,
7941 ..BallisticInputs::default()
7942 };
7943 let mut solver = TrajectorySolver::new(
7944 inputs,
7945 WindConditions::default(),
7946 AtmosphericConditions::default(),
7947 );
7948 solver.set_max_range(120.0);
7949
7950 let previous_speed = 700.0;
7951 let mut points = vec![TrajectoryPoint {
7952 time: 99.0,
7953 position: Vector3::new(90.0, 1.0, -1.0),
7954 velocity_magnitude: previous_speed,
7955 kinetic_energy: 0.5 * solver.inputs.bullet_mass * previous_speed.powi(2),
7956 drag_coefficient: None,
7957 }];
7958 let mut max_height = 1.0;
7959 let termination = solver
7960 .append_terminal_endpoint(
7961 &mut points,
7962 Vector3::new(130.0, -3.0, 3.0),
7963 Vector3::new(600.0, 0.0, 0.0),
7964 101.0,
7965 &mut max_height,
7966 &mut 0,
7967 )
7968 .expect("the final step brackets supported boundaries");
7969
7970 assert_eq!(termination, TrajectoryTermination::GroundThreshold);
7971 assert_eq!(points.len(), 2);
7972 let terminal = points.last().expect("terminal point");
7973 assert_eq!(terminal.time, 99.5);
7974 assert_eq!(terminal.position, Vector3::new(100.0, 0.0, 0.0));
7975 assert_eq!(terminal.velocity_magnitude, 675.0);
7976 assert_eq!(
7977 terminal.kinetic_energy,
7978 0.5 * solver.inputs.bullet_mass * 675.0_f64.powi(2)
7979 );
7980
7981 solver.set_max_range(100.0);
7983 let mut tied_points = vec![points[0].clone()];
7984 assert_eq!(
7985 solver
7986 .append_terminal_endpoint(
7987 &mut tied_points,
7988 Vector3::new(130.0, -3.0, 3.0),
7989 Vector3::new(600.0, 0.0, 0.0),
7990 101.0,
7991 &mut max_height,
7992 &mut 0,
7993 )
7994 .expect("tied boundaries remain a valid terminal"),
7995 TrajectoryTermination::GroundThreshold
7996 );
7997 }
7998
7999 #[test]
8000 fn sub_ulp_terminal_crossing_replaces_instead_of_duplicating_range() {
8001 let ground_threshold = f64::from_bits(1.0_f64.to_bits() - 1);
8002 let inputs = BallisticInputs {
8003 ground_threshold,
8004 ..BallisticInputs::default()
8005 };
8006 let mut solver = TrajectorySolver::new(
8007 inputs,
8008 WindConditions::default(),
8009 AtmosphericConditions::default(),
8010 );
8011 solver.set_max_range(1_000.0);
8012
8013 let speed = 700.0;
8014 let mut points = vec![TrajectoryPoint {
8015 time: 0.0,
8016 position: Vector3::new(100.0, 1.0, 0.0),
8017 velocity_magnitude: speed,
8018 kinetic_energy: 0.5 * solver.inputs.bullet_mass * speed.powi(2),
8019 drag_coefficient: None,
8020 }];
8021 let mut max_height = 1.0;
8022 let termination = solver
8023 .append_terminal_endpoint(
8024 &mut points,
8025 Vector3::new(101.0, 0.0, 0.0),
8026 Vector3::new(699.0, 0.0, 0.0),
8027 1.0,
8028 &mut max_height,
8029 &mut 0,
8030 )
8031 .expect("sub-ULP ground crossing remains representable as one terminal state");
8032
8033 assert_eq!(termination, TrajectoryTermination::GroundThreshold);
8034 assert_eq!(points.len(), 1);
8035 assert_eq!(points[0].position.x, 100.0);
8036 assert_eq!(points[0].position.y.to_bits(), ground_threshold.to_bits());
8037 assert!(points[0].time > 0.0);
8038 }
8039
8040 #[test]
8046 fn downrange_stall_replaces_instead_of_duplicating_range() {
8047 for (name, use_rk4, use_adaptive_rk45, bc_value) in [
8048 ("Euler", false, false, 2.0e-4),
8049 ("RK4", true, false, 1.0e-4),
8050 ("RK45", true, true, 1.0e-4),
8051 ] {
8052 let inputs = BallisticInputs {
8053 bc_value,
8054 bc_type: DragModel::G7,
8055 bullet_mass: 0.01134,
8056 bullet_diameter: 0.00782,
8057 muzzle_velocity: 823.0,
8058 use_rk4,
8059 use_adaptive_rk45,
8060 ground_threshold: -100.0,
8061 ..BallisticInputs::default()
8062 };
8063 let mut solver = TrajectorySolver::new(
8064 inputs,
8065 WindConditions::default(),
8066 AtmosphericConditions::default(),
8067 );
8068 solver.set_max_range(300.0);
8069 solver.set_time_step(0.001);
8070
8071 let result = solver
8072 .solve()
8073 .unwrap_or_else(|error| panic!("{name}: a stalled flight still solves: {error}"));
8074 assert!(
8075 result
8076 .points
8077 .windows(2)
8078 .all(|pair| pair[1].position.x > pair[0].position.x),
8079 "{name}: stored downrange distances must strictly increase"
8080 );
8081 assert_eq!(
8082 result.termination,
8083 TrajectoryTermination::GroundThreshold,
8084 "{name}"
8085 );
8086 assert!(
8087 result.max_range > 0.0 && result.max_range < 300.0,
8088 "{name}: the stall ends short of max_range, got {} m",
8089 result.max_range
8090 );
8091 let terminal = result.points.last().expect("terminal point");
8092 assert_eq!(terminal.position.y, -100.0, "{name}");
8093 result
8094 .sample_observations(50.0, 100)
8095 .unwrap_or_else(|error| panic!("{name}: checked sampling refused: {error}"));
8096 }
8097 }
8098
8099 #[test]
8100 fn every_solver_appends_an_exact_max_range_endpoint() {
8101 let target_range = 0.1;
8102 let modes = [
8103 ("Euler", false, false),
8104 ("RK4", true, false),
8105 ("RK45", true, true),
8106 ];
8107
8108 for (name, use_rk4, use_adaptive_rk45) in modes {
8109 let inputs = BallisticInputs {
8110 use_rk4,
8111 use_adaptive_rk45,
8112 ground_threshold: f64::NEG_INFINITY,
8113 enable_trajectory_sampling: true,
8114 sample_interval: target_range,
8115 ..BallisticInputs::default()
8116 };
8117 let mut solver = TrajectorySolver::new(
8118 inputs,
8119 WindConditions::default(),
8120 AtmosphericConditions::default(),
8121 );
8122 solver.set_max_range(target_range);
8123
8124 let result = solver.solve().expect("short-range solve should succeed");
8125 let terminal = result.points.last().expect("terminal point is missing");
8126 let muzzle = result.points.first().expect("muzzle point is missing");
8127
8128 assert_eq!(result.termination, TrajectoryTermination::MaxRange);
8129 assert_eq!(
8130 terminal.position.x.to_bits(),
8131 target_range.to_bits(),
8132 "{name} did not terminate exactly at max_range"
8133 );
8134 assert_eq!(result.max_range.to_bits(), target_range.to_bits());
8135 assert!(
8136 result.time_of_flight > 0.0 && result.time_of_flight < solver.time_step,
8137 "{name} terminal time was not interpolated within the crossing step: {}",
8138 result.time_of_flight
8139 );
8140 assert_eq!(result.time_of_flight.to_bits(), terminal.time.to_bits());
8141 assert_eq!(
8142 result.impact_velocity.to_bits(),
8143 terminal.velocity_magnitude.to_bits()
8144 );
8145 assert_eq!(
8146 result.impact_energy.to_bits(),
8147 terminal.kinetic_energy.to_bits()
8148 );
8149 let expected_energy = 0.5 * solver.inputs.bullet_mass * result.impact_velocity.powi(2);
8150 assert!((result.impact_energy - expected_energy).abs() < 1e-12);
8151 assert!(terminal.velocity_magnitude < muzzle.velocity_magnitude);
8152 assert!(terminal.kinetic_energy < muzzle.kinetic_energy);
8153
8154 let terminal_sample = result
8155 .sampled_points
8156 .as_ref()
8157 .and_then(|samples| samples.last())
8158 .expect("terminal trajectory sample is missing");
8159 assert_eq!(
8160 terminal_sample.distance_m.to_bits(),
8161 target_range.to_bits(),
8162 "{name} sampling did not include max_range"
8163 );
8164 assert_eq!(
8165 terminal_sample.time_s.to_bits(),
8166 result.time_of_flight.to_bits()
8167 );
8168 assert_eq!(
8169 terminal_sample.velocity_mps.to_bits(),
8170 result.impact_velocity.to_bits()
8171 );
8172 assert!((terminal_sample.energy_j - result.impact_energy).abs() < 1e-12);
8173 }
8174 }
8175}
8176
8177#[cfg(test)]
8178mod precession_inertia_wiring_tests {
8179 use super::*;
8180
8181 #[test]
8182 fn solver_uses_projectile_specific_moments_of_inertia() {
8183 let mass_kg = 55.0 * crate::constants::GRAINS_TO_KG;
8184 let caliber_m = 0.224 * 0.0254;
8185 let length_m = 0.75 * 0.0254;
8186 let inputs = BallisticInputs {
8187 bullet_mass: mass_kg,
8188 bullet_diameter: caliber_m,
8189 bullet_length: length_m,
8190 muzzle_velocity: 800.0,
8191 twist_rate: 7.0,
8192 enable_precession_nutation: true,
8193 use_rk4: false,
8194 use_adaptive_rk45: false,
8195 ..BallisticInputs::default()
8196 };
8197 let mut solver = TrajectorySolver::new(
8198 inputs,
8199 WindConditions::default(),
8200 AtmosphericConditions::default(),
8201 );
8202 solver.set_max_range(0.1);
8203
8204 let (air_density, speed_of_sound, _, _) = solver.resolved_atmosphere();
8205 let velocity_mps = solver.inputs.muzzle_velocity;
8206 let velocity_fps = velocity_mps * 3.28084;
8207 let twist_rate_ft = solver.inputs.twist_rate / 12.0;
8208 let spin_rate_rad_s = (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI;
8209 let initial_state = AngularState {
8210 pitch_angle: 0.001,
8211 yaw_angle: 0.001,
8212 pitch_rate: 0.0,
8213 yaw_rate: 0.0,
8214 precession_angle: 0.0,
8215 nutation_phase: 0.0,
8216 };
8217 let params = PrecessionNutationParams {
8218 mass_kg,
8219 caliber_m,
8220 length_m,
8221 spin_rate_rad_s,
8222 spin_inertia: crate::spin_decay::calculate_moment_of_inertia(
8223 mass_kg, caliber_m, length_m, "ogive",
8224 ),
8225 transverse_inertia: crate::pitch_damping::calculate_transverse_moment_of_inertia(
8226 mass_kg, caliber_m, length_m, "ogive",
8227 ),
8228 velocity_mps,
8229 air_density_kg_m3: air_density,
8230 mach: velocity_mps / speed_of_sound,
8231 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
8232 nutation_damping_factor: 0.05,
8233 };
8234 let expected = calculate_combined_angular_motion(
8235 ¶ms,
8236 &initial_state,
8237 0.0,
8238 solver.time_step,
8239 0.001,
8240 );
8241 let actual = solver
8242 .solve()
8243 .expect("one-step solve should succeed")
8244 .angular_state
8245 .expect("precession/nutation was enabled");
8246
8247 assert!(
8248 (actual.precession_angle - expected.precession_angle).abs() < 1e-15,
8249 "precession phase used the wrong inertia: actual={}, expected={}",
8250 actual.precession_angle,
8251 expected.precession_angle
8252 );
8253 assert!(
8254 (actual.nutation_phase - expected.nutation_phase).abs() < 1e-15,
8255 "nutation phase used the wrong inertia: actual={}, expected={}",
8256 actual.nutation_phase,
8257 expected.nutation_phase
8258 );
8259 }
8260}
8261
8262#[cfg(test)]
8263mod form_factor_drag_tests {
8264 use super::*;
8265
8266 fn acceleration_with_form_factor_flag(enabled: bool) -> Vector3<f64> {
8267 let inputs = BallisticInputs {
8268 bc_value: 0.462,
8269 bc_type: DragModel::G1,
8270 bullet_model: Some("168gr SMK Match".to_string()),
8271 use_form_factor: enabled,
8272 ..BallisticInputs::default()
8273 };
8274 let solver = TrajectorySolver::new(
8275 inputs,
8276 WindConditions::default(),
8277 AtmosphericConditions::default(),
8278 );
8279 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
8280 solver.calculate_acceleration(
8281 &Vector3::zeros(),
8282 &Vector3::new(600.0, 0.0, 0.0),
8283 &Vector3::zeros(),
8284 (temp_c, pressure_hpa, density / 1.225),
8285 )
8286 }
8287
8288 #[test]
8289 fn measured_bc_drag_does_not_apply_name_based_form_factor_again() {
8290 let baseline = acceleration_with_form_factor_flag(false);
8291 let flagged = acceleration_with_form_factor_flag(true);
8292
8293 assert!(
8294 (flagged - baseline).norm() < 1e-12,
8295 "published BC already encodes form factor: baseline={baseline:?} flagged={flagged:?}"
8296 );
8297 }
8298}
8299
8300#[cfg(test)]
8301mod rk45_adaptivity_tests {
8302 use super::*;
8303
8304 #[test]
8305 fn cli_rk45_error_norm_scales_components_independently() {
8306 let position = Vector3::new(1.0e9, 0.0, 0.0);
8307 let velocity = Vector3::new(800.0, 0.0, 0.0);
8308 let fifth_position = position;
8309 let fifth_velocity = velocity;
8310 let fourth_position = position;
8311 let fourth_velocity = Vector3::new(800.0, 1.0e-3, 0.0);
8312
8313 let error = cli_rk45_error_norm(
8314 &position,
8315 &velocity,
8316 &fifth_position,
8317 &fifth_velocity,
8318 &fourth_position,
8319 &fourth_velocity,
8320 );
8321 let expected = 1.0e-3 / 6.0_f64.sqrt();
8322
8323 assert!(
8324 (error - expected).abs() <= 1e-15,
8325 "large downrange position masked a velocity-component error: {error}"
8326 );
8327 }
8328
8329 fn discontinuous_wind_solver() -> TrajectorySolver {
8330 let inputs = BallisticInputs::default();
8331 let mut solver = TrajectorySolver::new(
8332 inputs,
8333 WindConditions::default(),
8334 AtmosphericConditions::default(),
8335 );
8336 solver.set_wind_segments(vec![
8337 crate::wind::WindSegment::new(0.0, 90.0, 4.0),
8338 crate::wind::WindSegment::new(1_000.0, 90.0, 10_000.0),
8339 ]);
8340 solver
8341 }
8342
8343 #[test]
8344 fn rk45_retries_discontinuous_trial_before_advancing() {
8345 let solver = discontinuous_wind_solver();
8346 let position = Vector3::new(0.0, solver.inputs.muzzle_height, 0.0);
8347 let velocity = Vector3::new(solver.inputs.muzzle_velocity, 0.0, 0.0);
8348 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
8349 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
8350 let dt = 0.01;
8351
8352 let rejected_trial = solver.rk45_step(
8353 &position,
8354 &velocity,
8355 dt,
8356 &Vector3::zeros(),
8357 RK45_TOLERANCE,
8358 resolved_atmo,
8359 );
8360 assert!(
8361 rejected_trial.error > RK45_TOLERANCE,
8362 "discontinuous full step must exceed tolerance, got {}",
8363 rejected_trial.error
8364 );
8365
8366 let accepted =
8367 solver.adaptive_rk45_step(&position, &velocity, dt, &Vector3::zeros(), resolved_atmo);
8368 assert!(accepted.used_dt < dt, "oversized trial was not retried");
8369 assert!(
8370 accepted.error <= RK45_TOLERANCE || accepted.used_dt <= RK45_MIN_DT,
8371 "accepted error {} exceeds tolerance at dt {}",
8372 accepted.error,
8373 accepted.used_dt
8374 );
8375
8376 let accepted_trial = solver.rk45_step(
8377 &position,
8378 &velocity,
8379 accepted.used_dt,
8380 &Vector3::zeros(),
8381 RK45_TOLERANCE,
8382 resolved_atmo,
8383 );
8384 assert_eq!(accepted.position, accepted_trial.position);
8385 assert_eq!(accepted.velocity, accepted_trial.velocity);
8386 assert!((RK45_MIN_DT..=RK45_MAX_DT).contains(&accepted.next_dt));
8387 }
8388}
8389
8390#[cfg(test)]
8391mod ground_termination_tests {
8392 use super::*;
8393 use crate::trajectory_observation::TrajectoryObservationFlag;
8394
8395 #[test]
8396 fn every_solver_reports_one_exact_early_ground_endpoint() {
8397 for (name, use_rk4, use_adaptive_rk45) in [
8398 ("Euler", false, false),
8399 ("RK4", true, false),
8400 ("RK45", true, true),
8401 ] {
8402 let inputs = BallisticInputs {
8403 muzzle_height: 1.0,
8404 muzzle_angle: -0.2,
8405 ground_threshold: 0.0,
8406 use_rk4,
8407 use_adaptive_rk45,
8408 ..BallisticInputs::default()
8409 };
8410 let mut solver = TrajectorySolver::new(
8411 inputs,
8412 WindConditions::default(),
8413 AtmosphericConditions::default(),
8414 );
8415 solver.set_max_range(1_000.0);
8416
8417 let result = solver.solve().expect("early-ground solve should succeed");
8418 let terminal = result.points.last().expect("terminal point is missing");
8419
8420 assert_eq!(result.termination, TrajectoryTermination::GroundThreshold);
8421 assert_eq!(terminal.position.y.to_bits(), 0.0_f64.to_bits());
8422 assert!(
8423 terminal.position.x < 1_000.0,
8424 "{name} incorrectly reached max range"
8425 );
8426 assert_eq!(result.max_range.to_bits(), terminal.position.x.to_bits());
8427 assert_eq!(
8428 result
8429 .points
8430 .iter()
8431 .filter(|point| point.position.y == 0.0)
8432 .count(),
8433 1,
8434 "{name} did not retain exactly one ground endpoint"
8435 );
8436
8437 let observations = result
8438 .sample_observations(1.0, 100)
8439 .expect("checked early-ground sampling should succeed");
8440 assert!(observations[..observations.len() - 1]
8441 .iter()
8442 .all(|observation| observation.distance_m < terminal.position.x));
8443 let terminal_observation = observations.last().expect("terminal observation");
8444 assert_eq!(
8445 terminal_observation.distance_m.to_bits(),
8446 terminal.position.x.to_bits()
8447 );
8448 assert!(terminal_observation
8449 .flags
8450 .contains(&TrajectoryObservationFlag::Terminal));
8451 assert!(terminal_observation
8452 .flags
8453 .contains(&TrajectoryObservationFlag::GroundThreshold));
8454 assert_eq!(
8455 observations
8456 .iter()
8457 .filter(|observation| observation
8458 .flags
8459 .contains(&TrajectoryObservationFlag::Terminal))
8460 .count(),
8461 1,
8462 "{name} repeated the terminal observation"
8463 );
8464 }
8465 }
8466
8467 #[test]
8472 fn rk4_and_rk45_descend_to_ground_threshold() {
8473 for adaptive in [false, true] {
8474 let inputs = BallisticInputs {
8475 muzzle_angle: 0.1, use_rk4: true,
8477 use_adaptive_rk45: adaptive,
8478 ..BallisticInputs::default()
8479 };
8480 assert_eq!(
8481 inputs.ground_threshold, -100.0,
8482 "default ground_threshold is -100 m"
8483 );
8484
8485 let mut solver = TrajectorySolver::new(
8486 inputs,
8487 WindConditions::default(),
8488 AtmosphericConditions::default(),
8489 );
8490 solver.set_max_range(1.0e7);
8492
8493 let result = solver.solve().expect("solve should succeed");
8494 let final_y = result
8495 .points
8496 .last()
8497 .expect("trajectory has points")
8498 .position
8499 .y;
8500 assert!(
8501 final_y < -1.0,
8502 "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
8503 past launch level toward the ground_threshold floor, not stop at y = 0"
8504 );
8505 }
8506 }
8507}
8508
8509#[cfg(test)]
8510mod magnus_stability_tests {
8511 use super::*;
8512
8513 #[test]
8514 fn yaw_of_repose_magnus_force_is_vertical_and_twist_signed() {
8515 let acceleration = |enable_magnus, is_twist_right| {
8516 let inputs = BallisticInputs {
8517 muzzle_velocity: 822.96,
8518 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
8519 bullet_diameter: 0.308 * 0.0254,
8520 bullet_length: 1.215 * 0.0254,
8521 twist_rate: 10.0,
8522 is_twist_right,
8523 enable_magnus,
8524 ..BallisticInputs::default()
8525 };
8526 let solver = TrajectorySolver::new(
8527 inputs,
8528 WindConditions::default(),
8529 AtmosphericConditions::default(),
8530 );
8531 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
8532 solver.calculate_acceleration(
8533 &Vector3::zeros(),
8534 &Vector3::new(822.96, 0.0, 0.0),
8535 &Vector3::zeros(),
8536 (temp_c, pressure_hpa, density / 1.225),
8537 )
8538 };
8539
8540 let baseline = acceleration(false, true);
8541 let right_twist = acceleration(true, true) - baseline;
8542 let left_twist = acceleration(true, false) - baseline;
8543
8544 assert!(
8545 right_twist.y < 0.0,
8546 "right-hand Magnus must point down, got {right_twist:?}"
8547 );
8548 assert!(
8549 left_twist.y > 0.0,
8550 "left-hand Magnus must point up, got {left_twist:?}"
8551 );
8552 assert!((right_twist.y + left_twist.y).abs() < 1e-12);
8553 assert!(right_twist.x.abs() < 1e-12 && right_twist.z.abs() < 1e-12);
8554 assert!(left_twist.x.abs() < 1e-12 && left_twist.z.abs() < 1e-12);
8555 }
8556
8557 #[test]
8558 fn magnus_uses_velocity_corrected_muzzle_stability_gate() {
8559 let muzzle_velocity = 1_400.0 / 3.28084;
8560 let inputs = BallisticInputs {
8561 muzzle_velocity,
8562 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
8563 bullet_diameter: 0.308 * 0.0254,
8564 bullet_length: 1.215 * 0.0254,
8565 twist_rate: 15.0,
8566 enable_magnus: true,
8567 ..BallisticInputs::default()
8568 };
8569 let solver = TrajectorySolver::new(
8570 inputs.clone(),
8571 WindConditions::default(),
8572 AtmosphericConditions::default(),
8573 );
8574
8575 let bare_sg = crate::spin_drift::miller_stability(0.308, 168.0, 15.0, 1.215);
8576 let canonical_sg = solver.effective_spin_drift_sg();
8577 assert!(bare_sg > 1.0, "test requires bare Sg above the Magnus gate");
8578 assert!(
8579 canonical_sg < 1.0,
8580 "velocity-corrected Sg must be below the gate, got {canonical_sg}"
8581 );
8582
8583 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
8584 let acceleration = solver.calculate_acceleration(
8585 &Vector3::zeros(),
8586 &Vector3::new(muzzle_velocity, 0.0, 0.0),
8587 &Vector3::zeros(),
8588 (temp_c, pressure_hpa, density / 1.225),
8589 );
8590 let mut baseline_inputs = inputs;
8591 baseline_inputs.enable_magnus = false;
8592 let baseline_solver = TrajectorySolver::new(
8593 baseline_inputs,
8594 WindConditions::default(),
8595 AtmosphericConditions::default(),
8596 );
8597 let baseline = baseline_solver.calculate_acceleration(
8598 &Vector3::zeros(),
8599 &Vector3::new(muzzle_velocity, 0.0, 0.0),
8600 &Vector3::zeros(),
8601 (temp_c, pressure_hpa, density / 1.225),
8602 );
8603
8604 assert_eq!(
8605 acceleration, baseline,
8606 "canonical Sg below 1 must suppress every Magnus acceleration component"
8607 );
8608 }
8609
8610 #[test]
8611 fn magnus_force_grows_as_fixed_spin_projectile_slows() {
8612 let inputs = BallisticInputs {
8613 muzzle_velocity: 800.0,
8614 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
8615 bullet_diameter: 0.308 * 0.0254,
8616 bullet_length: 1.215 * 0.0254,
8617 twist_rate: 12.0,
8618 enable_magnus: true,
8619 ..BallisticInputs::default()
8620 };
8621
8622 let magnus_acceleration = |speed_mps| {
8623 let evaluate = |enable_magnus| {
8624 let mut run_inputs = inputs.clone();
8625 run_inputs.enable_magnus = enable_magnus;
8626 let solver = TrajectorySolver::new(
8627 run_inputs,
8628 WindConditions::default(),
8629 AtmosphericConditions::default(),
8630 );
8631 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
8632 solver
8633 .calculate_acceleration(
8634 &Vector3::zeros(),
8635 &Vector3::new(speed_mps, 0.0, 0.0),
8636 &Vector3::zeros(),
8637 (temp_c, pressure_hpa, density / 1.225),
8638 )
8639 .y
8640 };
8641 (evaluate(true) - evaluate(false)).abs()
8642 };
8643
8644 let fast = magnus_acceleration(200.0);
8645 let slow = magnus_acceleration(100.0);
8646 let ratio = slow / fast;
8647 let expected_ratio = 2.0_f64.powf(5.0 / 3.0);
8648
8649 assert!(fast > 0.0 && slow > 0.0, "fast={fast}, slow={slow}");
8650 assert!(
8651 (ratio - expected_ratio).abs() < 1e-3,
8652 "fixed-spin Magnus acceleration must grow downrange; slow/fast={ratio}, \
8653 expected={expected_ratio}"
8654 );
8655 }
8656}
8657
8658#[cfg(test)]
8659mod coriolis_direction_tests {
8660 use super::*;
8661 use std::f64::consts::FRAC_PI_2;
8662
8663 #[test]
8664 fn supersonic_crossing_flags_a_positive_range_sample() {
8665 use crate::trajectory_sampling::TrajectoryFlag;
8669
8670 for (solver_name, use_rk4, use_adaptive_rk45) in [
8671 ("Euler", false, false),
8672 ("RK4", true, false),
8673 ("RK45", true, true),
8674 ] {
8675 let inputs = BallisticInputs {
8676 muzzle_velocity: 850.0,
8677 bc_value: 0.2,
8678 bc_type: DragModel::G7,
8679 muzzle_angle: 0.03,
8680 enable_trajectory_sampling: true,
8681 sample_interval: 50.0,
8682 use_rk4,
8683 use_adaptive_rk45,
8684 ..BallisticInputs::default()
8685 };
8686 let mut solver = TrajectorySolver::new(
8687 inputs,
8688 WindConditions::default(),
8689 AtmosphericConditions::default(),
8690 );
8691 solver.set_max_range(2000.0);
8692 let samples = solver
8693 .solve()
8694 .expect("supersonic solve should succeed")
8695 .sampled_points
8696 .expect("sampling was enabled");
8697 let flagged_distances: Vec<_> = samples
8698 .iter()
8699 .filter(|sample| sample.flags.contains(&TrajectoryFlag::MachTransition))
8700 .map(|sample| sample.distance_m)
8701 .collect();
8702
8703 assert!(
8704 !flagged_distances.is_empty()
8705 && flagged_distances.iter().all(|distance| *distance > 0.0),
8706 "{solver_name} must flag genuine crossings only at positive range: {flagged_distances:?}"
8707 );
8708 }
8709 }
8710
8711 #[test]
8712 fn subsonic_launch_does_not_flag_a_muzzle_transition() {
8713 use crate::trajectory_sampling::TrajectoryFlag;
8714
8715 for (solver_name, use_rk4, use_adaptive_rk45) in [
8716 ("Euler", false, false),
8717 ("RK4", true, false),
8718 ("RK45", true, true),
8719 ] {
8720 let inputs = BallisticInputs {
8721 muzzle_velocity: 250.0,
8722 muzzle_angle: 0.02,
8723 enable_trajectory_sampling: true,
8724 sample_interval: 25.0,
8725 use_rk4,
8726 use_adaptive_rk45,
8727 ..BallisticInputs::default()
8728 };
8729 let mut solver = TrajectorySolver::new(
8730 inputs,
8731 WindConditions::default(),
8732 AtmosphericConditions::default(),
8733 );
8734 solver.set_max_range(300.0);
8735 let samples = solver
8736 .solve()
8737 .expect("subsonic solve should succeed")
8738 .sampled_points
8739 .expect("sampling was enabled");
8740
8741 assert!(
8742 samples
8743 .iter()
8744 .all(|sample| !sample.flags.contains(&TrajectoryFlag::MachTransition)),
8745 "{solver_name} marked a Mach transition for a launch already below Mach 1"
8746 );
8747 }
8748 }
8749
8750 #[test]
8751 fn mach_transition_tracker_requires_a_downward_crossing() {
8752 fn record(mach_values: &[f64]) -> Vec<f64> {
8753 let mut tracker = MachTransitionTracker::default();
8754 let mut distances = Vec::new();
8755 for (index, mach) in mach_values.iter().copied().enumerate() {
8756 tracker.record_downward_crossings(mach, index as f64 * 10.0, &mut distances);
8757 }
8758 distances
8759 }
8760
8761 assert!(record(&[0.9, 0.8, 0.7]).is_empty());
8762 assert_eq!(record(&[1.1, 1.05, 0.99]), vec![20.0]);
8763 assert_eq!(record(&[1.2, 1.19, 1.0, 0.99]), vec![10.0, 30.0]);
8764 assert_eq!(record(&[0.9, 1.3, 1.1, 0.9, 1.3, 0.8]), vec![20.0, 30.0]);
8765 assert!(record(&[1.3, f64::NAN, 1.1]).is_empty());
8766 }
8767
8768 #[test]
8769 fn mach_transition_tracker_labels_0_9_without_touching_the_flat_vec() {
8770 fn record(mach_values: &[f64]) -> (Vec<f64>, MachTransitionTracker) {
8776 let mut tracker = MachTransitionTracker::default();
8777 let mut distances = Vec::new();
8778 for (index, mach) in mach_values.iter().copied().enumerate() {
8779 tracker.record_downward_crossings(mach, index as f64 * 10.0, &mut distances);
8780 }
8781 (distances, tracker)
8782 }
8783
8784 let (distances, tracker) = record(&[0.9, 0.8, 0.7]);
8787 assert!(distances.is_empty()); assert_eq!(tracker.mach_1_2_distance_m, None);
8789 assert_eq!(tracker.mach_1_0_distance_m, None);
8790 assert_eq!(tracker.mach_0_9_distance_m, Some(10.0));
8791
8792 let (distances, tracker) = record(&[1.1, 1.05, 0.99]);
8794 assert_eq!(distances, vec![20.0]);
8795 assert_eq!(tracker.mach_1_2_distance_m, None);
8796 assert_eq!(tracker.mach_1_0_distance_m, Some(20.0));
8797 assert_eq!(tracker.mach_0_9_distance_m, None);
8798
8799 let (distances, tracker) = record(&[1.2, 1.19, 1.0, 0.99]);
8801 assert_eq!(distances, vec![10.0, 30.0]); assert_eq!(tracker.mach_1_2_distance_m, Some(10.0));
8803 assert_eq!(tracker.mach_1_0_distance_m, Some(30.0));
8804 assert_eq!(tracker.mach_0_9_distance_m, None);
8805
8806 let (distances, tracker) = record(&[0.9, 1.3, 1.1, 0.9, 1.3, 0.8]);
8809 assert_eq!(distances, vec![20.0, 30.0]); assert_eq!(tracker.mach_1_2_distance_m, Some(20.0));
8811 assert_eq!(tracker.mach_1_0_distance_m, Some(30.0));
8812 assert_eq!(tracker.mach_0_9_distance_m, Some(50.0));
8813 assert!(
8814 tracker.mach_1_2_distance_m < tracker.mach_1_0_distance_m
8815 && tracker.mach_1_0_distance_m < tracker.mach_0_9_distance_m,
8816 "labeled crossings must be strictly increasing downrange"
8817 );
8818
8819 let (distances, tracker) = record(&[1.3, f64::NAN, 1.1]);
8821 assert!(distances.is_empty());
8822 assert_eq!(tracker.mach_1_2_distance_m, None);
8823 assert_eq!(tracker.mach_1_0_distance_m, None);
8824 assert_eq!(tracker.mach_0_9_distance_m, None);
8825 }
8826
8827 #[test]
8828 fn humidity_percent_converts_and_clamps() {
8829 let mut i = BallisticInputs {
8831 humidity: 0.5,
8832 ..BallisticInputs::default()
8833 };
8834 assert!((i.humidity_percent() - 50.0).abs() < 1e-9, "0.5 -> 50%");
8835 i.humidity = 0.0;
8836 assert_eq!(i.humidity_percent(), 0.0);
8837 i.humidity = 1.0;
8838 assert_eq!(i.humidity_percent(), 100.0);
8839 i.humidity = 1.5; assert_eq!(i.humidity_percent(), 100.0);
8841 }
8842
8843 fn vertical_at(shot_azimuth: f64, range_m: f64) -> f64 {
8846 let inputs = BallisticInputs {
8847 muzzle_velocity: 800.0,
8848 bc_value: 0.5,
8849 bc_type: DragModel::G7,
8850 muzzle_angle: 0.02, enable_coriolis: true,
8852 latitude: Some(45.0),
8853 shot_azimuth,
8854 ground_threshold: f64::NEG_INFINITY, ..BallisticInputs::default()
8856 };
8857 let mut solver = TrajectorySolver::new(
8858 inputs,
8859 WindConditions::default(),
8860 AtmosphericConditions::default(),
8861 );
8862 solver.set_max_range(range_m + 50.0);
8863 let r = solver.solve().expect("solve");
8864 let pts = &r.points;
8865 for i in 1..pts.len() {
8866 if pts[i].position.x >= range_m {
8867 let p1 = &pts[i - 1];
8868 let p2 = &pts[i];
8869 let t = (range_m - p1.position.x) / (p2.position.x - p1.position.x);
8870 return p1.position.y + t * (p2.position.y - p1.position.y);
8871 }
8872 }
8873 panic!("range {range_m} not reached");
8874 }
8875
8876 #[test]
8881 fn eotvos_east_higher_than_west() {
8882 let range = 600.0;
8883 let east = vertical_at(FRAC_PI_2, range); let west = vertical_at(3.0 * FRAC_PI_2, range); let north = vertical_at(0.0, range); assert!(
8887 east > west,
8888 "east ({east:.5}) must be higher than west ({west:.5}) at {range} m (Eötvös)"
8889 );
8890 assert!(
8891 east > north && north > west,
8892 "north ({north:.5}) must lie between east ({east:.5}) and west ({west:.5})"
8893 );
8894 assert!(
8895 (east - west) > 1e-3,
8896 "E-W vertical separation ({:.6} m) should be physically meaningful, not FP noise",
8897 east - west
8898 );
8899 }
8900
8901 #[test]
8909 fn labeled_mach_crossings_match_pinned_pre_change_flat_vec_across_solvers() {
8910 let cases = [
8912 (
8913 "Euler",
8914 false,
8915 false,
8916 670.9878683238721_f64,
8917 805.5274119916264_f64,
8918 ),
8919 (
8920 "RK4",
8921 true,
8922 false,
8923 671.7257336844475_f64,
8924 805.933409072171_f64,
8925 ),
8926 (
8927 "RK45",
8928 true,
8929 true,
8930 672.4905711917901_f64,
8931 806.5709746782849_f64,
8932 ),
8933 ];
8934
8935 for (solver_name, use_rk4, use_adaptive_rk45, expected_1_2, expected_1_0) in cases {
8936 let inputs = BallisticInputs {
8937 muzzle_velocity: 850.0,
8938 bc_value: 0.2,
8939 bc_type: DragModel::G7,
8940 muzzle_angle: 0.03,
8941 use_rk4,
8942 use_adaptive_rk45,
8943 ..BallisticInputs::default()
8944 };
8945 let mut solver = TrajectorySolver::new(
8946 inputs,
8947 WindConditions::default(),
8948 AtmosphericConditions::default(),
8949 );
8950 solver.set_max_range(2000.0);
8951 let result = solver.solve().expect("solve should succeed");
8952
8953 assert_eq!(
8954 result.mach_1_2_distance_m,
8955 Some(expected_1_2),
8956 "{solver_name}: mach_1_2_distance_m must match the pinned pre-change flat-Vec value"
8957 );
8958 assert_eq!(
8959 result.mach_1_0_distance_m,
8960 Some(expected_1_0),
8961 "{solver_name}: mach_1_0_distance_m must match the pinned pre-change flat-Vec value"
8962 );
8963
8964 let mach_1_2 = result.mach_1_2_distance_m.expect("crosses 1.2");
8965 let mach_1_0 = result.mach_1_0_distance_m.expect("crosses 1.0");
8966 let mach_0_9 = result
8967 .mach_0_9_distance_m
8968 .expect("this trajectory also goes past 0.9 within 2000 m");
8969 assert!(
8970 mach_1_2 < mach_1_0 && mach_1_0 < mach_0_9,
8971 "{solver_name}: labeled crossings must be strictly increasing downrange \
8972 (1.2={mach_1_2}, 1.0={mach_1_0}, 0.9={mach_0_9})"
8973 );
8974 }
8975 }
8976
8977 #[test]
8980 fn labeled_mach_crossings_are_none_for_a_fully_supersonic_trajectory() {
8981 for (solver_name, use_rk4, use_adaptive_rk45) in [
8982 ("Euler", false, false),
8983 ("RK4", true, false),
8984 ("RK45", true, true),
8985 ] {
8986 let inputs = BallisticInputs {
8987 muzzle_velocity: 850.0,
8988 bc_value: 0.2,
8989 bc_type: DragModel::G7,
8990 muzzle_angle: 0.03,
8991 use_rk4,
8992 use_adaptive_rk45,
8993 ..BallisticInputs::default()
8994 };
8995 let mut solver = TrajectorySolver::new(
8996 inputs,
8997 WindConditions::default(),
8998 AtmosphericConditions::default(),
8999 );
9000 solver.set_max_range(200.0);
9002 let result = solver.solve().expect("solve should succeed");
9003
9004 assert_eq!(
9005 result.mach_1_2_distance_m, None,
9006 "{solver_name}: must not report a 1.2 crossing that never happens"
9007 );
9008 assert_eq!(
9009 result.mach_1_0_distance_m, None,
9010 "{solver_name}: must not report a 1.0 crossing that never happens"
9011 );
9012 assert_eq!(
9013 result.mach_0_9_distance_m, None,
9014 "{solver_name}: must not report a 0.9 crossing that never happens"
9015 );
9016 }
9017 }
9018}
9019
9020#[cfg(test)]
9021mod cant_tests {
9022 use super::*;
9023
9024 fn base_inputs() -> BallisticInputs {
9025 BallisticInputs {
9026 muzzle_velocity: 800.0,
9027 bc_value: 0.5,
9028 bc_type: DragModel::G7,
9029 bullet_mass: 0.0109,
9030 bullet_diameter: 0.00782,
9031 bullet_length: 0.0309,
9032 sight_height: 0.05,
9033 twist_rate: 10.0,
9034 use_rk4: true,
9035 ..BallisticInputs::default()
9036 }
9037 }
9038
9039 fn solve_with(inputs: BallisticInputs, max_range: f64) -> TrajectoryResult {
9040 let mut s = TrajectorySolver::new(
9041 inputs,
9042 WindConditions::default(),
9043 AtmosphericConditions::default(),
9044 );
9045 s.set_max_range(max_range);
9046 s.solve().expect("solve")
9047 }
9048
9049 fn yz_at(result: &TrajectoryResult, x: f64) -> (f64, f64) {
9051 let pts = &result.points;
9052 for i in 1..pts.len() {
9053 if pts[i].position.x >= x {
9054 let (p1, p2) = (&pts[i - 1], &pts[i]);
9055 let dx = p2.position.x - p1.position.x;
9056 let t = if dx.abs() < 1e-12 {
9057 0.0
9058 } else {
9059 (x - p1.position.x) / dx
9060 };
9061 return (
9062 p1.position.y + t * (p2.position.y - p1.position.y),
9063 p1.position.z + t * (p2.position.z - p1.position.z),
9064 );
9065 }
9066 }
9067 panic!("trajectory never reached {x} m");
9068 }
9069
9070 #[test]
9071 fn cant_sign_clockwise_up_offset_goes_right_and_low() {
9072 let mut level = base_inputs();
9074 level.muzzle_angle = 0.003; let mut canted = level.clone();
9076 canted.cant_angle = 10f64.to_radians();
9077
9078 let (y0, z0) = yz_at(&solve_with(level, 400.0), 300.0);
9079 let (y1, z1) = yz_at(&solve_with(canted, 400.0), 300.0);
9080 assert!(
9081 z1 > z0 + 0.01,
9082 "clockwise cant must move POI right: z0={z0} z1={z1}"
9083 );
9084 assert!(
9085 y1 < y0 - 0.001,
9086 "clockwise cant must move POI low: y0={y0} y1={y1}"
9087 );
9088 }
9089
9090 #[test]
9091 fn pure_cant_shows_bore_offset_near_range() {
9092 let mut i = base_inputs();
9095 i.muzzle_angle = 0.0;
9096 i.cant_angle = 10f64.to_radians();
9097 let sh = i.sight_height;
9098 let r = solve_with(i, 60.0);
9099 let first = &r.points[1]; let expected = -sh * 10f64.to_radians().sin();
9101 assert!(
9102 (first.position.z - expected).abs() < 0.005,
9103 "near-muzzle lateral {} should be ~bore offset {expected}",
9104 first.position.z
9105 );
9106 }
9107
9108 #[test]
9109 fn zero_angle_is_independent_of_cant() {
9110 let a = base_inputs();
9111 let mut b = base_inputs();
9112 b.cant_angle = 15f64.to_radians();
9113 let za = calculate_zero_angle(a.clone(), 100.0, 0.0).expect("zero a");
9114 let zb = calculate_zero_angle(b.clone(), 100.0, 0.0).expect("zero b");
9115 assert_eq!(
9116 za.to_bits(),
9117 zb.to_bits(),
9118 "zeroing must ignore cant: {za} vs {zb}"
9119 );
9120 let _ = (a.cant_angle, b.cant_angle);
9122 }
9123
9124 #[test]
9125 fn nonfinite_cant_is_rejected() {
9126 let mut i = base_inputs();
9127 i.cant_angle = f64::NAN;
9128 let s = TrajectorySolver::new(
9129 i,
9130 WindConditions::default(),
9131 AtmosphericConditions::default(),
9132 );
9133 assert!(s.solve().is_err());
9134 }
9135
9136 #[test]
9137 fn incline_and_cant_compose_without_breaking() {
9138 let mut flat = base_inputs();
9140 flat.muzzle_angle = 0.003;
9141 flat.shooting_angle = 15f64.to_radians();
9142 let mut canted = flat.clone();
9143 canted.cant_angle = 10f64.to_radians();
9144 let (_, z_flat) = yz_at(&solve_with(flat, 400.0), 300.0);
9145 let (_, z_cant) = yz_at(&solve_with(canted, 400.0), 300.0);
9146 assert!(
9147 z_cant > z_flat,
9148 "cant must still deflect right on an incline"
9149 );
9150 }
9151}
9152
9153#[cfg(test)]
9154mod vertical_wind_tests {
9155 use super::*;
9156
9157 fn base_inputs() -> BallisticInputs {
9158 BallisticInputs {
9159 muzzle_velocity: 800.0,
9160 bc_value: 0.5,
9161 bc_type: DragModel::G7,
9162 bullet_mass: 0.0109,
9163 bullet_diameter: 0.00782,
9164 bullet_length: 0.0309,
9165 sight_height: 0.05,
9166 twist_rate: 10.0,
9167 use_rk4: true,
9168 ..BallisticInputs::default()
9169 }
9170 }
9171
9172 fn y_at(result: &TrajectoryResult, x: f64) -> f64 {
9174 let pts = &result.points;
9175 for i in 1..pts.len() {
9176 if pts[i].position.x >= x {
9177 let (p1, p2) = (&pts[i - 1], &pts[i]);
9178 let dx = p2.position.x - p1.position.x;
9179 let t = if dx.abs() < 1e-12 {
9180 0.0
9181 } else {
9182 (x - p1.position.x) / dx
9183 };
9184 return p1.position.y + t * (p2.position.y - p1.position.y);
9185 }
9186 }
9187 panic!("trajectory never reached {x} m");
9188 }
9189
9190 fn solve_with(
9191 inputs: BallisticInputs,
9192 wind: WindConditions,
9193 max_range: f64,
9194 ) -> TrajectoryResult {
9195 let mut s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
9196 s.set_max_range(max_range);
9197 s.solve().expect("solve")
9198 }
9199
9200 #[test]
9201 fn updraft_raises_poi_downrange() {
9202 let calm_inputs = base_inputs();
9205 let calm_wind = WindConditions::default();
9206 let updraft = WindConditions {
9207 vertical_speed: 5.0,
9208 ..Default::default()
9209 };
9210
9211 let calm = solve_with(calm_inputs.clone(), calm_wind, 500.0);
9212 let updraft_result = solve_with(calm_inputs, updraft, 500.0);
9213
9214 let y_calm = y_at(&calm, 400.0);
9215 let y_updraft = y_at(&updraft_result, 400.0);
9216 assert!(
9217 y_updraft > y_calm,
9218 "5 m/s updraft must raise POI at 400m: calm={y_calm}, updraft={y_updraft}"
9219 );
9220 }
9221
9222 #[test]
9223 fn zero_vertical_is_default_and_finite_required() {
9224 assert_eq!(WindConditions::default().vertical_speed, 0.0);
9225
9226 let inputs = base_inputs();
9227 let wind = WindConditions {
9228 vertical_speed: f64::NAN,
9229 ..Default::default()
9230 };
9231 let s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
9232 assert!(
9233 s.solve().is_err(),
9234 "NaN wind.vertical_speed must be rejected by validate_for_solve"
9235 );
9236 }
9237}
9238
9239#[cfg(test)]
9241mod bc_reference_standard_tests {
9242 use super::*;
9243
9244 fn base_inputs() -> BallisticInputs {
9245 BallisticInputs {
9246 muzzle_velocity: 800.0,
9247 bc_value: 0.5,
9248 bc_type: DragModel::G7,
9249 bullet_mass: 0.0109,
9250 bullet_diameter: 0.00782,
9251 bullet_length: 0.0309,
9252 sight_height: 0.05,
9253 twist_rate: 10.0,
9254 use_rk4: true,
9255 ..BallisticInputs::default()
9256 }
9257 }
9258
9259 fn y_and_speed_at(result: &TrajectoryResult, x: f64) -> (f64, f64) {
9261 let pts = &result.points;
9262 for i in 1..pts.len() {
9263 if pts[i].position.x >= x {
9264 let (p1, p2) = (&pts[i - 1], &pts[i]);
9265 let dx = p2.position.x - p1.position.x;
9266 let t = if dx.abs() < 1e-12 {
9267 0.0
9268 } else {
9269 (x - p1.position.x) / dx
9270 };
9271 return (
9272 p1.position.y + t * (p2.position.y - p1.position.y),
9273 p1.velocity_magnitude + t * (p2.velocity_magnitude - p1.velocity_magnitude),
9274 );
9275 }
9276 }
9277 panic!("trajectory never reached {x} m");
9278 }
9279
9280 #[test]
9283 fn asm_to_icao_ratio_matches_documented_value() {
9284 assert!(
9285 (crate::constants::ASM_TO_ICAO_BC - 0.98237).abs() < 1e-5,
9286 "ASM_TO_ICAO_BC = {} must equal 0.98237 to 5 decimal places",
9287 crate::constants::ASM_TO_ICAO_BC
9288 );
9289 assert_eq!(
9294 crate::constants::ASM_TO_ICAO_BC,
9295 crate::constants::ASM_DENSITY_LB_FT3 / crate::constants::ICAO_DENSITY_LB_FT3
9296 );
9297 }
9298
9299 #[test]
9302 fn default_bc_reference_standard_is_icao() {
9303 assert_eq!(
9304 BallisticInputs::default().bc_reference_standard,
9305 BcReferenceStandard::Icao
9306 );
9307 }
9308
9309 #[test]
9314 fn icao_reference_leaves_bc_value_bit_identical() {
9315 let raw_bc: f64 = 0.4372911; let inputs = BallisticInputs {
9317 bc_value: raw_bc,
9318 bc_reference_standard: BcReferenceStandard::Icao,
9319 ..base_inputs()
9320 };
9321 let solver = TrajectorySolver::new(
9322 inputs,
9323 WindConditions::default(),
9324 AtmosphericConditions::default(),
9325 );
9326 assert_eq!(solver.inputs.bc_value.to_bits(), raw_bc.to_bits());
9327 }
9328
9329 #[test]
9330 fn default_inputs_solve_is_unaffected_by_the_new_field_existing() {
9331 let a = TrajectorySolver::new(
9335 base_inputs(),
9336 WindConditions::default(),
9337 AtmosphericConditions::default(),
9338 )
9339 .solve()
9340 .expect("solve a");
9341 let b = TrajectorySolver::new(
9342 BallisticInputs { ..base_inputs() },
9343 WindConditions::default(),
9344 AtmosphericConditions::default(),
9345 )
9346 .solve()
9347 .expect("solve b");
9348 assert_eq!(a.impact_velocity.to_bits(), b.impact_velocity.to_bits());
9349 assert_eq!(a.max_range.to_bits(), b.max_range.to_bits());
9350 }
9351
9352 #[test]
9355 fn army_standard_metro_scales_bc_value_by_exactly_the_derived_ratio() {
9356 let raw_bc = 0.5;
9357 let inputs = BallisticInputs {
9358 bc_value: raw_bc,
9359 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
9360 ..base_inputs()
9361 };
9362 let solver = TrajectorySolver::new(
9363 inputs,
9364 WindConditions::default(),
9365 AtmosphericConditions::default(),
9366 );
9367 assert_eq!(
9368 solver.inputs.bc_value,
9369 raw_bc * crate::constants::ASM_TO_ICAO_BC
9370 );
9371 }
9372
9373 #[test]
9374 fn army_standard_metro_scales_mach_keyed_bc_segments() {
9375 let inputs = BallisticInputs {
9376 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
9377 bc_segments: Some(vec![(0.5, 0.40), (1.5, 0.30)]),
9378 ..base_inputs()
9379 };
9380 let solver = TrajectorySolver::new(
9381 inputs,
9382 WindConditions::default(),
9383 AtmosphericConditions::default(),
9384 );
9385 let segments = solver.inputs.bc_segments.as_ref().expect("segments");
9386 assert_eq!(segments[0], (0.5, 0.40 * crate::constants::ASM_TO_ICAO_BC));
9387 assert_eq!(segments[1], (1.5, 0.30 * crate::constants::ASM_TO_ICAO_BC));
9388 }
9389
9390 #[test]
9391 fn army_standard_metro_scales_velocity_keyed_bc_segments_data() {
9392 let inputs = BallisticInputs {
9393 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
9394 bc_segments_data: Some(vec![
9395 crate::BCSegmentData {
9396 velocity_min: 0.0,
9397 velocity_max: 500.0,
9398 bc_value: 0.40,
9399 },
9400 crate::BCSegmentData {
9401 velocity_min: 500.0,
9402 velocity_max: 900.0,
9403 bc_value: 0.45,
9404 },
9405 ]),
9406 ..base_inputs()
9407 };
9408 let solver = TrajectorySolver::new(
9409 inputs,
9410 WindConditions::default(),
9411 AtmosphericConditions::default(),
9412 );
9413 let segments = solver.inputs.bc_segments_data.as_ref().expect("segments");
9414 assert_eq!(
9415 segments[0].bc_value,
9416 0.40 * crate::constants::ASM_TO_ICAO_BC
9417 );
9418 assert_eq!(
9419 segments[1].bc_value,
9420 0.45 * crate::constants::ASM_TO_ICAO_BC
9421 );
9422 assert_eq!(segments[0].velocity_min, 0.0);
9424 assert_eq!(segments[1].velocity_max, 900.0);
9425 }
9426
9427 #[test]
9432 fn army_standard_metro_moves_impact_in_the_more_drag_direction() {
9433 let solve_at = |standard: BcReferenceStandard| {
9434 let inputs = BallisticInputs {
9435 bc_value: 0.475,
9436 bc_reference_standard: standard,
9437 ..base_inputs()
9438 };
9439 let mut solver = TrajectorySolver::new(
9440 inputs,
9441 WindConditions::default(),
9442 AtmosphericConditions::default(),
9443 );
9444 solver.set_max_range(500.0);
9445 solver.solve().expect("solve")
9446 };
9447
9448 let icao = solve_at(BcReferenceStandard::Icao);
9449 let asm = solve_at(BcReferenceStandard::ArmyStandardMetro);
9450
9451 let (y_icao, v_icao) = y_and_speed_at(&icao, 400.0);
9452 let (y_asm, v_asm) = y_and_speed_at(&asm, 400.0);
9453
9454 assert!(
9455 y_asm < y_icao,
9456 "ArmyStandardMetro must drop MORE (lower y) at 400m than Icao for the same raw \
9457 bc_value: icao_y={y_icao}, asm_y={y_asm}"
9458 );
9459 assert!(
9460 v_asm < v_icao,
9461 "ArmyStandardMetro must retain LESS velocity at 400m than Icao for the same raw \
9462 bc_value: icao_v={v_icao}, asm_v={v_asm}"
9463 );
9464 }
9465
9466 #[test]
9473 fn monte_carlo_inherits_the_normalized_bc_reference() {
9474 let base_inputs_asm = BallisticInputs {
9475 bc_value: 0.475,
9476 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
9477 ..base_inputs()
9478 };
9479 let wind = WindConditions::default();
9480
9481 let mut direct_solver = TrajectorySolver::new(
9487 base_inputs_asm.clone(),
9488 wind.clone(),
9489 AtmosphericConditions::default(),
9490 );
9491 direct_solver.set_max_range(base_inputs_asm.target_distance.max(1000.0) * 2.0);
9492 let direct = direct_solver.solve().expect("direct solve");
9493
9494 let mc_params = MonteCarloParams {
9495 num_simulations: 1,
9496 velocity_std_dev: 0.0,
9497 angle_std_dev: 0.0,
9498 bc_std_dev: 0.0,
9499 wind_speed_std_dev: 0.0,
9500 target_distance: None,
9501 base_wind_speed: 0.0,
9502 base_wind_direction: 0.0,
9503 azimuth_std_dev: 0.0,
9504 };
9505 let mc = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
9506 base_inputs_asm,
9507 wind,
9508 mc_params,
9509 0.0,
9510 42,
9511 )
9512 .expect("monte carlo");
9513
9514 assert_eq!(mc.ranges.len(), 1);
9515 assert_eq!(
9516 mc.ranges[0].to_bits(),
9517 direct.max_range.to_bits(),
9518 "a zero-dispersion single MC sample must match a plain solve of the same \
9519 ASM-referenced inputs bit-for-bit"
9520 );
9521 assert_eq!(
9522 mc.impact_velocities[0].to_bits(),
9523 direct.impact_velocity.to_bits()
9524 );
9525 }
9526
9527 #[test]
9535 fn estimate_bc_fit_recovers_an_icao_referenced_bc() {
9536 let known_bc = 0.475;
9537 let velocity = 800.0;
9538 let mass = 0.0109;
9539 let diameter = 0.00782;
9540 let atmosphere = AtmosphericConditions::default();
9541
9542 let synth_inputs = BallisticInputs {
9543 muzzle_velocity: velocity,
9544 bc_value: known_bc,
9545 bc_type: DragModel::G7,
9546 bullet_mass: mass,
9547 bullet_diameter: diameter,
9548 bullet_length: 0.0309,
9549 sight_height: 0.05,
9550 twist_rate: 10.0,
9551 use_rk4: true,
9552 bc_reference_standard: BcReferenceStandard::Icao,
9553 ..BallisticInputs::default()
9554 };
9555 let mut solver =
9556 TrajectorySolver::new(synth_inputs, WindConditions::default(), atmosphere.clone());
9557 solver.set_max_range(500.0);
9558 let trajectory = solver.solve().expect("synthetic solve");
9559
9560 let points: Vec<(f64, f64)> = [100.0, 200.0, 300.0, 400.0]
9561 .iter()
9562 .map(|&d| {
9563 let (y, _) = {
9564 let pts = &trajectory.points;
9565 let mut found = None;
9566 for i in 1..pts.len() {
9567 if pts[i].position.x >= d {
9568 let (p1, p2) = (&pts[i - 1], &pts[i]);
9569 let dx = p2.position.x - p1.position.x;
9570 let t = if dx.abs() < 1e-12 {
9571 0.0
9572 } else {
9573 (d - p1.position.x) / dx
9574 };
9575 found =
9576 Some((p1.position.y + t * (p2.position.y - p1.position.y), 0.0));
9577 break;
9578 }
9579 }
9580 found.expect("trajectory reached observation distance")
9581 };
9582 (d, -y) })
9584 .collect();
9585
9586 let estimate = estimate_bc_fit(
9587 velocity,
9588 mass,
9589 diameter,
9590 &points,
9591 DragModel::G7,
9592 BcFitMode::Drop,
9593 atmosphere,
9594 None,
9595 0.05,
9596 )
9597 .expect("fit should converge");
9598
9599 assert!(
9600 (estimate.bc - known_bc).abs() < 0.02,
9601 "fit should recover the known ICAO-referenced bc={known_bc}, got {}",
9602 estimate.bc
9603 );
9604 }
9605
9606 #[test]
9609 fn custom_drag_table_makes_bc_reference_standard_numerically_inert() {
9610 let table =
9611 crate::drag::DragTable::try_new(vec![0.5, 1.0, 2.0, 3.0], vec![0.3, 0.4, 0.3, 0.2])
9612 .expect("valid table");
9613
9614 let solve_with = |standard: BcReferenceStandard| {
9615 let inputs = BallisticInputs {
9616 bc_value: 0.5, bc_reference_standard: standard,
9618 custom_drag_table: Some(table.clone()),
9619 ..base_inputs()
9620 };
9621 let mut solver = TrajectorySolver::new(
9622 inputs,
9623 WindConditions::default(),
9624 AtmosphericConditions::default(),
9625 );
9626 solver.set_max_range(500.0);
9627 solver.solve().expect("solve")
9628 };
9629
9630 let icao = solve_with(BcReferenceStandard::Icao);
9631 let asm = solve_with(BcReferenceStandard::ArmyStandardMetro);
9632
9633 assert_eq!(
9634 icao.impact_velocity.to_bits(),
9635 asm.impact_velocity.to_bits(),
9636 "a custom drag table must make bc_reference_standard fully inert"
9637 );
9638 assert_eq!(icao.max_range.to_bits(), asm.max_range.to_bits());
9639 }
9640
9641 #[test]
9642 fn custom_drag_table_inert_warning_fires_only_for_army_standard_metro_with_a_table() {
9643 let table = crate::drag::DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.3, 0.4, 0.3])
9644 .expect("valid table");
9645
9646 let no_table_icao = base_inputs();
9648 assert!(no_table_icao
9649 .bc_reference_standard_inert_warning()
9650 .is_none());
9651 let no_table_asm = BallisticInputs {
9652 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
9653 ..base_inputs()
9654 };
9655 assert!(no_table_asm.bc_reference_standard_inert_warning().is_none());
9656
9657 let table_icao = BallisticInputs {
9659 custom_drag_table: Some(table.clone()),
9660 ..base_inputs()
9661 };
9662 assert!(table_icao.bc_reference_standard_inert_warning().is_none());
9663
9664 let table_asm = BallisticInputs {
9666 custom_drag_table: Some(table),
9667 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
9668 ..base_inputs()
9669 };
9670 let warning = table_asm
9671 .bc_reference_standard_inert_warning()
9672 .expect("must warn");
9673 assert!(warning.contains("--bc-reference"));
9674 assert!(warning.contains("--drag-table"));
9675 }
9676}
9677
9678#[cfg(test)]
9684mod effective_drag_coefficient_tests {
9685 use super::*;
9686
9687 fn inputs_175gr_g7() -> BallisticInputs {
9688 let mut inputs = BallisticInputs {
9689 bc_value: 0.243,
9690 bc_type: DragModel::G7,
9691 muzzle_velocity: 823.0,
9692 ..Default::default()
9693 };
9694 inputs.bullet_mass = 175.0 * crate::constants::GRAINS_TO_KG;
9698 inputs.bullet_diameter = 0.308 * 0.0254;
9699 inputs.weight_grains = 175.0;
9700 inputs.caliber_inches = 0.308;
9701 inputs
9702 }
9703
9704 fn solver(inputs: BallisticInputs) -> TrajectorySolver {
9705 TrajectorySolver::new(
9706 inputs,
9707 WindConditions::default(),
9708 AtmosphericConditions::default(),
9709 )
9710 }
9711
9712 #[test]
9716 fn reports_the_projectiles_own_cd_not_the_reference_tables() {
9717 let inputs = inputs_175gr_g7();
9718 let sd = inputs.sectional_density_lb_in2().expect("SD");
9719 let solver = solver(inputs);
9720
9721 let sos = 340.0;
9722 let velocity = 800.0;
9723 let mach = velocity / sos;
9724
9725 let reference = crate::drag::get_drag_coefficient(mach, &DragModel::G7);
9726 let reported = solver
9727 .effective_drag_coefficient(velocity, sos)
9728 .expect("mass and diameter are set");
9729
9730 let expected = reference * sd / 0.243;
9731 assert!(
9732 (reported - expected).abs() < 1e-12,
9733 "reported {reported} != Cd_ref * SD / BC {expected}"
9734 );
9735 assert!(
9738 (reported - reference).abs() > 1e-6,
9739 "form factor collapsed to 1; this fixture no longer distinguishes the two values"
9740 );
9741 }
9742
9743 #[test]
9746 fn a_custom_drag_table_passes_through_unscaled() {
9747 let mut inputs = inputs_175gr_g7();
9748 inputs.custom_drag_table = Some(crate::drag::DragTable::new(
9749 vec![0.5, 3.0],
9750 vec![0.15, 0.40],
9751 ));
9752 let solver = solver(inputs);
9753
9754 let sos = 340.0;
9755 let velocity = 0.9 * sos;
9756 let table_value = solver
9757 .inputs
9758 .custom_drag_table
9759 .as_ref()
9760 .expect("table")
9761 .interpolate(0.9);
9762
9763 let reported = solver
9764 .effective_drag_coefficient(velocity, sos)
9765 .expect("mass and diameter are set");
9766 assert!(
9767 (reported - table_value).abs() < 1e-12,
9768 "custom table Cd {table_value} was rescaled to {reported}"
9769 );
9770 }
9771
9772 #[test]
9775 fn a_velocity_segmented_bc_steps_the_reported_cd() {
9776 let mut inputs = inputs_175gr_g7();
9777 inputs.use_bc_segments = true;
9778 inputs.bc_segments_data = Some(vec![
9779 crate::BCSegmentData {
9780 velocity_min: 2400.0,
9781 velocity_max: 4000.0,
9782 bc_value: 0.243,
9783 },
9784 crate::BCSegmentData {
9785 velocity_min: 0.0,
9786 velocity_max: 2400.0,
9787 bc_value: 0.200,
9788 },
9789 ]);
9790 let solver = solver(inputs);
9791
9792 let sos = 340.0;
9793 let above = solver
9795 .effective_drag_coefficient(2500.0 / 3.28084, sos)
9796 .expect("cd");
9797 let below = solver
9798 .effective_drag_coefficient(2300.0 / 3.28084, sos)
9799 .expect("cd");
9800
9801 assert!(
9803 below > above,
9804 "expected the 0.200 band to report a higher Cd than the 0.243 band; got {below} vs {above}"
9805 );
9806 }
9807
9808 #[test]
9811 fn is_absent_when_sectional_density_is_unknown() {
9812 let mut inputs = inputs_175gr_g7();
9813 inputs.weight_grains = 0.0;
9814 inputs.bullet_mass = 0.0;
9815 let solver = solver(inputs);
9816 assert!(solver.effective_drag_coefficient(800.0, 340.0).is_none());
9817 }
9818
9819 #[test]
9824 fn the_json_emit_rule_is_flag_gated_and_absent_when_cd_is_unknown() {
9825 let mut point = TrajectoryPoint {
9826 time: 0.0,
9827 position: nalgebra::Vector3::new(0.0, 0.0, 0.0),
9828 velocity_magnitude: 800.0,
9829 kinetic_energy: 3000.0,
9830 drag_coefficient: Some(0.31),
9831 };
9832 assert_eq!(point.drag_coefficient_json_value(true), Some(0.31));
9833 assert_eq!(
9834 point.drag_coefficient_json_value(false),
9835 None,
9836 "without the flag the key must not exist, so default JSON stays byte-identical"
9837 );
9838 point.drag_coefficient = None;
9839 assert_eq!(
9840 point.drag_coefficient_json_value(true),
9841 None,
9842 "unknown sectional density must yield an ABSENT key, not null"
9843 );
9844 }
9845
9846 #[test]
9848 fn every_point_of_a_solved_trajectory_carries_the_value() {
9849 let mut solver = solver(inputs_175gr_g7());
9850 solver.set_max_range(300.0);
9851 let result = solver.solve().expect("solve");
9852 assert!(!result.points.is_empty());
9853 assert!(
9854 result.points.iter().all(|p| p.drag_coefficient.is_some()),
9855 "the post-integration pass missed at least one point"
9856 );
9857 }
9858}