1use crate::atmosphere::{PressureReferenceMode, StationAtmospherePresence};
3use crate::cluster_bc::ClusterBCDegradation;
4use crate::gravity::Gravity;
5use crate::mc_stats::{wilson_interval, BernoulliConfidenceSequence, ConfidenceLevel, Welford};
6use crate::pitch_damping::{calculate_pitch_damping_coefficient, PitchDampingCoefficients};
7use crate::precession_nutation::{
8 calculate_combined_angular_motion, projectile_moments_of_inertia, AngularState,
9 PrecessionNutationParams,
10};
11use crate::trajectory_observation::{bracket_param, Bracket, TrajectoryTermination};
12use crate::trajectory_sampling::{
13 projected_sample_count, sample_trajectory, TrajectoryData, TrajectoryOutputs, TrajectorySample,
14};
15use crate::wind_shear::WindShearModel;
16use crate::DragModel;
17use nalgebra::{Vector3, Vector6};
18use std::error::Error;
19use std::fmt;
20
21#[derive(Debug, Clone, Copy, PartialEq)]
31#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
32pub enum UnitSystem {
33 Metric,
35 Imperial,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq)]
41pub enum OutputFormat {
42 Table,
43 Json,
44 Csv,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
68#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
69pub enum BcReferenceStandard {
70 #[default]
73 Icao,
74 ArmyStandardMetro,
77}
78
79pub const BC_REFERENCE_STANDARD_INERT_WARNING: &str =
83 "warning: --bc-reference army-standard-metro has no effect together with a custom drag \
84 table (--drag-table): the deck's Cd is divided by sectional density, not a BC value, so \
85 no BC-reference conversion applies";
86
87#[derive(Debug)]
89pub struct BallisticsError {
90 message: String,
91}
92
93impl fmt::Display for BallisticsError {
94 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
95 write!(f, "{}", self.message)
96 }
97}
98
99impl Error for BallisticsError {}
100
101impl From<String> for BallisticsError {
102 fn from(msg: String) -> Self {
103 BallisticsError { message: msg }
104 }
105}
106
107impl From<&str> for BallisticsError {
108 fn from(msg: &str) -> Self {
109 BallisticsError {
110 message: msg.to_string(),
111 }
112 }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
123pub enum DropsReference {
124 #[default]
126 Los,
127 Target,
130}
131
132#[derive(Debug, Clone)]
136pub struct BallisticInputs {
137 pub bc_value: f64, pub bc_type: DragModel, pub bc_reference_standard: BcReferenceStandard,
145 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,
159 pub shooting_angle: f64, pub cant_angle: f64,
169 pub sight_height: f64, pub sight_offset_lateral_m: f64,
181 pub muzzle_height: f64, pub target_height: f64, pub zero_poi_vertical_m: f64,
192 pub zero_poi_horizontal_m: f64,
198 pub ground_threshold: f64, pub altitude: f64, pub temperature: f64, pub pressure: f64, pub humidity: f64,
209 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,
231 pub enable_magnus: bool, pub enable_coriolis: bool, pub use_powder_sensitivity: bool,
234 pub powder_temp_sensitivity: f64, pub powder_temp: f64, pub powder_temp_curve: Option<Vec<(f64, f64)>>,
243 pub powder_curve_temp_c: Option<f64>,
247 pub tipoff_yaw: f64, pub tipoff_decay_distance: f64, pub cd_delta2: f64,
255 pub use_bc_segments: bool,
265 pub bc_segments: Option<Vec<(f64, f64)>>, pub bc_segments_data: Option<Vec<crate::BCSegmentData>>, pub use_enhanced_spin_drift: bool,
268 pub use_form_factor: bool,
271 pub enable_wind_shear: bool,
272 pub wind_shear_model: String,
273 pub enable_trajectory_sampling: bool,
274 pub sample_interval: f64, pub drops_reference: DropsReference,
284 pub enable_pitch_damping: bool,
285 pub enable_precession_nutation: bool,
286 pub enable_aerodynamic_jump: bool,
289 pub use_cluster_bc: bool, pub custom_drag_table: Option<crate::drag::DragTable>,
306 pub cd_scale: f64,
320 pub custom_drag_reference: bool,
340
341 pub bc_type_str: Option<String>,
343}
344
345impl BallisticInputs {
346 pub fn humidity_percent(&self) -> f64 {
351 (self.humidity * 100.0).clamp(0.0, 100.0)
352 }
353
354 pub fn windage_zero_bias_rad(&self, zero_distance_m: f64) -> f64 {
369 if zero_distance_m > 0.0 {
370 (self.zero_poi_horizontal_m + self.sight_offset_lateral_m) / zero_distance_m
371 } else {
372 0.0
373 }
374 }
375
376 pub fn sectional_density_lb_in2(&self) -> Option<f64> {
382 let weight_gr = if self.weight_grains > 0.0 {
383 self.weight_grains
384 } else {
385 self.bullet_mass / crate::constants::GRAINS_TO_KG };
387 let diameter_in = if self.caliber_inches > 0.0 {
388 self.caliber_inches
389 } else {
390 self.bullet_diameter / 0.0254 };
392 if weight_gr > 0.0 && diameter_in > 0.0 {
393 Some(weight_gr / 7000.0 / (diameter_in * diameter_in))
394 } else {
395 None
396 }
397 }
398
399 pub fn custom_drag_denominator(&self, resolved_bc: f64) -> f64 {
417 if self.custom_drag_reference {
418 return resolved_bc;
419 }
420 match self.sectional_density_lb_in2() {
421 Some(sd) => sd,
422 None => {
423 static WARN_ONCE: std::sync::Once = std::sync::Once::new();
424 WARN_ONCE.call_once(|| {
425 eprintln!(
426 "Warning: custom drag table active but bullet mass/diameter are \
427 unavailable; falling back to bc_value for the retardation denominator"
428 );
429 });
430 resolved_bc
431 }
432 }
433 }
434
435 pub fn bc_reference_standard_inert_warning(&self) -> Option<&'static str> {
448 if self.custom_drag_table.is_some()
449 && !self.custom_drag_reference
450 && matches!(
451 self.bc_reference_standard,
452 BcReferenceStandard::ArmyStandardMetro
453 )
454 {
455 Some(BC_REFERENCE_STANDARD_INERT_WARNING)
456 } else {
457 None
458 }
459 }
460
461 pub fn normalize_for_solve(&mut self) {
480 if matches!(
493 self.bc_reference_standard,
494 BcReferenceStandard::ArmyStandardMetro
495 ) {
496 self.bc_value *= crate::constants::ASM_TO_ICAO_BC;
497 if let Some(segments) = self.bc_segments.as_mut() {
498 for (_mach, bc) in segments.iter_mut() {
499 *bc *= crate::constants::ASM_TO_ICAO_BC;
500 }
501 }
502 if let Some(segments) = self.bc_segments_data.as_mut() {
503 for segment in segments.iter_mut() {
504 segment.bc_value *= crate::constants::ASM_TO_ICAO_BC;
505 }
506 }
507 self.bc_reference_standard = BcReferenceStandard::Icao;
510 }
511
512 self.caliber_inches = self.bullet_diameter / 0.0254;
517 self.weight_grains = self.bullet_mass / crate::constants::GRAINS_TO_KG;
518
519 self.muzzle_velocity = resolve_powder_adjusted_velocity(
530 self.muzzle_velocity,
531 self.temperature,
532 self.use_powder_sensitivity,
533 self.powder_temp_sensitivity,
534 self.powder_temp,
535 self.powder_temp_curve.as_deref(),
536 self.powder_curve_temp_c,
537 );
538 }
539}
540
541impl Default for BallisticInputs {
542 fn default() -> Self {
543 let mass_kg = 0.01;
544 let diameter_m = 0.00762;
545 let bc = 0.5;
546 let muzzle_angle_rad = 0.0;
547 let bc_type = DragModel::G1;
548
549 Self {
550 bc_value: bc,
552 bc_type,
553 bc_reference_standard: BcReferenceStandard::Icao,
554 bullet_mass: mass_kg,
555 muzzle_velocity: 800.0,
556 bullet_diameter: diameter_m,
557 bullet_length: crate::stability::estimate_bullet_length_m(diameter_m, mass_kg),
561
562 muzzle_angle: muzzle_angle_rad,
564 target_distance: 100.0,
565 azimuth_angle: 0.0,
566 shot_azimuth: 0.0,
567 shooting_angle: 0.0,
568 cant_angle: 0.0,
569 sight_height: 0.05,
570 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,
575 ground_threshold: -100.0, altitude: 0.0,
579 temperature: 15.0,
580 pressure: 1013.25, humidity: 0.5, latitude: None,
583
584 wind_speed: 0.0,
586 wind_angle: 0.0,
587
588 twist_rate: 12.0, is_twist_right: true,
591 caliber_inches: diameter_m / 0.0254, weight_grains: mass_kg / crate::constants::GRAINS_TO_KG, manufacturer: None,
594 bullet_model: None,
595 bullet_id: None,
596 bullet_cluster: None,
597
598 use_rk4: true, use_adaptive_rk45: true, enable_advanced_effects: false,
604 enable_magnus: false,
605 enable_coriolis: false,
606 use_powder_sensitivity: false,
607 powder_temp_sensitivity: 0.0,
608 powder_temp: 15.0,
609 powder_temp_curve: None,
610 powder_curve_temp_c: None,
611 tipoff_yaw: 0.0,
612 tipoff_decay_distance: 50.0,
613 cd_delta2: 7.5,
614 use_bc_segments: false,
615 bc_segments: None,
616 bc_segments_data: None,
617 use_enhanced_spin_drift: false,
618 use_form_factor: false,
619 enable_wind_shear: false,
620 wind_shear_model: "none".to_string(),
621 enable_trajectory_sampling: false,
622 sample_interval: 10.0, drops_reference: DropsReference::Los, enable_pitch_damping: false,
625 enable_precession_nutation: false,
626 enable_aerodynamic_jump: false,
627 use_cluster_bc: false, custom_drag_table: None,
631 cd_scale: 1.0,
632 custom_drag_reference: false,
633
634 bc_type_str: None,
636 }
637 }
638}
639
640pub fn interpolate_powder_temp_curve(curve: &[(f64, f64)], temp_c: f64) -> f64 {
646 debug_assert!(!curve.is_empty());
647 if curve.is_empty() {
648 return 0.0;
649 }
650 let mut sorted;
653 let pts: &[(f64, f64)] = if curve.windows(2).all(|w| w[0].0 <= w[1].0) {
654 curve
655 } else {
656 sorted = curve.to_vec();
657 sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
658 &sorted
659 };
660 let n = pts.len();
661 if temp_c <= pts[0].0 {
662 return pts[0].1; }
664 if temp_c >= pts[n - 1].0 {
665 return pts[n - 1].1; }
667 for i in 1..n {
668 let (t0, v0) = pts[i - 1];
669 let (t1, v1) = pts[i];
670 if temp_c <= t1 {
671 let span = t1 - t0;
672 if span.abs() < f64::EPSILON {
673 return v1; }
675 let f = (temp_c - t0) / span;
676 return v0 + f * (v1 - v0);
677 }
678 }
679 pts[n - 1].1
680}
681
682pub fn parse_powder_sweep(s: &str) -> Result<Vec<f64>, String> {
688 const MAX_SWEEP_ROWS: usize = 500;
689 let parts: Vec<&str> = s.split(':').collect();
690 if parts.len() != 3 {
691 return Err(format!(
692 "Invalid --sweep '{}': expected START:END:STEP (e.g. \"20:110:10\")",
693 s
694 ));
695 }
696 let parse = |p: &str, name: &str| -> Result<f64, String> {
697 p.trim()
698 .parse::<f64>()
699 .map_err(|_| format!("Invalid --sweep {}: '{}' is not a number", name, p.trim()))
700 };
701 let start = parse(parts[0], "START")?;
702 let end = parse(parts[1], "END")?;
703 let step = parse(parts[2], "STEP")?;
704 if !step.is_finite() || step <= 0.0 {
705 return Err(format!("Invalid --sweep STEP {}: must be positive", step));
706 }
707 if !start.is_finite() || !end.is_finite() || end < start {
708 return Err(format!(
709 "Invalid --sweep range {}:{}: END must be >= START",
710 start, end
711 ));
712 }
713 let n_f = ((end - start) / step + 1e-9).floor();
719 if !n_f.is_finite() || n_f + 1.0 > MAX_SWEEP_ROWS as f64 {
720 return Err(format!(
721 "--sweep would produce more than {} rows; use a larger STEP",
722 MAX_SWEEP_ROWS
723 ));
724 }
725 let n = n_f as usize + 1;
726 Ok((0..n).map(|i| start + step * i as f64).collect())
728}
729
730pub fn resolve_powder_adjusted_velocity(
739 nominal_velocity_mps: f64,
740 ambient_temperature_c: f64,
741 use_powder_sensitivity: bool,
742 powder_temp_sensitivity_mps_per_c: f64,
743 powder_reference_temp_c: f64,
744 powder_temp_curve: Option<&[(f64, f64)]>,
745 powder_curve_temp_c: Option<f64>,
746) -> f64 {
747 if let Some(curve) = powder_temp_curve {
748 if !curve.is_empty() {
749 let lookup_c = powder_curve_temp_c.unwrap_or(ambient_temperature_c);
750 return interpolate_powder_temp_curve(curve, lookup_c);
751 }
752 return nominal_velocity_mps;
755 }
756 if use_powder_sensitivity {
757 let temp_delta_c = ambient_temperature_c - powder_reference_temp_c;
758 return nominal_velocity_mps + powder_temp_sensitivity_mps_per_c * temp_delta_c;
759 }
760 nominal_velocity_mps
761}
762
763#[derive(Debug, Clone)]
765pub struct WindConditions {
766 pub speed: f64, pub direction: f64,
770 pub vertical_speed: f64,
778}
779
780impl Default for WindConditions {
781 fn default() -> Self {
782 Self {
783 speed: 0.0,
784 direction: 0.0,
785 vertical_speed: 0.0,
786 }
787 }
788}
789
790#[derive(Debug, Clone)]
792pub struct AtmosphericConditions {
793 pub temperature: f64, pub pressure: f64, pub humidity: f64,
799 pub altitude: f64, }
801
802impl Default for AtmosphericConditions {
803 fn default() -> Self {
804 Self {
805 temperature: 15.0,
806 pressure: 1013.25,
807 humidity: 50.0,
808 altitude: 0.0,
809 }
810 }
811}
812
813#[derive(Debug, Clone)]
815pub struct TrajectoryPoint {
816 pub time: f64,
817 pub position: Vector3<f64>,
818 pub velocity_magnitude: f64,
819 pub kinetic_energy: f64,
820 pub drag_coefficient: Option<f64>,
827}
828
829impl TrajectoryPoint {
830 pub fn drag_coefficient_json_value(&self, with_drag_coefficient: bool) -> Option<f64> {
838 if with_drag_coefficient {
839 self.drag_coefficient
840 } else {
841 None
842 }
843 }
844}
845
846#[derive(Debug, Clone)]
848pub struct TrajectoryResult {
849 pub max_range: f64,
850 pub max_height: f64,
851 pub time_of_flight: f64,
852 pub impact_velocity: f64,
853 pub impact_energy: f64,
854 pub projectile_mass_kg: f64,
856 pub line_of_sight_height_m: f64,
858 pub station_speed_of_sound_mps: f64,
860 pub termination: TrajectoryTermination,
862 pub points: Vec<TrajectoryPoint>,
863 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>,
872 pub mach_1_2_distance_m: Option<f64>,
877 pub mach_1_0_distance_m: Option<f64>,
881 pub mach_0_9_distance_m: Option<f64>,
889}
890
891const RK45_TOLERANCE: f64 = 1e-6;
892const RK45_SAFETY_FACTOR: f64 = 0.9;
893const RK45_MAX_DT: f64 = 0.01;
894const RK45_MIN_DT: f64 = 1e-6;
895const TRAJECTORY_TIME_LIMIT_S: f64 = 100.0;
896
897pub const MAX_TRAJECTORY_POINTS: usize = 250_000;
903
904fn cli_rk45_error_norm(
906 position: &Vector3<f64>,
907 velocity: &Vector3<f64>,
908 fifth_position: &Vector3<f64>,
909 fifth_velocity: &Vector3<f64>,
910 fourth_position: &Vector3<f64>,
911 fourth_velocity: &Vector3<f64>,
912) -> f64 {
913 let pack_state = |position: &Vector3<f64>, velocity: &Vector3<f64>| {
914 Vector6::new(
915 position.x, position.y, position.z, velocity.x, velocity.y, velocity.z,
916 )
917 };
918 let state = pack_state(position, velocity);
919 let fifth_order = pack_state(fifth_position, fifth_velocity);
920 let fourth_order = pack_state(fourth_position, fourth_velocity);
921
922 crate::trajectory_integration::rk45_error_norm(&state, &fifth_order, &fourth_order)
923}
924
925struct Rk45Trial {
926 position: Vector3<f64>,
927 velocity: Vector3<f64>,
928 suggested_dt: f64,
929 error: f64,
930}
931
932struct Rk45AcceptedStep {
933 position: Vector3<f64>,
934 velocity: Vector3<f64>,
935 used_dt: f64,
936 next_dt: f64,
937 error: f64,
938}
939
940#[derive(Default)]
954struct MachTransitionTracker {
955 previous_mach: Option<f64>,
956 crossed_transonic: bool,
957 crossed_subsonic: bool,
958 crossed_narrow: bool,
959 mach_1_2_distance_m: Option<f64>,
962 mach_1_0_distance_m: Option<f64>,
965 mach_0_9_distance_m: Option<f64>,
968}
969
970impl MachTransitionTracker {
971 fn record_downward_crossings(&mut self, mach: f64, downrange_m: f64, distances: &mut Vec<f64>) {
972 if !mach.is_finite() {
973 self.previous_mach = None;
974 return;
975 }
976
977 if let Some(previous_mach) = self.previous_mach {
978 if !self.crossed_transonic && previous_mach >= 1.2 && mach < 1.2 {
979 self.crossed_transonic = true;
980 distances.push(downrange_m);
981 self.mach_1_2_distance_m = Some(downrange_m);
982 }
983 if !self.crossed_subsonic && previous_mach >= 1.0 && mach < 1.0 {
984 self.crossed_subsonic = true;
985 distances.push(downrange_m);
986 self.mach_1_0_distance_m = Some(downrange_m);
987 }
988 if !self.crossed_narrow && previous_mach >= 0.9 && mach < 0.9 {
989 self.crossed_narrow = true;
990 self.mach_0_9_distance_m = Some(downrange_m);
992 }
993 }
994 self.previous_mach = Some(mach);
995 }
996}
997
998impl TrajectoryResult {
999 pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
1003 if self.points.is_empty() {
1004 return None;
1005 }
1006
1007 for i in 0..self.points.len() - 1 {
1009 let p1 = &self.points[i];
1010 let p2 = &self.points[i + 1];
1011
1012 if p1.position.x <= target_range && p2.position.x >= target_range {
1014 let dx = p2.position.x - p1.position.x;
1016 if dx.abs() < 1e-10 {
1017 return Some(p1.position);
1018 }
1019 let t = (target_range - p1.position.x) / dx;
1020
1021 return Some(Vector3::new(
1023 target_range,
1024 p1.position.y + t * (p2.position.y - p1.position.y),
1025 p1.position.z + t * (p2.position.z - p1.position.z),
1026 ));
1027 }
1028 }
1029
1030 self.points.last().map(|p| p.position)
1032 }
1033}
1034
1035#[derive(Clone)]
1037pub struct TrajectorySolver {
1038 gravity: Gravity,
1039 inputs: BallisticInputs,
1040 wind: WindConditions,
1041 atmosphere: AtmosphericConditions,
1042 station_presence: StationAtmospherePresence,
1046 max_range: f64,
1047 time_step: f64,
1048 max_trajectory_points: usize,
1049 cluster_bc: Option<ClusterBCDegradation>,
1050 precession_nutation_inertias: (f64, f64),
1052 wind_sock: Option<crate::wind::WindSock>,
1057 atmo_sock: Option<crate::atmosphere::AtmoSock>,
1064}
1065
1066#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1078pub(crate) enum ZeroTargetFrame {
1079 SightLine,
1080 WorldVertical,
1081}
1082
1083#[derive(Debug, Clone, Copy, PartialEq)]
1096pub struct ZeroCrossings {
1097 pub near_m: Option<f64>,
1100 pub far_m: Option<f64>,
1103}
1104
1105impl TrajectorySolver {
1106 pub fn new(
1114 inputs: BallisticInputs,
1115 wind: WindConditions,
1116 atmosphere: AtmosphericConditions,
1117 ) -> Self {
1118 Self::new_with_station_presence(
1119 inputs,
1120 wind,
1121 atmosphere,
1122 StationAtmospherePresence::NEITHER,
1123 )
1124 }
1125
1126 pub fn new_with_station_presence(
1137 inputs: BallisticInputs,
1138 wind: WindConditions,
1139 atmosphere: AtmosphericConditions,
1140 station_presence: StationAtmospherePresence,
1141 ) -> Self {
1142 Self::new_with_station_presence_and_gravity(
1143 inputs,
1144 wind,
1145 atmosphere,
1146 station_presence,
1147 Gravity::EARTH,
1148 )
1149 }
1150
1151 pub fn new_with_resolved_station_atmosphere(
1162 inputs: BallisticInputs,
1163 wind: WindConditions,
1164 atmosphere: AtmosphericConditions,
1165 ) -> Self {
1166 Self::new_with_station_presence(inputs, wind, atmosphere, StationAtmospherePresence::BOTH)
1167 }
1168
1169 fn new_with_station_presence_and_gravity(
1170 mut inputs: BallisticInputs,
1171 wind: WindConditions,
1172 atmosphere: AtmosphericConditions,
1173 station_presence: StationAtmospherePresence,
1174 gravity: Gravity,
1175 ) -> Self {
1176 inputs.normalize_for_solve();
1181
1182 let cluster_bc = if inputs.use_cluster_bc {
1184 Some(ClusterBCDegradation::new())
1185 } else {
1186 None
1187 };
1188 let precession_nutation_inertias = projectile_moments_of_inertia(
1189 inputs.bullet_mass,
1190 inputs.bullet_diameter,
1191 inputs.bullet_length,
1192 );
1193
1194 Self {
1195 gravity,
1196 inputs,
1197 wind,
1198 atmosphere,
1199 station_presence,
1200 max_range: 1000.0,
1201 time_step: 0.001,
1202 max_trajectory_points: MAX_TRAJECTORY_POINTS,
1203 cluster_bc,
1204 precession_nutation_inertias,
1205 wind_sock: None,
1206 atmo_sock: None,
1207 }
1208 }
1209
1210 pub fn set_max_range(&mut self, range: f64) {
1211 self.max_range = range;
1212 }
1213
1214 pub fn set_time_step(&mut self, step: f64) {
1215 self.time_step = step;
1216 }
1217
1218 pub(crate) fn calculate_and_set_zero_angle(
1222 &mut self,
1223 target_distance_m: f64,
1224 target_height_m: f64,
1225 frame: ZeroTargetFrame,
1226 ) -> Result<f64, BallisticsError> {
1227 let angle = self.find_zero_angle(target_distance_m, target_height_m, frame)?;
1228 let angle = if target_distance_m > 0.0 {
1237 angle + self.inputs.zero_poi_vertical_m / target_distance_m
1238 } else {
1239 angle
1240 };
1241 self.inputs.muzzle_angle = angle;
1242 self.apply_windage_zero_bias(target_distance_m);
1243 Ok(angle)
1244 }
1245
1246 pub(crate) fn apply_windage_zero_bias(&mut self, target_distance_m: f64) {
1260 self.inputs.azimuth_angle += self.inputs.windage_zero_bias_rad(target_distance_m);
1261 }
1262
1263 fn find_zero_angle(
1264 &self,
1265 target_distance_m: f64,
1266 target_height_m: f64,
1267 frame: ZeroTargetFrame,
1268 ) -> Result<f64, BallisticsError> {
1269 let mut low_angle = 0.0;
1273 let mut high_angle = 0.2; let tolerance = 1e-7;
1275 let max_iterations = 60;
1276
1277 let low_height = self.zero_trial_height_at(low_angle, target_distance_m, frame)?;
1279 let high_height = self.zero_trial_height_at(high_angle, target_distance_m, frame)?;
1280
1281 match (low_height, high_height) {
1282 (Some(low_height), Some(high_height)) => {
1283 let low_error = low_height - target_height_m;
1284 let high_error = high_height - target_height_m;
1285
1286 if low_error > 0.0 && high_error > 0.0 {
1287 let mut expanded = false;
1294 for multiplier in [1.0, 2.0, 3.0, 4.0] {
1295 let new_low = (-0.2_f64 * multiplier).max(-0.785);
1296 match self.zero_trial_height_at(new_low, target_distance_m, frame) {
1297 Ok(Some(height)) if height - target_height_m > 0.0 => {}
1298 Ok(_) => {
1299 high_angle = low_angle;
1300 low_angle = new_low;
1301 expanded = true;
1302 break;
1303 }
1304 Err(_) => {}
1305 }
1306 }
1307 if !expanded {
1308 return Err("Cannot find zero angle: target is below the trajectory even at the steepest downward angle".into());
1309 }
1310 } else if low_error < 0.0 && high_error < 0.0 {
1311 let mut expanded = false;
1313 for multiplier in [2.0, 3.0, 4.0] {
1314 let new_high = (high_angle * multiplier).min(0.785);
1315 if let Ok(Some(height)) =
1316 self.zero_trial_height_at(new_high, target_distance_m, frame)
1317 {
1318 if height - target_height_m > 0.0 {
1319 high_angle = new_high;
1320 expanded = true;
1321 break;
1322 }
1323 }
1324 if new_high >= 0.785 {
1325 break;
1326 }
1327 }
1328 if !expanded {
1329 return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
1330 }
1331 }
1332 }
1333 (None, Some(_)) => {
1334 }
1337 (Some(_), None) => {
1338 return Err(
1339 "Cannot find zero angle: high angle trajectory doesn't reach target distance"
1340 .into(),
1341 );
1342 }
1343 (None, None) => {
1344 return Err(
1345 "Cannot find zero angle: trajectory cannot reach target distance at any angle"
1346 .into(),
1347 );
1348 }
1349 }
1350
1351 for _ in 0..max_iterations {
1352 let mid_angle = (low_angle + high_angle) / 2.0;
1353 match self.zero_trial_height_at(mid_angle, target_distance_m, frame)? {
1354 Some(height) => {
1355 let error = height - target_height_m;
1356 if error.abs() < 0.0001 {
1359 return Ok(mid_angle);
1360 }
1361
1362 if (high_angle - low_angle).abs() < tolerance {
1365 if error.abs() < 0.01 {
1366 return Ok(mid_angle);
1367 }
1368 return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
1369 }
1370
1371 if error > 0.0 {
1372 high_angle = mid_angle;
1373 } else {
1374 low_angle = mid_angle;
1375 }
1376 }
1377 None => {
1378 low_angle = mid_angle;
1379 if (high_angle - low_angle).abs() < tolerance {
1380 return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
1381 }
1382 }
1383 }
1384 }
1385
1386 Err("Failed to find zero angle".into())
1387 }
1388
1389 fn zero_trial_height_at(
1392 &self,
1393 angle_rad: f64,
1394 target_distance_m: f64,
1395 frame: ZeroTargetFrame,
1396 ) -> Result<Option<f64>, BallisticsError> {
1397 let mut trial = self.clone();
1398 trial.inputs.muzzle_angle = angle_rad;
1399 trial.inputs.enable_aerodynamic_jump = false;
1402 trial.inputs.cant_angle = 0.0;
1405 if frame == ZeroTargetFrame::SightLine {
1412 trial.inputs.shooting_angle = 0.0;
1413 }
1414 trial.set_max_range(target_distance_m * 2.0);
1415 let result = trial.solve()?;
1416
1417 for (index, point) in result.points.iter().enumerate() {
1418 if point.position.x >= target_distance_m {
1419 let shot_y_m = if index == 0 {
1420 point.position.y
1421 } else {
1422 let previous = &result.points[index - 1];
1423 let span = point.position.x - previous.position.x;
1424 let fraction = (target_distance_m - previous.position.x) / span;
1425 previous.position.y + fraction * (point.position.y - previous.position.y)
1426 };
1427 return Ok(Some(crate::atmosphere::shot_frame_altitude(
1428 0.0,
1429 target_distance_m,
1430 shot_y_m,
1431 trial.inputs.shooting_angle,
1432 )));
1433 }
1434 }
1435 Ok(None)
1436 }
1437
1438 fn find_zero_range(
1465 &self,
1466 angle_rad: f64,
1467 target_height_m: f64,
1468 frame: ZeroTargetFrame,
1469 ) -> Result<ZeroCrossings, BallisticsError> {
1470 let mut trial = self.clone();
1471 trial.inputs.muzzle_angle = angle_rad;
1472 trial.inputs.enable_aerodynamic_jump = false;
1475 trial.inputs.cant_angle = 0.0;
1476 if frame == ZeroTargetFrame::SightLine {
1477 trial.inputs.shooting_angle = 0.0;
1478 }
1479 let result = trial.solve()?;
1480
1481 let mut near_crossing: Option<f64> = None;
1489 let mut far_crossing: Option<f64> = None;
1490 let mut previous: Option<(f64, f64)> = None; for point in &result.points {
1492 let height = crate::atmosphere::shot_frame_altitude(
1493 0.0,
1494 point.position.x,
1495 point.position.y,
1496 trial.inputs.shooting_angle,
1497 );
1498 let error = height - target_height_m;
1499 if let Some((prev_x, prev_error)) = previous {
1500 if prev_error == 0.0 {
1501 if near_crossing.is_none() {
1504 near_crossing = Some(prev_x);
1505 } else {
1506 far_crossing = Some(prev_x);
1507 }
1508 }
1509 if prev_error * error < 0.0 {
1510 let fraction = prev_error / (prev_error - error);
1511 let crossing = prev_x + fraction * (point.position.x - prev_x);
1512 if prev_error < 0.0 && error > 0.0 {
1513 if near_crossing.is_none() {
1515 near_crossing = Some(crossing);
1516 }
1517 } else {
1518 far_crossing = Some(crossing);
1520 }
1521 }
1522 }
1523 previous = Some((point.position.x, error));
1524 }
1525 if let Some((last_x, last_error)) = previous {
1528 if last_error == 0.0 {
1529 if near_crossing.is_none() {
1530 near_crossing = Some(last_x);
1531 } else {
1532 far_crossing = Some(last_x);
1533 }
1534 }
1535 }
1536
1537 if near_crossing.is_none() && far_crossing.is_none() {
1538 return Err(BallisticsError::from(
1539 "Cannot find zero range: this angle never crosses the target height within the \
1540 solved range (angle too shallow to reach it, or both crossings lie beyond \
1541 the solver's max range)."
1542 .to_string(),
1543 ));
1544 }
1545
1546 Ok(ZeroCrossings {
1547 near_m: near_crossing,
1548 far_m: far_crossing,
1549 })
1550 }
1551
1552 pub fn equivalent_horizontal_range(
1576 &self,
1577 target_range_m: f64,
1578 zero_distance_m: f64,
1579 ) -> Option<f64> {
1580 if !target_range_m.is_finite() || !zero_distance_m.is_finite() {
1581 return None;
1582 }
1583 if target_range_m <= zero_distance_m || target_range_m <= 0.0 {
1584 return None;
1585 }
1586
1587 fn path_y_at(points: &[TrajectoryPoint], distance_m: f64) -> Option<f64> {
1592 match bracket_param(points.len(), |i| points[i].position.x, distance_m) {
1593 Bracket::Below => Some(points[0].position.y),
1598 Bracket::Above | Bracket::Degenerate => None,
1599 Bracket::Inside { lo, t } => {
1600 let hi = lo + 1;
1601 Some(
1602 points[lo].position.y + t * (points[hi].position.y - points[lo].position.y),
1603 )
1604 }
1605 }
1606 }
1607
1608 let mut inclined = self.clone();
1612 inclined.inputs.enable_trajectory_sampling = false;
1613 let inclined_result = inclined.solve().ok()?;
1614 let los_height = inclined_result.line_of_sight_height_m;
1615 let inclined_drop = los_height - path_y_at(&inclined_result.points, target_range_m)?;
1616 let correction = inclined_drop / target_range_m;
1617 if correction <= 0.0 {
1618 return None;
1619 }
1620
1621 let mut flat = self.clone();
1623 flat.inputs.enable_trajectory_sampling = false;
1624 flat.inputs.shooting_angle = 0.0;
1625 let flat_result = flat.solve().ok()?;
1626 let flat_correction_at = |range_m: f64| -> Option<f64> {
1627 Some((los_height - path_y_at(&flat_result.points, range_m)?) / range_m)
1628 };
1629
1630 let flat_terminal_m = flat_result.points.last().map(|p| p.position.x)?;
1638 let mut low = zero_distance_m.max(1.0);
1639 let mut high = target_range_m.min(flat_terminal_m);
1640 if high <= low {
1641 return None;
1642 }
1643 if flat_correction_at(low)? - correction > 0.0 {
1644 return None; }
1646 if flat_correction_at(high)? - correction < 0.0 {
1647 return None; }
1649 for _ in 0..60 {
1650 let mid = 0.5 * (low + high);
1651 let error = flat_correction_at(mid)? - correction;
1652 if error.abs() == 0.0 {
1653 return Some(mid);
1654 }
1655 if error < 0.0 {
1656 low = mid;
1657 } else {
1658 high = mid;
1659 }
1660 if high - low < 0.01 {
1661 break;
1662 }
1663 }
1664 Some(0.5 * (low + high))
1665 }
1666
1667 fn validate_for_solve(&self) -> Result<(), BallisticsError> {
1673 let require_finite = |name: &str, value: f64| {
1674 if value.is_finite() {
1675 Ok(())
1676 } else {
1677 Err(BallisticsError::from(format!("{name} must be finite")))
1678 }
1679 };
1680 let require_positive = |name: &str, value: f64| {
1681 if value.is_finite() && value > 0.0 {
1682 Ok(())
1683 } else {
1684 Err(BallisticsError::from(format!(
1685 "{name} must be finite and greater than zero"
1686 )))
1687 }
1688 };
1689
1690 if self.inputs.custom_drag_table.is_none() || self.inputs.custom_drag_reference {
1699 require_positive("bc_value", self.inputs.bc_value)?;
1700 }
1701 require_positive("bullet_mass", self.inputs.bullet_mass)?;
1702 require_positive("bullet_diameter", self.inputs.bullet_diameter)?;
1703 require_positive("muzzle_velocity", self.inputs.muzzle_velocity)?;
1704 require_positive("cd_scale", self.inputs.cd_scale)?;
1710
1711 require_finite("muzzle_angle", self.inputs.muzzle_angle)?;
1712 require_finite("azimuth_angle", self.inputs.azimuth_angle)?;
1713 require_finite("shooting_angle", self.inputs.shooting_angle)?;
1714 require_finite("cant_angle", self.inputs.cant_angle)?;
1715 require_finite("muzzle_height", self.inputs.muzzle_height)?;
1716
1717 for (name, value) in [
1721 ("zero_poi_vertical_m", self.inputs.zero_poi_vertical_m),
1722 ("zero_poi_horizontal_m", self.inputs.zero_poi_horizontal_m),
1723 ] {
1724 require_finite(name, value)?;
1725 if value.abs() >= 1.0 {
1726 return Err(BallisticsError::from(format!(
1727 "{name} must be smaller than 1.0 m in magnitude (it is a linear POI \
1728 offset at the zero range, in meters)"
1729 )));
1730 }
1731 }
1732
1733 require_finite("sight_offset_lateral_m", self.inputs.sight_offset_lateral_m)?;
1737 if self.inputs.sight_offset_lateral_m.abs() >= 0.5 {
1738 return Err(BallisticsError::from(
1739 "sight_offset_lateral_m must be smaller than 0.5 m in magnitude (it is \
1740 the lateral sight-to-bore mount offset, in meters)",
1741 ));
1742 }
1743
1744 if !(self.inputs.ground_threshold.is_finite()
1747 || self.inputs.ground_threshold == f64::NEG_INFINITY)
1748 {
1749 return Err(BallisticsError::from(
1750 "ground_threshold must be finite or negative infinity",
1751 ));
1752 }
1753
1754 match &self.wind_sock {
1755 Some(wind_sock) => wind_sock
1756 .validate_segments()
1757 .map_err(BallisticsError::from)?,
1758 None => {
1759 require_finite("wind.speed", self.wind.speed)?;
1760 require_finite("wind.direction", self.wind.direction)?;
1761 require_finite("wind.vertical_speed", self.wind.vertical_speed)?;
1762 }
1763 }
1764
1765 require_finite("atmosphere.temperature", self.atmosphere.temperature)?;
1766 require_finite("atmosphere.pressure", self.atmosphere.pressure)?;
1767 require_finite("atmosphere.humidity", self.atmosphere.humidity)?;
1768 require_finite("atmosphere.altitude", self.atmosphere.altitude)?;
1769
1770 require_positive("max_range", self.max_range)?;
1771 if !self.inputs.use_rk4 || !self.inputs.use_adaptive_rk45 {
1774 require_positive("time_step", self.time_step)?;
1775 }
1776
1777 if self.inputs.enable_trajectory_sampling {
1778 require_finite("sight_height", self.inputs.sight_height)?;
1779 require_positive("sample_interval", self.inputs.sample_interval)?;
1780 projected_sample_count(self.max_range, self.inputs.sample_interval)?;
1781 }
1782
1783 if self.inputs.drops_reference == DropsReference::Target {
1787 require_finite("target_height", self.inputs.target_height)?;
1788 if self.inputs.shooting_angle.cos() <= 1e-9 {
1789 return Err(BallisticsError::from(
1790 "drops reference 'target' is undefined for shooting angles at or beyond 90 degrees",
1791 ));
1792 }
1793 }
1794
1795 if self.inputs.enable_coriolis {
1796 require_finite("shot_azimuth", self.inputs.shot_azimuth)?;
1797 if let Some(latitude) = self.inputs.latitude {
1798 require_finite("latitude", latitude)?;
1799 }
1800 }
1801
1802 Ok(())
1803 }
1804
1805 fn validate_result_sanity(&self, result: &TrajectoryResult) -> Result<(), BallisticsError> {
1812 let require_finite = |name: &str, value: f64| {
1813 if value.is_finite() {
1814 Ok(())
1815 } else {
1816 Err(BallisticsError::from(format!(
1817 "trajectory result contains non-finite {name}"
1818 )))
1819 }
1820 };
1821 let require_non_negative = |name: &str, value: f64| {
1822 if value >= 0.0 {
1823 Ok(())
1824 } else {
1825 Err(BallisticsError::from(format!(
1826 "trajectory result contains non-physical negative {name} ({value})"
1827 )))
1828 }
1829 };
1830 let require_indexed_finite = |collection: &str, index: usize, field: &str, value: f64| {
1831 if value.is_finite() {
1832 Ok(())
1833 } else {
1834 Err(BallisticsError::from(format!(
1835 "trajectory result contains non-finite {collection}[{index}].{field}"
1836 )))
1837 }
1838 };
1839 let require_indexed_non_negative = |collection: &str,
1840 index: usize,
1841 field: &str,
1842 value: f64| {
1843 if value >= 0.0 {
1844 Ok(())
1845 } else {
1846 Err(BallisticsError::from(format!(
1847 "trajectory result contains non-physical negative {collection}[{index}].{field} ({value})"
1848 )))
1849 }
1850 };
1851
1852 require_finite("max_range", result.max_range)?;
1853 require_finite("max_height", result.max_height)?;
1854 require_finite("time_of_flight", result.time_of_flight)?;
1855 require_finite("impact_velocity", result.impact_velocity)?;
1856 require_finite("impact_energy", result.impact_energy)?;
1857 require_finite("projectile_mass_kg", result.projectile_mass_kg)?;
1858 require_finite("line_of_sight_height_m", result.line_of_sight_height_m)?;
1859 require_finite(
1860 "station_speed_of_sound_mps",
1861 result.station_speed_of_sound_mps,
1862 )?;
1863
1864 require_non_negative("max_range", result.max_range)?;
1868 require_non_negative("time_of_flight", result.time_of_flight)?;
1869 require_non_negative("impact_velocity", result.impact_velocity)?;
1870 require_non_negative("impact_energy", result.impact_energy)?;
1871 require_non_negative("projectile_mass_kg", result.projectile_mass_kg)?;
1872 require_non_negative(
1873 "station_speed_of_sound_mps",
1874 result.station_speed_of_sound_mps,
1875 )?;
1876
1877 for (index, point) in result.points.iter().enumerate() {
1878 require_indexed_finite("points", index, "time", point.time)?;
1879 require_indexed_finite("points", index, "position.x", point.position.x)?;
1880 require_indexed_finite("points", index, "position.y", point.position.y)?;
1881 require_indexed_finite("points", index, "position.z", point.position.z)?;
1882 require_indexed_finite(
1883 "points",
1884 index,
1885 "velocity_magnitude",
1886 point.velocity_magnitude,
1887 )?;
1888 require_indexed_finite("points", index, "kinetic_energy", point.kinetic_energy)?;
1889 require_indexed_non_negative("points", index, "time", point.time)?;
1890 require_indexed_non_negative(
1891 "points",
1892 index,
1893 "velocity_magnitude",
1894 point.velocity_magnitude,
1895 )?;
1896 require_indexed_non_negative("points", index, "kinetic_energy", point.kinetic_energy)?;
1897 }
1898
1899 if let Some(samples) = &result.sampled_points {
1900 for (index, sample) in samples.iter().enumerate() {
1901 require_indexed_finite("sampled_points", index, "distance_m", sample.distance_m)?;
1902 require_indexed_finite("sampled_points", index, "drop_m", sample.drop_m)?;
1903 require_indexed_finite(
1904 "sampled_points",
1905 index,
1906 "wind_drift_m",
1907 sample.wind_drift_m,
1908 )?;
1909 require_indexed_finite(
1910 "sampled_points",
1911 index,
1912 "velocity_mps",
1913 sample.velocity_mps,
1914 )?;
1915 require_indexed_finite("sampled_points", index, "energy_j", sample.energy_j)?;
1916 require_indexed_finite("sampled_points", index, "time_s", sample.time_s)?;
1917 }
1918 }
1919
1920 for (name, value) in [
1921 ("min_pitch_damping", result.min_pitch_damping),
1922 ("transonic_mach", result.transonic_mach),
1923 ("max_yaw_angle", result.max_yaw_angle),
1924 ("max_precession_angle", result.max_precession_angle),
1925 ] {
1926 if let Some(value) = value {
1927 require_finite(name, value)?;
1928 }
1929 }
1930
1931 if let Some(state) = result.angular_state {
1932 for (name, value) in [
1933 ("angular_state.pitch_angle", state.pitch_angle),
1934 ("angular_state.yaw_angle", state.yaw_angle),
1935 ("angular_state.pitch_rate", state.pitch_rate),
1936 ("angular_state.yaw_rate", state.yaw_rate),
1937 ("angular_state.precession_angle", state.precession_angle),
1938 ("angular_state.nutation_phase", state.nutation_phase),
1939 ] {
1940 require_finite(name, value)?;
1941 }
1942 }
1943
1944 if let Some(jump) = result.aerodynamic_jump {
1945 for (name, value) in [
1946 ("aerodynamic_jump.vertical_jump_moa", jump.vertical_jump_moa),
1947 (
1948 "aerodynamic_jump.horizontal_jump_moa",
1949 jump.horizontal_jump_moa,
1950 ),
1951 ("aerodynamic_jump.jump_angle_rad", jump.jump_angle_rad),
1952 (
1953 "aerodynamic_jump.magnus_component_moa",
1954 jump.magnus_component_moa,
1955 ),
1956 ("aerodynamic_jump.yaw_component_moa", jump.yaw_component_moa),
1957 (
1958 "aerodynamic_jump.stabilization_factor",
1959 jump.stabilization_factor,
1960 ),
1961 ] {
1962 require_finite(name, value)?;
1963 }
1964 }
1965
1966 Ok(())
1967 }
1968
1969 fn validate_integration_state(
1982 &self,
1983 position: &Vector3<f64>,
1984 velocity: &Vector3<f64>,
1985 time: f64,
1986 ) -> Result<(), BallisticsError> {
1987 if !(position.iter().all(|value| value.is_finite())
1988 && velocity.iter().all(|value| value.is_finite())
1989 && time.is_finite())
1990 {
1991 return Err(BallisticsError::from(
1992 "trajectory integration produced a non-finite state (often from physically \
1993 extreme inputs — e.g. an absurd bore/muzzle height placing the launch far \
1994 from sea level, or a degenerate atmosphere; check those inputs, or set \
1995 --altitude explicitly)",
1996 ));
1997 }
1998
1999 let speed = velocity.magnitude();
2000 let budget = self.speed_budget(time);
2001 if speed > budget {
2002 return Err(BallisticsError::from(format!(
2003 "trajectory integration diverged: speed {speed:.3e} m/s at t={time:.6}s exceeds \
2004 the physical budget of {budget:.3e} m/s"
2005 )));
2006 }
2007 Ok(())
2008 }
2009
2010 fn speed_budget(&self, time: f64) -> f64 {
2015 let scalar_wind = self.wind.speed.abs() + self.wind.vertical_speed.abs();
2016 let wind_bound = match &self.wind_sock {
2017 Some(sock) => scalar_wind.max(sock.max_speed_mps()),
2018 None => scalar_wind,
2019 };
2020 2.0 * (self.inputs.muzzle_velocity + wind_bound + 10.0) + self.gravity.magnitude() * time
2021 }
2022
2023 fn push_trajectory_point(
2027 &self,
2028 points: &mut Vec<TrajectoryPoint>,
2029 point: TrajectoryPoint,
2030 collapsed_steps: &mut usize,
2031 ) -> Result<(), BallisticsError> {
2032 if points.len() + *collapsed_steps >= self.max_trajectory_points {
2037 return Err(BallisticsError::from(format!(
2038 "trajectory point limit of {} exceeded",
2039 self.max_trajectory_points
2040 )));
2041 }
2042 if let Some(last) = points.last_mut() {
2053 if point.position.x == last.position.x {
2054 *last = point;
2055 *collapsed_steps += 1;
2056 return Ok(());
2057 }
2058 }
2059 points.push(point);
2060 Ok(())
2061 }
2062
2063 pub fn set_wind_segments(&mut self, segments: Vec<crate::wind::WindSegment>) {
2070 self.wind_sock = if segments.is_empty() {
2071 None
2072 } else {
2073 Some(crate::wind::WindSock::new(segments))
2074 };
2075 }
2076
2077 pub fn set_atmo_segments(&mut self, segments: Vec<crate::atmosphere::AtmoSegment>) {
2086 self.atmo_sock = if segments.is_empty() {
2087 None
2088 } else {
2089 Some(crate::atmosphere::AtmoSock::new(segments))
2090 };
2091 }
2092
2093 fn launch_angles_from(
2102 &self,
2103 aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
2104 ) -> (f64, f64) {
2105 let (mut elev, mut azim) = (self.inputs.muzzle_angle, self.inputs.azimuth_angle);
2106 if self.inputs.cant_angle != 0.0 {
2113 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
2114 let (e0, a0) = (elev, azim);
2115 elev = e0 * cos_c - a0 * sin_c;
2116 azim = a0 * cos_c + e0 * sin_c;
2117 }
2118 match aj {
2119 Some(c) => {
2120 const MOA_PER_RAD: f64 = 3437.7467707849;
2122 (
2123 elev + c.vertical_jump_moa / MOA_PER_RAD,
2124 azim + c.horizontal_jump_moa / MOA_PER_RAD,
2125 )
2126 }
2127 None => (elev, azim),
2128 }
2129 }
2130
2131 fn aerodynamic_jump_components(
2139 &self,
2140 ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
2141 if !self.inputs.enable_aerodynamic_jump {
2142 return None;
2143 }
2144 let diameter_m = self.inputs.bullet_diameter;
2148 if !(self.inputs.twist_rate.is_finite()
2149 && self.inputs.twist_rate != 0.0
2150 && diameter_m.is_finite()
2151 && diameter_m > 0.0
2152 && self.inputs.bullet_length.is_finite()
2153 && self.inputs.bullet_length > 0.0
2154 && self.inputs.muzzle_velocity.is_finite())
2155 {
2156 return None;
2157 }
2158
2159 let (_, _, temp_c, pressure_hpa) = self.resolved_atmosphere();
2161 let sg = crate::stability::compute_stability_coefficient(
2162 &self.inputs,
2163 (self.atmosphere.altitude, temp_c, pressure_hpa, 0.0),
2164 );
2165 if !(sg.is_finite() && sg > 0.0) {
2166 return None;
2167 }
2168 let length_calibers = self.inputs.bullet_length / diameter_m;
2169
2170 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
2176 let crosswind_from_right_mps = if let Some(sock) = &self.wind_sock {
2177 -sock.vector_for_range_stateless(0.0)[2]
2178 } else {
2179 self.wind.speed * self.wind.direction.sin()
2180 };
2181 let crosswind_from_right_mph = crosswind_from_right_mps * MS_TO_MPH;
2182
2183 let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
2184 sg,
2185 length_calibers,
2186 crosswind_from_right_mph,
2187 self.inputs.is_twist_right,
2188 );
2189 if !vertical_jump_moa.is_finite() {
2190 return None;
2191 }
2192
2193 const MOA_PER_RAD: f64 = 3437.7467707849;
2194 Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
2195 vertical_jump_moa,
2196 horizontal_jump_moa: 0.0,
2198 jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
2199 magnus_component_moa: 0.0,
2200 yaw_component_moa: 0.0,
2201 stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
2202 })
2203 }
2204
2205 fn resolved_atmosphere(&self) -> (f64, f64, f64, f64) {
2206 let (temp_c, pressure_hpa) = crate::atmosphere::resolve_station_conditions_with_presence(
2208 self.atmosphere.temperature,
2209 self.atmosphere.pressure,
2210 self.atmosphere.altitude,
2211 PressureReferenceMode::Absolute,
2212 self.station_presence,
2213 );
2214 let (density, speed_of_sound) = crate::atmosphere::calculate_atmosphere(
2215 self.atmosphere.altitude,
2216 Some(temp_c),
2217 Some(pressure_hpa),
2218 self.atmosphere.humidity,
2219 );
2220 (density, speed_of_sound, temp_c, pressure_hpa)
2221 }
2222
2223 fn precession_nutation_params(
2224 &self,
2225 velocity_mps: f64,
2226 air_density_kg_m3: f64,
2227 speed_of_sound_mps: f64,
2228 ) -> PrecessionNutationParams {
2229 let (spin_inertia, transverse_inertia) = self.precession_nutation_inertias;
2230 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
2231 let velocity_fps = velocity_mps * 3.28084;
2232 let twist_rate_ft = self.inputs.twist_rate / 12.0;
2233 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
2234 } else {
2235 0.0
2236 };
2237
2238 PrecessionNutationParams {
2239 mass_kg: self.inputs.bullet_mass,
2240 caliber_m: self.inputs.bullet_diameter,
2241 length_m: self.inputs.bullet_length,
2242 spin_rate_rad_s,
2243 spin_inertia,
2244 transverse_inertia,
2245 velocity_mps,
2246 air_density_kg_m3,
2247 mach: velocity_mps / speed_of_sound_mps,
2248 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
2249 nutation_damping_factor: 0.05,
2250 }
2251 }
2252
2253 fn append_terminal_endpoint(
2262 &self,
2263 points: &mut Vec<TrajectoryPoint>,
2264 post_position: Vector3<f64>,
2265 post_velocity: Vector3<f64>,
2266 post_time: f64,
2267 max_height: &mut f64,
2268 collapsed_steps: &mut usize,
2269 ) -> Result<TrajectoryTermination, BallisticsError> {
2270 let previous = points
2271 .last()
2272 .cloned()
2273 .ok_or_else(|| BallisticsError::from("No trajectory points generated"))?;
2274
2275 let mut crossings = Vec::with_capacity(3);
2276 if previous.position.x < self.max_range && post_position.x >= self.max_range {
2277 let span = post_position.x - previous.position.x;
2278 if span.is_finite() && span > 0.0 {
2279 crossings.push((
2280 (self.max_range - previous.position.x) / span,
2281 TrajectoryTermination::MaxRange,
2282 ));
2283 }
2284 }
2285 if self.inputs.ground_threshold.is_finite()
2286 && previous.position.y > self.inputs.ground_threshold
2287 && post_position.y <= self.inputs.ground_threshold
2288 {
2289 let span = post_position.y - previous.position.y;
2290 if span.is_finite() && span < 0.0 {
2291 crossings.push((
2292 (self.inputs.ground_threshold - previous.position.y) / span,
2293 TrajectoryTermination::GroundThreshold,
2294 ));
2295 }
2296 }
2297 if previous.time < TRAJECTORY_TIME_LIMIT_S && post_time >= TRAJECTORY_TIME_LIMIT_S {
2298 let span = post_time - previous.time;
2299 if span.is_finite() && span > 0.0 {
2300 crossings.push((
2301 (TRAJECTORY_TIME_LIMIT_S - previous.time) / span,
2302 TrajectoryTermination::TimeLimit,
2303 ));
2304 }
2305 }
2306
2307 let (fraction, termination) = crossings
2308 .into_iter()
2309 .filter(|(fraction, _)| fraction.is_finite() && (0.0..=1.0).contains(fraction))
2310 .min_by(|left, right| {
2311 let priority = |termination: TrajectoryTermination| match termination {
2312 TrajectoryTermination::GroundThreshold => 0,
2313 TrajectoryTermination::MaxRange => 1,
2314 TrajectoryTermination::TimeLimit => 2,
2315 TrajectoryTermination::VelocityFloor => 3,
2316 };
2317 left.0
2318 .total_cmp(&right.0)
2319 .then_with(|| priority(left.1).cmp(&priority(right.1)))
2320 })
2321 .ok_or_else(|| {
2322 BallisticsError::from(
2323 "trajectory integration stopped without crossing a supported boundary",
2324 )
2325 })?;
2326
2327 let mut position = previous.position + (post_position - previous.position) * fraction;
2328 match termination {
2329 TrajectoryTermination::MaxRange => position.x = self.max_range,
2330 TrajectoryTermination::GroundThreshold => {
2331 position.y = self.inputs.ground_threshold;
2332 }
2333 TrajectoryTermination::TimeLimit | TrajectoryTermination::VelocityFloor => {}
2334 }
2335 let velocity_magnitude = previous.velocity_magnitude
2336 + (post_velocity.magnitude() - previous.velocity_magnitude) * fraction;
2337 let mut time = previous.time + (post_time - previous.time) * fraction;
2338 if termination == TrajectoryTermination::TimeLimit {
2339 time = TRAJECTORY_TIME_LIMIT_S;
2340 }
2341 let kinetic_energy =
2342 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
2343
2344 if position.y > *max_height {
2345 *max_height = position.y;
2346 }
2347 let terminal_point = TrajectoryPoint {
2348 time,
2349 position,
2350 velocity_magnitude,
2351 kinetic_energy,
2352 drag_coefficient: None,
2353 };
2354 if terminal_point.position.x < previous.position.x {
2355 return Err(BallisticsError::from(
2356 "trajectory terminal state reversed downrange before the crossed boundary",
2357 ));
2358 }
2359 if terminal_point.position.x == previous.position.x {
2360 let last = points.last_mut().ok_or_else(|| {
2365 BallisticsError::from("trajectory points disappeared during terminal finalization")
2366 })?;
2367 *last = terminal_point;
2368 } else {
2369 self.push_trajectory_point(points, terminal_point, collapsed_steps)?;
2370 }
2371 Ok(termination)
2372 }
2373
2374 fn gravity_acceleration(&self) -> Vector3<f64> {
2375 self.gravity.in_frame(self.inputs.shooting_angle)
2376 }
2377
2378 fn boundary_layer_shear_model(&self) -> Option<WindShearModel> {
2388 if !self.inputs.enable_wind_shear {
2389 return None;
2390 }
2391 match self.inputs.wind_shear_model.as_str() {
2392 "none" => None,
2393 "logarithmic" => Some(WindShearModel::Logarithmic),
2394 "power_law" | "powerlaw" | "exponential" => Some(WindShearModel::PowerLaw),
2395 "ekman_spiral" | "ekman" => Some(WindShearModel::EkmanSpiral),
2396 "custom_layers" | "custom" => Some(WindShearModel::CustomLayers),
2397 _ => Some(WindShearModel::PowerLaw),
2398 }
2399 }
2400
2401 fn get_wind_at_altitude(&self, height_agl_m: f64, model: WindShearModel) -> Vector3<f64> {
2402 let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio_agl(height_agl_m, model);
2417
2418 crate::wind::wind_vector(self.wind.speed, self.wind.direction, 0.0) * speed_ratio
2425 + Vector3::new(0.0, self.wind.vertical_speed, 0.0)
2426 }
2427
2428 pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
2429 self.validate_for_solve()?;
2430 let mut result = if self.inputs.use_rk4 {
2431 if self.inputs.use_adaptive_rk45 {
2432 self.solve_rk45()?
2433 } else {
2434 self.solve_rk4()?
2435 }
2436 } else {
2437 self.solve_euler()?
2438 };
2439 self.apply_spin_drift(&mut result);
2440 self.validate_result_sanity(&result)?;
2441 Ok(result)
2442 }
2443
2444 fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
2450 if !self.inputs.use_enhanced_spin_drift {
2451 return;
2452 }
2453 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 {
2457 return;
2458 }
2459
2460 let sg = self.effective_spin_drift_sg();
2467
2468 for p in result.points.iter_mut() {
2469 if p.time <= 0.0 {
2470 continue;
2471 }
2472 p.position.z +=
2474 crate::spin_drift::litz_drift_meters(sg, p.time, self.inputs.is_twist_right);
2475 }
2476
2477 if let Some(samples) = result.sampled_points.as_mut() {
2481 for s in samples.iter_mut() {
2482 if s.time_s <= 0.0 {
2483 continue;
2484 }
2485 s.wind_drift_m +=
2486 crate::spin_drift::litz_drift_meters(sg, s.time_s, self.inputs.is_twist_right);
2487 }
2488 }
2489 }
2490
2491 fn effective_spin_drift_sg(&self) -> f64 {
2496 let (_, _, temp_c, press_hpa) = self.resolved_atmosphere();
2497 crate::spin_drift::effective_sg_from_inputs(&self.inputs, temp_c, press_hpa)
2498 }
2499
2500 fn initial_position(&self) -> Vector3<f64> {
2518 if self.inputs.cant_angle == 0.0 && self.inputs.sight_offset_lateral_m == 0.0 {
2519 return Vector3::new(0.0, self.inputs.muzzle_height, 0.0);
2520 }
2521 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
2522 let sh = self.inputs.sight_height;
2523 let off = self.inputs.sight_offset_lateral_m;
2524 Vector3::new(
2525 0.0,
2526 self.inputs.muzzle_height + sh * (1.0 - cos_c) + off * sin_c,
2527 -sh * sin_c - off * cos_c,
2528 )
2529 }
2530
2531 fn build_sampled_points(
2538 &self,
2539 points: &[TrajectoryPoint],
2540 max_height: f64,
2541 transonic_distances: Vec<f64>,
2542 mach_transitions: &MachTransitionTracker,
2543 ) -> Result<Option<Vec<TrajectorySample>>, BallisticsError> {
2544 if !self.inputs.enable_trajectory_sampling {
2545 return Ok(None);
2546 }
2547
2548 let last_point = points.last().ok_or("No trajectory points generated")?;
2549 let trajectory_data = TrajectoryData {
2550 times: points.iter().map(|p| p.time).collect(),
2551 positions: points.iter().map(|p| p.position).collect(),
2552 velocities: points
2553 .iter()
2554 .map(|p| {
2555 Vector3::new(0.0, 0.0, p.velocity_magnitude)
2557 })
2558 .collect(),
2559 transonic_distances, mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2561 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2562 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2563 };
2564
2565 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
2570 let target_reference = self.inputs.drops_reference == DropsReference::Target;
2571 let target_vertical_height_m = if target_reference && self.inputs.target_height != 0.0 {
2578 self.inputs.target_height
2579 } else {
2580 sight_position_m
2581 };
2582 let outputs = TrajectoryOutputs {
2583 target_distance_horiz_m: last_point.position.x, target_vertical_height_m,
2585 time_of_flight_s: last_point.time,
2586 max_ord_dist_horiz_m: max_height,
2587 sight_height_m: sight_position_m,
2588 };
2589
2590 let mut samples = sample_trajectory(
2592 &trajectory_data,
2593 &outputs,
2594 self.inputs.sample_interval,
2595 self.inputs.bullet_mass,
2596 )?;
2597
2598 if target_reference {
2605 let cos_theta = self.inputs.shooting_angle.cos();
2606 for sample in &mut samples {
2607 sample.drop_m /= cos_theta;
2608 }
2609 }
2610 Ok(Some(samples))
2611 }
2612
2613 fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
2614 let mut time = 0.0;
2616 let mut position = self.initial_position();
2620 let aj_components = self.aerodynamic_jump_components();
2626 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
2627 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
2628 let mut velocity = Vector3::new(
2629 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
2633
2634 let mut points = Vec::new();
2635 let mut collapsed_steps = 0;
2638 let mut max_height = position.y;
2639 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
2645 let mut mach_transitions = MachTransitionTracker::default();
2646
2647 let mut angular_state = if self.inputs.enable_precession_nutation {
2649 Some(AngularState {
2650 pitch_angle: 0.001, yaw_angle: 0.001,
2652 pitch_rate: 0.0,
2653 yaw_rate: 0.0,
2654 precession_angle: 0.0,
2655 nutation_phase: 0.0,
2656 })
2657 } else {
2658 None
2659 };
2660 let mut max_yaw_angle = 0.0;
2661 let mut max_precession_angle = 0.0;
2662
2663 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2665 self.resolved_atmosphere();
2666 let base_ratio = air_density / 1.225;
2671
2672 let wind_vector = crate::wind::wind_vector(
2678 self.wind.speed,
2679 self.wind.direction,
2680 self.wind.vertical_speed,
2681 );
2682
2683 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2686 self.inputs.bullet_model.as_deref().unwrap_or("default"),
2687 );
2688
2689 while position.x < self.max_range
2691 && position.y > self.inputs.ground_threshold
2692 && time < TRAJECTORY_TIME_LIMIT_S
2693 {
2694 let velocity_magnitude = velocity.magnitude();
2696 let kinetic_energy =
2697 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
2698
2699 self.push_trajectory_point(
2700 &mut points,
2701 TrajectoryPoint {
2702 time,
2703 position,
2704 velocity_magnitude,
2705 kinetic_energy,
2706 drag_coefficient: None,
2707 },
2708 &mut collapsed_steps,
2709 )?;
2710
2711 {
2714 let mach_here = if speed_of_sound > 0.0 {
2715 velocity_magnitude / speed_of_sound
2716 } else {
2717 0.0
2718 };
2719 mach_transitions.record_downward_crossings(
2720 mach_here,
2721 position.x,
2722 &mut transonic_distances,
2723 );
2724 }
2725
2726 if position.y > max_height {
2728 max_height = position.y;
2729 }
2730
2731 if self.inputs.enable_pitch_damping {
2733 let mach = velocity_magnitude / speed_of_sound;
2734
2735 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2737 transonic_mach = Some(mach);
2738 }
2739
2740 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2742
2743 if pitch_damping < min_pitch_damping {
2745 min_pitch_damping = pitch_damping;
2746 }
2747 }
2748
2749 if self.inputs.enable_precession_nutation {
2751 if let Some(ref mut state) = angular_state {
2752 let velocity_magnitude = velocity.magnitude();
2753 let params = self.precession_nutation_params(
2754 velocity_magnitude,
2755 air_density,
2756 speed_of_sound,
2757 );
2758
2759 *state = calculate_combined_angular_motion(
2761 ¶ms,
2762 state,
2763 time,
2764 self.time_step,
2765 0.001, );
2767
2768 if state.yaw_angle.abs() > max_yaw_angle {
2770 max_yaw_angle = state.yaw_angle.abs();
2771 }
2772 if state.precession_angle.abs() > max_precession_angle {
2773 max_precession_angle = state.precession_angle.abs();
2774 }
2775 }
2776 }
2777
2778 let acceleration = self.calculate_acceleration(
2785 &position,
2786 &velocity,
2787 &wind_vector,
2788 (resolved_temp_c, resolved_press_hpa, base_ratio),
2789 );
2790
2791 velocity += acceleration * self.time_step;
2793 position += velocity * self.time_step;
2794 time += self.time_step;
2795 self.validate_integration_state(&position, &velocity, time)?;
2796 }
2797
2798 let termination = self.append_terminal_endpoint(
2799 &mut points,
2800 position,
2801 velocity,
2802 time,
2803 &mut max_height,
2804 &mut collapsed_steps,
2805 )?;
2806
2807 self.annotate_drag_coefficients(&mut points, speed_of_sound);
2812
2813 let last_point = points.last().ok_or("No trajectory points generated")?;
2814
2815 let sampled_points =
2817 self.build_sampled_points(&points, max_height, transonic_distances, &mach_transitions)?;
2818
2819 Ok(TrajectoryResult {
2820 max_range: last_point.position.x, max_height,
2822 time_of_flight: last_point.time,
2823 impact_velocity: last_point.velocity_magnitude,
2824 impact_energy: last_point.kinetic_energy,
2825 projectile_mass_kg: self.inputs.bullet_mass,
2826 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
2827 station_speed_of_sound_mps: speed_of_sound,
2828 termination,
2829 points,
2830 sampled_points,
2831 min_pitch_damping: if self.inputs.enable_pitch_damping {
2832 Some(min_pitch_damping)
2833 } else {
2834 None
2835 },
2836 transonic_mach,
2837 angular_state,
2838 max_yaw_angle: if self.inputs.enable_precession_nutation {
2839 Some(max_yaw_angle)
2840 } else {
2841 None
2842 },
2843 max_precession_angle: if self.inputs.enable_precession_nutation {
2844 Some(max_precession_angle)
2845 } else {
2846 None
2847 },
2848 aerodynamic_jump: aj_components,
2849 mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2850 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2851 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2852 })
2853 }
2854
2855 fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
2856 let mut time = 0.0;
2858 let mut position = self.initial_position();
2863
2864 let aj_components = self.aerodynamic_jump_components();
2870 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
2871 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
2872 let mut velocity = Vector3::new(
2873 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
2877
2878 let mut points = Vec::new();
2879 let mut collapsed_steps = 0;
2882 let mut max_height = position.y;
2883 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
2889 let mut mach_transitions = MachTransitionTracker::default();
2890
2891 let mut angular_state = if self.inputs.enable_precession_nutation {
2893 Some(AngularState {
2894 pitch_angle: 0.001, yaw_angle: 0.001,
2896 pitch_rate: 0.0,
2897 yaw_rate: 0.0,
2898 precession_angle: 0.0,
2899 nutation_phase: 0.0,
2900 })
2901 } else {
2902 None
2903 };
2904 let mut max_yaw_angle = 0.0;
2905 let mut max_precession_angle = 0.0;
2906
2907 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2909 self.resolved_atmosphere();
2910 let base_ratio = air_density / 1.225;
2915
2916 let wind_vector = crate::wind::wind_vector(
2922 self.wind.speed,
2923 self.wind.direction,
2924 self.wind.vertical_speed,
2925 );
2926
2927 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2930 self.inputs.bullet_model.as_deref().unwrap_or("default"),
2931 );
2932
2933 while position.x < self.max_range
2935 && position.y > self.inputs.ground_threshold
2936 && time < TRAJECTORY_TIME_LIMIT_S
2937 {
2938 let velocity_magnitude = velocity.magnitude();
2940 let kinetic_energy =
2941 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
2942
2943 self.push_trajectory_point(
2944 &mut points,
2945 TrajectoryPoint {
2946 time,
2947 position,
2948 velocity_magnitude,
2949 kinetic_energy,
2950 drag_coefficient: None,
2951 },
2952 &mut collapsed_steps,
2953 )?;
2954
2955 {
2958 let mach_here = if speed_of_sound > 0.0 {
2959 velocity_magnitude / speed_of_sound
2960 } else {
2961 0.0
2962 };
2963 mach_transitions.record_downward_crossings(
2964 mach_here,
2965 position.x,
2966 &mut transonic_distances,
2967 );
2968 }
2969
2970 if position.y > max_height {
2971 max_height = position.y;
2972 }
2973
2974 if self.inputs.enable_pitch_damping {
2976 let mach = velocity_magnitude / speed_of_sound;
2977
2978 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2980 transonic_mach = Some(mach);
2981 }
2982
2983 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2985
2986 if pitch_damping < min_pitch_damping {
2988 min_pitch_damping = pitch_damping;
2989 }
2990 }
2991
2992 if self.inputs.enable_precession_nutation {
2994 if let Some(ref mut state) = angular_state {
2995 let velocity_magnitude = velocity.magnitude();
2996 let params = self.precession_nutation_params(
2997 velocity_magnitude,
2998 air_density,
2999 speed_of_sound,
3000 );
3001
3002 *state = calculate_combined_angular_motion(
3004 ¶ms,
3005 state,
3006 time,
3007 self.time_step,
3008 0.001, );
3010
3011 if state.yaw_angle.abs() > max_yaw_angle {
3013 max_yaw_angle = state.yaw_angle.abs();
3014 }
3015 if state.precession_angle.abs() > max_precession_angle {
3016 max_precession_angle = state.precession_angle.abs();
3017 }
3018 }
3019 }
3020
3021 let dt = self.time_step;
3023
3024 let acc1 = self.calculate_acceleration(
3026 &position,
3027 &velocity,
3028 &wind_vector,
3029 (resolved_temp_c, resolved_press_hpa, base_ratio),
3030 );
3031
3032 let pos2 = position + velocity * (dt * 0.5);
3034 let vel2 = velocity + acc1 * (dt * 0.5);
3035 let acc2 = self.calculate_acceleration(
3036 &pos2,
3037 &vel2,
3038 &wind_vector,
3039 (resolved_temp_c, resolved_press_hpa, base_ratio),
3040 );
3041
3042 let pos3 = position + vel2 * (dt * 0.5);
3044 let vel3 = velocity + acc2 * (dt * 0.5);
3045 let acc3 = self.calculate_acceleration(
3046 &pos3,
3047 &vel3,
3048 &wind_vector,
3049 (resolved_temp_c, resolved_press_hpa, base_ratio),
3050 );
3051
3052 let pos4 = position + vel3 * dt;
3054 let vel4 = velocity + acc3 * dt;
3055 let acc4 = self.calculate_acceleration(
3056 &pos4,
3057 &vel4,
3058 &wind_vector,
3059 (resolved_temp_c, resolved_press_hpa, base_ratio),
3060 );
3061
3062 position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
3064 velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
3065 time += dt;
3066 self.validate_integration_state(&position, &velocity, time)?;
3067 }
3068
3069 let termination = self.append_terminal_endpoint(
3070 &mut points,
3071 position,
3072 velocity,
3073 time,
3074 &mut max_height,
3075 &mut collapsed_steps,
3076 )?;
3077
3078 self.annotate_drag_coefficients(&mut points, speed_of_sound);
3083
3084 let last_point = points.last().ok_or("No trajectory points generated")?;
3085
3086 let sampled_points =
3088 self.build_sampled_points(&points, max_height, transonic_distances, &mach_transitions)?;
3089
3090 Ok(TrajectoryResult {
3091 max_range: last_point.position.x, max_height,
3093 time_of_flight: last_point.time,
3094 impact_velocity: last_point.velocity_magnitude,
3095 impact_energy: last_point.kinetic_energy,
3096 projectile_mass_kg: self.inputs.bullet_mass,
3097 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
3098 station_speed_of_sound_mps: speed_of_sound,
3099 termination,
3100 points,
3101 sampled_points,
3102 min_pitch_damping: if self.inputs.enable_pitch_damping {
3103 Some(min_pitch_damping)
3104 } else {
3105 None
3106 },
3107 transonic_mach,
3108 angular_state,
3109 max_yaw_angle: if self.inputs.enable_precession_nutation {
3110 Some(max_yaw_angle)
3111 } else {
3112 None
3113 },
3114 max_precession_angle: if self.inputs.enable_precession_nutation {
3115 Some(max_precession_angle)
3116 } else {
3117 None
3118 },
3119 aerodynamic_jump: aj_components,
3120 mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
3121 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
3122 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
3123 })
3124 }
3125
3126 fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
3127 let mut time = 0.0;
3129 let mut position = self.initial_position();
3133
3134 let aj_components = self.aerodynamic_jump_components();
3140 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
3141 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
3142 let mut velocity = Vector3::new(
3143 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
3147
3148 let mut points = Vec::new();
3149 let mut collapsed_steps = 0;
3152 let mut max_height = position.y;
3153 let mut dt = 0.001; let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
3158 self.resolved_atmosphere();
3159 let base_ratio = air_density / 1.225;
3164 let wind_vector = crate::wind::wind_vector(
3169 self.wind.speed,
3170 self.wind.direction,
3171 self.wind.vertical_speed,
3172 );
3173
3174 let mut transonic_distances: Vec<f64> = Vec::new();
3176 let mut mach_transitions = MachTransitionTracker::default();
3177
3178 let mut min_pitch_damping = f64::INFINITY;
3183 let mut transonic_mach: Option<f64> = None;
3184 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
3185 self.inputs.bullet_model.as_deref().unwrap_or("default"),
3186 );
3187 let mut angular_state = if self.inputs.enable_precession_nutation {
3188 Some(AngularState {
3189 pitch_angle: 0.001,
3190 yaw_angle: 0.001,
3191 pitch_rate: 0.0,
3192 yaw_rate: 0.0,
3193 precession_angle: 0.0,
3194 nutation_phase: 0.0,
3195 })
3196 } else {
3197 None
3198 };
3199 let mut max_yaw_angle = 0.0;
3200 let mut max_precession_angle = 0.0;
3201
3202 while position.x < self.max_range
3203 && position.y > self.inputs.ground_threshold
3204 && time < TRAJECTORY_TIME_LIMIT_S
3205 {
3206 let velocity_magnitude = velocity.magnitude();
3208 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
3209
3210 self.push_trajectory_point(
3211 &mut points,
3212 TrajectoryPoint {
3213 time,
3214 position,
3215 velocity_magnitude,
3216 kinetic_energy,
3217 drag_coefficient: None,
3218 },
3219 &mut collapsed_steps,
3220 )?;
3221
3222 {
3225 let mach_here = if speed_of_sound > 0.0 {
3226 velocity_magnitude / speed_of_sound
3227 } else {
3228 0.0
3229 };
3230 mach_transitions.record_downward_crossings(
3231 mach_here,
3232 position.x,
3233 &mut transonic_distances,
3234 );
3235 }
3236
3237 if position.y > max_height {
3238 max_height = position.y;
3239 }
3240
3241 if self.inputs.enable_pitch_damping {
3244 let mach = velocity_magnitude / speed_of_sound;
3245 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
3246 transonic_mach = Some(mach);
3247 }
3248 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
3249 if pitch_damping < min_pitch_damping {
3250 min_pitch_damping = pitch_damping;
3251 }
3252 }
3253
3254 let accepted_step = self.adaptive_rk45_step(
3257 &position,
3258 &velocity,
3259 dt,
3260 &wind_vector,
3261 (resolved_temp_c, resolved_press_hpa, base_ratio),
3262 );
3263 debug_assert!(
3264 accepted_step.error <= RK45_TOLERANCE || accepted_step.used_dt <= RK45_MIN_DT
3265 );
3266
3267 if self.inputs.enable_precession_nutation {
3271 if let Some(ref mut state) = angular_state {
3272 let params = self.precession_nutation_params(
3273 velocity_magnitude,
3274 air_density,
3275 speed_of_sound,
3276 );
3277
3278 *state = calculate_combined_angular_motion(
3279 ¶ms,
3280 state,
3281 time,
3282 accepted_step.used_dt,
3283 0.001,
3284 );
3285
3286 if state.yaw_angle.abs() > max_yaw_angle {
3287 max_yaw_angle = state.yaw_angle.abs();
3288 }
3289 if state.precession_angle.abs() > max_precession_angle {
3290 max_precession_angle = state.precession_angle.abs();
3291 }
3292 }
3293 }
3294
3295 position = accepted_step.position;
3296 velocity = accepted_step.velocity;
3297 time += accepted_step.used_dt;
3298 self.validate_integration_state(&position, &velocity, time)?;
3299
3300 dt = accepted_step.next_dt;
3302 }
3303
3304 if points.is_empty() {
3306 return Err(BallisticsError::from("No trajectory points calculated"));
3307 }
3308
3309 let termination = self.append_terminal_endpoint(
3311 &mut points,
3312 position,
3313 velocity,
3314 time,
3315 &mut max_height,
3316 &mut collapsed_steps,
3317 )?;
3318
3319 self.annotate_drag_coefficients(&mut points, speed_of_sound);
3321
3322 let last_point = points.last().unwrap();
3323
3324 let sampled_points =
3326 self.build_sampled_points(&points, max_height, transonic_distances, &mach_transitions)?;
3327
3328 Ok(TrajectoryResult {
3329 max_range: last_point.position.x, max_height,
3331 time_of_flight: last_point.time,
3332 impact_velocity: last_point.velocity_magnitude,
3333 impact_energy: last_point.kinetic_energy,
3334 projectile_mass_kg: self.inputs.bullet_mass,
3335 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
3336 station_speed_of_sound_mps: speed_of_sound,
3337 termination,
3338 points,
3339 sampled_points,
3340 min_pitch_damping: if self.inputs.enable_pitch_damping {
3341 Some(min_pitch_damping)
3342 } else {
3343 None
3344 },
3345 transonic_mach,
3346 angular_state,
3347 max_yaw_angle: if self.inputs.enable_precession_nutation {
3348 Some(max_yaw_angle)
3349 } else {
3350 None
3351 },
3352 max_precession_angle: if self.inputs.enable_precession_nutation {
3353 Some(max_precession_angle)
3354 } else {
3355 None
3356 },
3357 aerodynamic_jump: aj_components,
3358 mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
3359 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
3360 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
3361 })
3362 }
3363
3364 fn adaptive_rk45_step(
3365 &self,
3366 position: &Vector3<f64>,
3367 velocity: &Vector3<f64>,
3368 initial_dt: f64,
3369 wind_vector: &Vector3<f64>,
3370 resolved_atmo: (f64, f64, f64),
3371 ) -> Rk45AcceptedStep {
3372 let mut trial_dt = initial_dt;
3373
3374 loop {
3375 let trial = self.rk45_step(
3376 position,
3377 velocity,
3378 trial_dt,
3379 wind_vector,
3380 RK45_TOLERANCE,
3381 resolved_atmo,
3382 );
3383 let next_dt = if trial.suggested_dt.is_finite() {
3388 (RK45_SAFETY_FACTOR * trial.suggested_dt).clamp(RK45_MIN_DT, RK45_MAX_DT)
3389 } else {
3390 RK45_MIN_DT
3391 };
3392
3393 if trial.error <= RK45_TOLERANCE || trial_dt <= RK45_MIN_DT {
3394 return Rk45AcceptedStep {
3395 position: trial.position,
3396 velocity: trial.velocity,
3397 used_dt: trial_dt,
3398 next_dt,
3399 error: trial.error,
3400 };
3401 }
3402
3403 trial_dt = next_dt;
3404 }
3405 }
3406
3407 fn rk45_step(
3408 &self,
3409 position: &Vector3<f64>,
3410 velocity: &Vector3<f64>,
3411 dt: f64,
3412 wind_vector: &Vector3<f64>,
3413 tolerance: f64,
3414 resolved_atmo: (f64, f64, f64), ) -> Rk45Trial {
3416 const A21: f64 = 1.0 / 5.0;
3418 const A31: f64 = 3.0 / 40.0;
3419 const A32: f64 = 9.0 / 40.0;
3420 const A41: f64 = 44.0 / 45.0;
3421 const A42: f64 = -56.0 / 15.0;
3422 const A43: f64 = 32.0 / 9.0;
3423 const A51: f64 = 19372.0 / 6561.0;
3424 const A52: f64 = -25360.0 / 2187.0;
3425 const A53: f64 = 64448.0 / 6561.0;
3426 const A54: f64 = -212.0 / 729.0;
3427 const A61: f64 = 9017.0 / 3168.0;
3428 const A62: f64 = -355.0 / 33.0;
3429 const A63: f64 = 46732.0 / 5247.0;
3430 const A64: f64 = 49.0 / 176.0;
3431 const A65: f64 = -5103.0 / 18656.0;
3432 const A71: f64 = 35.0 / 384.0;
3433 const A73: f64 = 500.0 / 1113.0;
3434 const A74: f64 = 125.0 / 192.0;
3435 const A75: f64 = -2187.0 / 6784.0;
3436 const A76: f64 = 11.0 / 84.0;
3437
3438 const B1: f64 = 35.0 / 384.0;
3440 const B3: f64 = 500.0 / 1113.0;
3441 const B4: f64 = 125.0 / 192.0;
3442 const B5: f64 = -2187.0 / 6784.0;
3443 const B6: f64 = 11.0 / 84.0;
3444
3445 const B1_ERR: f64 = 5179.0 / 57600.0;
3447 const B3_ERR: f64 = 7571.0 / 16695.0;
3448 const B4_ERR: f64 = 393.0 / 640.0;
3449 const B5_ERR: f64 = -92097.0 / 339200.0;
3450 const B6_ERR: f64 = 187.0 / 2100.0;
3451 const B7_ERR: f64 = 1.0 / 40.0;
3452
3453 let k1_v = self.calculate_acceleration(position, velocity, wind_vector, resolved_atmo);
3455 let k1_p = *velocity;
3456
3457 let p2 = position + dt * A21 * k1_p;
3458 let v2 = velocity + dt * A21 * k1_v;
3459 let k2_v = self.calculate_acceleration(&p2, &v2, wind_vector, resolved_atmo);
3460 let k2_p = v2;
3461
3462 let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
3463 let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
3464 let k3_v = self.calculate_acceleration(&p3, &v3, wind_vector, resolved_atmo);
3465 let k3_p = v3;
3466
3467 let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
3468 let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
3469 let k4_v = self.calculate_acceleration(&p4, &v4, wind_vector, resolved_atmo);
3470 let k4_p = v4;
3471
3472 let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
3473 let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
3474 let k5_v = self.calculate_acceleration(&p5, &v5, wind_vector, resolved_atmo);
3475 let k5_p = v5;
3476
3477 let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
3478 let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
3479 let k6_v = self.calculate_acceleration(&p6, &v6, wind_vector, resolved_atmo);
3480 let k6_p = v6;
3481
3482 let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
3483 let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
3484 let k7_v = self.calculate_acceleration(&p7, &v7, wind_vector, resolved_atmo);
3485 let k7_p = v7;
3486
3487 let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
3489 let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
3490
3491 let pos_err = position
3493 + dt * (B1_ERR * k1_p
3494 + B3_ERR * k3_p
3495 + B4_ERR * k4_p
3496 + B5_ERR * k5_p
3497 + B6_ERR * k6_p
3498 + B7_ERR * k7_p);
3499 let vel_err = velocity
3500 + dt * (B1_ERR * k1_v
3501 + B3_ERR * k3_v
3502 + B4_ERR * k4_v
3503 + B5_ERR * k5_v
3504 + B6_ERR * k6_v
3505 + B7_ERR * k7_v);
3506
3507 let error = cli_rk45_error_norm(position, velocity, &new_pos, &new_vel, &pos_err, &vel_err);
3509
3510 let dt_new = if error < tolerance {
3512 dt * (tolerance / error).powf(0.2).min(2.0)
3513 } else {
3514 dt * (tolerance / error).powf(0.25).max(0.1)
3515 };
3516
3517 Rk45Trial {
3518 position: new_pos,
3519 velocity: new_vel,
3520 suggested_dt: dt_new,
3521 error,
3522 }
3523 }
3524
3525 fn apply_cluster_bc_correction(&self, base_bc: f64, velocity_fps: f64) -> f64 {
3526 if let Some(ref cluster_bc) = self.cluster_bc {
3527 cluster_bc.apply_correction_for_drag_model(
3528 base_bc,
3529 self.inputs.caliber_inches,
3530 self.inputs.weight_grains,
3531 velocity_fps,
3532 self.inputs.bc_type,
3533 )
3534 } else {
3535 base_bc
3536 }
3537 }
3538
3539 fn calculate_acceleration(
3540 &self,
3541 position: &Vector3<f64>,
3542 velocity: &Vector3<f64>,
3543 wind_vector: &Vector3<f64>,
3544 resolved_atmo: (f64, f64, f64), ) -> Vector3<f64> {
3546 let actual_wind = if let Some(ref sock) = self.wind_sock {
3552 sock.vector_for_range_stateless(position.x)
3553 } else if let Some(model) = self.boundary_layer_shear_model() {
3554 self.get_wind_at_altitude(
3555 crate::wind_shear::shear_height_agl_m(position.y, self.inputs.muzzle_height),
3556 model,
3557 )
3558 } else {
3559 *wind_vector
3560 };
3561 let actual_wind =
3562 crate::derivatives::level_vector_to_shot_frame(actual_wind, self.inputs.shooting_angle);
3563
3564 let relative_velocity = velocity - actual_wind;
3565 let velocity_magnitude = relative_velocity.magnitude();
3566
3567 if velocity_magnitude < 0.001 {
3568 return self.gravity_acceleration();
3569 }
3570
3571 let (base_temp_c, base_press_hpa, station_ratio) = resolved_atmo;
3582
3583 let (drag_base_temp_c, drag_base_press_hpa, drag_base_ratio, drag_humidity_percent) =
3591 if let Some(ref sock) = self.atmo_sock {
3592 let (zone_temp_c, zone_press_hpa, zone_humidity) = sock.atmo_for_range(position.x);
3593 let zone_base_ratio = crate::atmosphere::calculate_air_density_cimp(
3594 zone_temp_c,
3595 zone_press_hpa,
3596 zone_humidity,
3597 ) / 1.225;
3598 (zone_temp_c, zone_press_hpa, zone_base_ratio, zone_humidity)
3599 } else {
3600 (
3601 base_temp_c,
3602 base_press_hpa,
3603 station_ratio,
3604 self.atmosphere.humidity,
3605 )
3606 };
3607 let local_alt = crate::atmosphere::shot_frame_altitude(
3608 self.atmosphere.altitude,
3609 position.x,
3610 position.y,
3611 self.inputs.shooting_angle,
3612 );
3613 let (air_density, speed_of_sound) =
3614 crate::atmosphere::get_local_atmosphere_humid_with_gravity(
3615 local_alt,
3616 self.atmosphere.altitude,
3617 drag_base_temp_c,
3618 drag_base_press_hpa,
3619 drag_base_ratio,
3620 drag_humidity_percent,
3621 self.gravity,
3622 );
3623
3624 let (cd, retard_denom) = self.drag_terms(velocity_magnitude, speed_of_sound);
3628
3629 let velocity_fps = velocity_magnitude * 3.28084;
3631
3632 let cd_to_retard = crate::constants::CD_TO_RETARD;
3637 let standard_factor = cd * cd_to_retard;
3638 let density_scale = air_density / 1.225; let a_drag_ft_s2 =
3642 (velocity_fps * velocity_fps) * standard_factor * density_scale / retard_denom;
3643 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
3647
3648 let mut accel = drag_acceleration + self.gravity_acceleration();
3651
3652 if self.inputs.enable_coriolis {
3655 if let Some(lat_deg) = self.inputs.latitude {
3656 let omega_earth = 7.2921159e-5_f64; let lat = lat_deg.to_radians();
3658 let az = self.inputs.shot_azimuth; let omega = Vector3::new(
3665 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), );
3669 let omega = crate::derivatives::level_vector_to_shot_frame(
3670 omega,
3671 self.inputs.shooting_angle,
3672 );
3673 accel += -2.0 * omega.cross(velocity);
3678 }
3679 }
3680
3681 if self.inputs.enable_magnus
3688 && !self.inputs.use_enhanced_spin_drift
3689 && self.inputs.bullet_diameter > 0.0
3690 && self.inputs.twist_rate > 0.0
3691 {
3692 let diameter_m = self.inputs.bullet_diameter;
3693 let (spin_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
3694 self.inputs.muzzle_velocity,
3695 velocity_magnitude,
3696 self.inputs.twist_rate,
3697 diameter_m,
3698 );
3699 let mach = velocity_magnitude / speed_of_sound;
3701
3702 let d_in = self.inputs.bullet_diameter / 0.0254;
3704 let m_gr = self.inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
3705 let l_in = if self.inputs.bullet_length > 0.0 {
3706 self.inputs.bullet_length / 0.0254
3707 } else {
3708 let est_m = crate::stability::estimate_bullet_length_m(
3710 self.inputs.bullet_diameter,
3711 self.inputs.bullet_mass,
3712 );
3713 if est_m > 0.0 {
3714 est_m / 0.0254
3715 } else {
3716 4.5 * d_in
3717 }
3718 };
3719 let sg = crate::spin_drift::calculate_dynamic_stability(
3723 m_gr,
3724 velocity_magnitude,
3725 spin_rad_s,
3726 d_in,
3727 l_in,
3728 air_density,
3729 );
3730
3731 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose_with_gravity(
3733 sg,
3734 velocity_magnitude,
3735 spin_rad_s,
3736 0.0, 0.0, air_density,
3739 d_in,
3740 l_in,
3741 m_gr,
3742 mach,
3743 "match",
3744 false,
3745 self.gravity,
3746 );
3747
3748 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
3750 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
3751 let magnus_force = 0.5
3752 * air_density
3753 * velocity_magnitude.powi(2)
3754 * area
3755 * c_np
3756 * spin_param
3757 * yaw_rad.sin();
3758
3759 if magnus_force.abs() > 1e-12 {
3763 if let Some(dir) = crate::derivatives::yaw_of_repose_magnus_direction(
3764 relative_velocity,
3765 self.gravity_acceleration(),
3766 self.inputs.is_twist_right,
3767 ) {
3768 accel += (magnus_force / self.inputs.bullet_mass) * dir;
3769 }
3770 }
3771 }
3772
3773 accel
3774 }
3775
3776 fn drag_terms(&self, velocity_magnitude: f64, speed_of_sound: f64) -> (f64, f64) {
3790 let cd = self.calculate_drag_coefficient(velocity_magnitude, speed_of_sound);
3791
3792 let velocity_fps = velocity_magnitude * 3.28084;
3793
3794 let (base_bc, bc_from_segments) = if let Some(segments) = self
3799 .inputs
3800 .bc_segments_data
3801 .as_ref()
3802 .filter(|segments| self.inputs.use_bc_segments && !segments.is_empty())
3803 {
3804 (
3806 crate::bc_estimation::velocity_segment_bc(
3807 velocity_fps,
3808 segments,
3809 self.inputs.bc_value,
3810 ),
3811 true,
3812 )
3813 } else if let Some(segments) = self
3814 .inputs
3815 .bc_segments
3816 .as_ref()
3817 .filter(|segments| !segments.is_empty())
3818 {
3819 (
3820 crate::derivatives::interpolated_bc(
3821 velocity_magnitude / speed_of_sound,
3822 segments,
3823 Some(&self.inputs),
3824 ),
3825 true,
3826 )
3827 } else {
3828 (self.inputs.bc_value, false)
3829 };
3830
3831 let effective_bc = if bc_from_segments {
3836 base_bc
3837 } else {
3838 self.apply_cluster_bc_correction(base_bc, velocity_fps)
3839 };
3840 let effective_bc = effective_bc.max(1e-6);
3843
3844 let retard_denom = if self.inputs.custom_drag_table.is_some() {
3850 self.inputs.custom_drag_denominator(effective_bc)
3851 } else {
3852 effective_bc
3853 };
3854
3855 (cd, retard_denom)
3856 }
3857
3858 pub fn effective_drag_coefficient(
3881 &self,
3882 velocity_magnitude: f64,
3883 speed_of_sound: f64,
3884 ) -> Option<f64> {
3885 if !velocity_magnitude.is_finite() || speed_of_sound <= 1e-9 {
3886 return None;
3887 }
3888 let sectional_density = self.inputs.sectional_density_lb_in2()?;
3889 let (cd, retard_denom) = self.drag_terms(velocity_magnitude, speed_of_sound);
3890 if retard_denom <= 0.0 {
3891 return None;
3892 }
3893 let effective = cd * sectional_density / retard_denom;
3894 effective.is_finite().then_some(effective)
3895 }
3896
3897 fn annotate_drag_coefficients(&self, points: &mut [TrajectoryPoint], speed_of_sound: f64) {
3907 for point in points.iter_mut() {
3908 point.drag_coefficient =
3909 self.effective_drag_coefficient(point.velocity_magnitude, speed_of_sound);
3910 }
3911 }
3912
3913 fn calculate_drag_coefficient(&self, velocity: f64, speed_of_sound: f64) -> f64 {
3914 let mach = velocity / speed_of_sound;
3915
3916 if let Some(ref table) = self.inputs.custom_drag_table {
3920 return table.interpolate(mach) * self.inputs.cd_scale;
3925 }
3926
3927 crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type)
3930 }
3931}
3932
3933#[derive(Debug, Clone)]
3935pub struct MonteCarloParams {
3936 pub num_simulations: usize,
3937 pub velocity_std_dev: f64,
3938 pub angle_std_dev: f64,
3939 pub bc_std_dev: f64,
3940 pub wind_speed_std_dev: f64,
3941 pub target_distance: Option<f64>,
3942 pub base_wind_speed: f64,
3943 pub base_wind_direction: f64,
3944 pub azimuth_std_dev: f64, }
3946
3947impl Default for MonteCarloParams {
3948 fn default() -> Self {
3949 Self {
3950 num_simulations: 1000,
3951 velocity_std_dev: 1.0,
3952 angle_std_dev: 0.001,
3953 bc_std_dev: 0.01,
3954 wind_speed_std_dev: 1.0,
3955 target_distance: None,
3956 base_wind_speed: 0.0,
3957 base_wind_direction: 0.0,
3958 azimuth_std_dev: 0.001, }
3960 }
3961}
3962
3963#[derive(Debug, Clone)]
3965pub struct MonteCarloResults {
3966 pub ranges: Vec<f64>,
3967 pub impact_velocities: Vec<f64>,
3968 pub impact_positions: Vec<Vector3<f64>>,
3974}
3975
3976pub const DEFAULT_HIT_RADIUS_M: f64 = 0.3;
3979
3980pub const TARGET_NOT_REACHED_SENTINEL_M: f64 = -1.0e9;
3986
3987impl MonteCarloResults {
3988 pub fn position_reached_target(position: &Vector3<f64>) -> bool {
3990 position.iter().all(|component| component.is_finite())
3991 && position.y != TARGET_NOT_REACHED_SENTINEL_M
3992 }
3993
3994 pub fn target_arrival_count(&self) -> usize {
3996 self.impact_positions
3997 .iter()
3998 .filter(|position| Self::position_reached_target(position))
3999 .count()
4000 }
4001
4002 pub fn target_shortfall_fraction(&self) -> f64 {
4005 if self.impact_positions.is_empty() {
4006 return 0.0;
4007 }
4008 (self.impact_positions.len() - self.target_arrival_count()) as f64
4009 / self.impact_positions.len() as f64
4010 }
4011
4012 pub fn target_plane_cep(&self) -> Option<f64> {
4018 let mut radial_misses: Vec<f64> = self
4019 .impact_positions
4020 .iter()
4021 .filter(|position| Self::position_reached_target(position))
4022 .map(Vector3::norm)
4023 .filter(|miss| miss.is_finite())
4024 .collect();
4025 radial_misses.sort_by(f64::total_cmp);
4026 if radial_misses.is_empty() {
4027 None
4028 } else {
4029 Some(radial_misses[radial_misses.len() / 2])
4030 }
4031 }
4032
4033 pub fn hit_probability(&self, hit_radius_m: f64) -> f64 {
4042 if self.impact_positions.is_empty() {
4043 return 0.0;
4044 }
4045 let hits = self
4046 .impact_positions
4047 .iter()
4048 .filter(|position| Self::position_is_hit(position, hit_radius_m))
4049 .count();
4050 hits as f64 / self.impact_positions.len() as f64
4051 }
4052
4053 pub fn position_is_hit(position: &Vector3<f64>, hit_radius_m: f64) -> bool {
4065 Self::position_reached_target(position) && position.norm() < hit_radius_m
4066 }
4067
4068 pub fn hit_probability_wilson(
4090 &self,
4091 hit_radius_m: f64,
4092 level: ConfidenceLevel,
4093 ) -> (f64, (f64, f64), u64) {
4094 let trials = self.impact_positions.len() as u64;
4095 let hits = self
4096 .impact_positions
4097 .iter()
4098 .filter(|position| Self::position_is_hit(position, hit_radius_m))
4099 .count() as u64;
4100 (
4101 self.hit_probability(hit_radius_m),
4102 wilson_interval(hits, trials, level),
4103 trials,
4104 )
4105 }
4106
4107 pub fn rect_hit_probability(&self, width_m: f64, height_m: f64) -> f64 {
4119 let dimensions_invalid =
4120 width_m.is_nan() || width_m <= 0.0 || height_m.is_nan() || height_m <= 0.0;
4121 if self.impact_positions.is_empty() || dimensions_invalid {
4122 return 0.0;
4123 }
4124 let half_width = width_m / 2.0;
4125 let half_height = height_m / 2.0;
4126 let hits = self
4127 .impact_positions
4128 .iter()
4129 .filter(|position| {
4130 Self::position_reached_target(position)
4131 && position.z.abs() <= half_width
4132 && position.y.abs() <= half_height
4133 })
4134 .count();
4135 hits as f64 / self.impact_positions.len() as f64
4136 }
4137}
4138
4139fn wind_from_signed_speed_sample(
4140 signed_speed: f64,
4141 sampled_direction: f64,
4142 vertical_speed: f64,
4143) -> WindConditions {
4144 if signed_speed < 0.0 {
4149 WindConditions {
4150 speed: -signed_speed,
4151 direction: sampled_direction + std::f64::consts::PI,
4152 vertical_speed,
4153 }
4154 } else {
4155 WindConditions {
4156 speed: signed_speed,
4157 direction: sampled_direction,
4158 vertical_speed,
4159 }
4160 }
4161}
4162
4163struct MonteCarloWindSampler {
4164 speed: rand_distr::Normal<f64>,
4165 direction: rand_distr::Normal<f64>,
4166 vertical_speed: f64,
4168}
4169
4170impl MonteCarloWindSampler {
4171 fn new(
4172 base_wind: &WindConditions,
4173 wind_speed_std_dev: f64,
4174 wind_direction_std_dev: f64,
4175 ) -> Result<Self, BallisticsError> {
4176 use rand_distr::Normal;
4177
4178 if !wind_direction_std_dev.is_finite() || wind_direction_std_dev < 0.0 {
4179 return Err("Wind direction standard deviation must be finite and non-negative".into());
4180 }
4181
4182 let speed = Normal::new(base_wind.speed, wind_speed_std_dev)
4183 .map_err(|e| format!("Invalid wind speed distribution: {e}"))?;
4184 let direction = Normal::new(base_wind.direction, wind_direction_std_dev)
4185 .map_err(|e| format!("Invalid wind direction distribution: {e}"))?;
4186 Ok(Self {
4187 speed,
4188 direction,
4189 vertical_speed: base_wind.vertical_speed,
4190 })
4191 }
4192
4193 fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> WindConditions {
4194 use rand_distr::Distribution;
4195
4196 wind_from_signed_speed_sample(
4197 self.speed.sample(rng),
4198 self.direction.sample(rng),
4199 self.vertical_speed,
4200 )
4201 }
4202}
4203
4204#[derive(Debug, Clone, Copy)]
4207struct TrialOutcome {
4208 range: f64,
4211 impact_velocity: f64,
4213 impact_position: Vector3<f64>,
4216}
4217
4218struct MonteCarloTrialSampler {
4238 base_inputs: BallisticInputs,
4239 atmosphere: AtmosphericConditions,
4240 station_presence: StationAtmospherePresence,
4242 solver_max_range: f64,
4243 target_distance: f64,
4246 baseline_at_target: Vector3<f64>,
4249 velocity_delta_dist: rand_distr::Normal<f64>,
4250 angle_dist: rand_distr::Normal<f64>,
4251 bc_dist: rand_distr::Normal<f64>,
4252 wind_sampler: MonteCarloWindSampler,
4253 azimuth_dist: rand_distr::Normal<f64>,
4254}
4255
4256impl MonteCarloTrialSampler {
4257 fn new(
4261 base_inputs: BallisticInputs,
4262 base_wind: &WindConditions,
4263 params: &MonteCarloParams,
4264 wind_direction_std_dev: f64,
4265 station_presence: StationAtmospherePresence,
4266 ) -> Result<Self, BallisticsError> {
4267 use rand_distr::Normal;
4268
4269 let atmosphere = AtmosphericConditions {
4270 temperature: base_inputs.temperature,
4271 pressure: base_inputs.pressure,
4272 humidity: base_inputs.humidity_percent(),
4273 altitude: base_inputs.altitude,
4274 };
4275 let target_hint = params
4276 .target_distance
4277 .unwrap_or(base_inputs.target_distance);
4278 let solver_max_range = target_hint.max(1000.0) * 2.0;
4279
4280 let mut baseline_solver = TrajectorySolver::new_with_station_presence(
4282 base_inputs.clone(),
4283 base_wind.clone(),
4284 atmosphere.clone(),
4285 station_presence,
4286 );
4287 baseline_solver.set_max_range(solver_max_range);
4288 let baseline_result = baseline_solver.solve()?;
4289
4290 let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
4292
4293 let baseline_at_target = baseline_result
4295 .position_at_range(target_distance)
4296 .ok_or("Could not interpolate baseline at target distance")?;
4297
4298 let velocity_delta_dist = Normal::new(0.0, params.velocity_std_dev)
4303 .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
4304 let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
4305 .map_err(|e| format!("Invalid angle distribution: {}", e))?;
4306 let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
4307 .map_err(|e| format!("Invalid BC distribution: {}", e))?;
4308 let wind_sampler = MonteCarloWindSampler::new(
4311 base_wind,
4312 params.wind_speed_std_dev,
4313 wind_direction_std_dev,
4314 )?;
4315 let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
4316 .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
4317
4318 Ok(Self {
4319 base_inputs,
4320 atmosphere,
4321 station_presence,
4322 solver_max_range,
4323 target_distance,
4324 baseline_at_target,
4325 velocity_delta_dist,
4326 angle_dist,
4327 bc_dist,
4328 wind_sampler,
4329 azimuth_dist,
4330 })
4331 }
4332
4333 fn sample_one_trial<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> Option<TrialOutcome> {
4343 use rand_distr::Distribution;
4344
4345 let mut inputs = self.base_inputs.clone();
4347 let muzzle_velocity_delta = self.velocity_delta_dist.sample(&mut *rng);
4348 inputs.muzzle_angle = self.angle_dist.sample(&mut *rng);
4349 inputs.bc_value = self.bc_dist.sample(&mut *rng).max(0.01);
4350 inputs.azimuth_angle = self.azimuth_dist.sample(&mut *rng); let wind = self.wind_sampler.sample(&mut *rng);
4354
4355 let mut solver = TrajectorySolver::new_with_station_presence(
4359 inputs,
4360 wind,
4361 self.atmosphere.clone(),
4362 self.station_presence,
4363 );
4364 solver.inputs.muzzle_velocity =
4365 (solver.inputs.muzzle_velocity + muzzle_velocity_delta).max(0.0);
4366 solver.set_max_range(self.solver_max_range);
4367 let result = solver.solve().ok()?;
4369
4370 let impact_position = if result.max_range < self.target_distance {
4376 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)
4379 } else {
4380 let pos_at_target = result.position_at_range(self.target_distance)?;
4382 Vector3::new(
4387 0.0,
4388 pos_at_target.y - self.baseline_at_target.y,
4389 pos_at_target.z - self.baseline_at_target.z,
4390 )
4391 };
4392
4393 Some(TrialOutcome {
4394 range: result.max_range,
4395 impact_velocity: result.impact_velocity,
4396 impact_position,
4397 })
4398 }
4399}
4400
4401pub fn run_monte_carlo(
4407 base_inputs: BallisticInputs,
4408 params: MonteCarloParams,
4409) -> Result<MonteCarloResults, BallisticsError> {
4410 run_monte_carlo_with_direction_std_dev(base_inputs, params, 0.0)
4411}
4412
4413pub fn run_monte_carlo_with_direction_std_dev(
4420 base_inputs: BallisticInputs,
4421 params: MonteCarloParams,
4422 wind_direction_std_dev: f64,
4423) -> Result<MonteCarloResults, BallisticsError> {
4424 let base_wind = WindConditions {
4425 speed: params.base_wind_speed,
4426 direction: params.base_wind_direction,
4427 vertical_speed: 0.0,
4428 };
4429 run_monte_carlo_with_wind_and_direction_std_dev(
4430 base_inputs,
4431 base_wind,
4432 params,
4433 wind_direction_std_dev,
4434 )
4435}
4436
4437pub fn run_monte_carlo_with_wind(
4441 base_inputs: BallisticInputs,
4442 base_wind: WindConditions,
4443 params: MonteCarloParams,
4444) -> Result<MonteCarloResults, BallisticsError> {
4445 run_monte_carlo_with_wind_and_direction_std_dev(base_inputs, base_wind, params, 0.0)
4446}
4447
4448pub fn run_monte_carlo_with_wind_and_direction_std_dev(
4455 base_inputs: BallisticInputs,
4456 base_wind: WindConditions,
4457 params: MonteCarloParams,
4458 wind_direction_std_dev: f64,
4459) -> Result<MonteCarloResults, BallisticsError> {
4460 run_monte_carlo_with_station_presence(
4461 base_inputs,
4462 base_wind,
4463 params,
4464 wind_direction_std_dev,
4465 None,
4466 StationAtmospherePresence::NEITHER,
4467 )
4468}
4469
4470pub fn run_monte_carlo_with_station_presence(
4481 base_inputs: BallisticInputs,
4482 base_wind: WindConditions,
4483 params: MonteCarloParams,
4484 wind_direction_std_dev: f64,
4485 seed: Option<u64>,
4486 station_presence: StationAtmospherePresence,
4487) -> Result<MonteCarloResults, BallisticsError> {
4488 match seed {
4489 None => {
4490 let mut rng = rand::rng();
4491 run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
4492 base_inputs,
4493 base_wind,
4494 params,
4495 wind_direction_std_dev,
4496 station_presence,
4497 &mut rng,
4498 )
4499 }
4500 Some(seed) => {
4501 use rand::{rngs::StdRng, SeedableRng};
4502 let mut rng = StdRng::seed_from_u64(seed);
4503 run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
4504 base_inputs,
4505 base_wind,
4506 params,
4507 wind_direction_std_dev,
4508 station_presence,
4509 &mut rng,
4510 )
4511 }
4512 }
4513}
4514
4515pub fn run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4524 base_inputs: BallisticInputs,
4525 base_wind: WindConditions,
4526 params: MonteCarloParams,
4527 wind_direction_std_dev: f64,
4528 seed: u64,
4529) -> Result<MonteCarloResults, BallisticsError> {
4530 run_monte_carlo_with_station_presence(
4531 base_inputs,
4532 base_wind,
4533 params,
4534 wind_direction_std_dev,
4535 Some(seed),
4536 StationAtmospherePresence::NEITHER,
4537 )
4538}
4539
4540fn run_monte_carlo_with_wind_and_direction_std_dev_using_rng<R: rand::Rng + ?Sized>(
4541 base_inputs: BallisticInputs,
4542 base_wind: WindConditions,
4543 params: MonteCarloParams,
4544 wind_direction_std_dev: f64,
4545 station_presence: StationAtmospherePresence,
4546 rng: &mut R,
4547) -> Result<MonteCarloResults, BallisticsError> {
4548 let mut ranges = Vec::new();
4549 let mut impact_velocities = Vec::new();
4550 let mut impact_positions = Vec::new();
4551
4552 let sampler = MonteCarloTrialSampler::new(
4553 base_inputs,
4554 &base_wind,
4555 ¶ms,
4556 wind_direction_std_dev,
4557 station_presence,
4558 )?;
4559
4560 for _ in 0..params.num_simulations {
4561 if let Some(outcome) = sampler.sample_one_trial(rng) {
4565 ranges.push(outcome.range);
4566 impact_velocities.push(outcome.impact_velocity);
4567 impact_positions.push(outcome.impact_position);
4568 }
4569 }
4570
4571 if ranges.is_empty() {
4572 return Err("No successful simulations".into());
4573 }
4574
4575 Ok(MonteCarloResults {
4576 ranges,
4577 impact_velocities,
4578 impact_positions,
4579 })
4580}
4581
4582pub const MC_ADAPTIVE_SCHEMA_VERSION_V1: u32 = 1;
4584
4585pub const MC_ADAPTIVE_METHOD_V1: &str = "anytime_beta_binomial_mixture_cs_v1";
4592
4593pub const MC_ADAPTIVE_ASSUMPTIONS_V1: [&str; 4] = [
4607 "Sampling uncertainty only: intervals cover Monte Carlo sampling error, not model error in the trajectory solver or its inputs.",
4608 "Anytime-valid stopping: the beta-binomial mixture confidence sequence keeps its coverage guarantee despite stopping the moment the target half-width is met.",
4609 "Input dispersions are the independent normal distributions declared in MonteCarloParams; correlations between inputs are not modeled.",
4610 "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.",
4611];
4612
4613#[derive(Debug, Clone)]
4620pub struct McConvergence {
4621 pub level: ConfidenceLevel,
4623 pub target_half_width: f64,
4626 pub min_samples: u64,
4630 pub max_samples: u64,
4633 pub batch_size: u64,
4636}
4637
4638impl Default for McConvergence {
4639 fn default() -> Self {
4640 Self {
4641 level: ConfidenceLevel::P95,
4642 target_half_width: 0.02,
4643 min_samples: 1_000,
4644 max_samples: 100_000,
4645 batch_size: 500,
4646 }
4647 }
4648}
4649
4650impl McConvergence {
4651 pub fn validate(&self) -> Result<(), String> {
4660 if !self.target_half_width.is_finite() || self.target_half_width <= 0.0 {
4661 return Err(format!(
4662 "McConvergence.target_half_width must be a finite value greater than zero (got {})",
4663 self.target_half_width
4664 ));
4665 }
4666 if self.batch_size == 0 {
4667 return Err("McConvergence.batch_size must be greater than zero".to_string());
4668 }
4669 if self.max_samples == 0 {
4670 return Err("McConvergence.max_samples must be greater than zero".to_string());
4671 }
4672 if self.max_samples < self.min_samples {
4673 return Err(format!(
4674 "McConvergence.max_samples ({}) must be at least McConvergence.min_samples ({})",
4675 self.max_samples, self.min_samples
4676 ));
4677 }
4678 Ok(())
4679 }
4680}
4681
4682#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
4684#[serde(rename_all = "snake_case")]
4685pub enum McStopReason {
4686 TargetHalfWidthMet,
4689 MaxSamplesReached,
4692}
4693
4694#[derive(Debug, Clone, serde::Serialize)]
4701pub struct AdaptiveMcReportV1 {
4702 pub schema_version: u32,
4704 pub method: String,
4706 pub assumptions: Vec<String>,
4708 pub confidence_percent: u32,
4710 pub hit_probability: f64,
4713 pub ci_low: f64,
4715 pub ci_high: f64,
4717 pub samples: u64,
4719 pub attempts: u64,
4730 pub arrivals: u64,
4745 pub stop_reason: McStopReason,
4747 pub hit_radius_m: f64,
4749 pub target_distance_m: f64,
4751 pub mean_impact_velocity_mps: f64,
4756 pub std_impact_velocity_mps: f64,
4758 pub mean_drop_at_target_m: f64,
4766 pub std_drop_at_target_m: f64,
4769 pub mean_wind_drift_at_target_m: f64,
4772 pub std_wind_drift_at_target_m: f64,
4775}
4776
4777pub fn run_monte_carlo_adaptive_seeded(
4855 base_inputs: &BallisticInputs,
4856 base_wind: &WindConditions,
4857 params: &MonteCarloParams,
4858 convergence: &McConvergence,
4859 hit_radius_m: f64,
4860 seed: u64,
4861) -> Result<AdaptiveMcReportV1, String> {
4862 run_monte_carlo_adaptive_seeded_with_station_presence(
4863 base_inputs,
4864 base_wind,
4865 params,
4866 convergence,
4867 hit_radius_m,
4868 seed,
4869 StationAtmospherePresence::NEITHER,
4870 )
4871}
4872
4873pub fn run_monte_carlo_adaptive_seeded_with_station_presence(
4878 base_inputs: &BallisticInputs,
4879 base_wind: &WindConditions,
4880 params: &MonteCarloParams,
4881 convergence: &McConvergence,
4882 hit_radius_m: f64,
4883 seed: u64,
4884 station_presence: StationAtmospherePresence,
4885) -> Result<AdaptiveMcReportV1, String> {
4886 use rand::{rngs::StdRng, SeedableRng};
4887
4888 convergence.validate()?;
4889
4890 let sampler = MonteCarloTrialSampler::new(
4893 base_inputs.clone(),
4894 base_wind,
4895 params,
4896 0.0,
4897 station_presence,
4898 )
4899 .map_err(|e| e.to_string())?;
4900
4901 let mut rng = StdRng::seed_from_u64(seed);
4902 let mut hits_cs = BernoulliConfidenceSequence::new(convergence.level);
4903 let mut impact_velocity = Welford::new();
4904 let mut drop_at_target = Welford::new();
4905 let mut drift_at_target = Welford::new();
4906
4907 let mut attempts: u64 = 0;
4908 let mut stop_reason = McStopReason::MaxSamplesReached;
4909
4910 while attempts < convergence.max_samples {
4911 let batch = convergence
4914 .batch_size
4915 .min(convergence.max_samples - attempts);
4916 let mut batch_hits: u64 = 0;
4917 let mut batch_trials: u64 = 0;
4918
4919 for _ in 0..batch {
4920 attempts += 1;
4921 let Some(outcome) = sampler.sample_one_trial(&mut rng) else {
4922 continue; };
4924 batch_trials += 1;
4925 if MonteCarloResults::position_is_hit(&outcome.impact_position, hit_radius_m) {
4926 batch_hits += 1;
4927 }
4928 if MonteCarloResults::position_reached_target(&outcome.impact_position) {
4932 impact_velocity.push(outcome.impact_velocity);
4933 drop_at_target.push(outcome.impact_position.y);
4934 drift_at_target.push(outcome.impact_position.z);
4935 }
4936 }
4937
4938 hits_cs.update_batch(batch_hits, batch_trials);
4939
4940 if hits_cs.trials() >= convergence.min_samples
4941 && hits_cs.half_width() <= convergence.target_half_width
4942 {
4943 stop_reason = McStopReason::TargetHalfWidthMet;
4944 break;
4945 }
4946 }
4947
4948 let samples = hits_cs.trials();
4949 if samples == 0 {
4950 return Err("No successful simulations".to_string());
4951 }
4952 let (ci_low, ci_high) = hits_cs.bounds();
4953
4954 Ok(AdaptiveMcReportV1 {
4955 schema_version: MC_ADAPTIVE_SCHEMA_VERSION_V1,
4956 method: MC_ADAPTIVE_METHOD_V1.to_string(),
4957 assumptions: MC_ADAPTIVE_ASSUMPTIONS_V1
4958 .iter()
4959 .map(|s| s.to_string())
4960 .collect(),
4961 confidence_percent: convergence.level.as_percent(),
4962 hit_probability: hits_cs.successes() as f64 / samples as f64,
4963 ci_low,
4964 ci_high,
4965 samples,
4966 attempts,
4967 arrivals: drop_at_target.count(),
4971 stop_reason,
4972 hit_radius_m,
4973 target_distance_m: sampler.target_distance,
4974 mean_impact_velocity_mps: impact_velocity.mean(),
4975 std_impact_velocity_mps: impact_velocity.sample_std(),
4976 mean_drop_at_target_m: drop_at_target.mean(),
4977 std_drop_at_target_m: drop_at_target.sample_std(),
4978 mean_wind_drift_at_target_m: drift_at_target.mean(),
4979 std_wind_drift_at_target_m: drift_at_target.sample_std(),
4980 })
4981}
4982
4983pub fn calculate_zero_angle(
4985 inputs: BallisticInputs,
4986 target_distance: f64,
4987 target_height: f64,
4988) -> Result<f64, BallisticsError> {
4989 calculate_zero_angle_with_conditions(
4990 inputs,
4991 target_distance,
4992 target_height,
4993 WindConditions::default(),
4994 AtmosphericConditions::default(),
4995 )
4996}
4997
4998pub fn calculate_zero_angle_with_conditions(
5003 inputs: BallisticInputs,
5004 target_distance: f64,
5005 target_height: f64,
5006 wind: WindConditions,
5007 atmosphere: AtmosphericConditions,
5008) -> Result<f64, BallisticsError> {
5009 calculate_zero_angle_with_station_presence(
5010 inputs,
5011 target_distance,
5012 target_height,
5013 wind,
5014 atmosphere,
5015 StationAtmospherePresence::NEITHER,
5016 )
5017}
5018
5019pub fn calculate_zero_angle_with_station_presence(
5024 inputs: BallisticInputs,
5025 target_distance: f64,
5026 target_height: f64,
5027 wind: WindConditions,
5028 atmosphere: AtmosphericConditions,
5029 station_presence: StationAtmospherePresence,
5030) -> Result<f64, BallisticsError> {
5031 let mut solver =
5032 TrajectorySolver::new_with_station_presence(inputs, wind, atmosphere, station_presence);
5033 solver.calculate_and_set_zero_angle(target_distance, target_height, ZeroTargetFrame::SightLine)
5034}
5035
5036pub fn calculate_zero_angle_with_resolved_conditions(
5042 inputs: BallisticInputs,
5043 target_distance: f64,
5044 target_height: f64,
5045 wind: WindConditions,
5046 atmosphere: AtmosphericConditions,
5047) -> Result<f64, BallisticsError> {
5048 let mut solver =
5049 TrajectorySolver::new_with_resolved_station_atmosphere(inputs, wind, atmosphere);
5050 solver.calculate_and_set_zero_angle(target_distance, target_height, ZeroTargetFrame::SightLine)
5051}
5052
5053pub const ZERO_RANGE_FROM_ANGLE_MAX_RANGE_M: f64 = 2000.0;
5061
5062pub fn calculate_zero_range_from_angle_with_conditions(
5080 inputs: BallisticInputs,
5081 zero_angle_rad: f64,
5082 target_height: f64,
5083 wind: WindConditions,
5084 atmosphere: AtmosphericConditions,
5085) -> Result<ZeroCrossings, BallisticsError> {
5086 calculate_zero_range_from_angle_with_station_presence(
5087 inputs,
5088 zero_angle_rad,
5089 target_height,
5090 wind,
5091 atmosphere,
5092 StationAtmospherePresence::NEITHER,
5093 )
5094}
5095
5096pub fn calculate_zero_range_from_angle_with_station_presence(
5101 inputs: BallisticInputs,
5102 zero_angle_rad: f64,
5103 target_height: f64,
5104 wind: WindConditions,
5105 atmosphere: AtmosphericConditions,
5106 station_presence: StationAtmospherePresence,
5107) -> Result<ZeroCrossings, BallisticsError> {
5108 let mut solver =
5109 TrajectorySolver::new_with_station_presence(inputs, wind, atmosphere, station_presence);
5110 solver.set_max_range(ZERO_RANGE_FROM_ANGLE_MAX_RANGE_M);
5111 solver.find_zero_range(zero_angle_rad, target_height, ZeroTargetFrame::SightLine)
5112}
5113
5114pub fn calculate_zero_range_from_angle_with_resolved_conditions(
5120 inputs: BallisticInputs,
5121 zero_angle_rad: f64,
5122 target_height: f64,
5123 wind: WindConditions,
5124 atmosphere: AtmosphericConditions,
5125) -> Result<ZeroCrossings, BallisticsError> {
5126 let mut solver =
5127 TrajectorySolver::new_with_resolved_station_atmosphere(inputs, wind, atmosphere);
5128 solver.set_max_range(ZERO_RANGE_FROM_ANGLE_MAX_RANGE_M);
5129 solver.find_zero_range(zero_angle_rad, target_height, ZeroTargetFrame::SightLine)
5130}
5131
5132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5134pub enum BcFitMode {
5135 Drop,
5137 Velocity,
5140}
5141
5142#[derive(Debug, Clone, Copy)]
5144pub struct BcEstimate {
5145 pub bc: f64,
5147 pub rms_error: f64,
5149 pub drag_model: DragModel,
5151 pub mode: BcFitMode,
5153 pub at_bound: bool,
5157}
5158
5159fn fit_value_at(
5167 points: &[TrajectoryPoint],
5168 target_dist: f64,
5169 mode: BcFitMode,
5170 drop_offset: f64,
5171) -> Option<f64> {
5172 let val = |p: &TrajectoryPoint| match mode {
5173 BcFitMode::Drop => drop_offset - p.position.y,
5174 BcFitMode::Velocity => p.velocity_magnitude,
5175 };
5176 for i in 0..points.len() {
5177 if points[i].position.x >= target_dist {
5178 if i == 0 {
5179 return Some(val(&points[0]));
5180 }
5181 let p1 = &points[i - 1];
5182 let p2 = &points[i];
5183 let dx = p2.position.x - p1.position.x;
5184 if dx.abs() < 1e-9 {
5185 return Some(val(p2));
5186 }
5187 let t = (target_dist - p1.position.x) / dx;
5188 return Some(val(p1) + t * (val(p2) - val(p1)));
5189 }
5190 }
5191 None
5192}
5193
5194fn fit_residual_sse(
5195 trajectory: &[TrajectoryPoint],
5196 observations: &[(f64, f64)],
5197 mode: BcFitMode,
5198 drop_offset: f64,
5199) -> Option<f64> {
5200 if observations.is_empty() {
5201 return None;
5202 }
5203 let mut total = 0.0;
5204 for (target_dist, target_val) in observations {
5205 let value = fit_value_at(trajectory, *target_dist, mode, drop_offset)?;
5208 let error = value - target_val;
5209 total += error * error;
5210 }
5211 Some(total)
5212}
5213
5214#[allow(clippy::too_many_arguments)] pub fn estimate_bc_fit(
5235 velocity: f64,
5236 mass: f64,
5237 diameter: f64,
5238 points: &[(f64, f64)],
5239 drag_model: DragModel,
5240 mode: BcFitMode,
5241 atmosphere: AtmosphericConditions,
5242 zero_range: Option<f64>,
5243 sight_height: f64,
5244) -> Result<BcEstimate, BallisticsError> {
5245 estimate_bc_fit_with_station_presence(
5246 velocity,
5247 mass,
5248 diameter,
5249 points,
5250 drag_model,
5251 mode,
5252 atmosphere,
5253 zero_range,
5254 sight_height,
5255 StationAtmospherePresence::NEITHER,
5256 )
5257}
5258
5259#[allow(clippy::too_many_arguments)] pub fn estimate_bc_fit_with_station_presence(
5264 velocity: f64,
5265 mass: f64,
5266 diameter: f64,
5267 points: &[(f64, f64)],
5268 drag_model: DragModel,
5269 mode: BcFitMode,
5270 atmosphere: AtmosphericConditions,
5271 zero_range: Option<f64>,
5272 sight_height: f64,
5273 station_presence: StationAtmospherePresence,
5274) -> Result<BcEstimate, BallisticsError> {
5275 if points.is_empty() {
5276 return Err(BallisticsError::from(
5277 "No data points provided for BC estimation.".to_string(),
5278 ));
5279 }
5280 let max_dist = points.iter().map(|(d, _)| *d).fold(0.0_f64, f64::max);
5281 let drop_offset = if zero_range.is_some() {
5284 sight_height
5285 } else {
5286 0.0
5287 };
5288
5289 let sse = |bc_value: f64| -> Option<f64> {
5291 let mut inputs = BallisticInputs {
5292 muzzle_velocity: velocity,
5293 bc_value,
5294 bc_type: drag_model,
5295 bullet_mass: mass,
5296 bullet_diameter: diameter,
5297 sight_height,
5298 ..Default::default()
5299 };
5300 if let Some(zr) = zero_range {
5303 let za = calculate_zero_angle_with_station_presence(
5309 inputs.clone(),
5310 zr,
5311 sight_height,
5312 WindConditions::default(),
5313 atmosphere.clone(),
5314 station_presence,
5315 )
5316 .ok()?;
5317 inputs.muzzle_angle = za;
5318 }
5319 let mut solver = TrajectorySolver::new_with_station_presence(
5320 inputs,
5321 WindConditions::default(),
5322 atmosphere.clone(),
5323 station_presence,
5324 );
5325 solver.set_max_range(max_dist * 1.5);
5326 let result = solver.solve().ok()?;
5327 fit_residual_sse(&result.points, points, mode, drop_offset)
5328 };
5329
5330 let (bc_min, bc_max) = match drag_model {
5334 DragModel::G7 => (0.05, 0.70),
5335 _ => (0.10, 1.20),
5336 };
5337
5338 let mut best_bc = f64::NAN;
5340 let mut best_sse = f64::MAX;
5341 let mut bc = bc_min;
5342 while bc <= bc_max + 1e-9 {
5343 if let Some(s) = sse(bc) {
5344 if s < best_sse {
5345 best_sse = s;
5346 best_bc = bc;
5347 }
5348 }
5349 bc += 0.01;
5350 }
5351 if !best_bc.is_finite() {
5352 return Err(BallisticsError::from(
5353 "Unable to estimate BC from provided data. Check that the values and units are correct."
5354 .to_string(),
5355 ));
5356 }
5357
5358 let lo = (best_bc - 0.01).max(bc_min);
5360 let hi = (best_bc + 0.01).min(bc_max);
5361 let mut bc = lo;
5362 while bc <= hi + 1e-9 {
5363 if let Some(s) = sse(bc) {
5364 if s < best_sse {
5365 best_sse = s;
5366 best_bc = bc;
5367 }
5368 }
5369 bc += 0.001;
5370 }
5371
5372 let at_bound = best_bc <= bc_min + 0.011 || best_bc >= bc_max - 0.011;
5375 let rms_error = (best_sse / points.len() as f64).sqrt();
5378 Ok(BcEstimate {
5379 bc: best_bc,
5380 rms_error,
5381 drag_model,
5382 mode,
5383 at_bound,
5384 })
5385}
5386
5387pub fn estimate_bc_from_trajectory(
5390 velocity: f64,
5391 mass: f64,
5392 diameter: f64,
5393 points: &[(f64, f64)], ) -> Result<f64, BallisticsError> {
5395 estimate_bc_fit(
5396 velocity,
5397 mass,
5398 diameter,
5399 points,
5400 DragModel::G1,
5401 BcFitMode::Drop,
5402 AtmosphericConditions::default(),
5403 None,
5404 0.05,
5405 )
5406 .map(|e| e.bc)
5407}
5408
5409use rand;
5411use rand_distr;
5412
5413#[cfg(test)]
5414mod mba737_powder_resolution_tests {
5415 use super::*;
5416
5417 #[test]
5418 fn linear_model_cold_powder_subtracts() {
5419 let v = resolve_powder_adjusted_velocity(823.0, 11.1, true, 0.5486, 21.1, None, None);
5421 assert!((v - (823.0 + 0.5486 * (11.1 - 21.1))).abs() < 1e-12);
5422 assert!(v < 823.0);
5423 }
5424
5425 #[test]
5426 fn linear_model_hot_powder_adds() {
5427 let v = resolve_powder_adjusted_velocity(823.0, 31.1, true, 0.5486, 21.1, None, None);
5428 assert!((v - (823.0 + 0.5486 * 10.0)).abs() < 1e-12);
5429 }
5430
5431 #[test]
5432 fn disabled_flag_is_passthrough() {
5433 let v = resolve_powder_adjusted_velocity(823.0, 40.0, false, 0.5486, 21.1, None, None);
5434 assert_eq!(v, 823.0);
5435 }
5436
5437 #[test]
5438 fn curve_overrides_linear_and_interpolates_at_powder_temp() {
5439 let curve = [(4.4, 798.6), (21.1, 823.0), (37.8, 841.2)];
5440 let v = resolve_powder_adjusted_velocity(
5442 823.0,
5443 30.0,
5444 true,
5445 99.0,
5446 21.1,
5447 Some(&curve),
5448 Some(4.4),
5449 );
5450 assert!((v - 798.6).abs() < 1e-9);
5451 }
5452
5453 #[test]
5454 fn curve_falls_back_to_ambient_and_clamps() {
5455 let curve = [(4.4, 798.6), (37.8, 841.2)];
5456 let v = resolve_powder_adjusted_velocity(823.0, -40.0, true, 1.0, 21.1, Some(&curve), None);
5458 assert!((v - 798.6).abs() < 1e-9);
5459 let v_hot =
5460 resolve_powder_adjusted_velocity(823.0, 60.0, true, 1.0, 21.1, Some(&curve), None);
5461 assert!((v_hot - 841.2).abs() < 1e-9);
5462 }
5463
5464 #[test]
5465 fn empty_curve_suppresses_linear_fallback() {
5466 let v = resolve_powder_adjusted_velocity(823.0, 40.0, true, 0.5486, 21.1, Some(&[]), None);
5468 assert_eq!(v, 823.0);
5469 }
5470
5471 #[test]
5472 fn sweep_huge_range_errors_instead_of_overflowing() {
5473 assert!(parse_powder_sweep("0:1e20:1").is_err());
5476 assert!(parse_powder_sweep("0:1e308:1e-3").is_err());
5477 }
5478
5479 #[test]
5480 fn sweep_fractional_step_keeps_end_row() {
5481 let rows = parse_powder_sweep("0:0.3:0.1").unwrap();
5483 assert_eq!(rows.len(), 4);
5484 assert!((rows[3] - 0.3).abs() < 1e-9);
5485 }
5486
5487 #[test]
5488 fn solver_and_helper_agree_on_linear_model() {
5489 let inputs = BallisticInputs {
5491 use_powder_sensitivity: true,
5492 powder_temp_sensitivity: 0.5486,
5493 powder_temp: 21.1,
5494 temperature: 4.4,
5495 ..Default::default()
5496 };
5497 let expected = resolve_powder_adjusted_velocity(
5498 inputs.muzzle_velocity,
5499 inputs.temperature,
5500 true,
5501 0.5486,
5502 21.1,
5503 None,
5504 None,
5505 );
5506 let solver = TrajectorySolver::new(
5507 inputs,
5508 WindConditions::default(),
5509 AtmosphericConditions::default(),
5510 );
5511 assert!((solver.inputs.muzzle_velocity - expected).abs() < 1e-12);
5512 }
5513}
5514
5515#[cfg(test)]
5516mod mba1302_solver_seam_tests {
5517 use super::*;
5518 use crate::wind::WindSegment;
5519
5520 #[test]
5521 fn authoritative_station_atmosphere_preserves_explicit_standard_values_at_altitude() {
5522 let atmosphere = AtmosphericConditions {
5523 temperature: 15.0,
5524 pressure: 1013.25,
5525 humidity: 50.0,
5526 altitude: 2_000.0,
5527 };
5528 let legacy = TrajectorySolver::new(
5529 BallisticInputs::default(),
5530 WindConditions::default(),
5531 atmosphere.clone(),
5532 );
5533 let authoritative = TrajectorySolver::new_with_resolved_station_atmosphere(
5534 BallisticInputs::default(),
5535 WindConditions::default(),
5536 atmosphere,
5537 );
5538
5539 let (legacy_density, _, legacy_temp_c, legacy_pressure_hpa) = legacy.resolved_atmosphere();
5540 let (authoritative_density, _, authoritative_temp_c, authoritative_pressure_hpa) =
5541 authoritative.resolved_atmosphere();
5542 let (icao_temp_k, icao_pressure_pa) =
5543 crate::atmosphere::calculate_icao_standard_atmosphere(2_000.0);
5544 let (expected_authoritative_density, _) =
5545 crate::atmosphere::calculate_atmosphere(2_000.0, Some(15.0), Some(1013.25), 50.0);
5546
5547 assert!((legacy_temp_c - (icao_temp_k - 273.15)).abs() < 1e-12);
5548 assert!((legacy_pressure_hpa - icao_pressure_pa / 100.0).abs() < 1e-12);
5549 assert_eq!(authoritative_temp_c.to_bits(), 15.0_f64.to_bits());
5550 assert_eq!(authoritative_pressure_hpa.to_bits(), 1013.25_f64.to_bits());
5551 assert_eq!(
5552 authoritative_density.to_bits(),
5553 expected_authoritative_density.to_bits()
5554 );
5555 assert!(
5556 (authoritative_density - legacy_density).abs() > 0.1,
5557 "explicit standard values at altitude must differ from ICAO-at-altitude: explicit={authoritative_density}, ICAO={legacy_density}"
5558 );
5559 }
5560
5561 #[test]
5570 fn precomputed_absolute_resolution_via_authoritative_matches_legacy_new() {
5571 for (temperature, pressure, altitude) in [
5572 (15.0, 1013.25, 0.0), (15.0, 1013.25, 2000.0), (-5.0, 850.0, 2000.0), (22.0, 950.0, 500.0),
5576 ] {
5577 let atmosphere = AtmosphericConditions {
5578 temperature,
5579 pressure,
5580 humidity: 50.0,
5581 altitude,
5582 };
5583 let legacy = TrajectorySolver::new(
5584 BallisticInputs::default(),
5585 WindConditions::default(),
5586 atmosphere.clone(),
5587 );
5588
5589 let (resolved_temp_c, resolved_pressure_hpa) =
5590 crate::atmosphere::resolve_station_conditions_with_pressure_mode(
5591 temperature,
5592 pressure,
5593 altitude,
5594 crate::atmosphere::PressureReferenceMode::Absolute,
5595 );
5596 let precomputed_atmosphere = AtmosphericConditions {
5597 temperature: resolved_temp_c,
5598 pressure: resolved_pressure_hpa,
5599 humidity: 50.0,
5600 altitude,
5601 };
5602 let precomputed = TrajectorySolver::new_with_resolved_station_atmosphere(
5603 BallisticInputs::default(),
5604 WindConditions::default(),
5605 precomputed_atmosphere,
5606 );
5607
5608 let (legacy_density, legacy_sos, legacy_temp_c, legacy_pressure_hpa) =
5609 legacy.resolved_atmosphere();
5610 let (pre_density, pre_sos, pre_temp_c, pre_pressure_hpa) =
5611 precomputed.resolved_atmosphere();
5612
5613 assert_eq!(
5614 legacy_temp_c.to_bits(),
5615 pre_temp_c.to_bits(),
5616 "temperature=({temperature}, {pressure}, {altitude})"
5617 );
5618 assert_eq!(
5619 legacy_pressure_hpa.to_bits(),
5620 pre_pressure_hpa.to_bits(),
5621 "pressure=({temperature}, {pressure}, {altitude})"
5622 );
5623 assert_eq!(legacy_density.to_bits(), pre_density.to_bits());
5624 assert_eq!(legacy_sos.to_bits(), pre_sos.to_bits());
5625 }
5626
5627 for (altitude, pinned) in [
5632 (
5633 150.0,
5634 [
5636 f64::from_bits(0x3ff344d9d9fd1ea1),
5637 f64::from_bits(0x40754269eee53ba5),
5638 f64::from_bits(0x402c0ccfd0c49b20),
5639 f64::from_bits(0x408f1ae13f434f1f),
5640 ],
5641 ),
5642 (
5643 1500.0,
5644 [
5646 f64::from_bits(0x3ff0e75c08c70bc0),
5647 f64::from_bits(0x4074ec457a661d16),
5648 f64::from_bits(0x4015025af8d136c0),
5649 f64::from_bits(0x408a6cc6358cefbd),
5650 ],
5651 ),
5652 ] {
5653 let atmosphere = AtmosphericConditions {
5654 temperature: 15.05,
5655 pressure: 1013.0,
5656 humidity: 50.0,
5657 altitude,
5658 };
5659 let bits = |r: (f64, f64, f64, f64)| [r.0, r.1, r.2, r.3].map(f64::to_bits);
5660 let pinned = pinned.map(f64::to_bits);
5661 let legacy = TrajectorySolver::new(
5662 BallisticInputs::default(),
5663 WindConditions::default(),
5664 atmosphere.clone(),
5665 );
5666 assert_eq!(
5667 bits(legacy.resolved_atmosphere()),
5668 pinned,
5669 "new at {altitude} m"
5670 );
5671 let neither = TrajectorySolver::new_with_station_presence(
5672 BallisticInputs::default(),
5673 WindConditions::default(),
5674 atmosphere.clone(),
5675 StationAtmospherePresence::NEITHER,
5676 );
5677 assert_eq!(
5678 bits(neither.resolved_atmosphere()),
5679 pinned,
5680 "NEITHER at {altitude} m"
5681 );
5682
5683 let both = TrajectorySolver::new_with_station_presence(
5684 BallisticInputs::default(),
5685 WindConditions::default(),
5686 atmosphere.clone(),
5687 StationAtmospherePresence::BOTH,
5688 );
5689 let authoritative = TrajectorySolver::new_with_resolved_station_atmosphere(
5690 BallisticInputs::default(),
5691 WindConditions::default(),
5692 atmosphere,
5693 );
5694 let both_resolved = both.resolved_atmosphere();
5695 assert_eq!(
5696 bits(both_resolved),
5697 bits(authoritative.resolved_atmosphere())
5698 );
5699 assert_eq!(both_resolved.2.to_bits(), 15.05_f64.to_bits());
5700 assert_eq!(both_resolved.3.to_bits(), 1013.0_f64.to_bits());
5701 assert_ne!(
5702 bits(both_resolved),
5703 pinned,
5704 "BOTH must not swap at {altitude} m"
5705 );
5706 }
5707 }
5708
5709 fn configured_euler_zero(vertical_wind_mps: f64, time_step_s: f64) -> TrajectorySolver {
5710 let inputs = BallisticInputs {
5711 muzzle_velocity: 800.0,
5712 bc_value: 0.5,
5713 bc_type: DragModel::G7,
5714 bullet_mass: 0.0109,
5715 bullet_diameter: 0.00782,
5716 bullet_length: 0.0309,
5717 sight_height: 0.05,
5718 ground_threshold: -100.0,
5719 use_rk4: false,
5720 use_adaptive_rk45: false,
5721 ..BallisticInputs::default()
5722 };
5723 let mut solver = TrajectorySolver::new_with_resolved_station_atmosphere(
5724 inputs,
5725 WindConditions::default(),
5726 AtmosphericConditions::default(),
5727 );
5728 solver.set_max_range(300.0);
5729 solver.set_time_step(time_step_s);
5730 if vertical_wind_mps != 0.0 {
5731 solver.set_wind_segments(vec![WindSegment {
5732 speed_kmh: 0.0,
5733 angle_deg: 0.0,
5734 until_m: 400.0,
5735 vertical_mps: vertical_wind_mps,
5736 }]);
5737 }
5738 solver
5739 }
5740
5741 #[test]
5742 fn inclined_shot_zeroes_like_a_level_rifle() {
5743 const ZERO_DISTANCE_M: f64 = 91.44; const SIGHT_HEIGHT_M: f64 = 0.0381; let inputs = BallisticInputs {
5752 bc_value: 0.5,
5753 bullet_mass: 150.0 * 0.06479891 / 1000.0,
5754 muzzle_velocity: 2700.0 * 0.3048,
5755 sight_height: SIGHT_HEIGHT_M,
5756 ..Default::default()
5757 };
5758
5759 let mut level = inputs.clone();
5760 level.shooting_angle = 0.0;
5761 let level_angle = TrajectorySolver::new(level, Default::default(), Default::default())
5762 .find_zero_angle(ZERO_DISTANCE_M, SIGHT_HEIGHT_M, ZeroTargetFrame::SightLine)
5763 .expect("level zero must solve");
5764
5765 let mut inclined = inputs;
5766 inclined.shooting_angle = 5.71_f64.to_radians();
5767 let inclined_angle =
5768 TrajectorySolver::new(inclined, Default::default(), Default::default())
5769 .find_zero_angle(ZERO_DISTANCE_M, SIGHT_HEIGHT_M, ZeroTargetFrame::SightLine)
5770 .expect("MBA-1412: a 5.71 deg incline at a 100 yd zero must be solvable");
5771
5772 assert!(
5773 (inclined_angle - level_angle).abs() < 1e-9,
5774 "zeroing is level-rifle sight geometry; incline must not move the solved zero: \
5775 level={level_angle}, inclined={inclined_angle}"
5776 );
5777 }
5778
5779 #[test]
5780 fn target_below_the_level_bore_impact_zeroes_with_a_downward_bore() {
5781 const TARGET_DISTANCE_M: f64 = 457.2; let inputs = BallisticInputs {
5786 bc_value: 0.462,
5787 bullet_mass: 168.0 * 0.06479891 / 1000.0,
5788 bullet_diameter: 0.308 * 0.0254,
5789 muzzle_velocity: 2700.0 * 0.3048,
5790 sight_height: 0.05,
5791 ..Default::default()
5792 };
5793 let solver = TrajectorySolver::new(inputs, Default::default(), Default::default());
5794 let level_impact_m = solver
5795 .zero_trial_height_at(0.0, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
5796 .expect("level trial")
5797 .expect("level trial reaches the target distance");
5798
5799 for depth_m in [0.005, 1.0, 4.572, 91.44] {
5802 let target_m = level_impact_m - depth_m;
5803 let angle = solver
5804 .find_zero_angle(TARGET_DISTANCE_M, target_m, ZeroTargetFrame::SightLine)
5805 .unwrap_or_else(|e| panic!("target {depth_m} m below the level impact: {e}"));
5806 assert!(
5807 angle < 0.0,
5808 "a target below the level impact needs a downward bore: depth={depth_m} m angle={angle}"
5809 );
5810 let hit_m = solver
5811 .zero_trial_height_at(angle, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
5812 .expect("verify trial")
5813 .expect("zeroed trial reaches the target distance");
5814 assert!(
5815 (hit_m - target_m).abs() < 0.0001,
5816 "zero missed a target {depth_m} m below the level impact: hit={hit_m} target={target_m}"
5817 );
5818 }
5819 }
5820
5821 #[test]
5822 fn configured_zero_keeps_segments_method_and_time_step_then_sets_base_angle() {
5823 const TARGET_DISTANCE_M: f64 = 150.0;
5824 const TARGET_HEIGHT_M: f64 = 0.05;
5825
5826 let mut segmented = configured_euler_zero(-10.0, 0.02);
5829 let coarse_height = segmented
5830 .zero_trial_height_at(0.0, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
5831 .expect("coarse configured trial")
5832 .expect("coarse trial reaches target");
5833 let mut fine = segmented.clone();
5834 fine.set_time_step(0.001);
5835 let fine_height = fine
5836 .zero_trial_height_at(0.0, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
5837 .expect("fine configured trial")
5838 .expect("fine trial reaches target");
5839 assert!(
5840 (coarse_height - fine_height).abs() > 1e-5,
5841 "configured Euler step must affect zero trials: coarse={coarse_height}, fine={fine_height}"
5842 );
5843
5844 let segmented_angle = segmented
5845 .calculate_and_set_zero_angle(
5846 TARGET_DISTANCE_M,
5847 TARGET_HEIGHT_M,
5848 ZeroTargetFrame::SightLine,
5849 )
5850 .expect("segmented zero");
5851 assert_eq!(
5852 segmented.inputs.muzzle_angle.to_bits(),
5853 segmented_angle.to_bits(),
5854 "successful zero must install its angle on the configured solver"
5855 );
5856 assert_eq!(segmented.time_step.to_bits(), 0.02_f64.to_bits());
5857 assert_eq!(segmented.max_range.to_bits(), 300.0_f64.to_bits());
5858 assert!(segmented.wind_sock.is_some());
5859 assert_eq!(segmented.station_presence, StationAtmospherePresence::BOTH);
5860 let zero_height = segmented
5861 .zero_trial_height_at(
5862 segmented_angle,
5863 TARGET_DISTANCE_M,
5864 ZeroTargetFrame::SightLine,
5865 )
5866 .expect("verify segmented zero")
5867 .expect("zeroed trial reaches target");
5868 assert!(
5869 (zero_height - TARGET_HEIGHT_M).abs() < 0.0001,
5870 "configured zero missed target: height={zero_height}"
5871 );
5872
5873 let mut calm = configured_euler_zero(0.0, 0.02);
5874 let calm_angle = calm
5875 .calculate_and_set_zero_angle(
5876 TARGET_DISTANCE_M,
5877 TARGET_HEIGHT_M,
5878 ZeroTargetFrame::SightLine,
5879 )
5880 .expect("calm zero");
5881 assert!(
5882 (segmented_angle - calm_angle).abs() > 1e-5,
5883 "segmented vertical wind must participate in zero trials: segmented={segmented_angle}, calm={calm_angle}"
5884 );
5885 }
5886}
5887
5888#[cfg(test)]
5889mod uniform_gravity_tests {
5890 use super::*;
5891
5892 #[test]
5893 fn uniform_gravity_is_per_solver_and_survives_cloning() {
5894 for (use_rk4, use_adaptive_rk45) in [(false, false), (true, false), (true, true)] {
5895 let inputs = BallisticInputs {
5897 bullet_mass: 0.0459,
5898 bullet_diameter: 0.0427,
5899 bullet_length: 0.0427,
5900 muzzle_velocity: 10.0,
5901 muzzle_height: 10.0,
5902 muzzle_angle: 0.0,
5903 bc_type: DragModel::GS,
5904 custom_drag_table: Some(crate::drag::DragTable::new(
5905 vec![0.0, 5.0],
5906 vec![1e-20, 1e-20],
5907 )),
5908 use_rk4,
5909 use_adaptive_rk45,
5910 ..BallisticInputs::default()
5911 };
5912 let public = TrajectorySolver::new(
5913 inputs.clone(),
5914 WindConditions::default(),
5915 AtmosphericConditions::default(),
5916 );
5917 for magnitude in [0.0, 1.62, 9.80665, 20.0] {
5918 let mut solver = TrajectorySolver::new_with_station_presence_and_gravity(
5919 inputs.clone(),
5920 WindConditions::default(),
5921 AtmosphericConditions::default(),
5922 StationAtmospherePresence::NEITHER,
5923 Gravity::new(magnitude).unwrap(),
5924 )
5925 .clone();
5926 solver.set_max_range(10.0);
5927 let result = solver.solve().unwrap();
5928 let end = result.points.last().unwrap();
5929 assert!((end.time - 1.0).abs() < 1e-8);
5930 let step = if use_adaptive_rk45 {
5933 RK45_MAX_DT
5934 } else {
5935 solver.time_step
5936 };
5937 let tolerance = magnitude * step * step / 8.0
5938 + if use_rk4 { 0.0 } else { magnitude * step / 2.0 }
5939 + 1e-8;
5940 assert!(
5941 (end.position.y - (10.0 - 0.5 * magnitude)).abs() < tolerance,
5942 "g={magnitude}, rk4={use_rk4}, adaptive={use_adaptive_rk45}, y={}, time={}",
5943 end.position.y,
5944 end.time
5945 );
5946 assert!(
5947 (solver.speed_budget(1.0) - solver.speed_budget(0.0) - magnitude).abs() < 1e-12
5948 );
5949 assert_eq!(public.gravity, Gravity::EARTH);
5950 if magnitude == 9.80665 {
5951 let mut earth = public.clone();
5952 earth.set_max_range(10.0);
5953 let reference = earth.solve().unwrap();
5954 assert_eq!(result.points.len(), reference.points.len());
5955 for (actual, expected) in result.points.iter().zip(&reference.points) {
5956 assert_eq!(actual.time.to_bits(), expected.time.to_bits());
5957 assert_eq!(actual.position, expected.position);
5958 assert_eq!(
5959 actual.velocity_magnitude.to_bits(),
5960 expected.velocity_magnitude.to_bits()
5961 );
5962 }
5963 }
5964 }
5965 }
5966 }
5967
5968 fn match_308_inputs() -> BallisticInputs {
5969 BallisticInputs {
5970 muzzle_velocity: 800.0,
5971 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
5972 bullet_diameter: 0.308 * 0.0254,
5973 bullet_length: 1.215 * 0.0254,
5974 twist_rate: 10.0,
5975 ..BallisticInputs::default()
5976 }
5977 }
5978
5979 fn station_1000_m() -> AtmosphericConditions {
5980 AtmosphericConditions {
5981 temperature: 15.0,
5982 pressure: 1013.25,
5983 humidity: 0.0,
5984 altitude: 1000.0,
5985 }
5986 }
5987
5988 fn aerodynamic_acceleration_with_gravity(
5992 inputs: &BallisticInputs,
5993 gravity: Gravity,
5994 position: Vector3<f64>,
5995 velocity: Vector3<f64>,
5996 ) -> Vector3<f64> {
5997 let solver = TrajectorySolver::new_with_station_presence_and_gravity(
5998 inputs.clone(),
5999 WindConditions::default(),
6000 station_1000_m(),
6001 StationAtmospherePresence::BOTH,
6002 gravity,
6003 );
6004 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
6005 solver.calculate_acceleration(
6006 &position,
6007 &velocity,
6008 &Vector3::zeros(),
6009 (temp_c, pressure_hpa, density / 1.225),
6010 ) - solver.gravity_acceleration()
6011 }
6012
6013 #[test]
6014 fn local_atmosphere_uses_the_simulation_gravity() {
6015 let inputs = match_308_inputs(); let velocity = Vector3::new(600.0, 0.0, 0.0);
6017 let low_gravity = Gravity::new(3.71).unwrap();
6018 let aerodynamic = |position, gravity| {
6019 aerodynamic_acceleration_with_gravity(&inputs, gravity, position, velocity)
6020 };
6021
6022 let earth = aerodynamic(Vector3::zeros(), Gravity::EARTH);
6025 let low = aerodynamic(Vector3::zeros(), low_gravity);
6026 assert!(
6027 (earth - low).norm() < 1e-12,
6028 "station-altitude drag must not depend on gravity: earth={earth:?}, low={low:?}"
6029 );
6030
6031 let aloft = Vector3::new(0.0, 2_000.0, 0.0);
6034 let earth = aerodynamic(aloft, Gravity::EARTH);
6035 let low = aerodynamic(aloft, low_gravity);
6036 let ratio = low.x / earth.x;
6037 assert!(earth.x < 0.0, "drag must oppose flight: {earth:?}");
6038 assert!(
6039 (ratio - 1.162_702).abs() < 1e-4,
6040 "drag aloft must follow the simulation's hydrostatic density: low/earth={ratio}, \
6041 expected 1.162702 (standard lapse rate, 1000 m station, 2000 m above it)"
6042 );
6043 }
6044
6045 #[test]
6046 fn magnus_yaw_of_repose_uses_the_simulation_gravity() {
6047 let velocity = Vector3::new(800.0, 0.0, 0.0);
6048 let low_gravity = Gravity::new(3.71).unwrap();
6049 let aerodynamic = |enable_magnus, gravity| {
6051 let inputs = BallisticInputs {
6052 enable_magnus,
6053 ..match_308_inputs()
6054 };
6055 aerodynamic_acceleration_with_gravity(&inputs, gravity, Vector3::zeros(), velocity)
6056 };
6057
6058 let earth = aerodynamic(false, Gravity::EARTH);
6060 let low = aerodynamic(false, low_gravity);
6061 assert!(
6062 (earth - low).norm() < 1e-12,
6063 "Magnus-off drag must not depend on gravity: earth={earth:?}, low={low:?}"
6064 );
6065
6066 let earth = aerodynamic(true, Gravity::EARTH) - earth;
6068 let low = aerodynamic(true, low_gravity) - low;
6069 let ratio = low.y / earth.y;
6070 let expected = low_gravity.magnitude() / Gravity::EARTH.magnitude();
6071 assert!(
6072 earth.y < 0.0,
6073 "right-hand Magnus must point down: {earth:?}"
6074 );
6075 assert!(
6076 (ratio - expected).abs() < 1e-6,
6077 "Magnus must scale with the simulation's gravity: low/earth={ratio}, expected={expected}"
6078 );
6079 }
6080}
6081
6082#[cfg(test)]
6083mod result_sanity_tests {
6084 use super::*;
6085
6086 fn default_solver() -> TrajectorySolver {
6087 TrajectorySolver::new(
6088 BallisticInputs::default(),
6089 WindConditions::default(),
6090 AtmosphericConditions::default(),
6091 )
6092 }
6093
6094 fn minimal_result() -> TrajectoryResult {
6095 TrajectoryResult {
6096 max_range: 100.0,
6097 max_height: 1.0,
6098 time_of_flight: 0.5,
6099 impact_velocity: 700.0,
6100 impact_energy: 2450.0,
6101 projectile_mass_kg: 0.01,
6102 line_of_sight_height_m: 1.5,
6103 station_speed_of_sound_mps: 340.0,
6104 termination: TrajectoryTermination::MaxRange,
6105 points: vec![],
6106 sampled_points: None,
6107 min_pitch_damping: None,
6108 transonic_mach: None,
6109 angular_state: None,
6110 max_yaw_angle: None,
6111 max_precession_angle: None,
6112 aerodynamic_jump: None,
6113 mach_1_2_distance_m: None,
6114 mach_1_0_distance_m: None,
6115 mach_0_9_distance_m: None,
6116 }
6117 }
6118
6119 #[test]
6120 fn mba1293_negative_scalars_fail_the_result_postcondition() {
6121 let solver = default_solver();
6122 solver
6123 .validate_result_sanity(&minimal_result())
6124 .expect("a sane result must pass");
6125
6126 for (name, mutate) in [
6127 (
6128 "max_range",
6129 (|r| r.max_range = -50.588) as fn(&mut TrajectoryResult),
6130 ),
6131 ("time_of_flight", |r| r.time_of_flight = -1.0),
6132 ("impact_velocity", |r| r.impact_velocity = -700.0),
6133 ("impact_energy", |r| r.impact_energy = -1.0),
6134 ] {
6135 let mut result = minimal_result();
6136 mutate(&mut result);
6137 let error = solver
6138 .validate_result_sanity(&result)
6139 .expect_err("negative scalar must fail");
6140 assert!(
6141 error.to_string().contains(name),
6142 "error for {name} did not name the field: {error}"
6143 );
6144 }
6145 }
6146
6147 #[test]
6148 fn mba1293_speed_budget_bounds_legitimate_states_and_rejects_divergence() {
6149 let solver = default_solver();
6150 let mv = solver.inputs.muzzle_velocity;
6151
6152 let position = Vector3::new(10.0, 0.0, 0.0);
6154 solver
6155 .validate_integration_state(&position, &Vector3::new(mv, 0.0, 0.0), 0.01)
6156 .expect("muzzle-speed state must pass");
6157
6158 let error = solver
6160 .validate_integration_state(&position, &Vector3::new(-13.0 * mv, 0.0, 0.0), 0.01)
6161 .expect_err("13x muzzle speed must fail the budget");
6162 assert!(error.to_string().contains("diverged"), "{error}");
6163
6164 let after_fall = mv + crate::constants::G_ACCEL_MPS2 * 60.0;
6166 solver
6167 .validate_integration_state(&position, &Vector3::new(0.0, -after_fall, 0.0), 60.0)
6168 .expect("gravity-accelerated speed within g*t must pass");
6169 }
6170}
6171
6172#[cfg(test)]
6173mod trajectory_point_budget_tests {
6174 use super::*;
6175 use crate::MAX_TRAJECTORY_SAMPLES;
6176
6177 fn solver_with_budget(
6178 use_rk4: bool,
6179 use_adaptive_rk45: bool,
6180 point_budget: usize,
6181 max_range: f64,
6182 ) -> TrajectorySolver {
6183 let inputs = BallisticInputs {
6184 use_rk4,
6185 use_adaptive_rk45,
6186 ground_threshold: f64::NEG_INFINITY,
6187 ..BallisticInputs::default()
6188 };
6189 let mut solver = TrajectorySolver::new(
6190 inputs,
6191 WindConditions::default(),
6192 AtmosphericConditions::default(),
6193 );
6194 solver.max_trajectory_points = point_budget;
6195 solver.set_max_range(max_range);
6196 solver.set_time_step(0.001);
6197 solver
6198 }
6199
6200 #[test]
6201 fn mba1283_every_solver_errors_instead_of_exceeding_point_budget() {
6202 for (mode, use_rk4, use_adaptive_rk45) in [
6203 ("Euler", false, false),
6204 ("RK4", true, false),
6205 ("RK45", true, true),
6206 ] {
6207 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 3, 10.0)
6208 .solve()
6209 .expect_err("a solve requiring more than three points must fail");
6210 assert!(
6211 error.to_string().contains("point limit of 3"),
6212 "unexpected {mode} point-budget error: {error}"
6213 );
6214 }
6215 }
6216
6217 #[test]
6218 fn mba1283_interpolated_endpoint_counts_toward_point_budget() {
6219 for (mode, use_rk4, use_adaptive_rk45) in [
6220 ("Euler", false, false),
6221 ("RK4", true, false),
6222 ("RK45", true, true),
6223 ] {
6224 let result = solver_with_budget(use_rk4, use_adaptive_rk45, 2, 0.1)
6225 .solve()
6226 .expect("the initial point plus exact endpoint fit a two-point budget");
6227 assert_eq!(result.points.len(), 2, "unexpected {mode} point count");
6228
6229 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 1, 0.1)
6230 .solve()
6231 .expect_err("the exact endpoint must not exceed a one-point budget");
6232 assert!(
6233 error.to_string().contains("point limit of 1"),
6234 "unexpected {mode} endpoint-budget error: {error}"
6235 );
6236 }
6237 }
6238
6239 #[test]
6247 fn mba1599_collapsed_stall_steps_count_toward_point_budget() {
6248 for (mode, use_rk4, use_adaptive_rk45, bc_value) in [
6249 ("Euler", false, false, 2.0e-4),
6250 ("RK4", true, false, 1.0e-4),
6251 ("RK45", true, true, 1.0e-4),
6252 ] {
6253 let stalled = |point_budget: usize| {
6254 let inputs = BallisticInputs {
6255 bc_value,
6256 bc_type: DragModel::G7,
6257 bullet_mass: 0.01134,
6258 bullet_diameter: 0.00782,
6259 muzzle_velocity: 823.0,
6260 use_rk4,
6261 use_adaptive_rk45,
6262 ground_threshold: -100.0,
6263 ..BallisticInputs::default()
6264 };
6265 let mut solver = TrajectorySolver::new(
6266 inputs,
6267 WindConditions::default(),
6268 AtmosphericConditions::default(),
6269 );
6270 solver.max_trajectory_points = point_budget;
6271 solver.set_max_range(300.0);
6272 solver.set_time_step(0.001);
6273 solver.solve()
6274 };
6275
6276 let flown = stalled(MAX_TRAJECTORY_POINTS)
6277 .unwrap_or_else(|error| panic!("{mode}: the stall ends inside the cap: {error}"));
6278 assert_eq!(
6279 flown.termination,
6280 TrajectoryTermination::GroundThreshold,
6281 "{mode}"
6282 );
6283 let stored = flown.points.len();
6284 let budget = stored + 1;
6285 let Err(error) = stalled(budget) else {
6286 panic!("{mode}: the flight fit a {budget}-point budget with {stored} stored points, so the collapsed steps were not counted");
6287 };
6288 assert!(
6289 error
6290 .to_string()
6291 .contains(&format!("trajectory point limit of {budget} exceeded")),
6292 "unexpected {mode} stall-budget error: {error}"
6293 );
6294 }
6295 }
6296
6297 #[test]
6301 fn mba1599_point_budget_fails_on_the_same_call_with_or_without_collapsing() {
6302 let solver = solver_with_budget(true, false, 4, 10.0);
6303 let point = |x: f64| TrajectoryPoint {
6304 time: x,
6305 position: Vector3::new(x, 0.0, 0.0),
6306 velocity_magnitude: 1.0,
6307 kinetic_energy: 0.0,
6308 drag_coefficient: None,
6309 };
6310 let assert_over_budget = |error: BallisticsError| {
6311 assert!(
6312 error
6313 .to_string()
6314 .contains("trajectory point limit of 4 exceeded"),
6315 "unexpected point-budget error: {error}"
6316 );
6317 };
6318 for distances in [
6319 [0.0, 1.0, 2.0, 3.0],
6320 [0.0, 1.0, 1.0, 2.0],
6321 [0.0, 0.0, 0.0, 0.0],
6322 ] {
6323 let mut points = Vec::new();
6324 let mut collapsed_steps = 0;
6325 for x in distances {
6326 solver
6327 .push_trajectory_point(&mut points, point(x), &mut collapsed_steps)
6328 .expect("four calls fit a four-point budget");
6329 }
6330 assert_eq!(points.len() + collapsed_steps, 4, "{distances:?}");
6331
6332 for x in [distances[3], 5.0] {
6334 let error = solver
6335 .push_trajectory_point(
6336 &mut points.clone(),
6337 point(x),
6338 &mut collapsed_steps.clone(),
6339 )
6340 .expect_err("a fifth call exceeds a four-point budget");
6341 assert_over_budget(error);
6342 }
6343 let error = solver
6345 .append_terminal_endpoint(
6346 &mut points.clone(),
6347 Vector3::new(20.0, 0.0, 0.0),
6348 Vector3::new(1.0, 0.0, 0.0),
6349 10.0,
6350 &mut 0.0,
6351 &mut collapsed_steps.clone(),
6352 )
6353 .expect_err("the terminal point is a fifth call");
6354 assert_over_budget(error);
6355 }
6356 }
6357
6358 #[test]
6359 fn mba1299_every_solver_preflights_the_sample_budget() {
6360 for (mode, use_rk4, use_adaptive_rk45) in [
6361 ("Euler", false, false),
6362 ("RK4", true, false),
6363 ("RK45", true, true),
6364 ] {
6365 let inputs = BallisticInputs {
6366 use_rk4,
6367 use_adaptive_rk45,
6368 enable_trajectory_sampling: true,
6369 sample_interval: 1.0,
6370 ground_threshold: f64::NEG_INFINITY,
6371 ..BallisticInputs::default()
6372 };
6373 let mut solver = TrajectorySolver::new(
6374 inputs,
6375 WindConditions::default(),
6376 AtmosphericConditions::default(),
6377 );
6378 solver.set_max_range(MAX_TRAJECTORY_SAMPLES as f64);
6379 solver.max_trajectory_points = 0;
6382
6383 let error = solver
6384 .solve()
6385 .expect_err("an over-limit sample grid must fail before integration");
6386 assert!(
6387 error
6388 .to_string()
6389 .contains("trajectory sample limit of 250000 exceeded"),
6390 "unexpected {mode} sample-budget error: {error}"
6391 );
6392 }
6393 }
6394
6395 #[test]
6396 fn mba1299_normal_sampling_does_not_change_solver_results() {
6397 for (mode, use_rk4, use_adaptive_rk45) in [
6398 ("Euler", false, false),
6399 ("RK4", true, false),
6400 ("RK45", true, true),
6401 ] {
6402 let solve = |enable_trajectory_sampling| {
6403 let inputs = BallisticInputs {
6404 use_rk4,
6405 use_adaptive_rk45,
6406 enable_trajectory_sampling,
6407 sample_interval: 0.5,
6408 ground_threshold: f64::NEG_INFINITY,
6409 ..BallisticInputs::default()
6410 };
6411 let mut solver = TrajectorySolver::new(
6412 inputs,
6413 WindConditions::default(),
6414 AtmosphericConditions::default(),
6415 );
6416 solver.set_max_range(2.0);
6417 solver.solve().expect("normal short-range solve")
6418 };
6419
6420 let baseline = solve(false);
6421 let sampled = solve(true);
6422 for (field, left, right) in [
6423 ("max_range", baseline.max_range, sampled.max_range),
6424 ("max_height", baseline.max_height, sampled.max_height),
6425 (
6426 "time_of_flight",
6427 baseline.time_of_flight,
6428 sampled.time_of_flight,
6429 ),
6430 (
6431 "impact_velocity",
6432 baseline.impact_velocity,
6433 sampled.impact_velocity,
6434 ),
6435 (
6436 "impact_energy",
6437 baseline.impact_energy,
6438 sampled.impact_energy,
6439 ),
6440 ] {
6441 assert_eq!(
6442 left.to_bits(),
6443 right.to_bits(),
6444 "{mode} sampling changed {field}"
6445 );
6446 }
6447 assert_eq!(baseline.points.len(), sampled.points.len());
6448 for (index, (left, right)) in baseline.points.iter().zip(&sampled.points).enumerate() {
6449 assert_eq!(
6450 left.time.to_bits(),
6451 right.time.to_bits(),
6452 "{mode} point {index}"
6453 );
6454 assert_eq!(
6455 left.position.map(f64::to_bits),
6456 right.position.map(f64::to_bits),
6457 "{mode} point {index} position"
6458 );
6459 assert_eq!(
6460 left.velocity_magnitude.to_bits(),
6461 right.velocity_magnitude.to_bits(),
6462 "{mode} point {index} velocity"
6463 );
6464 assert_eq!(
6465 left.kinetic_energy.to_bits(),
6466 right.kinetic_energy.to_bits(),
6467 "{mode} point {index} energy"
6468 );
6469 }
6470 assert!(baseline.sampled_points.is_none());
6471 let samples = sampled
6472 .sampled_points
6473 .expect("sampling-enabled solve should return observations");
6474 assert_eq!(
6475 samples
6476 .iter()
6477 .map(|sample| sample.distance_m)
6478 .collect::<Vec<_>>(),
6479 vec![0.0, 0.5, 1.0, 1.5, 2.0],
6480 "{mode} normal sampling grid changed"
6481 );
6482 }
6483 }
6484}
6485
6486#[cfg(test)]
6487mod monte_carlo_result_tests {
6488 use super::*;
6489
6490 fn make_results(impact_positions: Vec<Vector3<f64>>) -> MonteCarloResults {
6491 let count = impact_positions.len();
6492 MonteCarloResults {
6493 ranges: vec![500.0; count],
6494 impact_velocities: vec![300.0; count],
6495 impact_positions,
6496 }
6497 }
6498
6499 #[test]
6500 fn target_plane_cep_excludes_shortfall_markers() {
6501 let mut positions: Vec<Vector3<f64>> = (1..=5)
6502 .map(|radius| Vector3::new(0.0, radius as f64, 0.0))
6503 .collect();
6504 positions.extend((0..5).map(|_| Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)));
6505 let results = make_results(positions);
6506
6507 assert_eq!(results.target_arrival_count(), 5);
6508 assert_eq!(results.target_shortfall_fraction(), 0.5);
6509 assert_eq!(results.target_plane_cep(), Some(3.0));
6510
6511 let one_shortfall = make_results(vec![
6512 Vector3::new(0.0, 1.0, 0.0),
6513 Vector3::new(0.0, 2.0, 0.0),
6514 Vector3::new(0.0, 3.0, 0.0),
6515 Vector3::new(0.0, 4.0, 0.0),
6516 Vector3::new(0.0, 5.0, 0.0),
6517 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
6518 ]);
6519 assert_eq!(one_shortfall.target_plane_cep(), Some(3.0));
6520 }
6521
6522 #[test]
6523 fn all_shortfalls_have_no_cep_but_still_count_as_misses() {
6524 let all_shortfalls = make_results(vec![
6525 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
6526 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
6527 ]);
6528 assert_eq!(all_shortfalls.target_arrival_count(), 0);
6529 assert_eq!(all_shortfalls.target_shortfall_fraction(), 1.0);
6530 assert_eq!(all_shortfalls.target_plane_cep(), None);
6531 assert_eq!(all_shortfalls.hit_probability(0.3), 0.0);
6532
6533 let one_hit_one_shortfall = make_results(vec![
6534 Vector3::new(0.0, 0.1, 0.0),
6535 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
6536 ]);
6537 assert_eq!(one_hit_one_shortfall.hit_probability(0.3), 0.5);
6538 }
6539
6540 #[test]
6542 fn rect_hit_probability_checks_independent_axis_halves() {
6543 let results = make_results(vec![
6544 Vector3::new(0.0, 0.1, 0.1),
6546 Vector3::new(0.0, 0.0, 0.2),
6548 Vector3::new(0.0, 0.0, 0.201),
6550 Vector3::new(0.0, 0.301, 0.0),
6552 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
6554 ]);
6555 assert!((results.rect_hit_probability(0.4, 0.6) - 0.4).abs() < 1e-12);
6557 }
6558
6559 #[test]
6560 fn rect_hit_probability_matches_circular_hit_probability_for_a_centered_hit() {
6561 let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
6562 assert_eq!(results.rect_hit_probability(0.5, 0.5), 1.0);
6563 assert_eq!(results.hit_probability(0.3), 1.0);
6564 }
6565
6566 #[test]
6567 fn rect_hit_probability_is_zero_for_empty_or_nonpositive_dimensions() {
6568 let empty = make_results(vec![]);
6569 assert_eq!(empty.rect_hit_probability(1.0, 1.0), 0.0);
6570
6571 let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
6572 assert_eq!(results.rect_hit_probability(0.0, 1.0), 0.0);
6573 assert_eq!(results.rect_hit_probability(1.0, 0.0), 0.0);
6574 assert_eq!(results.rect_hit_probability(-1.0, 1.0), 0.0);
6575 }
6576}
6577
6578#[cfg(test)]
6582mod station_presence_twin_tests {
6583 use super::*;
6584
6585 fn rifle() -> BallisticInputs {
6586 BallisticInputs {
6587 muzzle_velocity: 823.0,
6588 bc_value: 0.475,
6589 bc_type: DragModel::G1,
6590 bullet_mass: 0.0109,
6591 bullet_diameter: 0.00782,
6592 sight_height: 0.0381,
6593 ..Default::default()
6594 }
6595 }
6596
6597 fn air(temperature: f64, pressure: f64, altitude: f64) -> AtmosphericConditions {
6598 AtmosphericConditions {
6599 temperature,
6600 pressure,
6601 humidity: 50.0,
6602 altitude,
6603 }
6604 }
6605
6606 #[test]
6607 fn zero_twins_reproduce_ab4eb0c_with_neither_and_fly_a_supplied_reading() {
6608 let cases = [
6611 (
6612 20.0,
6613 1013.0,
6614 150.0,
6615 f64::from_bits(0x3f65f73333333333),
6616 f64::from_bits(0x4034aaf5701ff812),
6617 f64::from_bits(0x406c0aa313fbdeae),
6618 ),
6619 (
6620 15.0,
6621 850.0,
6622 1500.0,
6623 f64::from_bits(0x3f6594ccccccccce),
6624 f64::from_bits(0x4034aa5d88f5baa5),
6625 f64::from_bits(0x406c6ab97964e714),
6626 ),
6627 ];
6628 for (t, p, alt, zero, near, far) in cases {
6629 let calm = WindConditions::default;
6630 let legacy = calculate_zero_angle_with_conditions(
6631 rifle(),
6632 300.0,
6633 0.0381,
6634 calm(),
6635 air(t, p, alt),
6636 )
6637 .unwrap();
6638 let neither = calculate_zero_angle_with_station_presence(
6639 rifle(),
6640 300.0,
6641 0.0381,
6642 calm(),
6643 air(t, p, alt),
6644 StationAtmospherePresence::NEITHER,
6645 )
6646 .unwrap();
6647 assert_eq!(
6648 legacy.to_bits(),
6649 zero.to_bits(),
6650 "legacy zero ({t}, {p}, {alt})"
6651 );
6652 assert_eq!(
6653 neither.to_bits(),
6654 zero.to_bits(),
6655 "NEITHER zero ({t}, {p}, {alt})"
6656 );
6657
6658 let legacy_range = calculate_zero_range_from_angle_with_conditions(
6659 rifle(),
6660 0.002,
6661 0.0381,
6662 calm(),
6663 air(t, p, alt),
6664 )
6665 .unwrap();
6666 let neither_range = calculate_zero_range_from_angle_with_station_presence(
6667 rifle(),
6668 0.002,
6669 0.0381,
6670 calm(),
6671 air(t, p, alt),
6672 StationAtmospherePresence::NEITHER,
6673 )
6674 .unwrap();
6675 for crossings in [legacy_range, neither_range] {
6676 assert_eq!(crossings.near_m.unwrap().to_bits(), near.to_bits());
6677 assert_eq!(crossings.far_m.unwrap().to_bits(), far.to_bits());
6678 }
6679
6680 let both = calculate_zero_angle_with_station_presence(
6683 rifle(),
6684 300.0,
6685 0.0381,
6686 calm(),
6687 air(t, p, alt),
6688 StationAtmospherePresence::BOTH,
6689 )
6690 .unwrap();
6691 let authoritative = calculate_zero_angle_with_resolved_conditions(
6692 rifle(),
6693 300.0,
6694 0.0381,
6695 calm(),
6696 air(t, p, alt),
6697 )
6698 .unwrap();
6699 assert_eq!(both.to_bits(), authoritative.to_bits());
6700 assert_ne!(
6701 both.to_bits(),
6702 zero.to_bits(),
6703 "BOTH must not swap ({t}, {p}, {alt})"
6704 );
6705
6706 let both_range = calculate_zero_range_from_angle_with_station_presence(
6707 rifle(),
6708 0.002,
6709 0.0381,
6710 calm(),
6711 air(t, p, alt),
6712 StationAtmospherePresence::BOTH,
6713 )
6714 .unwrap();
6715 let authoritative_range = calculate_zero_range_from_angle_with_resolved_conditions(
6716 rifle(),
6717 0.002,
6718 0.0381,
6719 calm(),
6720 air(t, p, alt),
6721 )
6722 .unwrap();
6723 assert_eq!(both_range, authoritative_range);
6724 assert_ne!(both_range.far_m.unwrap().to_bits(), far.to_bits());
6725 }
6726 }
6727
6728 #[test]
6729 fn solver_twin_with_both_lies_between_out_of_band_neighbours() {
6730 let impact = |p: f64, presence| {
6731 let mut solver = TrajectorySolver::new_with_station_presence(
6732 rifle(),
6733 WindConditions::default(),
6734 air(20.0, p, 150.0),
6735 presence,
6736 );
6737 solver.set_max_range(600.0);
6738 solver.solve().unwrap().impact_velocity
6739 };
6740 let low = impact(1012.0, StationAtmospherePresence::NEITHER);
6741 let high = impact(1014.0, StationAtmospherePresence::NEITHER);
6742 let both = impact(1013.0, StationAtmospherePresence::BOTH);
6743 let neither = impact(1013.0, StationAtmospherePresence::NEITHER);
6744 assert!(high < both && both < low, "{high} < {both} < {low}");
6746 assert_ne!(both.to_bits(), neither.to_bits());
6747
6748 let temperature = |t: f64, presence| {
6749 let mut solver = TrajectorySolver::new_with_station_presence(
6750 rifle(),
6751 WindConditions::default(),
6752 air(t, 850.0, 1500.0),
6753 presence,
6754 );
6755 solver.set_max_range(600.0);
6756 solver.solve().unwrap().impact_velocity
6757 };
6758 let cold = temperature(14.8, StationAtmospherePresence::NEITHER);
6759 let warm = temperature(15.2, StationAtmospherePresence::NEITHER);
6760 let both = temperature(15.0, StationAtmospherePresence::BOTH);
6761 assert!(cold < both && both < warm, "{cold} < {both} < {warm}");
6763 assert_ne!(
6764 both.to_bits(),
6765 temperature(15.0, StationAtmospherePresence::NEITHER).to_bits()
6766 );
6767 }
6768
6769 #[test]
6771 fn monte_carlo_twin_with_neither_reproduces_the_legacy_seeded_pins() {
6772 let inputs = BallisticInputs {
6773 muzzle_velocity: 800.0,
6774 ..BallisticInputs::default()
6775 };
6776 let params = MonteCarloParams {
6777 num_simulations: 200,
6778 target_distance: Some(500.0),
6779 ..MonteCarloParams::default()
6780 };
6781 let results = run_monte_carlo_with_station_presence(
6782 inputs,
6783 WindConditions::default(),
6784 params,
6785 0.01,
6786 Some(0x1352_5EED),
6787 StationAtmospherePresence::NEITHER,
6788 )
6789 .unwrap();
6790 assert_eq!(results.ranges.len(), 200);
6791 assert_eq!(
6792 results.hit_probability(DEFAULT_HIT_RADIUS_M).to_bits(),
6793 0.14_f64.to_bits()
6794 );
6795 let expected_ranges: [f64; 3] = [1907.972891143359, 1936.408435469319, 1912.8150447617645];
6796 let expected_velocities: [f64; 3] =
6797 [238.6187151542299, 239.91923651600106, 243.14112455427164];
6798 let expected_positions: [(f64, f64); 3] = [
6799 (-0.0643556039548101, 0.7344970252014579),
6800 (0.5769422971539162, 0.27227201756386726),
6801 (-0.7440425792472842, 0.1541804446282822),
6802 ];
6803 for i in 0..3 {
6804 assert_eq!(results.ranges[i].to_bits(), expected_ranges[i].to_bits());
6805 assert_eq!(
6806 results.impact_velocities[i].to_bits(),
6807 expected_velocities[i].to_bits()
6808 );
6809 assert_eq!(
6810 results.impact_positions[i].y.to_bits(),
6811 expected_positions[i].0.to_bits()
6812 );
6813 assert_eq!(
6814 results.impact_positions[i].z.to_bits(),
6815 expected_positions[i].1.to_bits()
6816 );
6817 }
6818 }
6819
6820 fn in_band_mc_inputs() -> BallisticInputs {
6821 BallisticInputs {
6822 muzzle_velocity: 800.0,
6823 temperature: 20.0,
6824 pressure: 1013.0,
6825 altitude: 150.0,
6826 ..BallisticInputs::default()
6827 }
6828 }
6829
6830 #[test]
6831 fn monte_carlo_in_band_seeded_case_is_pinned_and_both_differs() {
6832 let params = MonteCarloParams {
6833 num_simulations: 50,
6834 target_distance: Some(500.0),
6835 ..MonteCarloParams::default()
6836 };
6837 let pinned = [
6839 (
6840 f64::from_bits(0x409e36cf28692da7),
6841 f64::from_bits(0x406e6111e25be26e),
6842 ),
6843 (
6844 f64::from_bits(0x409eaa63c504259d),
6845 f64::from_bits(0x406e8a3b4e34eef5),
6846 ),
6847 (
6848 f64::from_bits(0x409e4838920e477c),
6849 f64::from_bits(0x406ef31c9a289584),
6850 ),
6851 ];
6852 let seeded = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
6853 in_band_mc_inputs(),
6854 WindConditions::default(),
6855 params.clone(),
6856 0.01,
6857 0x1352_5EED,
6858 )
6859 .unwrap();
6860 let neither = run_monte_carlo_with_station_presence(
6861 in_band_mc_inputs(),
6862 WindConditions::default(),
6863 params.clone(),
6864 0.01,
6865 Some(0x1352_5EED),
6866 StationAtmospherePresence::NEITHER,
6867 )
6868 .unwrap();
6869 for results in [&seeded, &neither] {
6870 assert_eq!(results.ranges.len(), 50);
6871 for (i, (range, velocity)) in pinned.iter().enumerate() {
6872 assert_eq!(results.ranges[i].to_bits(), range.to_bits(), "range[{i}]");
6873 assert_eq!(
6874 results.impact_velocities[i].to_bits(),
6875 velocity.to_bits(),
6876 "velocity[{i}]"
6877 );
6878 }
6879 }
6880 let both = run_monte_carlo_with_station_presence(
6881 in_band_mc_inputs(),
6882 WindConditions::default(),
6883 params,
6884 0.01,
6885 Some(0x1352_5EED),
6886 StationAtmospherePresence::BOTH,
6887 )
6888 .unwrap();
6889 assert_ne!(both.ranges[0].to_bits(), pinned[0].0.to_bits());
6890 assert!(both.ranges[0] < pinned[0].0);
6892 }
6893
6894 #[test]
6895 fn adaptive_monte_carlo_twin_is_pinned_and_both_differs() {
6896 let params = MonteCarloParams {
6897 num_simulations: 1,
6898 velocity_std_dev: 3.0,
6899 angle_std_dev: 3.5e-4,
6900 bc_std_dev: 0.01,
6901 wind_speed_std_dev: 1.0,
6902 target_distance: Some(300.0),
6903 base_wind_speed: 0.0,
6904 base_wind_direction: 0.0,
6905 azimuth_std_dev: 3.5e-4,
6906 };
6907 let convergence = McConvergence {
6908 min_samples: 40,
6909 max_samples: 80,
6910 batch_size: 40,
6911 ..McConvergence::default()
6912 };
6913 let legacy = run_monte_carlo_adaptive_seeded(
6914 &in_band_mc_inputs(),
6915 &WindConditions::default(),
6916 ¶ms,
6917 &convergence,
6918 DEFAULT_HIT_RADIUS_M,
6919 7,
6920 )
6921 .unwrap();
6922 let neither = run_monte_carlo_adaptive_seeded_with_station_presence(
6923 &in_band_mc_inputs(),
6924 &WindConditions::default(),
6925 ¶ms,
6926 &convergence,
6927 DEFAULT_HIT_RADIUS_M,
6928 7,
6929 StationAtmospherePresence::NEITHER,
6930 )
6931 .unwrap();
6932 for report in [&legacy, &neither] {
6933 assert_eq!(report.samples, 80);
6934 assert_eq!(report.attempts, 80);
6935 assert_eq!(
6937 report.hit_probability.to_bits(),
6938 f64::from_bits(0x3fef99999999999a).to_bits()
6939 );
6940 assert_eq!(
6941 report.mean_impact_velocity_mps.to_bits(),
6942 0x406e99be313a64aa
6943 );
6944 assert_eq!(report.mean_drop_at_target_m.to_bits(), 0x3f63de496e905406);
6945 }
6946 let both = run_monte_carlo_adaptive_seeded_with_station_presence(
6947 &in_band_mc_inputs(),
6948 &WindConditions::default(),
6949 ¶ms,
6950 &convergence,
6951 DEFAULT_HIT_RADIUS_M,
6952 7,
6953 StationAtmospherePresence::BOTH,
6954 )
6955 .unwrap();
6956 assert_ne!(both.mean_impact_velocity_mps.to_bits(), 0x406e99be313a64aa);
6957 }
6958
6959 #[test]
6963 fn monte_carlo_baseline_flies_the_trials_air() {
6964 let params = MonteCarloParams {
6965 num_simulations: 3,
6966 velocity_std_dev: 0.0,
6967 angle_std_dev: 0.0,
6968 bc_std_dev: 0.0,
6969 wind_speed_std_dev: 0.0,
6970 target_distance: Some(500.0),
6971 base_wind_speed: 0.0,
6972 base_wind_direction: 0.0,
6973 azimuth_std_dev: 0.0,
6974 };
6975 let inputs = BallisticInputs {
6976 temperature: 15.0,
6977 ..in_band_mc_inputs()
6978 };
6979 for presence in [
6980 StationAtmospherePresence::NEITHER,
6981 StationAtmospherePresence::new(true, false),
6982 StationAtmospherePresence::new(false, true),
6983 StationAtmospherePresence::BOTH,
6984 ] {
6985 let results = run_monte_carlo_with_station_presence(
6986 inputs.clone(),
6987 WindConditions::default(),
6988 params.clone(),
6989 0.0,
6990 Some(7),
6991 presence,
6992 )
6993 .unwrap();
6994 assert_eq!(results.impact_positions.len(), 3);
6995 for position in &results.impact_positions {
6996 assert_eq!(position.y.to_bits(), 0.0_f64.to_bits(), "{presence:?}");
6997 assert_eq!(position.z.to_bits(), 0.0_f64.to_bits(), "{presence:?}");
6998 }
6999 }
7000 }
7001
7002 #[test]
7006 fn estimate_bc_fit_twin_zeroes_in_the_supplied_air() {
7007 let atmosphere = air(15.0, 1013.0, 150.0);
7008 let presence = StationAtmospherePresence::BOTH;
7009 let mut inputs = BallisticInputs {
7010 muzzle_velocity: 823.0,
7011 bc_value: 0.475,
7012 bc_type: DragModel::G1,
7013 bullet_mass: 0.0109,
7014 bullet_diameter: 0.00782,
7015 sight_height: 0.0381,
7016 ..Default::default()
7017 };
7018 inputs.muzzle_angle = calculate_zero_angle_with_station_presence(
7019 inputs.clone(),
7020 100.0,
7021 0.0381,
7022 WindConditions::default(),
7023 atmosphere.clone(),
7024 presence,
7025 )
7026 .unwrap();
7027 let mut solver = TrajectorySolver::new_with_station_presence(
7028 inputs,
7029 WindConditions::default(),
7030 atmosphere.clone(),
7031 presence,
7032 );
7033 solver.set_max_range(750.0);
7034 let flight = solver.solve().unwrap();
7035 let points: Vec<(f64, f64)> = [300.0, 500.0]
7036 .iter()
7037 .map(|&d| {
7038 (
7039 d,
7040 fit_value_at(&flight.points, d, BcFitMode::Drop, 0.0381).unwrap(),
7041 )
7042 })
7043 .collect();
7044 let fit = estimate_bc_fit_with_station_presence(
7045 823.0,
7046 0.0109,
7047 0.00782,
7048 &points,
7049 DragModel::G1,
7050 BcFitMode::Drop,
7051 atmosphere,
7052 Some(100.0),
7053 0.0381,
7054 presence,
7055 )
7056 .unwrap();
7057 assert!((fit.bc - 0.475).abs() < 1e-9, "{fit:?}");
7058 assert!(fit.rms_error < 1e-9, "{fit:?}");
7059 }
7060
7061 #[test]
7062 fn estimate_bc_fit_twin_is_pinned_and_both_fits_the_supplied_air() {
7063 let points = [(300.0, 650.0), (500.0, 560.0)];
7064 let fit = |p: f64, presence| {
7065 estimate_bc_fit_with_station_presence(
7066 823.0,
7067 0.0109,
7068 0.00782,
7069 &points,
7070 DragModel::G1,
7071 BcFitMode::Velocity,
7072 air(20.0, p, 150.0),
7073 None,
7074 0.0381,
7075 presence,
7076 )
7077 .unwrap()
7078 .bc
7079 };
7080 let legacy = estimate_bc_fit(
7081 823.0,
7082 0.0109,
7083 0.00782,
7084 &points,
7085 DragModel::G1,
7086 BcFitMode::Velocity,
7087 air(20.0, 1013.0, 150.0),
7088 None,
7089 0.0381,
7090 )
7091 .unwrap()
7092 .bc;
7093 let pinned = f64::from_bits(0x3fe0000000000003);
7095 assert_eq!(legacy.to_bits(), pinned.to_bits());
7096 assert_eq!(
7097 fit(1013.0, StationAtmospherePresence::NEITHER).to_bits(),
7098 pinned.to_bits()
7099 );
7100 let low = fit(1010.0, StationAtmospherePresence::NEITHER);
7104 let high = fit(1016.0, StationAtmospherePresence::NEITHER);
7105 let both = fit(1013.0, StationAtmospherePresence::BOTH);
7106 assert!(low < both && both < high, "{low} < {both} < {high}");
7107 }
7108
7109 #[test]
7110 fn monte_carlo_kernel_twin_is_pinned_and_both_lies_between_neighbours() {
7111 let kernel_inputs = |p: f64| BallisticInputs {
7112 muzzle_velocity: 823.0,
7113 bc_value: 0.475,
7114 bullet_mass: 0.0109,
7115 bullet_diameter: 0.00782,
7116 target_distance: 500.0,
7117 temperature: 20.0,
7118 pressure: p,
7119 altitude: 150.0,
7120 ..Default::default()
7121 };
7122 let solve = |p: f64, presence| {
7123 crate::monte_carlo::solve_trajectory_for_monte_carlo_with_station_presence(
7124 &kernel_inputs(p),
7125 presence,
7126 )
7127 .unwrap()
7128 };
7129 let legacy =
7130 crate::monte_carlo::solve_trajectory_for_monte_carlo(&kernel_inputs(1013.0)).unwrap();
7131 let neither = solve(1013.0, StationAtmospherePresence::NEITHER);
7132 for output in [&legacy, &neither] {
7133 assert_eq!(output.drop.to_bits(), 0x40039eddd41bdb44);
7135 assert_eq!(output.velocity.to_bits(), 0x4080fe7009642434);
7136 }
7137 let low = solve(1012.0, StationAtmospherePresence::NEITHER).velocity;
7138 let high = solve(1014.0, StationAtmospherePresence::NEITHER).velocity;
7139 let both = solve(1013.0, StationAtmospherePresence::BOTH).velocity;
7140 assert!(high < both && both < low, "{high} < {both} < {low}");
7141 }
7142}
7143
7144#[cfg(test)]
7145mod monte_carlo_seeded_tests {
7146 use super::*;
7147
7148 fn seeded_test_fixture() -> (BallisticInputs, WindConditions) {
7157 (
7158 BallisticInputs {
7159 muzzle_velocity: 800.0,
7160 ..BallisticInputs::default()
7161 },
7162 WindConditions::default(),
7163 )
7164 }
7165
7166 fn loose_params() -> MonteCarloParams {
7176 MonteCarloParams {
7177 num_simulations: 1, velocity_std_dev: 3.0,
7179 angle_std_dev: 3.5e-4,
7180 bc_std_dev: 0.01,
7181 wind_speed_std_dev: 1.0,
7182 target_distance: Some(300.0),
7183 base_wind_speed: 0.0,
7184 base_wind_direction: 0.0,
7185 azimuth_std_dev: 3.5e-4,
7186 }
7187 }
7188
7189 fn mixed_arrival_params() -> MonteCarloParams {
7197 MonteCarloParams {
7198 num_simulations: 1,
7199 target_distance: Some(1920.0),
7200 ..MonteCarloParams::default()
7201 }
7202 }
7203
7204 #[test]
7220 fn legacy_seeded_estimates_are_pinned_bit_for_bit() {
7221 let (inputs, wind) = seeded_test_fixture();
7222 let params = MonteCarloParams {
7223 num_simulations: 200,
7224 target_distance: Some(500.0),
7225 ..MonteCarloParams::default()
7226 };
7227
7228 let results = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
7229 inputs,
7230 wind,
7231 params,
7232 0.01,
7233 0x1352_5EED,
7234 )
7235 .expect("seeded legacy run");
7236
7237 assert_eq!(results.ranges.len(), 200, "ranges length");
7242 assert_eq!(
7243 results.impact_velocities.len(),
7244 200,
7245 "impact_velocities length"
7246 );
7247 assert_eq!(
7248 results.impact_positions.len(),
7249 200,
7250 "impact_positions length"
7251 );
7252
7253 assert_eq!(
7255 results.hit_probability(DEFAULT_HIT_RADIUS_M).to_bits(),
7256 0.14_f64.to_bits(),
7257 "hit_probability = {:?}",
7258 results.hit_probability(DEFAULT_HIT_RADIUS_M)
7259 );
7260
7261 let expected_ranges: [f64; 3] = [1907.972891143359, 1936.408435469319, 1912.8150447617645];
7262 let expected_velocities: [f64; 3] =
7263 [238.6187151542299, 239.91923651600106, 243.14112455427164];
7264 let expected_positions: [(f64, f64, f64); 3] = [
7265 (0.0, -0.0643556039548101, 0.7344970252014579),
7266 (0.0, 0.5769422971539162, 0.27227201756386726),
7267 (0.0, -0.7440425792472842, 0.1541804446282822),
7268 ];
7269
7270 for (i, expected) in expected_ranges.iter().enumerate() {
7271 assert_eq!(
7272 results.ranges[i].to_bits(),
7273 expected.to_bits(),
7274 "ranges[{i}] = {:?}, pinned {expected:?}",
7275 results.ranges[i]
7276 );
7277 }
7278 for (i, expected) in expected_velocities.iter().enumerate() {
7279 assert_eq!(
7280 results.impact_velocities[i].to_bits(),
7281 expected.to_bits(),
7282 "impact_velocities[{i}] = {:?}, pinned {expected:?}",
7283 results.impact_velocities[i]
7284 );
7285 }
7286 for (i, (x, y, z)) in expected_positions.iter().enumerate() {
7287 let actual = results.impact_positions[i];
7288 assert_eq!(
7289 actual.x.to_bits(),
7290 x.to_bits(),
7291 "impact_positions[{i}].x = {:?}",
7292 actual.x
7293 );
7294 assert_eq!(
7295 actual.y.to_bits(),
7296 y.to_bits(),
7297 "impact_positions[{i}].y = {:?}",
7298 actual.y
7299 );
7300 assert_eq!(
7301 actual.z.to_bits(),
7302 z.to_bits(),
7303 "impact_positions[{i}].z = {:?}",
7304 actual.z
7305 );
7306 }
7307 }
7308
7309 #[test]
7310 fn seeded_runs_are_deterministic_and_match_the_using_rng_path() {
7311 let inputs = BallisticInputs {
7312 muzzle_velocity: 800.0,
7313 ..BallisticInputs::default()
7314 };
7315 let params = MonteCarloParams {
7316 num_simulations: 64,
7317 target_distance: Some(200.0),
7318 ..MonteCarloParams::default()
7319 };
7320
7321 let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
7322 inputs.clone(),
7323 WindConditions::default(),
7324 params.clone(),
7325 0.01,
7326 42,
7327 )
7328 .expect("seeded run a");
7329 let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
7330 inputs,
7331 WindConditions::default(),
7332 params,
7333 0.01,
7334 42,
7335 )
7336 .expect("seeded run b");
7337
7338 assert_eq!(a.ranges.len(), b.ranges.len());
7339 for (ra, rb) in a.ranges.iter().zip(b.ranges.iter()) {
7340 assert_eq!(ra.to_bits(), rb.to_bits());
7341 }
7342 for (pa, pb) in a.impact_positions.iter().zip(b.impact_positions.iter()) {
7343 assert_eq!(pa.x.to_bits(), pb.x.to_bits());
7344 assert_eq!(pa.y.to_bits(), pb.y.to_bits());
7345 assert_eq!(pa.z.to_bits(), pb.z.to_bits());
7346 }
7347 }
7348
7349 #[test]
7350 fn different_seeds_generally_produce_different_draws() {
7351 let inputs = BallisticInputs {
7352 muzzle_velocity: 800.0,
7353 ..BallisticInputs::default()
7354 };
7355 let params = MonteCarloParams {
7356 num_simulations: 32,
7357 velocity_std_dev: 5.0,
7358 target_distance: Some(200.0),
7359 ..MonteCarloParams::default()
7360 };
7361
7362 let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
7363 inputs.clone(),
7364 WindConditions::default(),
7365 params.clone(),
7366 0.0,
7367 1,
7368 )
7369 .expect("seeded run a");
7370 let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
7371 inputs,
7372 WindConditions::default(),
7373 params,
7374 0.0,
7375 2,
7376 )
7377 .expect("seeded run b");
7378
7379 assert_ne!(a.impact_velocities, b.impact_velocities);
7380 }
7381
7382 #[test]
7383 fn adaptive_stops_at_target_half_width_on_an_easy_case() {
7384 let (inputs, wind) = seeded_test_fixture();
7385 let conv = McConvergence {
7386 target_half_width: 0.05,
7387 ..Default::default()
7388 };
7389 let r = run_monte_carlo_adaptive_seeded(
7390 &inputs,
7391 &wind,
7392 &loose_params(),
7393 &conv,
7394 DEFAULT_HIT_RADIUS_M,
7395 0x1352_ADA9,
7396 )
7397 .unwrap();
7398
7399 assert_eq!(r.stop_reason, McStopReason::TargetHalfWidthMet);
7400 assert!(
7401 (r.ci_high - r.ci_low) / 2.0 <= 0.05 + 1e-12,
7402 "half-width {} exceeds the requested 0.05",
7403 (r.ci_high - r.ci_low) / 2.0
7404 );
7405 assert!(r.samples >= conv.min_samples, "stopped below min_samples");
7406 assert!(r.samples < conv.max_samples, "did not actually stop early");
7407 assert!(r.samples.is_multiple_of(conv.batch_size) || r.samples == conv.min_samples);
7408 assert!(r.ci_low <= r.hit_probability && r.hit_probability <= r.ci_high);
7409
7410 assert_eq!(r.hit_radius_m, DEFAULT_HIT_RADIUS_M);
7412 assert_eq!(r.target_distance_m, 300.0);
7413 assert_eq!(r.confidence_percent, 95);
7414 assert!(
7416 r.samples > 1,
7417 "params.num_simulations must be ignored by the adaptive driver"
7418 );
7419 assert!(
7422 r.mean_impact_velocity_mps > 0.0,
7423 "no impact velocity accumulated"
7424 );
7425 assert!(
7426 r.std_drop_at_target_m > 0.0 && r.std_wind_drift_at_target_m > 0.0,
7427 "dispersion collapsed: drop sd {} drift sd {}",
7428 r.std_drop_at_target_m,
7429 r.std_wind_drift_at_target_m
7430 );
7431 }
7432
7433 #[test]
7434 fn adaptive_caps_at_max_samples_on_an_impossible_target() {
7435 let (inputs, wind) = seeded_test_fixture();
7436 let conv = McConvergence {
7437 target_half_width: 1e-6,
7438 max_samples: 3_000,
7439 batch_size: 500,
7440 min_samples: 1_000,
7441 level: ConfidenceLevel::P95,
7442 };
7443 let r = run_monte_carlo_adaptive_seeded(
7444 &inputs,
7445 &wind,
7446 &loose_params(),
7447 &conv,
7448 DEFAULT_HIT_RADIUS_M,
7449 7,
7450 )
7451 .unwrap();
7452
7453 assert_eq!(r.stop_reason, McStopReason::MaxSamplesReached);
7454 assert_eq!(r.samples, 3_000);
7455 }
7456
7457 #[test]
7466 fn adaptive_stops_between_the_floor_and_the_ceiling() {
7467 let (inputs, wind) = seeded_test_fixture();
7468 let conv = McConvergence {
7469 level: ConfidenceLevel::P95,
7470 target_half_width: 0.03,
7471 min_samples: 0,
7472 max_samples: 10_000,
7473 batch_size: 100,
7474 };
7475 let r = run_monte_carlo_adaptive_seeded(
7476 &inputs,
7477 &wind,
7478 &loose_params(),
7479 &conv,
7480 DEFAULT_HIT_RADIUS_M,
7481 0x1352_5A1D,
7482 )
7483 .unwrap();
7484
7485 assert_eq!(r.stop_reason, McStopReason::TargetHalfWidthMet);
7486 assert!(r.samples > 0);
7487 assert!(
7488 r.samples.is_multiple_of(conv.batch_size),
7489 "samples {} is not a whole number of batches",
7490 r.samples
7491 );
7492 assert!(
7493 r.samples > conv.min_samples,
7494 "stopped on the floor, not on the data"
7495 );
7496 assert!(
7497 r.samples < conv.max_samples,
7498 "ran to the ceiling, so nothing adaptive was exercised"
7499 );
7500 assert!(
7501 r.samples > conv.batch_size,
7502 "stopped on the very first look ({} samples); the multi-batch path is untested",
7503 r.samples
7504 );
7505 assert!((r.ci_high - r.ci_low) / 2.0 <= 0.03 + 1e-12);
7506 assert!(r.ci_low <= r.hit_probability && r.hit_probability <= r.ci_high);
7507 assert_eq!(r.attempts, r.samples, "no trial should have been dropped");
7508 }
7509
7510 #[test]
7518 fn adaptive_runs_a_truncated_final_batch_up_to_max_samples() {
7519 let (inputs, wind) = seeded_test_fixture();
7520 let conv = McConvergence {
7521 level: ConfidenceLevel::P95,
7522 target_half_width: 1e-6,
7523 min_samples: 0,
7524 max_samples: 750,
7525 batch_size: 500,
7526 };
7527 let r = run_monte_carlo_adaptive_seeded(
7528 &inputs,
7529 &wind,
7530 &loose_params(),
7531 &conv,
7532 DEFAULT_HIT_RADIUS_M,
7533 0x1352_7B10,
7534 )
7535 .unwrap();
7536
7537 assert_eq!(r.stop_reason, McStopReason::MaxSamplesReached);
7538 assert_eq!(
7539 r.samples, 750,
7540 "the 250-trial final batch did not run, or was not truncated"
7541 );
7542 assert_eq!(r.attempts, 750);
7543 assert!(!r.samples.is_multiple_of(conv.batch_size));
7544 }
7545
7546 #[test]
7547 fn adaptive_is_deterministic_for_a_seed() {
7548 let (inputs, wind) = seeded_test_fixture();
7549 let conv = McConvergence::default();
7550 let a = run_monte_carlo_adaptive_seeded(
7551 &inputs,
7552 &wind,
7553 &loose_params(),
7554 &conv,
7555 DEFAULT_HIT_RADIUS_M,
7556 99,
7557 )
7558 .unwrap();
7559 let b = run_monte_carlo_adaptive_seeded(
7560 &inputs,
7561 &wind,
7562 &loose_params(),
7563 &conv,
7564 DEFAULT_HIT_RADIUS_M,
7565 99,
7566 )
7567 .unwrap();
7568
7569 assert_eq!(a.hit_probability.to_bits(), b.hit_probability.to_bits());
7570 assert_eq!(a.samples, b.samples);
7571 assert_eq!(a.ci_low.to_bits(), b.ci_low.to_bits());
7572 assert_eq!(a.ci_high.to_bits(), b.ci_high.to_bits());
7573 assert_eq!(
7576 a.mean_impact_velocity_mps.to_bits(),
7577 b.mean_impact_velocity_mps.to_bits()
7578 );
7579 assert_eq!(
7580 a.std_drop_at_target_m.to_bits(),
7581 b.std_drop_at_target_m.to_bits()
7582 );
7583 }
7584
7585 #[test]
7586 fn adaptive_report_carries_schema_method_and_all_four_assumptions() {
7587 let (inputs, wind) = seeded_test_fixture();
7588 let conv = McConvergence {
7592 min_samples: 0,
7593 max_samples: 50,
7594 batch_size: 50,
7595 target_half_width: 1.0,
7596 level: ConfidenceLevel::P90,
7597 };
7598 let r = run_monte_carlo_adaptive_seeded(
7599 &inputs,
7600 &wind,
7601 &mixed_arrival_params(),
7602 &conv,
7603 DEFAULT_HIT_RADIUS_M,
7604 0x1352_D0C5,
7605 )
7606 .unwrap();
7607
7608 assert_eq!(r.schema_version, MC_ADAPTIVE_SCHEMA_VERSION_V1);
7609 assert_eq!(r.schema_version, 1);
7610 assert_eq!(r.method, "anytime_beta_binomial_mixture_cs_v1");
7611 assert_eq!(r.confidence_percent, 90);
7612
7613 assert_eq!(r.attempts, 50, "one full batch was drawn");
7619 assert_eq!(r.samples, 50, "no trial was dropped by the solver");
7620 assert!(
7621 r.arrivals > 0 && r.arrivals < r.samples,
7622 "fixture must split the run: arrivals {} of samples {}",
7623 r.arrivals,
7624 r.samples
7625 );
7626 assert!(
7629 r.arrivals >= 2,
7630 "arrivals {} too few for a sample sd",
7631 r.arrivals
7632 );
7633 assert!(r.std_drop_at_target_m > 0.0 && r.std_impact_velocity_mps > 0.0);
7634 assert!(r.attempts >= r.samples && r.samples >= r.arrivals);
7636
7637 assert_eq!(r.assumptions.len(), 4, "exactly four assumptions expected");
7643 assert_eq!(
7644 r.assumptions[0],
7645 "Sampling uncertainty only: intervals cover Monte Carlo sampling error, not model error in the trajectory solver or its inputs."
7646 );
7647 assert_eq!(
7648 r.assumptions[1],
7649 "Anytime-valid stopping: the beta-binomial mixture confidence sequence keeps its coverage guarantee despite stopping the moment the target half-width is met."
7650 );
7651 assert_eq!(
7652 r.assumptions[2],
7653 "Input dispersions are the independent normal distributions declared in MonteCarloParams; correlations between inputs are not modeled."
7654 );
7655 assert_eq!(
7656 r.assumptions[3],
7657 "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."
7658 );
7659
7660 assert_eq!(
7662 serde_json::to_string(&McStopReason::TargetHalfWidthMet).unwrap(),
7663 "\"target_half_width_met\""
7664 );
7665 assert_eq!(
7666 serde_json::to_string(&McStopReason::MaxSamplesReached).unwrap(),
7667 "\"max_samples_reached\""
7668 );
7669 }
7670
7671 #[test]
7672 fn adaptive_rejects_nonsense_convergence() {
7673 let (inputs, wind) = seeded_test_fixture();
7674 let run = |conv: McConvergence| {
7675 run_monte_carlo_adaptive_seeded(
7676 &inputs,
7677 &wind,
7678 &loose_params(),
7679 &conv,
7680 DEFAULT_HIT_RADIUS_M,
7681 1,
7682 )
7683 .unwrap_err()
7684 };
7685
7686 for bad_width in [0.0, -0.01, f64::NAN] {
7687 let err = run(McConvergence {
7688 target_half_width: bad_width,
7689 ..Default::default()
7690 });
7691 assert!(
7692 err.contains("target_half_width"),
7693 "error must name the field, got: {err}"
7694 );
7695 }
7696
7697 let err = run(McConvergence {
7698 batch_size: 0,
7699 ..Default::default()
7700 });
7701 assert!(err.contains("batch_size"), "got: {err}");
7702
7703 let err = run(McConvergence {
7704 min_samples: 5_000,
7705 max_samples: 1_000,
7706 ..Default::default()
7707 });
7708 assert!(err.contains("max_samples"), "got: {err}");
7709 assert!(err.contains("min_samples"), "got: {err}");
7710
7711 let err = run(McConvergence {
7712 max_samples: 0,
7713 min_samples: 0,
7714 ..Default::default()
7715 });
7716 assert!(err.contains("max_samples"), "got: {err}");
7717
7718 let err = McConvergence {
7720 batch_size: 0,
7721 ..Default::default()
7722 }
7723 .validate()
7724 .unwrap_err();
7725 assert!(err.contains("batch_size"));
7726 }
7727
7728 #[test]
7729 fn wilson_companion_matches_hit_probability_and_wilson_interval() {
7730 let (inputs, wind) = seeded_test_fixture();
7731 let params = MonteCarloParams {
7732 num_simulations: 128,
7733 target_distance: Some(500.0),
7734 ..MonteCarloParams::default()
7735 };
7736 let results = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
7737 inputs,
7738 wind,
7739 params,
7740 0.01,
7741 0x1352_C0DE,
7742 )
7743 .expect("seeded legacy run");
7744
7745 for level in [
7746 ConfidenceLevel::P90,
7747 ConfidenceLevel::P95,
7748 ConfidenceLevel::P99,
7749 ] {
7750 let (p_hat, (lo, hi), n) = results.hit_probability_wilson(DEFAULT_HIT_RADIUS_M, level);
7751
7752 assert_eq!(
7755 p_hat.to_bits(),
7756 results.hit_probability(DEFAULT_HIT_RADIUS_M).to_bits()
7757 );
7758 assert_eq!(n, results.impact_positions.len() as u64);
7759
7760 let hits = results
7764 .impact_positions
7765 .iter()
7766 .filter(|p| MonteCarloResults::position_is_hit(p, DEFAULT_HIT_RADIUS_M))
7767 .count() as u64;
7768 let (want_lo, want_hi) = wilson_interval(hits, n, level);
7769 assert_eq!(lo.to_bits(), want_lo.to_bits());
7770 assert_eq!(hi.to_bits(), want_hi.to_bits());
7771 assert!(lo <= p_hat && p_hat <= hi, "interval excludes p_hat");
7772 }
7773
7774 let empty = MonteCarloResults {
7776 ranges: Vec::new(),
7777 impact_velocities: Vec::new(),
7778 impact_positions: Vec::new(),
7779 };
7780 assert_eq!(
7781 empty.hit_probability_wilson(DEFAULT_HIT_RADIUS_M, ConfidenceLevel::P95),
7782 (0.0, (0.0, 1.0), 0)
7783 );
7784 }
7785}
7786
7787#[cfg(test)]
7788mod monte_carlo_powder_curve_tests {
7789 use super::*;
7790 use rand::{rngs::StdRng, SeedableRng};
7791
7792 #[test]
7793 fn powder_curve_preserves_sampled_muzzle_velocity_dispersion() {
7794 let inputs = BallisticInputs {
7795 muzzle_velocity: 700.0,
7796 powder_temp_curve: Some(vec![(15.0, 800.0)]),
7797 powder_curve_temp_c: Some(15.0),
7798 ..BallisticInputs::default()
7799 };
7800 let params = MonteCarloParams {
7801 num_simulations: 16,
7802 velocity_std_dev: 20.0,
7803 angle_std_dev: 1e-12,
7804 bc_std_dev: 1e-12,
7805 wind_speed_std_dev: 1e-12,
7806 target_distance: Some(100.0),
7807 azimuth_std_dev: 1e-12,
7808 ..MonteCarloParams::default()
7809 };
7810
7811 let mut rng = StdRng::seed_from_u64(0x5EED_1176);
7812 let results = run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
7813 inputs,
7814 WindConditions::default(),
7815 params,
7816 0.0,
7817 StationAtmospherePresence::NEITHER,
7818 &mut rng,
7819 )
7820 .expect("Monte Carlo solve");
7821 let min_velocity = results
7822 .impact_velocities
7823 .iter()
7824 .copied()
7825 .fold(f64::INFINITY, f64::min);
7826 let max_velocity = results
7827 .impact_velocities
7828 .iter()
7829 .copied()
7830 .fold(f64::NEG_INFINITY, f64::max);
7831
7832 assert!(
7833 max_velocity - min_velocity > 1.0,
7834 "20 m/s muzzle spread collapsed after curve resolution: impact-velocity span={} m/s",
7835 max_velocity - min_velocity
7836 );
7837 }
7838}
7839
7840#[cfg(test)]
7841mod monte_carlo_wind_sampling_tests {
7842 use super::*;
7843 use rand::{rngs::StdRng, SeedableRng};
7844
7845 #[test]
7846 fn wind_speed_sigma_does_not_change_seeded_direction_draws() {
7847 let base_wind = WindConditions {
7848 speed: 100.0,
7849 direction: 0.37,
7850 vertical_speed: 0.0,
7851 };
7852 let narrow_speed = MonteCarloWindSampler::new(&base_wind, 0.5, 0.2).unwrap();
7853 let wide_speed = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
7854 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
7855 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
7856 let mut speed_changed = false;
7857
7858 for _ in 0..32 {
7859 let narrow = narrow_speed.sample(&mut narrow_rng);
7860 let wide = wide_speed.sample(&mut wide_rng);
7861 assert!(narrow.speed > 0.0 && wide.speed > 0.0);
7862 assert_eq!(narrow.direction.to_bits(), wide.direction.to_bits());
7863 speed_changed |= narrow.speed.to_bits() != wide.speed.to_bits();
7864 }
7865 assert!(
7866 speed_changed,
7867 "different speed sigmas must still vary speed draws"
7868 );
7869 }
7870
7871 #[test]
7872 fn zero_direction_sigma_has_no_angular_jitter() {
7873 let base_wind = WindConditions {
7874 speed: 100.0,
7875 direction: 0.37,
7876 vertical_speed: 0.0,
7877 };
7878 let sampler = MonteCarloWindSampler::new(&base_wind, 4.0, 0.0).unwrap();
7879 let mut rng = StdRng::seed_from_u64(0x5EED_1223);
7880 let mut speed_changed = false;
7881
7882 for _ in 0..32 {
7883 let wind = sampler.sample(&mut rng);
7884 speed_changed |= wind.speed.to_bits() != base_wind.speed.to_bits();
7885 assert_eq!(wind.direction.to_bits(), base_wind.direction.to_bits());
7886 }
7887 assert!(speed_changed, "speed uncertainty should remain active");
7888 }
7889
7890 #[test]
7891 fn direction_sigma_controls_seeded_angular_spread_in_radians() {
7892 let base_wind = WindConditions {
7893 speed: 100.0,
7894 direction: 0.37,
7895 vertical_speed: 0.0,
7896 };
7897 let narrow = MonteCarloWindSampler::new(&base_wind, 4.0, 0.1).unwrap();
7898 let wide = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
7899 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
7900 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
7901 let mut nonzero_direction_draw = false;
7902
7903 for _ in 0..32 {
7904 let narrow_wind = narrow.sample(&mut narrow_rng);
7905 let wide_wind = wide.sample(&mut wide_rng);
7906 assert_eq!(narrow_wind.speed.to_bits(), wide_wind.speed.to_bits());
7907
7908 let narrow_delta = narrow_wind.direction - base_wind.direction;
7909 let wide_delta = wide_wind.direction - base_wind.direction;
7910 assert!((wide_delta - 2.0 * narrow_delta).abs() < 1e-12);
7911 nonzero_direction_draw |= narrow_delta.abs() > 1e-6;
7912 }
7913 assert!(
7914 nonzero_direction_draw,
7915 "positive radians sigma must vary direction"
7916 );
7917 }
7918
7919 #[test]
7920 fn direction_sigma_rejects_negative_or_nonfinite_values() {
7921 let base_wind = WindConditions::default();
7922 for sigma in [-0.1, f64::NAN, f64::INFINITY] {
7923 assert!(MonteCarloWindSampler::new(&base_wind, 1.0, sigma).is_err());
7924 }
7925 }
7926
7927 #[test]
7928 fn base_vertical_wind_rides_into_every_mc_sample() {
7929 use rand::SeedableRng;
7933 let base_wind = WindConditions {
7934 vertical_speed: 4.2,
7935 ..Default::default()
7936 };
7937 let sampler = MonteCarloWindSampler::new(&base_wind, 1.0, 0.2).unwrap();
7938 let mut rng = rand::rngs::StdRng::seed_from_u64(7);
7939 for _ in 0..32 {
7940 let w = sampler.sample(&mut rng);
7941 assert_eq!(w.vertical_speed, 4.2);
7942 }
7943 }
7944
7945 #[test]
7946 fn negative_speed_sample_reverses_wind_direction() {
7947 let direction = 0.25;
7948 let signed_speed = -2.5;
7949 let wind = wind_from_signed_speed_sample(signed_speed, direction, 0.0);
7950 let positive_wind = wind_from_signed_speed_sample(2.5, direction, 0.0);
7951
7952 assert_eq!(wind.speed, 2.5);
7953 assert!(
7954 (wind.direction - (direction + std::f64::consts::PI)).abs() < f64::EPSILON,
7955 "negative speed must reverse direction by pi: got {}",
7956 wind.direction
7957 );
7958 assert_eq!(positive_wind.speed, 2.5);
7959 assert_eq!(positive_wind.direction, direction);
7960
7961 let normalized_x = -wind.speed * wind.direction.cos();
7962 let normalized_z = -wind.speed * wind.direction.sin();
7963 let signed_x = -signed_speed * direction.cos();
7964 let signed_z = -signed_speed * direction.sin();
7965 assert!((normalized_x - signed_x).abs() < 1e-12);
7966 assert!((normalized_z - signed_z).abs() < 1e-12);
7967 }
7968}
7969
7970#[cfg(test)]
7971mod bc_fit_objective_tests {
7972 use super::*;
7973
7974 fn velocity_point(range_m: f64, velocity_mps: f64) -> TrajectoryPoint {
7975 TrajectoryPoint {
7976 time: 0.0,
7977 position: Vector3::new(range_m, 0.0, 0.0),
7978 velocity_magnitude: velocity_mps,
7979 kinetic_energy: 0.0,
7980 drag_coefficient: None,
7981 }
7982 }
7983
7984 #[test]
7985 fn candidate_that_misses_an_observation_has_no_score() {
7986 let trajectory = vec![velocity_point(0.0, 800.0), velocity_point(100.0, 700.0)];
7987 let observations = vec![(50.0, 750.0), (150.0, 600.0)];
7988
7989 assert!(
7990 fit_residual_sse(&trajectory, &observations, BcFitMode::Velocity, 0.0).is_none(),
7991 "a candidate that reaches only one of two observations must not compete on partial SSE"
7992 );
7993
7994 let complete_observations = vec![(50.0, 740.0), (100.0, 680.0)];
7995 assert_eq!(
7996 fit_residual_sse(
7997 &trajectory,
7998 &complete_observations,
7999 BcFitMode::Velocity,
8000 0.0,
8001 ),
8002 Some(500.0)
8003 );
8004 }
8005}
8006
8007#[cfg(test)]
8008mod cluster_bc_reference_space_tests {
8009 use super::*;
8010
8011 fn acceleration_at_1100_fps(inputs: BallisticInputs) -> Vector3<f64> {
8012 let solver = TrajectorySolver::new(
8013 inputs,
8014 WindConditions::default(),
8015 AtmosphericConditions::default(),
8016 );
8017 let position = Vector3::zeros();
8018 let velocity = Vector3::new(1100.0 / 3.28084, 0.0, 0.0);
8019 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
8020 solver.calculate_acceleration(
8021 &position,
8022 &velocity,
8023 &Vector3::zeros(),
8024 (temp_c, pressure_hpa, density / 1.225),
8025 )
8026 }
8027
8028 #[test]
8029 fn solver_passes_g7_reference_model_to_cluster_classifier() {
8030 let inputs = BallisticInputs {
8031 bc_value: 0.190,
8032 bc_type: DragModel::G7,
8033 bullet_mass: 77.0 * crate::constants::GRAINS_TO_KG,
8034 bullet_diameter: 0.224 * 0.0254,
8035 use_cluster_bc: true,
8036 ..BallisticInputs::default()
8037 };
8038
8039 let solver = TrajectorySolver::new(
8040 inputs,
8041 WindConditions::default(),
8042 AtmosphericConditions::default(),
8043 );
8044 let corrected = solver.apply_cluster_bc_correction(0.190, 2800.0);
8045
8046 assert!(
8047 (corrected / 0.190 - 1.004).abs() < 1e-12,
8048 "solver selected the wrong G7 cluster multiplier: {}",
8049 corrected / 0.190
8050 );
8051 }
8052
8053 #[test]
8054 fn velocity_bc_segments_are_not_cluster_corrected_twice() {
8055 let segmented_clustered = BallisticInputs {
8056 bc_value: 0.5,
8057 bc_type: DragModel::G7,
8058 use_bc_segments: true,
8059 bc_segments_data: Some(vec![
8060 crate::BCSegmentData {
8061 velocity_min: 0.0,
8062 velocity_max: 1_600.0,
8063 bc_value: 0.4,
8064 },
8065 crate::BCSegmentData {
8066 velocity_min: 1_600.0,
8067 velocity_max: 5_000.0,
8068 bc_value: 0.45,
8069 },
8070 ]),
8071 use_cluster_bc: true,
8072 ..BallisticInputs::default()
8073 };
8074 let mut segmented_only = segmented_clustered.clone();
8075 segmented_only.use_cluster_bc = false;
8076 let mut constant_clustered = segmented_clustered.clone();
8077 constant_clustered.bc_value = 0.4;
8078 constant_clustered.bc_segments_data = None;
8079
8080 let stacked = acceleration_at_1100_fps(segmented_clustered);
8081 let segment_only = acceleration_at_1100_fps(segmented_only);
8082 let cluster_only = acceleration_at_1100_fps(constant_clustered);
8083
8084 assert!(
8085 (stacked.x - segment_only.x).abs() < 1e-12,
8086 "segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
8087 stacked.x,
8088 segment_only.x
8089 );
8090 assert!(
8091 (cluster_only.x - segment_only.x).abs() > 1e-6,
8092 "cluster correction must remain active for a constant BC"
8093 );
8094 }
8095
8096 #[test]
8097 fn mach_bc_segments_are_not_cluster_corrected_twice() {
8098 let mach_segmented_clustered = BallisticInputs {
8099 bc_value: 0.5,
8100 bc_type: DragModel::G7,
8101 use_bc_segments: false,
8102 bc_segments: Some(vec![(0.5, 0.3), (1.5, 0.5)]),
8103 use_cluster_bc: true,
8104 ..BallisticInputs::default()
8105 };
8106 let mut mach_segmented_only = mach_segmented_clustered.clone();
8107 mach_segmented_only.use_cluster_bc = false;
8108
8109 let stacked = acceleration_at_1100_fps(mach_segmented_clustered);
8110 let segment_only = acceleration_at_1100_fps(mach_segmented_only);
8111
8112 assert!(
8113 (stacked.x - segment_only.x).abs() < 1e-12,
8114 "Mach segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
8115 stacked.x,
8116 segment_only.x
8117 );
8118 }
8119}
8120
8121#[cfg(test)]
8122mod velocity_bc_flag_tests {
8123 use super::*;
8124
8125 fn acceleration_at_600_mps(inputs: BallisticInputs) -> Vector3<f64> {
8126 let solver = TrajectorySolver::new(
8127 inputs,
8128 WindConditions::default(),
8129 AtmosphericConditions::default(),
8130 );
8131 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
8132 solver.calculate_acceleration(
8133 &Vector3::zeros(),
8134 &Vector3::new(600.0, 0.0, 0.0),
8135 &Vector3::zeros(),
8136 (temp_c, pressure_hpa, density / 1.225),
8137 )
8138 }
8139
8140 #[test]
8141 fn velocity_bc_data_requires_opt_in_in_trajectory_solver() {
8142 let scalar_inputs = BallisticInputs {
8143 bc_value: 0.5,
8144 bc_type: DragModel::G7,
8145 ..BallisticInputs::default()
8146 };
8147 let mut disabled_inputs = scalar_inputs.clone();
8148 disabled_inputs.bc_segments_data = Some(vec![crate::BCSegmentData {
8149 velocity_min: 0.0,
8150 velocity_max: 4_000.0,
8151 bc_value: 0.46,
8152 }]);
8153 disabled_inputs.use_bc_segments = false;
8154 let mut enabled_inputs = disabled_inputs.clone();
8155 enabled_inputs.use_bc_segments = true;
8156 let mut mach_only_inputs = scalar_inputs.clone();
8157 mach_only_inputs.bc_segments = Some(vec![(0.0, 0.4), (3.0, 0.4)]);
8158 let mut disabled_with_both = mach_only_inputs.clone();
8159 disabled_with_both.bc_segments_data = disabled_inputs.bc_segments_data.clone();
8160
8161 let scalar = acceleration_at_600_mps(scalar_inputs);
8162 let disabled = acceleration_at_600_mps(disabled_inputs);
8163 let enabled = acceleration_at_600_mps(enabled_inputs);
8164 let mach_only = acceleration_at_600_mps(mach_only_inputs);
8165 let disabled_with_both = acceleration_at_600_mps(disabled_with_both);
8166
8167 assert_eq!(
8168 disabled.x.to_bits(),
8169 scalar.x.to_bits(),
8170 "a populated velocity table must not change drag while use_bc_segments is false"
8171 );
8172 assert!(
8173 enabled.x < disabled.x - 1.0,
8174 "enabling the lower BC table must increase drag: disabled ax={} enabled ax={}",
8175 disabled.x,
8176 enabled.x
8177 );
8178 assert_eq!(
8179 disabled_with_both.x.to_bits(),
8180 mach_only.x.to_bits(),
8181 "disabling velocity data must fall through to an explicit Mach table"
8182 );
8183 }
8184}
8185
8186#[cfg(test)]
8187mod mach_bc_segment_tests {
8188 use super::*;
8189
8190 #[test]
8191 fn trajectory_solver_interpolates_explicit_mach_bc_segments() {
8192 let segmented_inputs = BallisticInputs {
8193 bc_value: 0.8,
8194 use_bc_segments: false,
8195 bc_segments: Some(vec![(1.0, 0.2), (2.0, 0.4)]),
8196 bc_segments_data: None,
8197 ..BallisticInputs::default()
8198 };
8199
8200 let mut expected_inputs = segmented_inputs.clone();
8201 expected_inputs.bc_value = 0.3;
8202 expected_inputs.bc_segments = None;
8203
8204 let atmosphere = AtmosphericConditions::default();
8205 let segmented_solver = TrajectorySolver::new(
8206 segmented_inputs,
8207 WindConditions::default(),
8208 atmosphere.clone(),
8209 );
8210 let expected_solver =
8211 TrajectorySolver::new(expected_inputs, WindConditions::default(), atmosphere);
8212 let position = Vector3::zeros();
8213 let (density, _, temp_c, pressure_hpa) = segmented_solver.resolved_atmosphere();
8214 let (_, local_speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
8215 segmented_solver.atmosphere.altitude,
8216 segmented_solver.atmosphere.altitude,
8217 temp_c,
8218 pressure_hpa,
8219 density / 1.225,
8220 segmented_solver.atmosphere.humidity,
8221 );
8222 let velocity = Vector3::new(1.5 * local_speed_of_sound, 0.0, 0.0);
8223 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
8224
8225 let segmented_acceleration = segmented_solver.calculate_acceleration(
8226 &position,
8227 &velocity,
8228 &Vector3::zeros(),
8229 resolved_atmo,
8230 );
8231 let expected_acceleration = expected_solver.calculate_acceleration(
8232 &position,
8233 &velocity,
8234 &Vector3::zeros(),
8235 resolved_atmo,
8236 );
8237
8238 assert!(
8239 (segmented_acceleration.x - expected_acceleration.x).abs() < 1e-12,
8240 "Mach 1.5 must interpolate BC 0.3: segmented ax={} expected ax={}",
8241 segmented_acceleration.x,
8242 expected_acceleration.x
8243 );
8244 }
8245}
8246
8247#[cfg(test)]
8248mod custom_drag_table_validation_tests {
8249 use super::*;
8250
8251 #[test]
8252 fn solve_accepts_zero_bc_when_custom_table_present() {
8253 let inputs = BallisticInputs {
8254 bc_value: 0.0, bullet_mass: 0.0106,
8256 bullet_diameter: 0.00782,
8257 muzzle_velocity: 850.0,
8258 custom_drag_table: Some(crate::drag::DragTable::new(
8259 vec![0.5, 1.0, 2.0, 3.0],
8260 vec![0.23, 0.40, 0.30, 0.26],
8261 )),
8262 ..BallisticInputs::default()
8263 };
8264 let solver = TrajectorySolver::new(
8265 inputs,
8266 WindConditions::default(),
8267 AtmosphericConditions::default(),
8268 );
8269 assert!(solver.solve().is_ok());
8271 }
8272
8273 #[test]
8274 fn solve_still_requires_bc_without_table() {
8275 let inputs = BallisticInputs {
8276 bc_value: 0.0,
8277 bullet_mass: 0.0106,
8278 bullet_diameter: 0.00782,
8279 muzzle_velocity: 850.0,
8280 ..BallisticInputs::default()
8281 };
8282 let solver = TrajectorySolver::new(
8283 inputs,
8284 WindConditions::default(),
8285 AtmosphericConditions::default(),
8286 );
8287 assert!(solver.solve().is_err());
8288 }
8289}
8290
8291#[cfg(test)]
8293mod cd_scale_tests {
8294 use super::*;
8295
8296 fn deck() -> crate::drag::DragTable {
8297 crate::drag::DragTable::new(vec![0.5, 1.0, 2.0, 3.0], vec![0.23, 0.40, 0.30, 0.26])
8298 }
8299
8300 fn deck_inputs(cd_scale: f64) -> BallisticInputs {
8301 BallisticInputs {
8302 bullet_mass: 0.0106,
8303 bullet_diameter: 0.00782,
8304 muzzle_velocity: 850.0,
8305 custom_drag_table: Some(deck()),
8306 cd_scale,
8307 ..BallisticInputs::default()
8308 }
8309 }
8310
8311 #[test]
8312 fn default_cd_scale_is_one() {
8313 assert_eq!(BallisticInputs::default().cd_scale, 1.0);
8314 }
8315
8316 #[test]
8319 fn cd_scale_absent_is_byte_identical_to_explicit_one() {
8320 let omitted = BallisticInputs {
8321 bullet_mass: 0.0106,
8322 bullet_diameter: 0.00782,
8323 muzzle_velocity: 850.0,
8324 custom_drag_table: Some(deck()),
8325 ..BallisticInputs::default()
8326 };
8327 let explicit = BallisticInputs {
8328 cd_scale: 1.0,
8329 ..omitted.clone()
8330 };
8331
8332 let solver_omitted = TrajectorySolver::new(
8333 omitted,
8334 WindConditions::default(),
8335 AtmosphericConditions::default(),
8336 );
8337 let solver_explicit = TrajectorySolver::new(
8338 explicit,
8339 WindConditions::default(),
8340 AtmosphericConditions::default(),
8341 );
8342
8343 let cd_omitted = solver_omitted.calculate_drag_coefficient(700.0, 340.0);
8344 let cd_explicit = solver_explicit.calculate_drag_coefficient(700.0, 340.0);
8345 assert_eq!(
8346 cd_omitted.to_bits(),
8347 cd_explicit.to_bits(),
8348 "default cd_scale must be bit-identical to an explicit 1.0"
8349 );
8350
8351 let result = solver_omitted.solve();
8354 assert!(
8355 result.is_ok(),
8356 "existing custom-deck solves must pass unchanged"
8357 );
8358 }
8359
8360 #[test]
8362 fn cd_scale_multiplies_the_interpolated_cd_exactly() {
8363 let velocity = 700.0;
8364 let speed_of_sound = 340.0;
8365 let mach = velocity / speed_of_sound;
8366 let expected_unscaled = deck().interpolate(mach);
8367
8368 for &scale in &[0.90, 1.0, 1.10, 1.5] {
8369 let solver = TrajectorySolver::new(
8370 deck_inputs(scale),
8371 WindConditions::default(),
8372 AtmosphericConditions::default(),
8373 );
8374 let cd = solver.calculate_drag_coefficient(velocity, speed_of_sound);
8375 assert!(
8376 (cd - expected_unscaled * scale).abs() < 1e-12,
8377 "scale={scale}: cd={cd} expected={}",
8378 expected_unscaled * scale
8379 );
8380 }
8381 }
8382
8383 #[test]
8387 fn cd_scale_direction_on_cli_api_solver() {
8388 let solve = |scale: f64| {
8389 TrajectorySolver::new(
8390 deck_inputs(scale),
8391 WindConditions::default(),
8392 AtmosphericConditions::default(),
8393 )
8394 .solve()
8395 .expect("custom-deck solve should succeed")
8396 };
8397
8398 let baseline = solve(1.0);
8399 let scaled_up = solve(1.10);
8400 let scaled_down = solve(0.90);
8401
8402 assert!(
8403 scaled_up.impact_velocity < baseline.impact_velocity,
8404 "cd_scale=1.10 must increase drag -> lower impact velocity: base={} up={}",
8405 baseline.impact_velocity,
8406 scaled_up.impact_velocity
8407 );
8408 assert!(
8409 scaled_down.impact_velocity > baseline.impact_velocity,
8410 "cd_scale=0.90 must decrease drag -> higher impact velocity: base={} down={}",
8411 baseline.impact_velocity,
8412 scaled_down.impact_velocity
8413 );
8414 }
8415
8416 #[test]
8418 fn validate_for_solve_rejects_invalid_cd_scale() {
8419 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
8420 let solver = TrajectorySolver::new(
8421 deck_inputs(bad),
8422 WindConditions::default(),
8423 AtmosphericConditions::default(),
8424 );
8425 assert!(
8426 solver.solve().is_err(),
8427 "cd_scale={bad} must be rejected by validate_for_solve"
8428 );
8429 }
8430 }
8431
8432 #[test]
8439 fn validate_for_solve_rejects_invalid_cd_scale_without_a_custom_drag_table() {
8440 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
8441 let inputs = BallisticInputs {
8442 bc_value: 0.5,
8443 bc_type: crate::DragModel::G1,
8444 bullet_mass: 0.0106,
8445 bullet_diameter: 0.00782,
8446 muzzle_velocity: 850.0,
8447 cd_scale: bad,
8448 ..BallisticInputs::default()
8449 };
8450 assert!(
8451 inputs.custom_drag_table.is_none(),
8452 "precondition: no custom deck"
8453 );
8454 let solver = TrajectorySolver::new(
8455 inputs,
8456 WindConditions::default(),
8457 AtmosphericConditions::default(),
8458 );
8459 assert!(
8460 solver.solve().is_err(),
8461 "cd_scale={bad} must be rejected by validate_for_solve even without a custom \
8462 drag table"
8463 );
8464 }
8465 }
8466
8467 #[test]
8471 fn cd_scale_is_inert_without_a_custom_drag_table() {
8472 let make = |cd_scale: f64| BallisticInputs {
8473 bc_value: 0.5,
8474 bc_type: crate::DragModel::G1,
8475 bullet_mass: 0.0106,
8476 bullet_diameter: 0.00782,
8477 muzzle_velocity: 850.0,
8478 cd_scale,
8479 ..BallisticInputs::default()
8480 };
8481 let solver_neutral = TrajectorySolver::new(
8482 make(1.0),
8483 WindConditions::default(),
8484 AtmosphericConditions::default(),
8485 );
8486 let solver_far = TrajectorySolver::new(
8487 make(1.5),
8488 WindConditions::default(),
8489 AtmosphericConditions::default(),
8490 );
8491 let cd_neutral = solver_neutral.calculate_drag_coefficient(700.0, 340.0);
8492 let cd_far = solver_far.calculate_drag_coefficient(700.0, 340.0);
8493 assert_eq!(
8494 cd_neutral.to_bits(),
8495 cd_far.to_bits(),
8496 "cd_scale must not affect the G-model/BC drag path"
8497 );
8498 }
8499
8500 #[test]
8503 fn cd_scale_shifts_all_three_solver_paths_in_the_same_direction() {
8504 let cli_solve = |scale: f64| {
8506 TrajectorySolver::new(
8507 deck_inputs(scale),
8508 WindConditions::default(),
8509 AtmosphericConditions::default(),
8510 )
8511 .solve()
8512 .expect("cli_api custom-deck solve should succeed")
8513 };
8514 let cli_baseline = cli_solve(1.0);
8515 let cli_scaled = cli_solve(1.10);
8516 assert!(
8517 cli_scaled.impact_velocity < cli_baseline.impact_velocity,
8518 "cli_api: cd_scale=1.10 must lower impact velocity"
8519 );
8520
8521 let derivatives_accel_x = |scale: f64| {
8523 let inputs = deck_inputs(scale);
8524 crate::derivatives::compute_derivatives(
8525 nalgebra::Vector3::zeros(),
8526 nalgebra::Vector3::new(700.0, 0.0, 0.0),
8527 &inputs,
8528 nalgebra::Vector3::zeros(),
8529 (1.225, 340.0, 0.0, 0.0),
8530 inputs.bc_value,
8531 None,
8532 0.0,
8533 None,
8534 )[3]
8535 };
8536 let deriv_baseline = derivatives_accel_x(1.0);
8537 let deriv_scaled = derivatives_accel_x(1.10);
8538 assert!(
8539 deriv_scaled < deriv_baseline,
8540 "derivatives: cd_scale=1.10 must make x-acceleration more negative (more drag): \
8541 base={deriv_baseline} scaled={deriv_scaled}"
8542 );
8543
8544 let fast_final_speed = |scale: f64| {
8546 let inputs = deck_inputs(scale);
8547 let wind_sock = crate::wind::WindSock::new(vec![]);
8548 let params = crate::fast_trajectory::FastIntegrationParams {
8549 horiz: 500.0,
8550 vert: 0.0,
8551 initial_state: [0.0, 0.0, 0.0, 850.0, 0.0, 0.0],
8552 t_span: (0.0, 5.0),
8553 atmo_params: (0.0, 15.0, 1013.25, 1.0),
8554 atmo_sock: None,
8555 };
8556 let solution = crate::fast_trajectory::fast_integrate(&inputs, &wind_sock, params);
8557 assert!(
8558 solution.success,
8559 "fast_integrate must succeed for scale={scale}"
8560 );
8561 let last = solution.t.len() - 1;
8562 let (vx, vy, vz) = (
8563 solution.y[3][last],
8564 solution.y[4][last],
8565 solution.y[5][last],
8566 );
8567 (vx * vx + vy * vy + vz * vz).sqrt()
8568 };
8569 let fast_baseline = fast_final_speed(1.0);
8570 let fast_scaled = fast_final_speed(1.10);
8571 assert!(
8572 fast_scaled < fast_baseline,
8573 "fast_trajectory: cd_scale=1.10 must lower final speed: base={fast_baseline} scaled={fast_scaled}"
8574 );
8575 }
8576}
8577
8578#[cfg(test)]
8582mod custom_drag_reference_tests {
8583 use super::*;
8584
8585 fn g7_table() -> crate::drag::DragTable {
8587 crate::drag::DragTable::from_csv_str(include_str!("../data/g7.csv"))
8588 .expect("bundled table parses")
8589 }
8590
8591 fn g1_table() -> crate::drag::DragTable {
8593 crate::drag::DragTable::from_csv_str(include_str!("../data/g1.csv"))
8594 .expect("bundled table parses")
8595 }
8596
8597 fn laddered_inputs() -> BallisticInputs {
8601 BallisticInputs {
8602 bc_value: 0.243,
8603 bc_type: DragModel::G7,
8604 bullet_mass: 175.0 * crate::constants::GRAINS_TO_KG,
8605 bullet_diameter: 0.308 * 0.0254,
8606 weight_grains: 175.0,
8607 caliber_inches: 0.308,
8608 muzzle_velocity: 823.0,
8609 use_bc_segments: true,
8610 bc_segments_data: Some(vec![
8611 crate::BCSegmentData {
8612 velocity_min: 2400.0,
8613 velocity_max: 4000.0,
8614 bc_value: 0.260,
8615 },
8616 crate::BCSegmentData {
8617 velocity_min: 0.0,
8618 velocity_max: 2400.0,
8619 bc_value: 0.220,
8620 },
8621 ]),
8622 ..BallisticInputs::default()
8623 }
8624 }
8625
8626 fn reference(inputs: BallisticInputs) -> BallisticInputs {
8627 BallisticInputs {
8628 custom_drag_table: Some(g7_table()),
8629 custom_drag_reference: true,
8630 ..inputs
8631 }
8632 }
8633
8634 fn solve(inputs: BallisticInputs) -> TrajectoryResult {
8635 let mut solver = TrajectorySolver::new(
8636 inputs,
8637 WindConditions::default(),
8638 AtmosphericConditions::default(),
8639 );
8640 solver.set_max_range(800.0);
8641 solver.solve().expect("solve")
8642 }
8643
8644 #[test]
8645 fn default_is_the_projectiles_own_curve() {
8646 assert!(!BallisticInputs::default().custom_drag_reference);
8647 }
8648
8649 #[test]
8650 fn the_denominator_is_the_resolved_bc_for_a_reference_curve() {
8651 let own = BallisticInputs {
8652 custom_drag_table: Some(g7_table()),
8653 ..laddered_inputs()
8654 };
8655 let sd = own.sectional_density_lb_in2().expect("SD");
8656 assert_eq!(own.custom_drag_denominator(0.220), sd);
8657 let reference = reference(laddered_inputs());
8658 assert_eq!(reference.custom_drag_denominator(0.220), 0.220);
8659 assert_eq!(reference.custom_drag_denominator(0.260), 0.260);
8660 }
8661
8662 #[test]
8666 fn a_reference_curve_flies_a_ladder_exactly_as_the_builtin_model() {
8667 let builtin = solve(laddered_inputs());
8668 let table = solve(reference(laddered_inputs()));
8669 assert_eq!(table.points.len(), builtin.points.len());
8670 for (t, b) in table.points.iter().zip(&builtin.points) {
8671 assert_eq!(t.position, b.position);
8672 assert_eq!(
8673 t.velocity_magnitude.to_bits(),
8674 b.velocity_magnitude.to_bits()
8675 );
8676 }
8677
8678 let own_curve = solve(BallisticInputs {
8679 custom_drag_reference: false,
8680 ..reference(laddered_inputs())
8681 });
8682 assert!(
8683 (own_curve.impact_velocity - builtin.impact_velocity).abs() > 10.0,
8684 "the fixture must tell the readings apart: {} vs {}",
8685 own_curve.impact_velocity,
8686 builtin.impact_velocity
8687 );
8688 }
8689
8690 #[test]
8695 fn a_reference_curve_flies_the_table_not_bc_types_curve() {
8696 let table = solve(BallisticInputs {
8697 custom_drag_table: Some(g1_table()),
8698 custom_drag_reference: true,
8699 bc_type: DragModel::G7,
8700 ..laddered_inputs()
8701 });
8702 let builtin_g1 = solve(BallisticInputs {
8703 bc_type: DragModel::G1,
8704 ..laddered_inputs()
8705 });
8706 assert_eq!(table.points.len(), builtin_g1.points.len());
8707 for (t, b) in table.points.iter().zip(&builtin_g1.points) {
8708 assert_eq!(t.position, b.position);
8709 assert_eq!(
8710 t.velocity_magnitude.to_bits(),
8711 b.velocity_magnitude.to_bits()
8712 );
8713 }
8714
8715 let builtin_g7 = solve(laddered_inputs());
8716 assert!(
8717 (builtin_g1.impact_velocity - builtin_g7.impact_velocity).abs() > 50.0,
8718 "the fixture must tell the curves apart: G1 {} vs G7 {}",
8719 builtin_g1.impact_velocity,
8720 builtin_g7.impact_velocity
8721 );
8722 }
8723
8724 #[test]
8727 fn validate_for_solve_requires_a_bc_for_a_reference_curve() {
8728 for bad in [0.0, -0.1, f64::NAN, f64::INFINITY] {
8729 let inputs = BallisticInputs {
8730 bc_value: bad,
8731 ..reference(laddered_inputs())
8732 };
8733 let error = TrajectorySolver::new(
8734 inputs.clone(),
8735 WindConditions::default(),
8736 AtmosphericConditions::default(),
8737 )
8738 .solve()
8739 .expect_err("a reference curve needs a finite positive BC");
8740 assert!(error.to_string().contains("bc_value"), "{error}");
8741
8742 let own_curve = BallisticInputs {
8743 custom_drag_reference: false,
8744 use_bc_segments: false,
8745 bc_segments_data: None,
8746 ..inputs
8747 };
8748 assert!(
8749 TrajectorySolver::new(
8750 own_curve,
8751 WindConditions::default(),
8752 AtmosphericConditions::default(),
8753 )
8754 .solve()
8755 .is_ok(),
8756 "bc_value={bad} is still ignored by the own-curve reading"
8757 );
8758 }
8759 }
8760
8761 #[test]
8765 fn army_standard_metro_converts_a_reference_curves_bcs() {
8766 let asm = BallisticInputs {
8767 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
8768 ..reference(laddered_inputs())
8769 };
8770 assert!(asm.bc_reference_standard_inert_warning().is_none());
8771 assert!(BallisticInputs {
8772 custom_drag_reference: false,
8773 ..asm.clone()
8774 }
8775 .bc_reference_standard_inert_warning()
8776 .is_some());
8777
8778 let converted = solve(asm);
8779 let builtin_asm = solve(BallisticInputs {
8780 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
8781 ..laddered_inputs()
8782 });
8783 let icao = solve(reference(laddered_inputs()));
8784 assert_eq!(
8785 converted.impact_velocity.to_bits(),
8786 builtin_asm.impact_velocity.to_bits()
8787 );
8788 assert!(
8789 converted.impact_velocity < icao.impact_velocity,
8790 "the ASM BCs are smaller once converted, so the bullet must slow more: {} vs {}",
8791 converted.impact_velocity,
8792 icao.impact_velocity
8793 );
8794 }
8795
8796 #[test]
8799 fn the_reported_cd_is_the_projectiles_own() {
8800 let inputs = reference(laddered_inputs());
8801 let sd = inputs.sectional_density_lb_in2().expect("SD");
8802 let solver = TrajectorySolver::new(
8803 inputs,
8804 WindConditions::default(),
8805 AtmosphericConditions::default(),
8806 );
8807 let sos = 340.0;
8808 for (fps, band_bc) in [(2600.0, 0.260), (2200.0, 0.220)] {
8809 let velocity = fps / 3.28084;
8810 let expected = g7_table().interpolate(velocity / sos) * sd / band_bc;
8811 let reported = solver
8812 .effective_drag_coefficient(velocity, sos)
8813 .expect("mass and diameter are set");
8814 assert!(
8815 (reported - expected).abs() < 1e-12,
8816 "{fps} fps: reported {reported}, expected {expected}"
8817 );
8818 }
8819 }
8820}
8821
8822#[cfg(test)]
8823mod humid_local_mach_tests {
8824 use super::*;
8825
8826 fn solver_with_station_humidity(humidity_percent: f64) -> TrajectorySolver {
8827 let inputs = BallisticInputs {
8828 custom_drag_table: Some(crate::drag::DragTable::new(vec![0.5, 1.5], vec![0.1, 1.1])),
8829 ..BallisticInputs::default()
8830 };
8831 TrajectorySolver::new(
8832 inputs,
8833 WindConditions::default(),
8834 AtmosphericConditions {
8835 temperature: 30.0,
8836 pressure: 1013.25,
8837 humidity: humidity_percent,
8838 altitude: 0.0,
8839 },
8840 )
8841 }
8842
8843 fn acceleration(solver: &TrajectorySolver, base_ratio: f64) -> Vector3<f64> {
8844 solver.calculate_acceleration(
8845 &Vector3::zeros(),
8846 &Vector3::new(350.0, 0.0, 0.0),
8847 &Vector3::zeros(),
8848 (30.0, 1013.25, base_ratio),
8849 )
8850 }
8851
8852 #[test]
8853 fn local_mach_uses_station_humidity_when_density_is_held_constant() {
8854 let dry = acceleration(&solver_with_station_humidity(0.0), 1.0);
8855 let humid = acceleration(&solver_with_station_humidity(100.0), 1.0);
8856
8857 assert!(
8858 humid.x > dry.x,
8859 "humid sound speed should lower Mach and drag on the rising test curve: dry ax={} humid ax={}",
8860 dry.x,
8861 humid.x
8862 );
8863 }
8864
8865 #[test]
8866 fn active_atmosphere_zone_uses_zone_humidity_instead_of_station_humidity() {
8867 let zone_humidity = 80.0;
8868 let zone_ratio =
8869 crate::atmosphere::calculate_air_density_cimp(30.0, 1013.25, zone_humidity) / 1.225;
8870 let station_solver = solver_with_station_humidity(zone_humidity);
8871 let mut zoned_solver = solver_with_station_humidity(0.0);
8872 zoned_solver.set_atmo_segments(vec![(30.0, 1013.25, zone_humidity, 1_000.0)]);
8873
8874 let station = acceleration(&station_solver, zone_ratio);
8875 let zoned = acceleration(&zoned_solver, zone_ratio);
8876
8877 assert!(
8878 (zoned - station).norm() < 1e-12,
8879 "active zone T/P/RH should override the station atmosphere: station={station:?} zoned={zoned:?}"
8880 );
8881 }
8882}
8883
8884#[cfg(test)]
8885mod inclined_atmosphere_frame_tests {
8886 use super::*;
8887
8888 fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
8889 let (sin_angle, cos_angle) = angle.sin_cos();
8890 Vector3::new(
8891 level.x * cos_angle + level.y * sin_angle,
8892 -level.x * sin_angle + level.y * cos_angle,
8893 level.z,
8894 )
8895 }
8896
8897 #[test]
8898 fn inclined_positions_at_same_world_altitude_have_same_solver_acceleration() {
8899 let angle = std::f64::consts::FRAC_PI_6;
8900 let inputs = BallisticInputs {
8901 shooting_angle: angle,
8902 ..BallisticInputs::default()
8903 };
8904 let atmosphere = AtmosphericConditions {
8905 altitude: 100.0,
8906 ..AtmosphericConditions::default()
8907 };
8908 let solver = TrajectorySolver::new(inputs, WindConditions::default(), atmosphere);
8909 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
8910 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
8911 let velocity = Vector3::new(600.0, 0.0, 0.0);
8912 let along_slant = Vector3::new(1_000.0, 0.0, 0.0);
8913 let across_slant = Vector3::new(0.0, 500.0 / angle.cos(), 0.0);
8914
8915 let a = solver.calculate_acceleration(
8916 &along_slant,
8917 &velocity,
8918 &Vector3::zeros(),
8919 resolved_atmo,
8920 );
8921 let b = solver.calculate_acceleration(
8922 &across_slant,
8923 &velocity,
8924 &Vector3::zeros(),
8925 resolved_atmo,
8926 );
8927
8928 assert!(
8929 (a - b).norm() < 1e-10,
8930 "solver acceleration differs at equal world altitude: {a:?} vs {b:?}"
8931 );
8932 }
8933
8934 #[test]
8935 fn inclined_headwind_is_rotated_into_solver_frame() {
8936 let angle = std::f64::consts::FRAC_PI_6;
8937 let inputs = BallisticInputs {
8938 shooting_angle: angle,
8939 ..BallisticInputs::default()
8940 };
8941 let solver = TrajectorySolver::new(
8942 inputs,
8943 WindConditions::default(),
8944 AtmosphericConditions::default(),
8945 );
8946 let level_headwind = Vector3::new(-100.0, 0.0, 0.0);
8947 let velocity = expected_shot_frame_vector(level_headwind, angle);
8948 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
8949 let actual = solver.calculate_acceleration(
8950 &Vector3::zeros(),
8951 &velocity,
8952 &level_headwind,
8953 (temp_c, pressure_hpa, density / 1.225),
8954 );
8955
8956 assert!(
8957 (actual - solver.gravity_acceleration()).norm() < 1e-12,
8958 "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
8959 );
8960 }
8961
8962 #[test]
8963 fn inclined_coriolis_is_rotated_into_solver_frame() {
8964 let angle = std::f64::consts::FRAC_PI_6;
8965 let latitude_deg = 45.0_f64;
8966 let shot_azimuth = 0.4_f64;
8967 let velocity = Vector3::new(600.0, 20.0, 5.0);
8968 let base_inputs = BallisticInputs {
8969 shooting_angle: angle,
8970 latitude: Some(latitude_deg),
8971 shot_azimuth,
8972 ..BallisticInputs::default()
8973 };
8974 let acceleration = |enable_coriolis| {
8975 let mut inputs = base_inputs.clone();
8976 inputs.enable_coriolis = enable_coriolis;
8977 let solver = TrajectorySolver::new(
8978 inputs,
8979 WindConditions::default(),
8980 AtmosphericConditions::default(),
8981 );
8982 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
8983 solver.calculate_acceleration(
8984 &Vector3::zeros(),
8985 &velocity,
8986 &Vector3::zeros(),
8987 (temp_c, pressure_hpa, density / 1.225),
8988 )
8989 };
8990
8991 let omega_earth = 7.2921159e-5_f64;
8992 let latitude = latitude_deg.to_radians();
8993 let level_omega = Vector3::new(
8994 omega_earth * latitude.cos() * shot_azimuth.cos(),
8995 omega_earth * latitude.sin(),
8996 -omega_earth * latitude.cos() * shot_azimuth.sin(),
8997 );
8998 let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
8999 let actual = acceleration(true) - acceleration(false);
9000
9001 assert!(
9002 (actual - expected).norm() < 1e-12,
9003 "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
9004 );
9005 }
9006}
9007
9008#[cfg(test)]
9009mod terminal_range_interpolation_tests {
9010 use super::*;
9011
9012 #[test]
9013 fn terminal_finalizer_selects_the_earliest_crossed_boundary() {
9014 let inputs = BallisticInputs {
9015 ground_threshold: 0.0,
9016 ..BallisticInputs::default()
9017 };
9018 let mut solver = TrajectorySolver::new(
9019 inputs,
9020 WindConditions::default(),
9021 AtmosphericConditions::default(),
9022 );
9023 solver.set_max_range(120.0);
9024
9025 let previous_speed = 700.0;
9026 let mut points = vec![TrajectoryPoint {
9027 time: 99.0,
9028 position: Vector3::new(90.0, 1.0, -1.0),
9029 velocity_magnitude: previous_speed,
9030 kinetic_energy: 0.5 * solver.inputs.bullet_mass * previous_speed.powi(2),
9031 drag_coefficient: None,
9032 }];
9033 let mut max_height = 1.0;
9034 let termination = solver
9035 .append_terminal_endpoint(
9036 &mut points,
9037 Vector3::new(130.0, -3.0, 3.0),
9038 Vector3::new(600.0, 0.0, 0.0),
9039 101.0,
9040 &mut max_height,
9041 &mut 0,
9042 )
9043 .expect("the final step brackets supported boundaries");
9044
9045 assert_eq!(termination, TrajectoryTermination::GroundThreshold);
9046 assert_eq!(points.len(), 2);
9047 let terminal = points.last().expect("terminal point");
9048 assert_eq!(terminal.time, 99.5);
9049 assert_eq!(terminal.position, Vector3::new(100.0, 0.0, 0.0));
9050 assert_eq!(terminal.velocity_magnitude, 675.0);
9051 assert_eq!(
9052 terminal.kinetic_energy,
9053 0.5 * solver.inputs.bullet_mass * 675.0_f64.powi(2)
9054 );
9055
9056 solver.set_max_range(100.0);
9058 let mut tied_points = vec![points[0].clone()];
9059 assert_eq!(
9060 solver
9061 .append_terminal_endpoint(
9062 &mut tied_points,
9063 Vector3::new(130.0, -3.0, 3.0),
9064 Vector3::new(600.0, 0.0, 0.0),
9065 101.0,
9066 &mut max_height,
9067 &mut 0,
9068 )
9069 .expect("tied boundaries remain a valid terminal"),
9070 TrajectoryTermination::GroundThreshold
9071 );
9072 }
9073
9074 #[test]
9075 fn sub_ulp_terminal_crossing_replaces_instead_of_duplicating_range() {
9076 let ground_threshold = f64::from_bits(1.0_f64.to_bits() - 1);
9077 let inputs = BallisticInputs {
9078 ground_threshold,
9079 ..BallisticInputs::default()
9080 };
9081 let mut solver = TrajectorySolver::new(
9082 inputs,
9083 WindConditions::default(),
9084 AtmosphericConditions::default(),
9085 );
9086 solver.set_max_range(1_000.0);
9087
9088 let speed = 700.0;
9089 let mut points = vec![TrajectoryPoint {
9090 time: 0.0,
9091 position: Vector3::new(100.0, 1.0, 0.0),
9092 velocity_magnitude: speed,
9093 kinetic_energy: 0.5 * solver.inputs.bullet_mass * speed.powi(2),
9094 drag_coefficient: None,
9095 }];
9096 let mut max_height = 1.0;
9097 let termination = solver
9098 .append_terminal_endpoint(
9099 &mut points,
9100 Vector3::new(101.0, 0.0, 0.0),
9101 Vector3::new(699.0, 0.0, 0.0),
9102 1.0,
9103 &mut max_height,
9104 &mut 0,
9105 )
9106 .expect("sub-ULP ground crossing remains representable as one terminal state");
9107
9108 assert_eq!(termination, TrajectoryTermination::GroundThreshold);
9109 assert_eq!(points.len(), 1);
9110 assert_eq!(points[0].position.x, 100.0);
9111 assert_eq!(points[0].position.y.to_bits(), ground_threshold.to_bits());
9112 assert!(points[0].time > 0.0);
9113 }
9114
9115 #[test]
9121 fn downrange_stall_replaces_instead_of_duplicating_range() {
9122 for (name, use_rk4, use_adaptive_rk45, bc_value) in [
9123 ("Euler", false, false, 2.0e-4),
9124 ("RK4", true, false, 1.0e-4),
9125 ("RK45", true, true, 1.0e-4),
9126 ] {
9127 let inputs = BallisticInputs {
9128 bc_value,
9129 bc_type: DragModel::G7,
9130 bullet_mass: 0.01134,
9131 bullet_diameter: 0.00782,
9132 muzzle_velocity: 823.0,
9133 use_rk4,
9134 use_adaptive_rk45,
9135 ground_threshold: -100.0,
9136 ..BallisticInputs::default()
9137 };
9138 let mut solver = TrajectorySolver::new(
9139 inputs,
9140 WindConditions::default(),
9141 AtmosphericConditions::default(),
9142 );
9143 solver.set_max_range(300.0);
9144 solver.set_time_step(0.001);
9145
9146 let result = solver
9147 .solve()
9148 .unwrap_or_else(|error| panic!("{name}: a stalled flight still solves: {error}"));
9149 assert!(
9150 result
9151 .points
9152 .windows(2)
9153 .all(|pair| pair[1].position.x > pair[0].position.x),
9154 "{name}: stored downrange distances must strictly increase"
9155 );
9156 assert_eq!(
9157 result.termination,
9158 TrajectoryTermination::GroundThreshold,
9159 "{name}"
9160 );
9161 assert!(
9162 result.max_range > 0.0 && result.max_range < 300.0,
9163 "{name}: the stall ends short of max_range, got {} m",
9164 result.max_range
9165 );
9166 let terminal = result.points.last().expect("terminal point");
9167 assert_eq!(terminal.position.y, -100.0, "{name}");
9168 result
9169 .sample_observations(50.0, 100)
9170 .unwrap_or_else(|error| panic!("{name}: checked sampling refused: {error}"));
9171 }
9172 }
9173
9174 #[test]
9175 fn every_solver_appends_an_exact_max_range_endpoint() {
9176 let target_range = 0.1;
9177 let modes = [
9178 ("Euler", false, false),
9179 ("RK4", true, false),
9180 ("RK45", true, true),
9181 ];
9182
9183 for (name, use_rk4, use_adaptive_rk45) in modes {
9184 let inputs = BallisticInputs {
9185 use_rk4,
9186 use_adaptive_rk45,
9187 ground_threshold: f64::NEG_INFINITY,
9188 enable_trajectory_sampling: true,
9189 sample_interval: target_range,
9190 ..BallisticInputs::default()
9191 };
9192 let mut solver = TrajectorySolver::new(
9193 inputs,
9194 WindConditions::default(),
9195 AtmosphericConditions::default(),
9196 );
9197 solver.set_max_range(target_range);
9198
9199 let result = solver.solve().expect("short-range solve should succeed");
9200 let terminal = result.points.last().expect("terminal point is missing");
9201 let muzzle = result.points.first().expect("muzzle point is missing");
9202
9203 assert_eq!(result.termination, TrajectoryTermination::MaxRange);
9204 assert_eq!(
9205 terminal.position.x.to_bits(),
9206 target_range.to_bits(),
9207 "{name} did not terminate exactly at max_range"
9208 );
9209 assert_eq!(result.max_range.to_bits(), target_range.to_bits());
9210 assert!(
9211 result.time_of_flight > 0.0 && result.time_of_flight < solver.time_step,
9212 "{name} terminal time was not interpolated within the crossing step: {}",
9213 result.time_of_flight
9214 );
9215 assert_eq!(result.time_of_flight.to_bits(), terminal.time.to_bits());
9216 assert_eq!(
9217 result.impact_velocity.to_bits(),
9218 terminal.velocity_magnitude.to_bits()
9219 );
9220 assert_eq!(
9221 result.impact_energy.to_bits(),
9222 terminal.kinetic_energy.to_bits()
9223 );
9224 let expected_energy = 0.5 * solver.inputs.bullet_mass * result.impact_velocity.powi(2);
9225 assert!((result.impact_energy - expected_energy).abs() < 1e-12);
9226 assert!(terminal.velocity_magnitude < muzzle.velocity_magnitude);
9227 assert!(terminal.kinetic_energy < muzzle.kinetic_energy);
9228
9229 let terminal_sample = result
9230 .sampled_points
9231 .as_ref()
9232 .and_then(|samples| samples.last())
9233 .expect("terminal trajectory sample is missing");
9234 assert_eq!(
9235 terminal_sample.distance_m.to_bits(),
9236 target_range.to_bits(),
9237 "{name} sampling did not include max_range"
9238 );
9239 assert_eq!(
9240 terminal_sample.time_s.to_bits(),
9241 result.time_of_flight.to_bits()
9242 );
9243 assert_eq!(
9244 terminal_sample.velocity_mps.to_bits(),
9245 result.impact_velocity.to_bits()
9246 );
9247 assert!((terminal_sample.energy_j - result.impact_energy).abs() < 1e-12);
9248 }
9249 }
9250}
9251
9252#[cfg(test)]
9253mod precession_inertia_wiring_tests {
9254 use super::*;
9255
9256 #[test]
9257 fn solver_uses_projectile_specific_moments_of_inertia() {
9258 let mass_kg = 55.0 * crate::constants::GRAINS_TO_KG;
9259 let caliber_m = 0.224 * 0.0254;
9260 let length_m = 0.75 * 0.0254;
9261 let inputs = BallisticInputs {
9262 bullet_mass: mass_kg,
9263 bullet_diameter: caliber_m,
9264 bullet_length: length_m,
9265 muzzle_velocity: 800.0,
9266 twist_rate: 7.0,
9267 enable_precession_nutation: true,
9268 use_rk4: false,
9269 use_adaptive_rk45: false,
9270 ..BallisticInputs::default()
9271 };
9272 let mut solver = TrajectorySolver::new(
9273 inputs,
9274 WindConditions::default(),
9275 AtmosphericConditions::default(),
9276 );
9277 solver.set_max_range(0.1);
9278
9279 let (air_density, speed_of_sound, _, _) = solver.resolved_atmosphere();
9280 let velocity_mps = solver.inputs.muzzle_velocity;
9281 let velocity_fps = velocity_mps * 3.28084;
9282 let twist_rate_ft = solver.inputs.twist_rate / 12.0;
9283 let spin_rate_rad_s = (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI;
9284 let initial_state = AngularState {
9285 pitch_angle: 0.001,
9286 yaw_angle: 0.001,
9287 pitch_rate: 0.0,
9288 yaw_rate: 0.0,
9289 precession_angle: 0.0,
9290 nutation_phase: 0.0,
9291 };
9292 let params = PrecessionNutationParams {
9293 mass_kg,
9294 caliber_m,
9295 length_m,
9296 spin_rate_rad_s,
9297 spin_inertia: crate::spin_decay::calculate_moment_of_inertia(
9298 mass_kg, caliber_m, length_m, "ogive",
9299 ),
9300 transverse_inertia: crate::pitch_damping::calculate_transverse_moment_of_inertia(
9301 mass_kg, caliber_m, length_m, "ogive",
9302 ),
9303 velocity_mps,
9304 air_density_kg_m3: air_density,
9305 mach: velocity_mps / speed_of_sound,
9306 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
9307 nutation_damping_factor: 0.05,
9308 };
9309 let expected = calculate_combined_angular_motion(
9310 ¶ms,
9311 &initial_state,
9312 0.0,
9313 solver.time_step,
9314 0.001,
9315 );
9316 let actual = solver
9317 .solve()
9318 .expect("one-step solve should succeed")
9319 .angular_state
9320 .expect("precession/nutation was enabled");
9321
9322 assert!(
9323 (actual.precession_angle - expected.precession_angle).abs() < 1e-15,
9324 "precession phase used the wrong inertia: actual={}, expected={}",
9325 actual.precession_angle,
9326 expected.precession_angle
9327 );
9328 assert!(
9329 (actual.nutation_phase - expected.nutation_phase).abs() < 1e-15,
9330 "nutation phase used the wrong inertia: actual={}, expected={}",
9331 actual.nutation_phase,
9332 expected.nutation_phase
9333 );
9334 }
9335}
9336
9337#[cfg(test)]
9338mod form_factor_drag_tests {
9339 use super::*;
9340
9341 fn acceleration_with_form_factor_flag(enabled: bool) -> Vector3<f64> {
9342 let inputs = BallisticInputs {
9343 bc_value: 0.462,
9344 bc_type: DragModel::G1,
9345 bullet_model: Some("168gr SMK Match".to_string()),
9346 use_form_factor: enabled,
9347 ..BallisticInputs::default()
9348 };
9349 let solver = TrajectorySolver::new(
9350 inputs,
9351 WindConditions::default(),
9352 AtmosphericConditions::default(),
9353 );
9354 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
9355 solver.calculate_acceleration(
9356 &Vector3::zeros(),
9357 &Vector3::new(600.0, 0.0, 0.0),
9358 &Vector3::zeros(),
9359 (temp_c, pressure_hpa, density / 1.225),
9360 )
9361 }
9362
9363 #[test]
9364 fn measured_bc_drag_does_not_apply_name_based_form_factor_again() {
9365 let baseline = acceleration_with_form_factor_flag(false);
9366 let flagged = acceleration_with_form_factor_flag(true);
9367
9368 assert!(
9369 (flagged - baseline).norm() < 1e-12,
9370 "published BC already encodes form factor: baseline={baseline:?} flagged={flagged:?}"
9371 );
9372 }
9373}
9374
9375#[cfg(test)]
9376mod rk45_adaptivity_tests {
9377 use super::*;
9378
9379 #[test]
9380 fn cli_rk45_error_norm_scales_components_independently() {
9381 let position = Vector3::new(1.0e9, 0.0, 0.0);
9382 let velocity = Vector3::new(800.0, 0.0, 0.0);
9383 let fifth_position = position;
9384 let fifth_velocity = velocity;
9385 let fourth_position = position;
9386 let fourth_velocity = Vector3::new(800.0, 1.0e-3, 0.0);
9387
9388 let error = cli_rk45_error_norm(
9389 &position,
9390 &velocity,
9391 &fifth_position,
9392 &fifth_velocity,
9393 &fourth_position,
9394 &fourth_velocity,
9395 );
9396 let expected = 1.0e-3 / 6.0_f64.sqrt();
9397
9398 assert!(
9399 (error - expected).abs() <= 1e-15,
9400 "large downrange position masked a velocity-component error: {error}"
9401 );
9402 }
9403
9404 fn discontinuous_wind_solver() -> TrajectorySolver {
9405 let inputs = BallisticInputs::default();
9406 let mut solver = TrajectorySolver::new(
9407 inputs,
9408 WindConditions::default(),
9409 AtmosphericConditions::default(),
9410 );
9411 solver.set_wind_segments(vec![
9412 crate::wind::WindSegment::new(0.0, 90.0, 4.0),
9413 crate::wind::WindSegment::new(1_000.0, 90.0, 10_000.0),
9414 ]);
9415 solver
9416 }
9417
9418 #[test]
9419 fn rk45_retries_discontinuous_trial_before_advancing() {
9420 let solver = discontinuous_wind_solver();
9421 let position = Vector3::new(0.0, solver.inputs.muzzle_height, 0.0);
9422 let velocity = Vector3::new(solver.inputs.muzzle_velocity, 0.0, 0.0);
9423 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
9424 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
9425 let dt = 0.01;
9426
9427 let rejected_trial = solver.rk45_step(
9428 &position,
9429 &velocity,
9430 dt,
9431 &Vector3::zeros(),
9432 RK45_TOLERANCE,
9433 resolved_atmo,
9434 );
9435 assert!(
9436 rejected_trial.error > RK45_TOLERANCE,
9437 "discontinuous full step must exceed tolerance, got {}",
9438 rejected_trial.error
9439 );
9440
9441 let accepted =
9442 solver.adaptive_rk45_step(&position, &velocity, dt, &Vector3::zeros(), resolved_atmo);
9443 assert!(accepted.used_dt < dt, "oversized trial was not retried");
9444 assert!(
9445 accepted.error <= RK45_TOLERANCE || accepted.used_dt <= RK45_MIN_DT,
9446 "accepted error {} exceeds tolerance at dt {}",
9447 accepted.error,
9448 accepted.used_dt
9449 );
9450
9451 let accepted_trial = solver.rk45_step(
9452 &position,
9453 &velocity,
9454 accepted.used_dt,
9455 &Vector3::zeros(),
9456 RK45_TOLERANCE,
9457 resolved_atmo,
9458 );
9459 assert_eq!(accepted.position, accepted_trial.position);
9460 assert_eq!(accepted.velocity, accepted_trial.velocity);
9461 assert!((RK45_MIN_DT..=RK45_MAX_DT).contains(&accepted.next_dt));
9462 }
9463}
9464
9465#[cfg(test)]
9466mod ground_termination_tests {
9467 use super::*;
9468 use crate::trajectory_observation::TrajectoryObservationFlag;
9469
9470 #[test]
9471 fn every_solver_reports_one_exact_early_ground_endpoint() {
9472 for (name, use_rk4, use_adaptive_rk45) in [
9473 ("Euler", false, false),
9474 ("RK4", true, false),
9475 ("RK45", true, true),
9476 ] {
9477 let inputs = BallisticInputs {
9478 muzzle_height: 1.0,
9479 muzzle_angle: -0.2,
9480 ground_threshold: 0.0,
9481 use_rk4,
9482 use_adaptive_rk45,
9483 ..BallisticInputs::default()
9484 };
9485 let mut solver = TrajectorySolver::new(
9486 inputs,
9487 WindConditions::default(),
9488 AtmosphericConditions::default(),
9489 );
9490 solver.set_max_range(1_000.0);
9491
9492 let result = solver.solve().expect("early-ground solve should succeed");
9493 let terminal = result.points.last().expect("terminal point is missing");
9494
9495 assert_eq!(result.termination, TrajectoryTermination::GroundThreshold);
9496 assert_eq!(terminal.position.y.to_bits(), 0.0_f64.to_bits());
9497 assert!(
9498 terminal.position.x < 1_000.0,
9499 "{name} incorrectly reached max range"
9500 );
9501 assert_eq!(result.max_range.to_bits(), terminal.position.x.to_bits());
9502 assert_eq!(
9503 result
9504 .points
9505 .iter()
9506 .filter(|point| point.position.y == 0.0)
9507 .count(),
9508 1,
9509 "{name} did not retain exactly one ground endpoint"
9510 );
9511
9512 let observations = result
9513 .sample_observations(1.0, 100)
9514 .expect("checked early-ground sampling should succeed");
9515 assert!(observations[..observations.len() - 1]
9516 .iter()
9517 .all(|observation| observation.distance_m < terminal.position.x));
9518 let terminal_observation = observations.last().expect("terminal observation");
9519 assert_eq!(
9520 terminal_observation.distance_m.to_bits(),
9521 terminal.position.x.to_bits()
9522 );
9523 assert!(terminal_observation
9524 .flags
9525 .contains(&TrajectoryObservationFlag::Terminal));
9526 assert!(terminal_observation
9527 .flags
9528 .contains(&TrajectoryObservationFlag::GroundThreshold));
9529 assert_eq!(
9530 observations
9531 .iter()
9532 .filter(|observation| observation
9533 .flags
9534 .contains(&TrajectoryObservationFlag::Terminal))
9535 .count(),
9536 1,
9537 "{name} repeated the terminal observation"
9538 );
9539 }
9540 }
9541
9542 #[test]
9547 fn rk4_and_rk45_descend_to_ground_threshold() {
9548 for adaptive in [false, true] {
9549 let inputs = BallisticInputs {
9550 muzzle_angle: 0.1, use_rk4: true,
9552 use_adaptive_rk45: adaptive,
9553 ..BallisticInputs::default()
9554 };
9555 assert_eq!(
9556 inputs.ground_threshold, -100.0,
9557 "default ground_threshold is -100 m"
9558 );
9559
9560 let mut solver = TrajectorySolver::new(
9561 inputs,
9562 WindConditions::default(),
9563 AtmosphericConditions::default(),
9564 );
9565 solver.set_max_range(1.0e7);
9567
9568 let result = solver.solve().expect("solve should succeed");
9569 let final_y = result
9570 .points
9571 .last()
9572 .expect("trajectory has points")
9573 .position
9574 .y;
9575 assert!(
9576 final_y < -1.0,
9577 "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
9578 past launch level toward the ground_threshold floor, not stop at y = 0"
9579 );
9580 }
9581 }
9582}
9583
9584#[cfg(test)]
9585mod magnus_stability_tests {
9586 use super::*;
9587
9588 #[test]
9589 fn yaw_of_repose_magnus_force_is_vertical_and_twist_signed() {
9590 let acceleration = |enable_magnus, is_twist_right| {
9591 let inputs = BallisticInputs {
9592 muzzle_velocity: 822.96,
9593 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
9594 bullet_diameter: 0.308 * 0.0254,
9595 bullet_length: 1.215 * 0.0254,
9596 twist_rate: 10.0,
9597 is_twist_right,
9598 enable_magnus,
9599 ..BallisticInputs::default()
9600 };
9601 let solver = TrajectorySolver::new(
9602 inputs,
9603 WindConditions::default(),
9604 AtmosphericConditions::default(),
9605 );
9606 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
9607 solver.calculate_acceleration(
9608 &Vector3::zeros(),
9609 &Vector3::new(822.96, 0.0, 0.0),
9610 &Vector3::zeros(),
9611 (temp_c, pressure_hpa, density / 1.225),
9612 )
9613 };
9614
9615 let baseline = acceleration(false, true);
9616 let right_twist = acceleration(true, true) - baseline;
9617 let left_twist = acceleration(true, false) - baseline;
9618
9619 assert!(
9620 right_twist.y < 0.0,
9621 "right-hand Magnus must point down, got {right_twist:?}"
9622 );
9623 assert!(
9624 left_twist.y > 0.0,
9625 "left-hand Magnus must point up, got {left_twist:?}"
9626 );
9627 assert!((right_twist.y + left_twist.y).abs() < 1e-12);
9628 assert!(right_twist.x.abs() < 1e-12 && right_twist.z.abs() < 1e-12);
9629 assert!(left_twist.x.abs() < 1e-12 && left_twist.z.abs() < 1e-12);
9630 }
9631
9632 #[test]
9633 fn magnus_uses_velocity_corrected_muzzle_stability_gate() {
9634 let muzzle_velocity = 1_400.0 / 3.28084;
9635 let inputs = BallisticInputs {
9636 muzzle_velocity,
9637 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
9638 bullet_diameter: 0.308 * 0.0254,
9639 bullet_length: 1.215 * 0.0254,
9640 twist_rate: 15.0,
9641 enable_magnus: true,
9642 ..BallisticInputs::default()
9643 };
9644 let solver = TrajectorySolver::new(
9645 inputs.clone(),
9646 WindConditions::default(),
9647 AtmosphericConditions::default(),
9648 );
9649
9650 let bare_sg = crate::spin_drift::miller_stability(0.308, 168.0, 15.0, 1.215);
9651 let canonical_sg = solver.effective_spin_drift_sg();
9652 assert!(bare_sg > 1.0, "test requires bare Sg above the Magnus gate");
9653 assert!(
9654 canonical_sg < 1.0,
9655 "velocity-corrected Sg must be below the gate, got {canonical_sg}"
9656 );
9657
9658 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
9659 let acceleration = solver.calculate_acceleration(
9660 &Vector3::zeros(),
9661 &Vector3::new(muzzle_velocity, 0.0, 0.0),
9662 &Vector3::zeros(),
9663 (temp_c, pressure_hpa, density / 1.225),
9664 );
9665 let mut baseline_inputs = inputs;
9666 baseline_inputs.enable_magnus = false;
9667 let baseline_solver = TrajectorySolver::new(
9668 baseline_inputs,
9669 WindConditions::default(),
9670 AtmosphericConditions::default(),
9671 );
9672 let baseline = baseline_solver.calculate_acceleration(
9673 &Vector3::zeros(),
9674 &Vector3::new(muzzle_velocity, 0.0, 0.0),
9675 &Vector3::zeros(),
9676 (temp_c, pressure_hpa, density / 1.225),
9677 );
9678
9679 assert_eq!(
9680 acceleration, baseline,
9681 "canonical Sg below 1 must suppress every Magnus acceleration component"
9682 );
9683 }
9684
9685 #[test]
9686 fn magnus_force_grows_as_fixed_spin_projectile_slows() {
9687 let inputs = BallisticInputs {
9688 muzzle_velocity: 800.0,
9689 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
9690 bullet_diameter: 0.308 * 0.0254,
9691 bullet_length: 1.215 * 0.0254,
9692 twist_rate: 12.0,
9693 enable_magnus: true,
9694 ..BallisticInputs::default()
9695 };
9696
9697 let magnus_acceleration = |speed_mps| {
9698 let evaluate = |enable_magnus| {
9699 let mut run_inputs = inputs.clone();
9700 run_inputs.enable_magnus = enable_magnus;
9701 let solver = TrajectorySolver::new(
9702 run_inputs,
9703 WindConditions::default(),
9704 AtmosphericConditions::default(),
9705 );
9706 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
9707 solver
9708 .calculate_acceleration(
9709 &Vector3::zeros(),
9710 &Vector3::new(speed_mps, 0.0, 0.0),
9711 &Vector3::zeros(),
9712 (temp_c, pressure_hpa, density / 1.225),
9713 )
9714 .y
9715 };
9716 (evaluate(true) - evaluate(false)).abs()
9717 };
9718
9719 let fast = magnus_acceleration(200.0);
9720 let slow = magnus_acceleration(100.0);
9721 let ratio = slow / fast;
9722 let expected_ratio = 2.0_f64.powf(5.0 / 3.0);
9723
9724 assert!(fast > 0.0 && slow > 0.0, "fast={fast}, slow={slow}");
9725 assert!(
9726 (ratio - expected_ratio).abs() < 1e-3,
9727 "fixed-spin Magnus acceleration must grow downrange; slow/fast={ratio}, \
9728 expected={expected_ratio}"
9729 );
9730 }
9731}
9732
9733#[cfg(test)]
9734mod coriolis_direction_tests {
9735 use super::*;
9736 use std::f64::consts::FRAC_PI_2;
9737
9738 #[test]
9739 fn supersonic_crossing_flags_a_positive_range_sample() {
9740 use crate::trajectory_sampling::TrajectoryFlag;
9744
9745 for (solver_name, use_rk4, use_adaptive_rk45) in [
9746 ("Euler", false, false),
9747 ("RK4", true, false),
9748 ("RK45", true, true),
9749 ] {
9750 let inputs = BallisticInputs {
9751 muzzle_velocity: 850.0,
9752 bc_value: 0.2,
9753 bc_type: DragModel::G7,
9754 muzzle_angle: 0.03,
9755 enable_trajectory_sampling: true,
9756 sample_interval: 50.0,
9757 use_rk4,
9758 use_adaptive_rk45,
9759 ..BallisticInputs::default()
9760 };
9761 let mut solver = TrajectorySolver::new(
9762 inputs,
9763 WindConditions::default(),
9764 AtmosphericConditions::default(),
9765 );
9766 solver.set_max_range(2000.0);
9767 let samples = solver
9768 .solve()
9769 .expect("supersonic solve should succeed")
9770 .sampled_points
9771 .expect("sampling was enabled");
9772 let flagged_distances: Vec<_> = samples
9773 .iter()
9774 .filter(|sample| sample.flags.contains(&TrajectoryFlag::MachTransition))
9775 .map(|sample| sample.distance_m)
9776 .collect();
9777
9778 assert!(
9779 !flagged_distances.is_empty()
9780 && flagged_distances.iter().all(|distance| *distance > 0.0),
9781 "{solver_name} must flag genuine crossings only at positive range: {flagged_distances:?}"
9782 );
9783 }
9784 }
9785
9786 #[test]
9787 fn subsonic_launch_does_not_flag_a_muzzle_transition() {
9788 use crate::trajectory_sampling::TrajectoryFlag;
9789
9790 for (solver_name, use_rk4, use_adaptive_rk45) in [
9791 ("Euler", false, false),
9792 ("RK4", true, false),
9793 ("RK45", true, true),
9794 ] {
9795 let inputs = BallisticInputs {
9796 muzzle_velocity: 250.0,
9797 muzzle_angle: 0.02,
9798 enable_trajectory_sampling: true,
9799 sample_interval: 25.0,
9800 use_rk4,
9801 use_adaptive_rk45,
9802 ..BallisticInputs::default()
9803 };
9804 let mut solver = TrajectorySolver::new(
9805 inputs,
9806 WindConditions::default(),
9807 AtmosphericConditions::default(),
9808 );
9809 solver.set_max_range(300.0);
9810 let samples = solver
9811 .solve()
9812 .expect("subsonic solve should succeed")
9813 .sampled_points
9814 .expect("sampling was enabled");
9815
9816 assert!(
9817 samples
9818 .iter()
9819 .all(|sample| !sample.flags.contains(&TrajectoryFlag::MachTransition)),
9820 "{solver_name} marked a Mach transition for a launch already below Mach 1"
9821 );
9822 }
9823 }
9824
9825 #[test]
9826 fn mach_transition_tracker_requires_a_downward_crossing() {
9827 fn record(mach_values: &[f64]) -> Vec<f64> {
9828 let mut tracker = MachTransitionTracker::default();
9829 let mut distances = Vec::new();
9830 for (index, mach) in mach_values.iter().copied().enumerate() {
9831 tracker.record_downward_crossings(mach, index as f64 * 10.0, &mut distances);
9832 }
9833 distances
9834 }
9835
9836 assert!(record(&[0.9, 0.8, 0.7]).is_empty());
9837 assert_eq!(record(&[1.1, 1.05, 0.99]), vec![20.0]);
9838 assert_eq!(record(&[1.2, 1.19, 1.0, 0.99]), vec![10.0, 30.0]);
9839 assert_eq!(record(&[0.9, 1.3, 1.1, 0.9, 1.3, 0.8]), vec![20.0, 30.0]);
9840 assert!(record(&[1.3, f64::NAN, 1.1]).is_empty());
9841 }
9842
9843 #[test]
9844 fn mach_transition_tracker_labels_0_9_without_touching_the_flat_vec() {
9845 fn record(mach_values: &[f64]) -> (Vec<f64>, MachTransitionTracker) {
9851 let mut tracker = MachTransitionTracker::default();
9852 let mut distances = Vec::new();
9853 for (index, mach) in mach_values.iter().copied().enumerate() {
9854 tracker.record_downward_crossings(mach, index as f64 * 10.0, &mut distances);
9855 }
9856 (distances, tracker)
9857 }
9858
9859 let (distances, tracker) = record(&[0.9, 0.8, 0.7]);
9862 assert!(distances.is_empty()); assert_eq!(tracker.mach_1_2_distance_m, None);
9864 assert_eq!(tracker.mach_1_0_distance_m, None);
9865 assert_eq!(tracker.mach_0_9_distance_m, Some(10.0));
9866
9867 let (distances, tracker) = record(&[1.1, 1.05, 0.99]);
9869 assert_eq!(distances, vec![20.0]);
9870 assert_eq!(tracker.mach_1_2_distance_m, None);
9871 assert_eq!(tracker.mach_1_0_distance_m, Some(20.0));
9872 assert_eq!(tracker.mach_0_9_distance_m, None);
9873
9874 let (distances, tracker) = record(&[1.2, 1.19, 1.0, 0.99]);
9876 assert_eq!(distances, vec![10.0, 30.0]); assert_eq!(tracker.mach_1_2_distance_m, Some(10.0));
9878 assert_eq!(tracker.mach_1_0_distance_m, Some(30.0));
9879 assert_eq!(tracker.mach_0_9_distance_m, None);
9880
9881 let (distances, tracker) = record(&[0.9, 1.3, 1.1, 0.9, 1.3, 0.8]);
9884 assert_eq!(distances, vec![20.0, 30.0]); assert_eq!(tracker.mach_1_2_distance_m, Some(20.0));
9886 assert_eq!(tracker.mach_1_0_distance_m, Some(30.0));
9887 assert_eq!(tracker.mach_0_9_distance_m, Some(50.0));
9888 assert!(
9889 tracker.mach_1_2_distance_m < tracker.mach_1_0_distance_m
9890 && tracker.mach_1_0_distance_m < tracker.mach_0_9_distance_m,
9891 "labeled crossings must be strictly increasing downrange"
9892 );
9893
9894 let (distances, tracker) = record(&[1.3, f64::NAN, 1.1]);
9896 assert!(distances.is_empty());
9897 assert_eq!(tracker.mach_1_2_distance_m, None);
9898 assert_eq!(tracker.mach_1_0_distance_m, None);
9899 assert_eq!(tracker.mach_0_9_distance_m, None);
9900 }
9901
9902 #[test]
9903 fn humidity_percent_converts_and_clamps() {
9904 let mut i = BallisticInputs {
9906 humidity: 0.5,
9907 ..BallisticInputs::default()
9908 };
9909 assert!((i.humidity_percent() - 50.0).abs() < 1e-9, "0.5 -> 50%");
9910 i.humidity = 0.0;
9911 assert_eq!(i.humidity_percent(), 0.0);
9912 i.humidity = 1.0;
9913 assert_eq!(i.humidity_percent(), 100.0);
9914 i.humidity = 1.5; assert_eq!(i.humidity_percent(), 100.0);
9916 }
9917
9918 fn vertical_at(shot_azimuth: f64, range_m: f64) -> f64 {
9921 let inputs = BallisticInputs {
9922 muzzle_velocity: 800.0,
9923 bc_value: 0.5,
9924 bc_type: DragModel::G7,
9925 muzzle_angle: 0.02, enable_coriolis: true,
9927 latitude: Some(45.0),
9928 shot_azimuth,
9929 ground_threshold: f64::NEG_INFINITY, ..BallisticInputs::default()
9931 };
9932 let mut solver = TrajectorySolver::new(
9933 inputs,
9934 WindConditions::default(),
9935 AtmosphericConditions::default(),
9936 );
9937 solver.set_max_range(range_m + 50.0);
9938 let r = solver.solve().expect("solve");
9939 let pts = &r.points;
9940 for i in 1..pts.len() {
9941 if pts[i].position.x >= range_m {
9942 let p1 = &pts[i - 1];
9943 let p2 = &pts[i];
9944 let t = (range_m - p1.position.x) / (p2.position.x - p1.position.x);
9945 return p1.position.y + t * (p2.position.y - p1.position.y);
9946 }
9947 }
9948 panic!("range {range_m} not reached");
9949 }
9950
9951 #[test]
9956 fn eotvos_east_higher_than_west() {
9957 let range = 600.0;
9958 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!(
9962 east > west,
9963 "east ({east:.5}) must be higher than west ({west:.5}) at {range} m (Eötvös)"
9964 );
9965 assert!(
9966 east > north && north > west,
9967 "north ({north:.5}) must lie between east ({east:.5}) and west ({west:.5})"
9968 );
9969 assert!(
9970 (east - west) > 1e-3,
9971 "E-W vertical separation ({:.6} m) should be physically meaningful, not FP noise",
9972 east - west
9973 );
9974 }
9975
9976 #[test]
9984 fn labeled_mach_crossings_match_pinned_pre_change_flat_vec_across_solvers() {
9985 let cases = [
9987 (
9988 "Euler",
9989 false,
9990 false,
9991 670.9878683238721_f64,
9992 805.5274119916264_f64,
9993 ),
9994 (
9995 "RK4",
9996 true,
9997 false,
9998 671.7257336844475_f64,
9999 805.933409072171_f64,
10000 ),
10001 (
10002 "RK45",
10003 true,
10004 true,
10005 672.4905711917901_f64,
10006 806.5709746782849_f64,
10007 ),
10008 ];
10009
10010 for (solver_name, use_rk4, use_adaptive_rk45, expected_1_2, expected_1_0) in cases {
10011 let inputs = BallisticInputs {
10012 muzzle_velocity: 850.0,
10013 bc_value: 0.2,
10014 bc_type: DragModel::G7,
10015 muzzle_angle: 0.03,
10016 use_rk4,
10017 use_adaptive_rk45,
10018 ..BallisticInputs::default()
10019 };
10020 let mut solver = TrajectorySolver::new(
10021 inputs,
10022 WindConditions::default(),
10023 AtmosphericConditions::default(),
10024 );
10025 solver.set_max_range(2000.0);
10026 let result = solver.solve().expect("solve should succeed");
10027
10028 assert_eq!(
10029 result.mach_1_2_distance_m,
10030 Some(expected_1_2),
10031 "{solver_name}: mach_1_2_distance_m must match the pinned pre-change flat-Vec value"
10032 );
10033 assert_eq!(
10034 result.mach_1_0_distance_m,
10035 Some(expected_1_0),
10036 "{solver_name}: mach_1_0_distance_m must match the pinned pre-change flat-Vec value"
10037 );
10038
10039 let mach_1_2 = result.mach_1_2_distance_m.expect("crosses 1.2");
10040 let mach_1_0 = result.mach_1_0_distance_m.expect("crosses 1.0");
10041 let mach_0_9 = result
10042 .mach_0_9_distance_m
10043 .expect("this trajectory also goes past 0.9 within 2000 m");
10044 assert!(
10045 mach_1_2 < mach_1_0 && mach_1_0 < mach_0_9,
10046 "{solver_name}: labeled crossings must be strictly increasing downrange \
10047 (1.2={mach_1_2}, 1.0={mach_1_0}, 0.9={mach_0_9})"
10048 );
10049 }
10050 }
10051
10052 #[test]
10055 fn labeled_mach_crossings_are_none_for_a_fully_supersonic_trajectory() {
10056 for (solver_name, use_rk4, use_adaptive_rk45) in [
10057 ("Euler", false, false),
10058 ("RK4", true, false),
10059 ("RK45", true, true),
10060 ] {
10061 let inputs = BallisticInputs {
10062 muzzle_velocity: 850.0,
10063 bc_value: 0.2,
10064 bc_type: DragModel::G7,
10065 muzzle_angle: 0.03,
10066 use_rk4,
10067 use_adaptive_rk45,
10068 ..BallisticInputs::default()
10069 };
10070 let mut solver = TrajectorySolver::new(
10071 inputs,
10072 WindConditions::default(),
10073 AtmosphericConditions::default(),
10074 );
10075 solver.set_max_range(200.0);
10077 let result = solver.solve().expect("solve should succeed");
10078
10079 assert_eq!(
10080 result.mach_1_2_distance_m, None,
10081 "{solver_name}: must not report a 1.2 crossing that never happens"
10082 );
10083 assert_eq!(
10084 result.mach_1_0_distance_m, None,
10085 "{solver_name}: must not report a 1.0 crossing that never happens"
10086 );
10087 assert_eq!(
10088 result.mach_0_9_distance_m, None,
10089 "{solver_name}: must not report a 0.9 crossing that never happens"
10090 );
10091 }
10092 }
10093}
10094
10095#[cfg(test)]
10096mod cant_tests {
10097 use super::*;
10098
10099 fn base_inputs() -> BallisticInputs {
10100 BallisticInputs {
10101 muzzle_velocity: 800.0,
10102 bc_value: 0.5,
10103 bc_type: DragModel::G7,
10104 bullet_mass: 0.0109,
10105 bullet_diameter: 0.00782,
10106 bullet_length: 0.0309,
10107 sight_height: 0.05,
10108 twist_rate: 10.0,
10109 use_rk4: true,
10110 ..BallisticInputs::default()
10111 }
10112 }
10113
10114 fn solve_with(inputs: BallisticInputs, max_range: f64) -> TrajectoryResult {
10115 let mut s = TrajectorySolver::new(
10116 inputs,
10117 WindConditions::default(),
10118 AtmosphericConditions::default(),
10119 );
10120 s.set_max_range(max_range);
10121 s.solve().expect("solve")
10122 }
10123
10124 fn yz_at(result: &TrajectoryResult, x: f64) -> (f64, f64) {
10126 let pts = &result.points;
10127 for i in 1..pts.len() {
10128 if pts[i].position.x >= x {
10129 let (p1, p2) = (&pts[i - 1], &pts[i]);
10130 let dx = p2.position.x - p1.position.x;
10131 let t = if dx.abs() < 1e-12 {
10132 0.0
10133 } else {
10134 (x - p1.position.x) / dx
10135 };
10136 return (
10137 p1.position.y + t * (p2.position.y - p1.position.y),
10138 p1.position.z + t * (p2.position.z - p1.position.z),
10139 );
10140 }
10141 }
10142 panic!("trajectory never reached {x} m");
10143 }
10144
10145 #[test]
10146 fn cant_sign_clockwise_up_offset_goes_right_and_low() {
10147 let mut level = base_inputs();
10149 level.muzzle_angle = 0.003; let mut canted = level.clone();
10151 canted.cant_angle = 10f64.to_radians();
10152
10153 let (y0, z0) = yz_at(&solve_with(level, 400.0), 300.0);
10154 let (y1, z1) = yz_at(&solve_with(canted, 400.0), 300.0);
10155 assert!(
10156 z1 > z0 + 0.01,
10157 "clockwise cant must move POI right: z0={z0} z1={z1}"
10158 );
10159 assert!(
10160 y1 < y0 - 0.001,
10161 "clockwise cant must move POI low: y0={y0} y1={y1}"
10162 );
10163 }
10164
10165 #[test]
10166 fn pure_cant_shows_bore_offset_near_range() {
10167 let mut i = base_inputs();
10170 i.muzzle_angle = 0.0;
10171 i.cant_angle = 10f64.to_radians();
10172 let sh = i.sight_height;
10173 let r = solve_with(i, 60.0);
10174 let first = &r.points[1]; let expected = -sh * 10f64.to_radians().sin();
10176 assert!(
10177 (first.position.z - expected).abs() < 0.005,
10178 "near-muzzle lateral {} should be ~bore offset {expected}",
10179 first.position.z
10180 );
10181 }
10182
10183 #[test]
10184 fn zero_angle_is_independent_of_cant() {
10185 let a = base_inputs();
10186 let mut b = base_inputs();
10187 b.cant_angle = 15f64.to_radians();
10188 let za = calculate_zero_angle(a.clone(), 100.0, 0.0).expect("zero a");
10189 let zb = calculate_zero_angle(b.clone(), 100.0, 0.0).expect("zero b");
10190 assert_eq!(
10191 za.to_bits(),
10192 zb.to_bits(),
10193 "zeroing must ignore cant: {za} vs {zb}"
10194 );
10195 let _ = (a.cant_angle, b.cant_angle);
10197 }
10198
10199 #[test]
10200 fn nonfinite_cant_is_rejected() {
10201 let mut i = base_inputs();
10202 i.cant_angle = f64::NAN;
10203 let s = TrajectorySolver::new(
10204 i,
10205 WindConditions::default(),
10206 AtmosphericConditions::default(),
10207 );
10208 assert!(s.solve().is_err());
10209 }
10210
10211 #[test]
10212 fn incline_and_cant_compose_without_breaking() {
10213 let mut flat = base_inputs();
10215 flat.muzzle_angle = 0.003;
10216 flat.shooting_angle = 15f64.to_radians();
10217 let mut canted = flat.clone();
10218 canted.cant_angle = 10f64.to_radians();
10219 let (_, z_flat) = yz_at(&solve_with(flat, 400.0), 300.0);
10220 let (_, z_cant) = yz_at(&solve_with(canted, 400.0), 300.0);
10221 assert!(
10222 z_cant > z_flat,
10223 "cant must still deflect right on an incline"
10224 );
10225 }
10226}
10227
10228#[cfg(test)]
10229mod vertical_wind_tests {
10230 use super::*;
10231
10232 fn base_inputs() -> BallisticInputs {
10233 BallisticInputs {
10234 muzzle_velocity: 800.0,
10235 bc_value: 0.5,
10236 bc_type: DragModel::G7,
10237 bullet_mass: 0.0109,
10238 bullet_diameter: 0.00782,
10239 bullet_length: 0.0309,
10240 sight_height: 0.05,
10241 twist_rate: 10.0,
10242 use_rk4: true,
10243 ..BallisticInputs::default()
10244 }
10245 }
10246
10247 fn y_at(result: &TrajectoryResult, x: f64) -> f64 {
10249 let pts = &result.points;
10250 for i in 1..pts.len() {
10251 if pts[i].position.x >= x {
10252 let (p1, p2) = (&pts[i - 1], &pts[i]);
10253 let dx = p2.position.x - p1.position.x;
10254 let t = if dx.abs() < 1e-12 {
10255 0.0
10256 } else {
10257 (x - p1.position.x) / dx
10258 };
10259 return p1.position.y + t * (p2.position.y - p1.position.y);
10260 }
10261 }
10262 panic!("trajectory never reached {x} m");
10263 }
10264
10265 fn solve_with(
10266 inputs: BallisticInputs,
10267 wind: WindConditions,
10268 max_range: f64,
10269 ) -> TrajectoryResult {
10270 let mut s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
10271 s.set_max_range(max_range);
10272 s.solve().expect("solve")
10273 }
10274
10275 #[test]
10276 fn updraft_raises_poi_downrange() {
10277 let calm_inputs = base_inputs();
10280 let calm_wind = WindConditions::default();
10281 let updraft = WindConditions {
10282 vertical_speed: 5.0,
10283 ..Default::default()
10284 };
10285
10286 let calm = solve_with(calm_inputs.clone(), calm_wind, 500.0);
10287 let updraft_result = solve_with(calm_inputs, updraft, 500.0);
10288
10289 let y_calm = y_at(&calm, 400.0);
10290 let y_updraft = y_at(&updraft_result, 400.0);
10291 assert!(
10292 y_updraft > y_calm,
10293 "5 m/s updraft must raise POI at 400m: calm={y_calm}, updraft={y_updraft}"
10294 );
10295 }
10296
10297 #[test]
10298 fn zero_vertical_is_default_and_finite_required() {
10299 assert_eq!(WindConditions::default().vertical_speed, 0.0);
10300
10301 let inputs = base_inputs();
10302 let wind = WindConditions {
10303 vertical_speed: f64::NAN,
10304 ..Default::default()
10305 };
10306 let s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
10307 assert!(
10308 s.solve().is_err(),
10309 "NaN wind.vertical_speed must be rejected by validate_for_solve"
10310 );
10311 }
10312}
10313
10314#[cfg(test)]
10316mod bc_reference_standard_tests {
10317 use super::*;
10318
10319 fn base_inputs() -> BallisticInputs {
10320 BallisticInputs {
10321 muzzle_velocity: 800.0,
10322 bc_value: 0.5,
10323 bc_type: DragModel::G7,
10324 bullet_mass: 0.0109,
10325 bullet_diameter: 0.00782,
10326 bullet_length: 0.0309,
10327 sight_height: 0.05,
10328 twist_rate: 10.0,
10329 use_rk4: true,
10330 ..BallisticInputs::default()
10331 }
10332 }
10333
10334 fn y_and_speed_at(result: &TrajectoryResult, x: f64) -> (f64, f64) {
10336 let pts = &result.points;
10337 for i in 1..pts.len() {
10338 if pts[i].position.x >= x {
10339 let (p1, p2) = (&pts[i - 1], &pts[i]);
10340 let dx = p2.position.x - p1.position.x;
10341 let t = if dx.abs() < 1e-12 {
10342 0.0
10343 } else {
10344 (x - p1.position.x) / dx
10345 };
10346 return (
10347 p1.position.y + t * (p2.position.y - p1.position.y),
10348 p1.velocity_magnitude + t * (p2.velocity_magnitude - p1.velocity_magnitude),
10349 );
10350 }
10351 }
10352 panic!("trajectory never reached {x} m");
10353 }
10354
10355 #[test]
10358 fn asm_to_icao_ratio_matches_documented_value() {
10359 assert!(
10360 (crate::constants::ASM_TO_ICAO_BC - 0.98237).abs() < 1e-5,
10361 "ASM_TO_ICAO_BC = {} must equal 0.98237 to 5 decimal places",
10362 crate::constants::ASM_TO_ICAO_BC
10363 );
10364 assert_eq!(
10369 crate::constants::ASM_TO_ICAO_BC,
10370 crate::constants::ASM_DENSITY_LB_FT3 / crate::constants::ICAO_DENSITY_LB_FT3
10371 );
10372 }
10373
10374 #[test]
10377 fn default_bc_reference_standard_is_icao() {
10378 assert_eq!(
10379 BallisticInputs::default().bc_reference_standard,
10380 BcReferenceStandard::Icao
10381 );
10382 }
10383
10384 #[test]
10389 fn icao_reference_leaves_bc_value_bit_identical() {
10390 let raw_bc: f64 = 0.4372911; let inputs = BallisticInputs {
10392 bc_value: raw_bc,
10393 bc_reference_standard: BcReferenceStandard::Icao,
10394 ..base_inputs()
10395 };
10396 let solver = TrajectorySolver::new(
10397 inputs,
10398 WindConditions::default(),
10399 AtmosphericConditions::default(),
10400 );
10401 assert_eq!(solver.inputs.bc_value.to_bits(), raw_bc.to_bits());
10402 }
10403
10404 #[test]
10405 fn default_inputs_solve_is_unaffected_by_the_new_field_existing() {
10406 let a = TrajectorySolver::new(
10410 base_inputs(),
10411 WindConditions::default(),
10412 AtmosphericConditions::default(),
10413 )
10414 .solve()
10415 .expect("solve a");
10416 let b = TrajectorySolver::new(
10417 BallisticInputs { ..base_inputs() },
10418 WindConditions::default(),
10419 AtmosphericConditions::default(),
10420 )
10421 .solve()
10422 .expect("solve b");
10423 assert_eq!(a.impact_velocity.to_bits(), b.impact_velocity.to_bits());
10424 assert_eq!(a.max_range.to_bits(), b.max_range.to_bits());
10425 }
10426
10427 #[test]
10430 fn army_standard_metro_scales_bc_value_by_exactly_the_derived_ratio() {
10431 let raw_bc = 0.5;
10432 let inputs = BallisticInputs {
10433 bc_value: raw_bc,
10434 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
10435 ..base_inputs()
10436 };
10437 let solver = TrajectorySolver::new(
10438 inputs,
10439 WindConditions::default(),
10440 AtmosphericConditions::default(),
10441 );
10442 assert_eq!(
10443 solver.inputs.bc_value,
10444 raw_bc * crate::constants::ASM_TO_ICAO_BC
10445 );
10446 }
10447
10448 #[test]
10449 fn army_standard_metro_scales_mach_keyed_bc_segments() {
10450 let inputs = BallisticInputs {
10451 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
10452 bc_segments: Some(vec![(0.5, 0.40), (1.5, 0.30)]),
10453 ..base_inputs()
10454 };
10455 let solver = TrajectorySolver::new(
10456 inputs,
10457 WindConditions::default(),
10458 AtmosphericConditions::default(),
10459 );
10460 let segments = solver.inputs.bc_segments.as_ref().expect("segments");
10461 assert_eq!(segments[0], (0.5, 0.40 * crate::constants::ASM_TO_ICAO_BC));
10462 assert_eq!(segments[1], (1.5, 0.30 * crate::constants::ASM_TO_ICAO_BC));
10463 }
10464
10465 #[test]
10466 fn army_standard_metro_scales_velocity_keyed_bc_segments_data() {
10467 let inputs = BallisticInputs {
10468 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
10469 bc_segments_data: Some(vec![
10470 crate::BCSegmentData {
10471 velocity_min: 0.0,
10472 velocity_max: 500.0,
10473 bc_value: 0.40,
10474 },
10475 crate::BCSegmentData {
10476 velocity_min: 500.0,
10477 velocity_max: 900.0,
10478 bc_value: 0.45,
10479 },
10480 ]),
10481 ..base_inputs()
10482 };
10483 let solver = TrajectorySolver::new(
10484 inputs,
10485 WindConditions::default(),
10486 AtmosphericConditions::default(),
10487 );
10488 let segments = solver.inputs.bc_segments_data.as_ref().expect("segments");
10489 assert_eq!(
10490 segments[0].bc_value,
10491 0.40 * crate::constants::ASM_TO_ICAO_BC
10492 );
10493 assert_eq!(
10494 segments[1].bc_value,
10495 0.45 * crate::constants::ASM_TO_ICAO_BC
10496 );
10497 assert_eq!(segments[0].velocity_min, 0.0);
10499 assert_eq!(segments[1].velocity_max, 900.0);
10500 }
10501
10502 #[test]
10507 fn army_standard_metro_moves_impact_in_the_more_drag_direction() {
10508 let solve_at = |standard: BcReferenceStandard| {
10509 let inputs = BallisticInputs {
10510 bc_value: 0.475,
10511 bc_reference_standard: standard,
10512 ..base_inputs()
10513 };
10514 let mut solver = TrajectorySolver::new(
10515 inputs,
10516 WindConditions::default(),
10517 AtmosphericConditions::default(),
10518 );
10519 solver.set_max_range(500.0);
10520 solver.solve().expect("solve")
10521 };
10522
10523 let icao = solve_at(BcReferenceStandard::Icao);
10524 let asm = solve_at(BcReferenceStandard::ArmyStandardMetro);
10525
10526 let (y_icao, v_icao) = y_and_speed_at(&icao, 400.0);
10527 let (y_asm, v_asm) = y_and_speed_at(&asm, 400.0);
10528
10529 assert!(
10530 y_asm < y_icao,
10531 "ArmyStandardMetro must drop MORE (lower y) at 400m than Icao for the same raw \
10532 bc_value: icao_y={y_icao}, asm_y={y_asm}"
10533 );
10534 assert!(
10535 v_asm < v_icao,
10536 "ArmyStandardMetro must retain LESS velocity at 400m than Icao for the same raw \
10537 bc_value: icao_v={v_icao}, asm_v={v_asm}"
10538 );
10539 }
10540
10541 #[test]
10548 fn monte_carlo_inherits_the_normalized_bc_reference() {
10549 let base_inputs_asm = BallisticInputs {
10550 bc_value: 0.475,
10551 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
10552 ..base_inputs()
10553 };
10554 let wind = WindConditions::default();
10555
10556 let mut direct_solver = TrajectorySolver::new(
10562 base_inputs_asm.clone(),
10563 wind.clone(),
10564 AtmosphericConditions::default(),
10565 );
10566 direct_solver.set_max_range(base_inputs_asm.target_distance.max(1000.0) * 2.0);
10567 let direct = direct_solver.solve().expect("direct solve");
10568
10569 let mc_params = MonteCarloParams {
10570 num_simulations: 1,
10571 velocity_std_dev: 0.0,
10572 angle_std_dev: 0.0,
10573 bc_std_dev: 0.0,
10574 wind_speed_std_dev: 0.0,
10575 target_distance: None,
10576 base_wind_speed: 0.0,
10577 base_wind_direction: 0.0,
10578 azimuth_std_dev: 0.0,
10579 };
10580 let mc = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
10581 base_inputs_asm,
10582 wind,
10583 mc_params,
10584 0.0,
10585 42,
10586 )
10587 .expect("monte carlo");
10588
10589 assert_eq!(mc.ranges.len(), 1);
10590 assert_eq!(
10591 mc.ranges[0].to_bits(),
10592 direct.max_range.to_bits(),
10593 "a zero-dispersion single MC sample must match a plain solve of the same \
10594 ASM-referenced inputs bit-for-bit"
10595 );
10596 assert_eq!(
10597 mc.impact_velocities[0].to_bits(),
10598 direct.impact_velocity.to_bits()
10599 );
10600 }
10601
10602 #[test]
10610 fn estimate_bc_fit_recovers_an_icao_referenced_bc() {
10611 let known_bc = 0.475;
10612 let velocity = 800.0;
10613 let mass = 0.0109;
10614 let diameter = 0.00782;
10615 let atmosphere = AtmosphericConditions::default();
10616
10617 let synth_inputs = BallisticInputs {
10618 muzzle_velocity: velocity,
10619 bc_value: known_bc,
10620 bc_type: DragModel::G7,
10621 bullet_mass: mass,
10622 bullet_diameter: diameter,
10623 bullet_length: 0.0309,
10624 sight_height: 0.05,
10625 twist_rate: 10.0,
10626 use_rk4: true,
10627 bc_reference_standard: BcReferenceStandard::Icao,
10628 ..BallisticInputs::default()
10629 };
10630 let mut solver =
10631 TrajectorySolver::new(synth_inputs, WindConditions::default(), atmosphere.clone());
10632 solver.set_max_range(500.0);
10633 let trajectory = solver.solve().expect("synthetic solve");
10634
10635 let points: Vec<(f64, f64)> = [100.0, 200.0, 300.0, 400.0]
10636 .iter()
10637 .map(|&d| {
10638 let (y, _) = {
10639 let pts = &trajectory.points;
10640 let mut found = None;
10641 for i in 1..pts.len() {
10642 if pts[i].position.x >= d {
10643 let (p1, p2) = (&pts[i - 1], &pts[i]);
10644 let dx = p2.position.x - p1.position.x;
10645 let t = if dx.abs() < 1e-12 {
10646 0.0
10647 } else {
10648 (d - p1.position.x) / dx
10649 };
10650 found =
10651 Some((p1.position.y + t * (p2.position.y - p1.position.y), 0.0));
10652 break;
10653 }
10654 }
10655 found.expect("trajectory reached observation distance")
10656 };
10657 (d, -y) })
10659 .collect();
10660
10661 let estimate = estimate_bc_fit(
10662 velocity,
10663 mass,
10664 diameter,
10665 &points,
10666 DragModel::G7,
10667 BcFitMode::Drop,
10668 atmosphere,
10669 None,
10670 0.05,
10671 )
10672 .expect("fit should converge");
10673
10674 assert!(
10675 (estimate.bc - known_bc).abs() < 0.02,
10676 "fit should recover the known ICAO-referenced bc={known_bc}, got {}",
10677 estimate.bc
10678 );
10679 }
10680
10681 #[test]
10684 fn custom_drag_table_makes_bc_reference_standard_numerically_inert() {
10685 let table =
10686 crate::drag::DragTable::try_new(vec![0.5, 1.0, 2.0, 3.0], vec![0.3, 0.4, 0.3, 0.2])
10687 .expect("valid table");
10688
10689 let solve_with = |standard: BcReferenceStandard| {
10690 let inputs = BallisticInputs {
10691 bc_value: 0.5, bc_reference_standard: standard,
10693 custom_drag_table: Some(table.clone()),
10694 ..base_inputs()
10695 };
10696 let mut solver = TrajectorySolver::new(
10697 inputs,
10698 WindConditions::default(),
10699 AtmosphericConditions::default(),
10700 );
10701 solver.set_max_range(500.0);
10702 solver.solve().expect("solve")
10703 };
10704
10705 let icao = solve_with(BcReferenceStandard::Icao);
10706 let asm = solve_with(BcReferenceStandard::ArmyStandardMetro);
10707
10708 assert_eq!(
10709 icao.impact_velocity.to_bits(),
10710 asm.impact_velocity.to_bits(),
10711 "a custom drag table must make bc_reference_standard fully inert"
10712 );
10713 assert_eq!(icao.max_range.to_bits(), asm.max_range.to_bits());
10714 }
10715
10716 #[test]
10717 fn custom_drag_table_inert_warning_fires_only_for_army_standard_metro_with_a_table() {
10718 let table = crate::drag::DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.3, 0.4, 0.3])
10719 .expect("valid table");
10720
10721 let no_table_icao = base_inputs();
10723 assert!(no_table_icao
10724 .bc_reference_standard_inert_warning()
10725 .is_none());
10726 let no_table_asm = BallisticInputs {
10727 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
10728 ..base_inputs()
10729 };
10730 assert!(no_table_asm.bc_reference_standard_inert_warning().is_none());
10731
10732 let table_icao = BallisticInputs {
10734 custom_drag_table: Some(table.clone()),
10735 ..base_inputs()
10736 };
10737 assert!(table_icao.bc_reference_standard_inert_warning().is_none());
10738
10739 let table_asm = BallisticInputs {
10741 custom_drag_table: Some(table),
10742 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
10743 ..base_inputs()
10744 };
10745 let warning = table_asm
10746 .bc_reference_standard_inert_warning()
10747 .expect("must warn");
10748 assert!(warning.contains("--bc-reference"));
10749 assert!(warning.contains("--drag-table"));
10750 }
10751}
10752
10753#[cfg(test)]
10759mod effective_drag_coefficient_tests {
10760 use super::*;
10761
10762 fn inputs_175gr_g7() -> BallisticInputs {
10763 let mut inputs = BallisticInputs {
10764 bc_value: 0.243,
10765 bc_type: DragModel::G7,
10766 muzzle_velocity: 823.0,
10767 ..Default::default()
10768 };
10769 inputs.bullet_mass = 175.0 * crate::constants::GRAINS_TO_KG;
10773 inputs.bullet_diameter = 0.308 * 0.0254;
10774 inputs.weight_grains = 175.0;
10775 inputs.caliber_inches = 0.308;
10776 inputs
10777 }
10778
10779 fn solver(inputs: BallisticInputs) -> TrajectorySolver {
10780 TrajectorySolver::new(
10781 inputs,
10782 WindConditions::default(),
10783 AtmosphericConditions::default(),
10784 )
10785 }
10786
10787 #[test]
10791 fn reports_the_projectiles_own_cd_not_the_reference_tables() {
10792 let inputs = inputs_175gr_g7();
10793 let sd = inputs.sectional_density_lb_in2().expect("SD");
10794 let solver = solver(inputs);
10795
10796 let sos = 340.0;
10797 let velocity = 800.0;
10798 let mach = velocity / sos;
10799
10800 let reference = crate::drag::get_drag_coefficient(mach, &DragModel::G7);
10801 let reported = solver
10802 .effective_drag_coefficient(velocity, sos)
10803 .expect("mass and diameter are set");
10804
10805 let expected = reference * sd / 0.243;
10806 assert!(
10807 (reported - expected).abs() < 1e-12,
10808 "reported {reported} != Cd_ref * SD / BC {expected}"
10809 );
10810 assert!(
10813 (reported - reference).abs() > 1e-6,
10814 "form factor collapsed to 1; this fixture no longer distinguishes the two values"
10815 );
10816 }
10817
10818 #[test]
10821 fn a_custom_drag_table_passes_through_unscaled() {
10822 let mut inputs = inputs_175gr_g7();
10823 inputs.custom_drag_table = Some(crate::drag::DragTable::new(
10824 vec![0.5, 3.0],
10825 vec![0.15, 0.40],
10826 ));
10827 let solver = solver(inputs);
10828
10829 let sos = 340.0;
10830 let velocity = 0.9 * sos;
10831 let table_value = solver
10832 .inputs
10833 .custom_drag_table
10834 .as_ref()
10835 .expect("table")
10836 .interpolate(0.9);
10837
10838 let reported = solver
10839 .effective_drag_coefficient(velocity, sos)
10840 .expect("mass and diameter are set");
10841 assert!(
10842 (reported - table_value).abs() < 1e-12,
10843 "custom table Cd {table_value} was rescaled to {reported}"
10844 );
10845 }
10846
10847 #[test]
10850 fn a_velocity_segmented_bc_steps_the_reported_cd() {
10851 let mut inputs = inputs_175gr_g7();
10852 inputs.use_bc_segments = true;
10853 inputs.bc_segments_data = Some(vec![
10854 crate::BCSegmentData {
10855 velocity_min: 2400.0,
10856 velocity_max: 4000.0,
10857 bc_value: 0.243,
10858 },
10859 crate::BCSegmentData {
10860 velocity_min: 0.0,
10861 velocity_max: 2400.0,
10862 bc_value: 0.200,
10863 },
10864 ]);
10865 let solver = solver(inputs);
10866
10867 let sos = 340.0;
10868 let above = solver
10870 .effective_drag_coefficient(2500.0 / 3.28084, sos)
10871 .expect("cd");
10872 let below = solver
10873 .effective_drag_coefficient(2300.0 / 3.28084, sos)
10874 .expect("cd");
10875
10876 assert!(
10878 below > above,
10879 "expected the 0.200 band to report a higher Cd than the 0.243 band; got {below} vs {above}"
10880 );
10881 }
10882
10883 #[test]
10886 fn is_absent_when_sectional_density_is_unknown() {
10887 let mut inputs = inputs_175gr_g7();
10888 inputs.weight_grains = 0.0;
10889 inputs.bullet_mass = 0.0;
10890 let solver = solver(inputs);
10891 assert!(solver.effective_drag_coefficient(800.0, 340.0).is_none());
10892 }
10893
10894 #[test]
10899 fn the_json_emit_rule_is_flag_gated_and_absent_when_cd_is_unknown() {
10900 let mut point = TrajectoryPoint {
10901 time: 0.0,
10902 position: nalgebra::Vector3::new(0.0, 0.0, 0.0),
10903 velocity_magnitude: 800.0,
10904 kinetic_energy: 3000.0,
10905 drag_coefficient: Some(0.31),
10906 };
10907 assert_eq!(point.drag_coefficient_json_value(true), Some(0.31));
10908 assert_eq!(
10909 point.drag_coefficient_json_value(false),
10910 None,
10911 "without the flag the key must not exist, so default JSON stays byte-identical"
10912 );
10913 point.drag_coefficient = None;
10914 assert_eq!(
10915 point.drag_coefficient_json_value(true),
10916 None,
10917 "unknown sectional density must yield an ABSENT key, not null"
10918 );
10919 }
10920
10921 #[test]
10923 fn every_point_of_a_solved_trajectory_carries_the_value() {
10924 let mut solver = solver(inputs_175gr_g7());
10925 solver.set_max_range(300.0);
10926 let result = solver.solve().expect("solve");
10927 assert!(!result.points.is_empty());
10928 assert!(
10929 result.points.iter().all(|p| p.drag_coefficient.is_some()),
10930 "the post-integration pass missed at least one point"
10931 );
10932 }
10933}
10934
10935#[cfg(test)]
10936mod boundary_layer_shear_height_tests {
10937 use super::*;
10944
10945 const MODELS: [&str; 2] = ["logarithmic", "power_law"];
10946
10947 fn ratio_rel_launch(height_rel_launch_m: f64, model: &str) -> f64 {
10948 crate::wind_shear::boundary_layer_speed_ratio(
10949 height_rel_launch_m,
10950 crate::wind_shear::boundary_layer_model_from_name(model),
10951 )
10952 }
10953
10954 fn assert_solver_shear_ratio(
10959 model: &str,
10960 muzzle_height: f64,
10961 shooting_angle: f64,
10962 position: Vector3<f64>,
10963 expected_ratio: f64,
10964 ) {
10965 let inputs = BallisticInputs {
10966 muzzle_height,
10967 shooting_angle,
10968 enable_wind_shear: true,
10969 wind_shear_model: model.to_string(),
10970 ..BallisticInputs::default()
10971 };
10972 let wind = WindConditions {
10973 speed: 10.0,
10974 direction: std::f64::consts::FRAC_PI_2,
10975 vertical_speed: 0.0,
10976 };
10977 let sheared = TrajectorySolver::new(
10978 inputs.clone(),
10979 wind.clone(),
10980 AtmosphericConditions::default(),
10981 );
10982 let uniform = TrajectorySolver::new(
10983 BallisticInputs {
10984 enable_wind_shear: false,
10985 ..inputs
10986 },
10987 wind.clone(),
10988 AtmosphericConditions::default(),
10989 );
10990 let (density, _, temp_c, pressure_hpa) = sheared.resolved_atmosphere();
10991 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
10992 let velocity = Vector3::new(700.0, 5.0, 0.0);
10993 let scaled_wind =
10994 crate::wind::wind_vector(wind.speed, wind.direction, 0.0) * expected_ratio;
10995
10996 let with_shear =
10997 sheared.calculate_acceleration(&position, &velocity, &Vector3::zeros(), resolved_atmo);
10998 let with_scaled_wind =
10999 uniform.calculate_acceleration(&position, &velocity, &scaled_wind, resolved_atmo);
11000 assert!(
11001 (with_shear - with_scaled_wind).norm() <= 1e-12,
11002 "{model}, muzzle_height {muzzle_height} m, shooting angle {shooting_angle} rad, \
11003 position {position:?}: the shear multiplier is not {expected_ratio} \
11004 (acceleration {with_shear:?}, with the wind scaled by it {with_scaled_wind:?})"
11005 );
11006 }
11007
11008 #[test]
11013 fn solver_shear_reads_one_multiplier_at_one_height_above_ground() {
11014 for model in MODELS {
11015 let expected = ratio_rel_launch(58.5, model);
11017 assert!(
11018 expected > 1.2,
11019 "{model}: the fixture must be well above the floor"
11020 );
11021 assert_solver_shear_ratio(model, 1.5, 0.0, Vector3::new(1_000.0, 60.0, 0.0), expected);
11022 assert_solver_shear_ratio(model, 0.0, 0.0, Vector3::new(1_000.0, 58.5, 0.0), expected);
11023 }
11024 }
11025
11026 #[test]
11036 fn inclined_solver_shear_measures_height_along_the_line_of_sight() {
11037 let (x, y) = (800.0, 60.0);
11038 for angle in [0.3_f64, -0.3] {
11039 let world_height = crate::atmosphere::shot_frame_altitude(0.0, x, y, angle);
11040 for model in MODELS {
11041 let bore_frame = ratio_rel_launch(y, model);
11044 let ground_frame = ratio_rel_launch(y - 1.5, model);
11046 assert!(
11047 bore_frame > 1.25 && ground_frame > 1.25,
11048 "{model}: fixture too low"
11049 );
11050 assert!(
11051 (ratio_rel_launch(world_height, model) - bore_frame).abs() > 0.1,
11052 "{model}, angle {angle}: the fixture must tell the world height apart"
11053 );
11054 assert_solver_shear_ratio(model, 0.0, angle, Vector3::new(x, y, 0.0), bore_frame);
11055 assert_solver_shear_ratio(model, 1.5, angle, Vector3::new(x, y, 0.0), ground_frame);
11056 }
11057 }
11058 }
11059
11060 fn solve_end(inputs: BallisticInputs, max_range: f64) -> (Vector3<f64>, f64) {
11061 let mut solver = TrajectorySolver::new(
11062 inputs,
11063 WindConditions {
11064 speed: 10.0,
11065 direction: std::f64::consts::FRAC_PI_2,
11066 vertical_speed: 0.0,
11067 },
11068 AtmosphericConditions::default(),
11069 );
11070 solver.set_max_range(max_range);
11071 let result = solver.solve().unwrap();
11072 let peak_y = result
11073 .points
11074 .iter()
11075 .map(|p| p.position.y)
11076 .fold(f64::NEG_INFINITY, f64::max);
11077 (result.points.last().unwrap().position, peak_y)
11078 }
11079
11080 #[test]
11085 fn inclined_flat_fire_keeps_the_operative_wind_with_shear_on() {
11086 for shooting_angle in [15.0_f64.to_radians(), (-10.0_f64).to_radians()] {
11087 let inputs = BallisticInputs {
11088 muzzle_height: 1.5,
11089 muzzle_angle: 0.002,
11090 shooting_angle,
11091 bc_type: DragModel::G7,
11092 ..BallisticInputs::default()
11093 };
11094 let (off, peak_y) = solve_end(inputs.clone(), 600.0);
11095 assert!(
11096 peak_y < 8.0 && off.z.abs() > 0.5,
11097 "the fixture must stay low and drift: peak y {peak_y} m, drift {} m",
11098 off.z
11099 );
11100 for model in MODELS {
11101 let (on, _) = solve_end(
11102 BallisticInputs {
11103 enable_wind_shear: true,
11104 wind_shear_model: model.to_string(),
11105 ..inputs.clone()
11106 },
11107 600.0,
11108 );
11109 assert_eq!(
11110 (on.x.to_bits(), on.y.to_bits(), on.z.to_bits()),
11111 (off.x.to_bits(), off.y.to_bits(), off.z.to_bits()),
11112 "{model}, shooting angle {shooting_angle} rad: shear moved a flat shot \
11113 ({on:?} vs {off:?})"
11114 );
11115 }
11116 }
11117 }
11118
11119 #[test]
11124 fn model_none_applies_no_shear_with_the_flag_set() {
11125 let inputs = BallisticInputs {
11126 muzzle_height: 1.5,
11127 muzzle_angle: 0.12,
11128 bc_type: DragModel::G7,
11129 ..BallisticInputs::default()
11130 };
11131 let (off, peak_y) = solve_end(inputs.clone(), 1_000.0);
11132 assert!(
11133 peak_y > 100.0,
11134 "the fixture must be lofted: peak y {peak_y} m"
11135 );
11136 let with_model = |model: &str| {
11137 solve_end(
11138 BallisticInputs {
11139 enable_wind_shear: true,
11140 wind_shear_model: model.to_string(),
11141 ..inputs.clone()
11142 },
11143 1_000.0,
11144 )
11145 .0
11146 };
11147 let none = with_model("none");
11148 assert_eq!(
11149 (none.x.to_bits(), none.y.to_bits(), none.z.to_bits()),
11150 (off.x.to_bits(), off.y.to_bits(), off.z.to_bits()),
11151 "model none moved the shot: {none:?} vs shear off {off:?}"
11152 );
11153 let power_law = with_model("power_law");
11154 assert!(
11155 (power_law.z - off.z).abs() > 0.05,
11156 "the fixture must show shear: power law {} m vs off {} m",
11157 power_law.z,
11158 off.z
11159 );
11160 assert_eq!(with_model("typo").z.to_bits(), power_law.z.to_bits());
11162 }
11163}