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