1use crate::cluster_bc::ClusterBCDegradation;
3use crate::pitch_damping::{calculate_pitch_damping_coefficient, PitchDampingCoefficients};
4use crate::precession_nutation::{
5 calculate_combined_angular_motion, projectile_moments_of_inertia, AngularState,
6 PrecessionNutationParams,
7};
8use crate::trajectory_sampling::{
9 projected_sample_count, sample_trajectory, TrajectoryData, TrajectoryOutputs,
10 TrajectorySample,
11};
12use crate::trajectory_observation::TrajectoryTermination;
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)]
64#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
65pub enum BcReferenceStandard {
66 #[default]
69 Icao,
70 ArmyStandardMetro,
73}
74
75pub const BC_REFERENCE_STANDARD_INERT_WARNING: &str =
79 "warning: --bc-reference army-standard-metro has no effect together with a custom drag \
80 table (--drag-table): the deck's Cd is divided by sectional density, not a BC value, so \
81 no BC-reference conversion applies";
82
83#[derive(Debug)]
85pub struct BallisticsError {
86 message: String,
87}
88
89impl fmt::Display for BallisticsError {
90 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
91 write!(f, "{}", self.message)
92 }
93}
94
95impl Error for BallisticsError {}
96
97impl From<String> for BallisticsError {
98 fn from(msg: String) -> Self {
99 BallisticsError { message: msg }
100 }
101}
102
103impl From<&str> for BallisticsError {
104 fn from(msg: &str) -> Self {
105 BallisticsError {
106 message: msg.to_string(),
107 }
108 }
109}
110
111#[derive(Debug, Clone)]
115pub struct BallisticInputs {
116 pub bc_value: f64, pub bc_type: DragModel, pub bc_reference_standard: BcReferenceStandard,
124 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,
138 pub shooting_angle: f64, pub cant_angle: f64,
148 pub sight_height: f64, pub muzzle_height: f64, pub target_height: f64, pub ground_threshold: f64, pub altitude: f64, pub temperature: f64, pub pressure: f64, pub humidity: f64,
162 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,
184 pub enable_magnus: bool, pub enable_coriolis: bool, pub use_powder_sensitivity: bool,
187 pub powder_temp_sensitivity: f64, pub powder_temp: f64, pub powder_temp_curve: Option<Vec<(f64, f64)>>,
196 pub powder_curve_temp_c: Option<f64>,
200 pub tipoff_yaw: f64, pub tipoff_decay_distance: f64, pub use_bc_segments: bool,
205 pub bc_segments: Option<Vec<(f64, f64)>>, pub bc_segments_data: Option<Vec<crate::BCSegmentData>>, pub use_enhanced_spin_drift: bool,
208 pub use_form_factor: bool,
211 pub enable_wind_shear: bool,
212 pub wind_shear_model: String,
213 pub enable_trajectory_sampling: bool,
214 pub sample_interval: f64, pub enable_pitch_damping: bool,
216 pub enable_precession_nutation: bool,
217 pub enable_aerodynamic_jump: bool,
220 pub use_cluster_bc: bool, pub custom_drag_table: Option<crate::drag::DragTable>,
224 pub cd_scale: f64,
232
233 pub bc_type_str: Option<String>,
235}
236
237impl BallisticInputs {
238 pub fn humidity_percent(&self) -> f64 {
243 (self.humidity * 100.0).clamp(0.0, 100.0)
244 }
245
246 pub fn sectional_density_lb_in2(&self) -> Option<f64> {
252 let weight_gr = if self.weight_grains > 0.0 {
253 self.weight_grains
254 } else {
255 self.bullet_mass / crate::constants::GRAINS_TO_KG };
257 let diameter_in = if self.caliber_inches > 0.0 {
258 self.caliber_inches
259 } else {
260 self.bullet_diameter / 0.0254 };
262 if weight_gr > 0.0 && diameter_in > 0.0 {
263 Some(weight_gr / 7000.0 / (diameter_in * diameter_in))
264 } else {
265 None
266 }
267 }
268
269 pub fn custom_drag_denominator(&self, fallback_bc: f64) -> f64 {
281 match self.sectional_density_lb_in2() {
282 Some(sd) => sd,
283 None => {
284 static WARN_ONCE: std::sync::Once = std::sync::Once::new();
285 WARN_ONCE.call_once(|| {
286 eprintln!(
287 "Warning: custom drag table active but bullet mass/diameter are \
288 unavailable; falling back to bc_value for the retardation denominator"
289 );
290 });
291 fallback_bc
292 }
293 }
294 }
295
296 pub fn bc_reference_standard_inert_warning(&self) -> Option<&'static str> {
307 if self.custom_drag_table.is_some()
308 && matches!(self.bc_reference_standard, BcReferenceStandard::ArmyStandardMetro)
309 {
310 Some(BC_REFERENCE_STANDARD_INERT_WARNING)
311 } else {
312 None
313 }
314 }
315
316 pub fn normalize_for_solve(&mut self) {
335 if matches!(
346 self.bc_reference_standard,
347 BcReferenceStandard::ArmyStandardMetro
348 ) {
349 self.bc_value *= crate::constants::ASM_TO_ICAO_BC;
350 if let Some(segments) = self.bc_segments.as_mut() {
351 for (_mach, bc) in segments.iter_mut() {
352 *bc *= crate::constants::ASM_TO_ICAO_BC;
353 }
354 }
355 if let Some(segments) = self.bc_segments_data.as_mut() {
356 for segment in segments.iter_mut() {
357 segment.bc_value *= crate::constants::ASM_TO_ICAO_BC;
358 }
359 }
360 self.bc_reference_standard = BcReferenceStandard::Icao;
363 }
364
365 self.caliber_inches = self.bullet_diameter / 0.0254;
370 self.weight_grains = self.bullet_mass / crate::constants::GRAINS_TO_KG;
371
372 self.muzzle_velocity = resolve_powder_adjusted_velocity(
383 self.muzzle_velocity,
384 self.temperature,
385 self.use_powder_sensitivity,
386 self.powder_temp_sensitivity,
387 self.powder_temp,
388 self.powder_temp_curve.as_deref(),
389 self.powder_curve_temp_c,
390 );
391 }
392}
393
394impl Default for BallisticInputs {
395 fn default() -> Self {
396 let mass_kg = 0.01;
397 let diameter_m = 0.00762;
398 let bc = 0.5;
399 let muzzle_angle_rad = 0.0;
400 let bc_type = DragModel::G1;
401
402 Self {
403 bc_value: bc,
405 bc_type,
406 bc_reference_standard: BcReferenceStandard::Icao,
407 bullet_mass: mass_kg,
408 muzzle_velocity: 800.0,
409 bullet_diameter: diameter_m,
410 bullet_length: crate::stability::estimate_bullet_length_m(diameter_m, mass_kg),
414
415 muzzle_angle: muzzle_angle_rad,
417 target_distance: 100.0,
418 azimuth_angle: 0.0,
419 shot_azimuth: 0.0,
420 shooting_angle: 0.0,
421 cant_angle: 0.0,
422 sight_height: 0.05,
423 muzzle_height: 0.0, target_height: 0.0, ground_threshold: -100.0, altitude: 0.0,
429 temperature: 15.0,
430 pressure: 1013.25, humidity: 0.5, latitude: None,
433
434 wind_speed: 0.0,
436 wind_angle: 0.0,
437
438 twist_rate: 12.0, is_twist_right: true,
441 caliber_inches: diameter_m / 0.0254, weight_grains: mass_kg / crate::constants::GRAINS_TO_KG, manufacturer: None,
444 bullet_model: None,
445 bullet_id: None,
446 bullet_cluster: None,
447
448 use_rk4: true, use_adaptive_rk45: true, enable_advanced_effects: false,
454 enable_magnus: false,
455 enable_coriolis: false,
456 use_powder_sensitivity: false,
457 powder_temp_sensitivity: 0.0,
458 powder_temp: 15.0,
459 powder_temp_curve: None,
460 powder_curve_temp_c: None,
461 tipoff_yaw: 0.0,
462 tipoff_decay_distance: 50.0,
463 use_bc_segments: false,
464 bc_segments: None,
465 bc_segments_data: None,
466 use_enhanced_spin_drift: false,
467 use_form_factor: false,
468 enable_wind_shear: false,
469 wind_shear_model: "none".to_string(),
470 enable_trajectory_sampling: false,
471 sample_interval: 10.0, enable_pitch_damping: false,
473 enable_precession_nutation: false,
474 enable_aerodynamic_jump: false,
475 use_cluster_bc: false, custom_drag_table: None,
479 cd_scale: 1.0,
480
481 bc_type_str: None,
483 }
484 }
485}
486
487pub fn interpolate_powder_temp_curve(curve: &[(f64, f64)], temp_c: f64) -> f64 {
493 debug_assert!(!curve.is_empty());
494 if curve.is_empty() {
495 return 0.0;
496 }
497 let mut sorted;
500 let pts: &[(f64, f64)] = if curve.windows(2).all(|w| w[0].0 <= w[1].0) {
501 curve
502 } else {
503 sorted = curve.to_vec();
504 sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
505 &sorted
506 };
507 let n = pts.len();
508 if temp_c <= pts[0].0 {
509 return pts[0].1; }
511 if temp_c >= pts[n - 1].0 {
512 return pts[n - 1].1; }
514 for i in 1..n {
515 let (t0, v0) = pts[i - 1];
516 let (t1, v1) = pts[i];
517 if temp_c <= t1 {
518 let span = t1 - t0;
519 if span.abs() < f64::EPSILON {
520 return v1; }
522 let f = (temp_c - t0) / span;
523 return v0 + f * (v1 - v0);
524 }
525 }
526 pts[n - 1].1
527}
528
529pub fn parse_powder_sweep(s: &str) -> Result<Vec<f64>, String> {
535 const MAX_SWEEP_ROWS: usize = 500;
536 let parts: Vec<&str> = s.split(':').collect();
537 if parts.len() != 3 {
538 return Err(format!(
539 "Invalid --sweep '{}': expected START:END:STEP (e.g. \"20:110:10\")",
540 s
541 ));
542 }
543 let parse = |p: &str, name: &str| -> Result<f64, String> {
544 p.trim()
545 .parse::<f64>()
546 .map_err(|_| format!("Invalid --sweep {}: '{}' is not a number", name, p.trim()))
547 };
548 let start = parse(parts[0], "START")?;
549 let end = parse(parts[1], "END")?;
550 let step = parse(parts[2], "STEP")?;
551 if !step.is_finite() || step <= 0.0 {
552 return Err(format!("Invalid --sweep STEP {}: must be positive", step));
553 }
554 if !start.is_finite() || !end.is_finite() || end < start {
555 return Err(format!(
556 "Invalid --sweep range {}:{}: END must be >= START",
557 start, end
558 ));
559 }
560 let n_f = ((end - start) / step + 1e-9).floor();
566 if !n_f.is_finite() || n_f + 1.0 > MAX_SWEEP_ROWS as f64 {
567 return Err(format!(
568 "--sweep would produce more than {} rows; use a larger STEP",
569 MAX_SWEEP_ROWS
570 ));
571 }
572 let n = n_f as usize + 1;
573 Ok((0..n).map(|i| start + step * i as f64).collect())
575}
576
577pub fn resolve_powder_adjusted_velocity(
586 nominal_velocity_mps: f64,
587 ambient_temperature_c: f64,
588 use_powder_sensitivity: bool,
589 powder_temp_sensitivity_mps_per_c: f64,
590 powder_reference_temp_c: f64,
591 powder_temp_curve: Option<&[(f64, f64)]>,
592 powder_curve_temp_c: Option<f64>,
593) -> f64 {
594 if let Some(curve) = powder_temp_curve {
595 if !curve.is_empty() {
596 let lookup_c = powder_curve_temp_c.unwrap_or(ambient_temperature_c);
597 return interpolate_powder_temp_curve(curve, lookup_c);
598 }
599 return nominal_velocity_mps;
602 }
603 if use_powder_sensitivity {
604 let temp_delta_c = ambient_temperature_c - powder_reference_temp_c;
605 return nominal_velocity_mps + powder_temp_sensitivity_mps_per_c * temp_delta_c;
606 }
607 nominal_velocity_mps
608}
609
610#[derive(Debug, Clone)]
612pub struct WindConditions {
613 pub speed: f64, pub direction: f64,
617 pub vertical_speed: f64,
625}
626
627impl Default for WindConditions {
628 fn default() -> Self {
629 Self {
630 speed: 0.0,
631 direction: 0.0,
632 vertical_speed: 0.0,
633 }
634 }
635}
636
637#[derive(Debug, Clone)]
639pub struct AtmosphericConditions {
640 pub temperature: f64, pub pressure: f64, pub humidity: f64,
646 pub altitude: f64, }
648
649impl Default for AtmosphericConditions {
650 fn default() -> Self {
651 Self {
652 temperature: 15.0,
653 pressure: 1013.25,
654 humidity: 50.0,
655 altitude: 0.0,
656 }
657 }
658}
659
660#[derive(Debug, Clone)]
662pub struct TrajectoryPoint {
663 pub time: f64,
664 pub position: Vector3<f64>,
665 pub velocity_magnitude: f64,
666 pub kinetic_energy: f64,
667 pub drag_coefficient: Option<f64>,
674}
675
676impl TrajectoryPoint {
677 pub fn drag_coefficient_json_value(&self, with_drag_coefficient: bool) -> Option<f64> {
685 if with_drag_coefficient {
686 self.drag_coefficient
687 } else {
688 None
689 }
690 }
691}
692
693#[derive(Debug, Clone)]
695pub struct TrajectoryResult {
696 pub max_range: f64,
697 pub max_height: f64,
698 pub time_of_flight: f64,
699 pub impact_velocity: f64,
700 pub impact_energy: f64,
701 pub projectile_mass_kg: f64,
703 pub line_of_sight_height_m: f64,
705 pub station_speed_of_sound_mps: f64,
707 pub termination: TrajectoryTermination,
709 pub points: Vec<TrajectoryPoint>,
710 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>,
719 pub mach_1_2_distance_m: Option<f64>,
724 pub mach_1_0_distance_m: Option<f64>,
728 pub mach_0_9_distance_m: Option<f64>,
736}
737
738const RK45_TOLERANCE: f64 = 1e-6;
739const RK45_SAFETY_FACTOR: f64 = 0.9;
740const RK45_MAX_DT: f64 = 0.01;
741const RK45_MIN_DT: f64 = 1e-6;
742const TRAJECTORY_TIME_LIMIT_S: f64 = 100.0;
743
744pub const MAX_TRAJECTORY_POINTS: usize = 250_000;
750
751fn cli_rk45_error_norm(
753 position: &Vector3<f64>,
754 velocity: &Vector3<f64>,
755 fifth_position: &Vector3<f64>,
756 fifth_velocity: &Vector3<f64>,
757 fourth_position: &Vector3<f64>,
758 fourth_velocity: &Vector3<f64>,
759) -> f64 {
760 let pack_state = |position: &Vector3<f64>, velocity: &Vector3<f64>| {
761 Vector6::new(
762 position.x, position.y, position.z, velocity.x, velocity.y, velocity.z,
763 )
764 };
765 let state = pack_state(position, velocity);
766 let fifth_order = pack_state(fifth_position, fifth_velocity);
767 let fourth_order = pack_state(fourth_position, fourth_velocity);
768
769 crate::trajectory_integration::rk45_error_norm(&state, &fifth_order, &fourth_order)
770}
771
772struct Rk45Trial {
773 position: Vector3<f64>,
774 velocity: Vector3<f64>,
775 suggested_dt: f64,
776 error: f64,
777}
778
779struct Rk45AcceptedStep {
780 position: Vector3<f64>,
781 velocity: Vector3<f64>,
782 used_dt: f64,
783 next_dt: f64,
784 error: f64,
785}
786
787#[derive(Default)]
801struct MachTransitionTracker {
802 previous_mach: Option<f64>,
803 crossed_transonic: bool,
804 crossed_subsonic: bool,
805 crossed_narrow: bool,
806 mach_1_2_distance_m: Option<f64>,
809 mach_1_0_distance_m: Option<f64>,
812 mach_0_9_distance_m: Option<f64>,
815}
816
817impl MachTransitionTracker {
818 fn record_downward_crossings(&mut self, mach: f64, downrange_m: f64, distances: &mut Vec<f64>) {
819 if !mach.is_finite() {
820 self.previous_mach = None;
821 return;
822 }
823
824 if let Some(previous_mach) = self.previous_mach {
825 if !self.crossed_transonic && previous_mach >= 1.2 && mach < 1.2 {
826 self.crossed_transonic = true;
827 distances.push(downrange_m);
828 self.mach_1_2_distance_m = Some(downrange_m);
829 }
830 if !self.crossed_subsonic && previous_mach >= 1.0 && mach < 1.0 {
831 self.crossed_subsonic = true;
832 distances.push(downrange_m);
833 self.mach_1_0_distance_m = Some(downrange_m);
834 }
835 if !self.crossed_narrow && previous_mach >= 0.9 && mach < 0.9 {
836 self.crossed_narrow = true;
837 self.mach_0_9_distance_m = Some(downrange_m);
839 }
840 }
841 self.previous_mach = Some(mach);
842 }
843}
844
845impl TrajectoryResult {
846 pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
850 if self.points.is_empty() {
851 return None;
852 }
853
854 for i in 0..self.points.len() - 1 {
856 let p1 = &self.points[i];
857 let p2 = &self.points[i + 1];
858
859 if p1.position.x <= target_range && p2.position.x >= target_range {
861 let dx = p2.position.x - p1.position.x;
863 if dx.abs() < 1e-10 {
864 return Some(p1.position);
865 }
866 let t = (target_range - p1.position.x) / dx;
867
868 return Some(Vector3::new(
870 target_range,
871 p1.position.y + t * (p2.position.y - p1.position.y),
872 p1.position.z + t * (p2.position.z - p1.position.z),
873 ));
874 }
875 }
876
877 self.points.last().map(|p| p.position)
879 }
880}
881
882#[derive(Debug, Clone, Copy, PartialEq, Eq)]
884enum StationAtmosphereResolution {
885 LegacyDefaultSentinels,
888 Authoritative,
891}
892
893#[derive(Clone)]
894pub struct TrajectorySolver {
895 inputs: BallisticInputs,
896 wind: WindConditions,
897 atmosphere: AtmosphericConditions,
898 station_atmosphere_resolution: StationAtmosphereResolution,
899 max_range: f64,
900 time_step: f64,
901 max_trajectory_points: usize,
902 cluster_bc: Option<ClusterBCDegradation>,
903 precession_nutation_inertias: (f64, f64),
905 wind_sock: Option<crate::wind::WindSock>,
910 atmo_sock: Option<crate::atmosphere::AtmoSock>,
917}
918
919#[derive(Debug, Clone, Copy, PartialEq, Eq)]
931pub(crate) enum ZeroTargetFrame {
932 SightLine,
933 WorldVertical,
934}
935
936#[derive(Debug, Clone, Copy, PartialEq)]
949pub struct ZeroCrossings {
950 pub near_m: Option<f64>,
953 pub far_m: Option<f64>,
956}
957
958impl TrajectorySolver {
959 pub fn new(
960 inputs: BallisticInputs,
961 wind: WindConditions,
962 atmosphere: AtmosphericConditions,
963 ) -> Self {
964 Self::new_with_station_atmosphere_resolution(
965 inputs,
966 wind,
967 atmosphere,
968 StationAtmosphereResolution::LegacyDefaultSentinels,
969 )
970 }
971
972 pub fn new_with_resolved_station_atmosphere(
983 inputs: BallisticInputs,
984 wind: WindConditions,
985 atmosphere: AtmosphericConditions,
986 ) -> Self {
987 Self::new_with_station_atmosphere_resolution(
988 inputs,
989 wind,
990 atmosphere,
991 StationAtmosphereResolution::Authoritative,
992 )
993 }
994
995 fn new_with_station_atmosphere_resolution(
996 mut inputs: BallisticInputs,
997 wind: WindConditions,
998 atmosphere: AtmosphericConditions,
999 station_atmosphere_resolution: StationAtmosphereResolution,
1000 ) -> Self {
1001 inputs.normalize_for_solve();
1006
1007 let cluster_bc = if inputs.use_cluster_bc {
1009 Some(ClusterBCDegradation::new())
1010 } else {
1011 None
1012 };
1013 let precession_nutation_inertias = projectile_moments_of_inertia(
1014 inputs.bullet_mass,
1015 inputs.bullet_diameter,
1016 inputs.bullet_length,
1017 );
1018
1019 Self {
1020 inputs,
1021 wind,
1022 atmosphere,
1023 station_atmosphere_resolution,
1024 max_range: 1000.0,
1025 time_step: 0.001,
1026 max_trajectory_points: MAX_TRAJECTORY_POINTS,
1027 cluster_bc,
1028 precession_nutation_inertias,
1029 wind_sock: None,
1030 atmo_sock: None,
1031 }
1032 }
1033
1034 pub fn set_max_range(&mut self, range: f64) {
1035 self.max_range = range;
1036 }
1037
1038 pub fn set_time_step(&mut self, step: f64) {
1039 self.time_step = step;
1040 }
1041
1042 pub(crate) fn calculate_and_set_zero_angle(
1046 &mut self,
1047 target_distance_m: f64,
1048 target_height_m: f64,
1049 frame: ZeroTargetFrame,
1050 ) -> Result<f64, BallisticsError> {
1051 let angle = self.find_zero_angle(target_distance_m, target_height_m, frame)?;
1052 self.inputs.muzzle_angle = angle;
1053 Ok(angle)
1054 }
1055
1056 fn find_zero_angle(
1057 &self,
1058 target_distance_m: f64,
1059 target_height_m: f64,
1060 frame: ZeroTargetFrame,
1061 ) -> Result<f64, BallisticsError> {
1062 let mut low_angle = 0.0;
1065 let mut high_angle = 0.2; let tolerance = 1e-7;
1067 let max_iterations = 60;
1068
1069 let low_height = self.zero_trial_height_at(low_angle, target_distance_m, frame)?;
1071 let high_height = self.zero_trial_height_at(high_angle, target_distance_m, frame)?;
1072
1073 match (low_height, high_height) {
1074 (Some(low_height), Some(high_height)) => {
1075 let low_error = low_height - target_height_m;
1076 let high_error = high_height - target_height_m;
1077
1078 if low_error > 0.0 && high_error > 0.0 {
1079 } else if low_error < 0.0 && high_error < 0.0 {
1082 let mut expanded = false;
1084 for multiplier in [2.0, 3.0, 4.0] {
1085 let new_high = (high_angle * multiplier).min(0.785);
1086 if let Ok(Some(height)) =
1087 self.zero_trial_height_at(new_high, target_distance_m, frame)
1088 {
1089 if height - target_height_m > 0.0 {
1090 high_angle = new_high;
1091 expanded = true;
1092 break;
1093 }
1094 }
1095 if new_high >= 0.785 {
1096 break;
1097 }
1098 }
1099 if !expanded {
1100 return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
1101 }
1102 }
1103 }
1104 (None, Some(_)) => {
1105 }
1108 (Some(_), None) => {
1109 return Err(
1110 "Cannot find zero angle: high angle trajectory doesn't reach target distance"
1111 .into(),
1112 );
1113 }
1114 (None, None) => {
1115 return Err(
1116 "Cannot find zero angle: trajectory cannot reach target distance at any angle"
1117 .into(),
1118 );
1119 }
1120 }
1121
1122 for _ in 0..max_iterations {
1123 let mid_angle = (low_angle + high_angle) / 2.0;
1124 match self.zero_trial_height_at(mid_angle, target_distance_m, frame)? {
1125 Some(height) => {
1126 let error = height - target_height_m;
1127 if error.abs() < 0.0001 {
1130 return Ok(mid_angle);
1131 }
1132
1133 if (high_angle - low_angle).abs() < tolerance {
1136 if error.abs() < 0.01 {
1137 return Ok(mid_angle);
1138 }
1139 return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
1140 }
1141
1142 if error > 0.0 {
1143 high_angle = mid_angle;
1144 } else {
1145 low_angle = mid_angle;
1146 }
1147 }
1148 None => {
1149 low_angle = mid_angle;
1150 if (high_angle - low_angle).abs() < tolerance {
1151 return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
1152 }
1153 }
1154 }
1155 }
1156
1157 Err("Failed to find zero angle".into())
1158 }
1159
1160 fn zero_trial_height_at(
1163 &self,
1164 angle_rad: f64,
1165 target_distance_m: f64,
1166 frame: ZeroTargetFrame,
1167 ) -> Result<Option<f64>, BallisticsError> {
1168 let mut trial = self.clone();
1169 trial.inputs.muzzle_angle = angle_rad;
1170 trial.inputs.enable_aerodynamic_jump = false;
1173 trial.inputs.cant_angle = 0.0;
1176 if frame == ZeroTargetFrame::SightLine {
1183 trial.inputs.shooting_angle = 0.0;
1184 }
1185 trial.set_max_range(target_distance_m * 2.0);
1186 let result = trial.solve()?;
1187
1188 for (index, point) in result.points.iter().enumerate() {
1189 if point.position.x >= target_distance_m {
1190 let shot_y_m = if index == 0 {
1191 point.position.y
1192 } else {
1193 let previous = &result.points[index - 1];
1194 let span = point.position.x - previous.position.x;
1195 let fraction = (target_distance_m - previous.position.x) / span;
1196 previous.position.y + fraction * (point.position.y - previous.position.y)
1197 };
1198 return Ok(Some(crate::atmosphere::shot_frame_altitude(
1199 0.0,
1200 target_distance_m,
1201 shot_y_m,
1202 trial.inputs.shooting_angle,
1203 )));
1204 }
1205 }
1206 Ok(None)
1207 }
1208
1209 fn find_zero_range(
1236 &self,
1237 angle_rad: f64,
1238 target_height_m: f64,
1239 frame: ZeroTargetFrame,
1240 ) -> Result<ZeroCrossings, BallisticsError> {
1241 let mut trial = self.clone();
1242 trial.inputs.muzzle_angle = angle_rad;
1243 trial.inputs.enable_aerodynamic_jump = false;
1246 trial.inputs.cant_angle = 0.0;
1247 if frame == ZeroTargetFrame::SightLine {
1248 trial.inputs.shooting_angle = 0.0;
1249 }
1250 let result = trial.solve()?;
1251
1252 let mut near_crossing: Option<f64> = None;
1260 let mut far_crossing: Option<f64> = None;
1261 let mut previous: Option<(f64, f64)> = None; for point in &result.points {
1263 let height = crate::atmosphere::shot_frame_altitude(
1264 0.0,
1265 point.position.x,
1266 point.position.y,
1267 trial.inputs.shooting_angle,
1268 );
1269 let error = height - target_height_m;
1270 if let Some((prev_x, prev_error)) = previous {
1271 if prev_error == 0.0 {
1272 if near_crossing.is_none() {
1275 near_crossing = Some(prev_x);
1276 } else {
1277 far_crossing = Some(prev_x);
1278 }
1279 }
1280 if prev_error * error < 0.0 {
1281 let fraction = prev_error / (prev_error - error);
1282 let crossing = prev_x + fraction * (point.position.x - prev_x);
1283 if prev_error < 0.0 && error > 0.0 {
1284 if near_crossing.is_none() {
1286 near_crossing = Some(crossing);
1287 }
1288 } else {
1289 far_crossing = Some(crossing);
1291 }
1292 }
1293 }
1294 previous = Some((point.position.x, error));
1295 }
1296 if let Some((last_x, last_error)) = previous {
1299 if last_error == 0.0 {
1300 if near_crossing.is_none() {
1301 near_crossing = Some(last_x);
1302 } else {
1303 far_crossing = Some(last_x);
1304 }
1305 }
1306 }
1307
1308 if near_crossing.is_none() && far_crossing.is_none() {
1309 return Err(BallisticsError::from(
1310 "Cannot find zero range: this angle never crosses the target height within the \
1311 solved range (angle too shallow to reach it, or both crossings lie beyond \
1312 the solver's max range)."
1313 .to_string(),
1314 ));
1315 }
1316
1317 Ok(ZeroCrossings {
1318 near_m: near_crossing,
1319 far_m: far_crossing,
1320 })
1321 }
1322
1323 fn validate_for_solve(&self) -> Result<(), BallisticsError> {
1329 let require_finite = |name: &str, value: f64| {
1330 if value.is_finite() {
1331 Ok(())
1332 } else {
1333 Err(BallisticsError::from(format!("{name} must be finite")))
1334 }
1335 };
1336 let require_positive = |name: &str, value: f64| {
1337 if value.is_finite() && value > 0.0 {
1338 Ok(())
1339 } else {
1340 Err(BallisticsError::from(format!(
1341 "{name} must be finite and greater than zero"
1342 )))
1343 }
1344 };
1345
1346 if self.inputs.custom_drag_table.is_none() {
1352 require_positive("bc_value", self.inputs.bc_value)?;
1353 }
1354 require_positive("bullet_mass", self.inputs.bullet_mass)?;
1355 require_positive("bullet_diameter", self.inputs.bullet_diameter)?;
1356 require_positive("muzzle_velocity", self.inputs.muzzle_velocity)?;
1357 require_positive("cd_scale", self.inputs.cd_scale)?;
1363
1364 require_finite("muzzle_angle", self.inputs.muzzle_angle)?;
1365 require_finite("azimuth_angle", self.inputs.azimuth_angle)?;
1366 require_finite("shooting_angle", self.inputs.shooting_angle)?;
1367 require_finite("cant_angle", self.inputs.cant_angle)?;
1368 require_finite("muzzle_height", self.inputs.muzzle_height)?;
1369
1370 if !(self.inputs.ground_threshold.is_finite()
1373 || self.inputs.ground_threshold == f64::NEG_INFINITY)
1374 {
1375 return Err(BallisticsError::from(
1376 "ground_threshold must be finite or negative infinity",
1377 ));
1378 }
1379
1380 match &self.wind_sock {
1381 Some(wind_sock) => wind_sock
1382 .validate_segments()
1383 .map_err(BallisticsError::from)?,
1384 None => {
1385 require_finite("wind.speed", self.wind.speed)?;
1386 require_finite("wind.direction", self.wind.direction)?;
1387 require_finite("wind.vertical_speed", self.wind.vertical_speed)?;
1388 }
1389 }
1390
1391 require_finite("atmosphere.temperature", self.atmosphere.temperature)?;
1392 require_finite("atmosphere.pressure", self.atmosphere.pressure)?;
1393 require_finite("atmosphere.humidity", self.atmosphere.humidity)?;
1394 require_finite("atmosphere.altitude", self.atmosphere.altitude)?;
1395
1396 require_positive("max_range", self.max_range)?;
1397 if !self.inputs.use_rk4 || !self.inputs.use_adaptive_rk45 {
1400 require_positive("time_step", self.time_step)?;
1401 }
1402
1403 if self.inputs.enable_trajectory_sampling {
1404 require_finite("sight_height", self.inputs.sight_height)?;
1405 require_positive("sample_interval", self.inputs.sample_interval)?;
1406 projected_sample_count(self.max_range, self.inputs.sample_interval)?;
1407 }
1408
1409 if self.inputs.enable_coriolis {
1410 require_finite("shot_azimuth", self.inputs.shot_azimuth)?;
1411 if let Some(latitude) = self.inputs.latitude {
1412 require_finite("latitude", latitude)?;
1413 }
1414 }
1415
1416 Ok(())
1417 }
1418
1419 fn validate_result_sanity(&self, result: &TrajectoryResult) -> Result<(), BallisticsError> {
1426 let require_finite = |name: &str, value: f64| {
1427 if value.is_finite() {
1428 Ok(())
1429 } else {
1430 Err(BallisticsError::from(format!(
1431 "trajectory result contains non-finite {name}"
1432 )))
1433 }
1434 };
1435 let require_non_negative = |name: &str, value: f64| {
1436 if value >= 0.0 {
1437 Ok(())
1438 } else {
1439 Err(BallisticsError::from(format!(
1440 "trajectory result contains non-physical negative {name} ({value})"
1441 )))
1442 }
1443 };
1444 let require_indexed_finite = |collection: &str, index: usize, field: &str, value: f64| {
1445 if value.is_finite() {
1446 Ok(())
1447 } else {
1448 Err(BallisticsError::from(format!(
1449 "trajectory result contains non-finite {collection}[{index}].{field}"
1450 )))
1451 }
1452 };
1453 let require_indexed_non_negative =
1454 |collection: &str, index: usize, field: &str, value: f64| {
1455 if value >= 0.0 {
1456 Ok(())
1457 } else {
1458 Err(BallisticsError::from(format!(
1459 "trajectory result contains non-physical negative {collection}[{index}].{field} ({value})"
1460 )))
1461 }
1462 };
1463
1464 require_finite("max_range", result.max_range)?;
1465 require_finite("max_height", result.max_height)?;
1466 require_finite("time_of_flight", result.time_of_flight)?;
1467 require_finite("impact_velocity", result.impact_velocity)?;
1468 require_finite("impact_energy", result.impact_energy)?;
1469 require_finite("projectile_mass_kg", result.projectile_mass_kg)?;
1470 require_finite(
1471 "line_of_sight_height_m",
1472 result.line_of_sight_height_m,
1473 )?;
1474 require_finite(
1475 "station_speed_of_sound_mps",
1476 result.station_speed_of_sound_mps,
1477 )?;
1478
1479 require_non_negative("max_range", result.max_range)?;
1483 require_non_negative("time_of_flight", result.time_of_flight)?;
1484 require_non_negative("impact_velocity", result.impact_velocity)?;
1485 require_non_negative("impact_energy", result.impact_energy)?;
1486 require_non_negative("projectile_mass_kg", result.projectile_mass_kg)?;
1487 require_non_negative(
1488 "station_speed_of_sound_mps",
1489 result.station_speed_of_sound_mps,
1490 )?;
1491
1492 for (index, point) in result.points.iter().enumerate() {
1493 require_indexed_finite("points", index, "time", point.time)?;
1494 require_indexed_finite("points", index, "position.x", point.position.x)?;
1495 require_indexed_finite("points", index, "position.y", point.position.y)?;
1496 require_indexed_finite("points", index, "position.z", point.position.z)?;
1497 require_indexed_finite(
1498 "points",
1499 index,
1500 "velocity_magnitude",
1501 point.velocity_magnitude,
1502 )?;
1503 require_indexed_finite("points", index, "kinetic_energy", point.kinetic_energy)?;
1504 require_indexed_non_negative("points", index, "time", point.time)?;
1505 require_indexed_non_negative(
1506 "points",
1507 index,
1508 "velocity_magnitude",
1509 point.velocity_magnitude,
1510 )?;
1511 require_indexed_non_negative("points", index, "kinetic_energy", point.kinetic_energy)?;
1512 }
1513
1514 if let Some(samples) = &result.sampled_points {
1515 for (index, sample) in samples.iter().enumerate() {
1516 require_indexed_finite("sampled_points", index, "distance_m", sample.distance_m)?;
1517 require_indexed_finite("sampled_points", index, "drop_m", sample.drop_m)?;
1518 require_indexed_finite(
1519 "sampled_points",
1520 index,
1521 "wind_drift_m",
1522 sample.wind_drift_m,
1523 )?;
1524 require_indexed_finite(
1525 "sampled_points",
1526 index,
1527 "velocity_mps",
1528 sample.velocity_mps,
1529 )?;
1530 require_indexed_finite("sampled_points", index, "energy_j", sample.energy_j)?;
1531 require_indexed_finite("sampled_points", index, "time_s", sample.time_s)?;
1532 }
1533 }
1534
1535 for (name, value) in [
1536 ("min_pitch_damping", result.min_pitch_damping),
1537 ("transonic_mach", result.transonic_mach),
1538 ("max_yaw_angle", result.max_yaw_angle),
1539 ("max_precession_angle", result.max_precession_angle),
1540 ] {
1541 if let Some(value) = value {
1542 require_finite(name, value)?;
1543 }
1544 }
1545
1546 if let Some(state) = result.angular_state {
1547 for (name, value) in [
1548 ("angular_state.pitch_angle", state.pitch_angle),
1549 ("angular_state.yaw_angle", state.yaw_angle),
1550 ("angular_state.pitch_rate", state.pitch_rate),
1551 ("angular_state.yaw_rate", state.yaw_rate),
1552 ("angular_state.precession_angle", state.precession_angle),
1553 ("angular_state.nutation_phase", state.nutation_phase),
1554 ] {
1555 require_finite(name, value)?;
1556 }
1557 }
1558
1559 if let Some(jump) = result.aerodynamic_jump {
1560 for (name, value) in [
1561 ("aerodynamic_jump.vertical_jump_moa", jump.vertical_jump_moa),
1562 (
1563 "aerodynamic_jump.horizontal_jump_moa",
1564 jump.horizontal_jump_moa,
1565 ),
1566 ("aerodynamic_jump.jump_angle_rad", jump.jump_angle_rad),
1567 (
1568 "aerodynamic_jump.magnus_component_moa",
1569 jump.magnus_component_moa,
1570 ),
1571 ("aerodynamic_jump.yaw_component_moa", jump.yaw_component_moa),
1572 (
1573 "aerodynamic_jump.stabilization_factor",
1574 jump.stabilization_factor,
1575 ),
1576 ] {
1577 require_finite(name, value)?;
1578 }
1579 }
1580
1581 Ok(())
1582 }
1583
1584 fn validate_integration_state(
1597 &self,
1598 position: &Vector3<f64>,
1599 velocity: &Vector3<f64>,
1600 time: f64,
1601 ) -> Result<(), BallisticsError> {
1602 if !(position.iter().all(|value| value.is_finite())
1603 && velocity.iter().all(|value| value.is_finite())
1604 && time.is_finite())
1605 {
1606 return Err(BallisticsError::from(
1607 "trajectory integration produced a non-finite state (often from physically \
1608 extreme inputs — e.g. an absurd bore/muzzle height placing the launch far \
1609 from sea level, or a degenerate atmosphere; check those inputs, or set \
1610 --altitude explicitly)",
1611 ));
1612 }
1613
1614 let speed = velocity.magnitude();
1615 let budget = self.speed_budget(time);
1616 if speed > budget {
1617 return Err(BallisticsError::from(format!(
1618 "trajectory integration diverged: speed {speed:.3e} m/s at t={time:.6}s exceeds \
1619 the physical budget of {budget:.3e} m/s"
1620 )));
1621 }
1622 Ok(())
1623 }
1624
1625 fn speed_budget(&self, time: f64) -> f64 {
1630 let scalar_wind = self.wind.speed.abs() + self.wind.vertical_speed.abs();
1631 let wind_bound = match &self.wind_sock {
1632 Some(sock) => scalar_wind.max(sock.max_speed_mps()),
1633 None => scalar_wind,
1634 };
1635 2.0 * (self.inputs.muzzle_velocity + wind_bound + 10.0)
1636 + crate::constants::G_ACCEL_MPS2 * time
1637 }
1638
1639 fn push_trajectory_point(
1641 &self,
1642 points: &mut Vec<TrajectoryPoint>,
1643 point: TrajectoryPoint,
1644 ) -> Result<(), BallisticsError> {
1645 if points.len() >= self.max_trajectory_points {
1646 return Err(BallisticsError::from(format!(
1647 "trajectory point limit of {} exceeded",
1648 self.max_trajectory_points
1649 )));
1650 }
1651 points.push(point);
1652 Ok(())
1653 }
1654
1655 pub fn set_wind_segments(&mut self, segments: Vec<crate::wind::WindSegment>) {
1662 self.wind_sock = if segments.is_empty() {
1663 None
1664 } else {
1665 Some(crate::wind::WindSock::new(segments))
1666 };
1667 }
1668
1669 pub fn set_atmo_segments(&mut self, segments: Vec<crate::atmosphere::AtmoSegment>) {
1678 self.atmo_sock = if segments.is_empty() {
1679 None
1680 } else {
1681 Some(crate::atmosphere::AtmoSock::new(segments))
1682 };
1683 }
1684
1685 fn launch_angles_from(
1694 &self,
1695 aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
1696 ) -> (f64, f64) {
1697 let (mut elev, mut azim) = (self.inputs.muzzle_angle, self.inputs.azimuth_angle);
1698 if self.inputs.cant_angle != 0.0 {
1705 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
1706 let (e0, a0) = (elev, azim);
1707 elev = e0 * cos_c - a0 * sin_c;
1708 azim = a0 * cos_c + e0 * sin_c;
1709 }
1710 match aj {
1711 Some(c) => {
1712 const MOA_PER_RAD: f64 = 3437.7467707849;
1714 (
1715 elev + c.vertical_jump_moa / MOA_PER_RAD,
1716 azim + c.horizontal_jump_moa / MOA_PER_RAD,
1717 )
1718 }
1719 None => (elev, azim),
1720 }
1721 }
1722
1723 fn aerodynamic_jump_components(
1731 &self,
1732 ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
1733 if !self.inputs.enable_aerodynamic_jump {
1734 return None;
1735 }
1736 let diameter_m = self.inputs.bullet_diameter;
1740 if !(self.inputs.twist_rate.is_finite()
1741 && self.inputs.twist_rate != 0.0
1742 && diameter_m.is_finite()
1743 && diameter_m > 0.0
1744 && self.inputs.bullet_length.is_finite()
1745 && self.inputs.bullet_length > 0.0
1746 && self.inputs.muzzle_velocity.is_finite())
1747 {
1748 return None;
1749 }
1750
1751 let (_, _, temp_c, pressure_hpa) = self.resolved_atmosphere();
1753 let sg = crate::stability::compute_stability_coefficient(
1754 &self.inputs,
1755 (self.atmosphere.altitude, temp_c, pressure_hpa, 0.0),
1756 );
1757 if !(sg.is_finite() && sg > 0.0) {
1758 return None;
1759 }
1760 let length_calibers = self.inputs.bullet_length / diameter_m;
1761
1762 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
1768 let crosswind_from_right_mps = if let Some(sock) = &self.wind_sock {
1769 -sock.vector_for_range_stateless(0.0)[2]
1770 } else {
1771 self.wind.speed * self.wind.direction.sin()
1772 };
1773 let crosswind_from_right_mph = crosswind_from_right_mps * MS_TO_MPH;
1774
1775 let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
1776 sg,
1777 length_calibers,
1778 crosswind_from_right_mph,
1779 self.inputs.is_twist_right,
1780 );
1781 if !vertical_jump_moa.is_finite() {
1782 return None;
1783 }
1784
1785 const MOA_PER_RAD: f64 = 3437.7467707849;
1786 Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
1787 vertical_jump_moa,
1788 horizontal_jump_moa: 0.0,
1790 jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
1791 magnus_component_moa: 0.0,
1792 yaw_component_moa: 0.0,
1793 stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
1794 })
1795 }
1796
1797 fn resolved_atmosphere(&self) -> (f64, f64, f64, f64) {
1798 let (temp_c, pressure_hpa) = match self.station_atmosphere_resolution {
1799 StationAtmosphereResolution::LegacyDefaultSentinels => {
1800 crate::atmosphere::resolve_station_conditions(
1801 self.atmosphere.temperature,
1802 self.atmosphere.pressure,
1803 self.atmosphere.altitude,
1804 )
1805 }
1806 StationAtmosphereResolution::Authoritative => {
1807 (self.atmosphere.temperature, self.atmosphere.pressure)
1808 }
1809 };
1810 let (density, speed_of_sound) = crate::atmosphere::calculate_atmosphere(
1811 self.atmosphere.altitude,
1812 Some(temp_c),
1813 Some(pressure_hpa),
1814 self.atmosphere.humidity,
1815 );
1816 (density, speed_of_sound, temp_c, pressure_hpa)
1817 }
1818
1819 fn precession_nutation_params(
1820 &self,
1821 velocity_mps: f64,
1822 air_density_kg_m3: f64,
1823 speed_of_sound_mps: f64,
1824 ) -> PrecessionNutationParams {
1825 let (spin_inertia, transverse_inertia) = self.precession_nutation_inertias;
1826 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
1827 let velocity_fps = velocity_mps * 3.28084;
1828 let twist_rate_ft = self.inputs.twist_rate / 12.0;
1829 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
1830 } else {
1831 0.0
1832 };
1833
1834 PrecessionNutationParams {
1835 mass_kg: self.inputs.bullet_mass,
1836 caliber_m: self.inputs.bullet_diameter,
1837 length_m: self.inputs.bullet_length,
1838 spin_rate_rad_s,
1839 spin_inertia,
1840 transverse_inertia,
1841 velocity_mps,
1842 air_density_kg_m3,
1843 mach: velocity_mps / speed_of_sound_mps,
1844 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
1845 nutation_damping_factor: 0.05,
1846 }
1847 }
1848
1849 fn append_terminal_endpoint(
1856 &self,
1857 points: &mut Vec<TrajectoryPoint>,
1858 post_position: Vector3<f64>,
1859 post_velocity: Vector3<f64>,
1860 post_time: f64,
1861 max_height: &mut f64,
1862 ) -> Result<TrajectoryTermination, BallisticsError> {
1863 let previous = points
1864 .last()
1865 .cloned()
1866 .ok_or_else(|| BallisticsError::from("No trajectory points generated"))?;
1867
1868 let mut crossings = Vec::with_capacity(3);
1869 if previous.position.x < self.max_range && post_position.x >= self.max_range {
1870 let span = post_position.x - previous.position.x;
1871 if span.is_finite() && span > 0.0 {
1872 crossings.push((
1873 (self.max_range - previous.position.x) / span,
1874 TrajectoryTermination::MaxRange,
1875 ));
1876 }
1877 }
1878 if self.inputs.ground_threshold.is_finite()
1879 && previous.position.y > self.inputs.ground_threshold
1880 && post_position.y <= self.inputs.ground_threshold
1881 {
1882 let span = post_position.y - previous.position.y;
1883 if span.is_finite() && span < 0.0 {
1884 crossings.push((
1885 (self.inputs.ground_threshold - previous.position.y) / span,
1886 TrajectoryTermination::GroundThreshold,
1887 ));
1888 }
1889 }
1890 if previous.time < TRAJECTORY_TIME_LIMIT_S && post_time >= TRAJECTORY_TIME_LIMIT_S {
1891 let span = post_time - previous.time;
1892 if span.is_finite() && span > 0.0 {
1893 crossings.push((
1894 (TRAJECTORY_TIME_LIMIT_S - previous.time) / span,
1895 TrajectoryTermination::TimeLimit,
1896 ));
1897 }
1898 }
1899
1900 let (fraction, termination) = crossings
1901 .into_iter()
1902 .filter(|(fraction, _)| fraction.is_finite() && (0.0..=1.0).contains(fraction))
1903 .min_by(|left, right| {
1904 let priority = |termination: TrajectoryTermination| match termination {
1905 TrajectoryTermination::GroundThreshold => 0,
1906 TrajectoryTermination::MaxRange => 1,
1907 TrajectoryTermination::TimeLimit => 2,
1908 TrajectoryTermination::VelocityFloor => 3,
1909 };
1910 left.0
1911 .total_cmp(&right.0)
1912 .then_with(|| priority(left.1).cmp(&priority(right.1)))
1913 })
1914 .ok_or_else(|| {
1915 BallisticsError::from(
1916 "trajectory integration stopped without crossing a supported boundary",
1917 )
1918 })?;
1919
1920 let mut position = previous.position + (post_position - previous.position) * fraction;
1921 match termination {
1922 TrajectoryTermination::MaxRange => position.x = self.max_range,
1923 TrajectoryTermination::GroundThreshold => {
1924 position.y = self.inputs.ground_threshold;
1925 }
1926 TrajectoryTermination::TimeLimit | TrajectoryTermination::VelocityFloor => {}
1927 }
1928 let velocity_magnitude = previous.velocity_magnitude
1929 + (post_velocity.magnitude() - previous.velocity_magnitude) * fraction;
1930 let mut time = previous.time + (post_time - previous.time) * fraction;
1931 if termination == TrajectoryTermination::TimeLimit {
1932 time = TRAJECTORY_TIME_LIMIT_S;
1933 }
1934 let kinetic_energy =
1935 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1936
1937 if position.y > *max_height {
1938 *max_height = position.y;
1939 }
1940 let terminal_point = TrajectoryPoint {
1941 time,
1942 position,
1943 velocity_magnitude,
1944 kinetic_energy,
1945 drag_coefficient: None,
1946 };
1947 if terminal_point.position.x < previous.position.x {
1948 return Err(BallisticsError::from(
1949 "trajectory terminal state reversed downrange before the crossed boundary",
1950 ));
1951 }
1952 if terminal_point.position.x == previous.position.x {
1953 let last = points.last_mut().ok_or_else(|| {
1958 BallisticsError::from("trajectory points disappeared during terminal finalization")
1959 })?;
1960 *last = terminal_point;
1961 } else {
1962 self.push_trajectory_point(points, terminal_point)?;
1963 }
1964 Ok(termination)
1965 }
1966
1967 fn gravity_acceleration(&self) -> Vector3<f64> {
1968 let theta = self.inputs.shooting_angle;
1969 Vector3::new(
1970 -crate::constants::G_ACCEL_MPS2 * theta.sin(),
1971 -crate::constants::G_ACCEL_MPS2 * theta.cos(),
1972 0.0,
1973 )
1974 }
1975
1976 fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
1977 let model = match self.inputs.wind_shear_model.as_str() {
1992 "logarithmic" => WindShearModel::Logarithmic,
1993 "power_law" | "powerlaw" | "exponential" => WindShearModel::PowerLaw,
1994 "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
1995 "custom_layers" | "custom" => WindShearModel::CustomLayers,
1996 _ => WindShearModel::PowerLaw,
1997 };
1998 let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
1999
2000 crate::wind::wind_vector(self.wind.speed, self.wind.direction, 0.0) * speed_ratio
2007 + Vector3::new(0.0, self.wind.vertical_speed, 0.0)
2008 }
2009
2010 pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
2011 self.validate_for_solve()?;
2012 let mut result = if self.inputs.use_rk4 {
2013 if self.inputs.use_adaptive_rk45 {
2014 self.solve_rk45()?
2015 } else {
2016 self.solve_rk4()?
2017 }
2018 } else {
2019 self.solve_euler()?
2020 };
2021 self.apply_spin_drift(&mut result);
2022 self.validate_result_sanity(&result)?;
2023 Ok(result)
2024 }
2025
2026 fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
2032 if !self.inputs.use_enhanced_spin_drift {
2033 return;
2034 }
2035 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 {
2039 return;
2040 }
2041
2042 let sg = self.effective_spin_drift_sg();
2049
2050 for p in result.points.iter_mut() {
2051 if p.time <= 0.0 {
2052 continue;
2053 }
2054 p.position.z +=
2056 crate::spin_drift::litz_drift_meters(sg, p.time, self.inputs.is_twist_right);
2057 }
2058
2059 if let Some(samples) = result.sampled_points.as_mut() {
2063 for s in samples.iter_mut() {
2064 if s.time_s <= 0.0 {
2065 continue;
2066 }
2067 s.wind_drift_m +=
2068 crate::spin_drift::litz_drift_meters(sg, s.time_s, self.inputs.is_twist_right);
2069 }
2070 }
2071 }
2072
2073 fn effective_spin_drift_sg(&self) -> f64 {
2078 let (_, _, temp_c, press_hpa) = self.resolved_atmosphere();
2079 crate::spin_drift::effective_sg_from_inputs(&self.inputs, temp_c, press_hpa)
2080 }
2081
2082 fn initial_position(&self) -> Vector3<f64> {
2088 if self.inputs.cant_angle == 0.0 {
2089 return Vector3::new(0.0, self.inputs.muzzle_height, 0.0);
2090 }
2091 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
2092 let sh = self.inputs.sight_height;
2093 Vector3::new(
2094 0.0,
2095 self.inputs.muzzle_height + sh * (1.0 - cos_c),
2096 -sh * sin_c,
2097 )
2098 }
2099
2100 fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
2101 let mut time = 0.0;
2103 let mut position = self.initial_position();
2107 let aj_components = self.aerodynamic_jump_components();
2113 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
2114 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
2115 let mut velocity = Vector3::new(
2116 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
2120
2121 let mut points = Vec::new();
2122 let mut max_height = position.y;
2123 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
2129 let mut mach_transitions = MachTransitionTracker::default();
2130
2131 let mut angular_state = if self.inputs.enable_precession_nutation {
2133 Some(AngularState {
2134 pitch_angle: 0.001, yaw_angle: 0.001,
2136 pitch_rate: 0.0,
2137 yaw_rate: 0.0,
2138 precession_angle: 0.0,
2139 nutation_phase: 0.0,
2140 })
2141 } else {
2142 None
2143 };
2144 let mut max_yaw_angle = 0.0;
2145 let mut max_precession_angle = 0.0;
2146
2147 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2149 self.resolved_atmosphere();
2150 let base_ratio = air_density / 1.225;
2155
2156 let wind_vector =
2162 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
2163
2164 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2167 self.inputs.bullet_model.as_deref().unwrap_or("default"),
2168 );
2169
2170 while position.x < self.max_range
2172 && position.y > self.inputs.ground_threshold
2173 && time < TRAJECTORY_TIME_LIMIT_S
2174 {
2175 let velocity_magnitude = velocity.magnitude();
2177 let kinetic_energy =
2178 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
2179
2180 self.push_trajectory_point(
2181 &mut points,
2182 TrajectoryPoint {
2183 time,
2184 position,
2185 velocity_magnitude,
2186 kinetic_energy,
2187 drag_coefficient: None,
2188 },
2189 )?;
2190
2191 {
2194 let mach_here = if speed_of_sound > 0.0 {
2195 velocity_magnitude / speed_of_sound
2196 } else {
2197 0.0
2198 };
2199 mach_transitions.record_downward_crossings(
2200 mach_here,
2201 position.x,
2202 &mut transonic_distances,
2203 );
2204 }
2205
2206 if position.y > max_height {
2208 max_height = position.y;
2209 }
2210
2211 if self.inputs.enable_pitch_damping {
2213 let mach = velocity_magnitude / speed_of_sound;
2214
2215 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2217 transonic_mach = Some(mach);
2218 }
2219
2220 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2222
2223 if pitch_damping < min_pitch_damping {
2225 min_pitch_damping = pitch_damping;
2226 }
2227 }
2228
2229 if self.inputs.enable_precession_nutation {
2231 if let Some(ref mut state) = angular_state {
2232 let velocity_magnitude = velocity.magnitude();
2233 let params = self.precession_nutation_params(
2234 velocity_magnitude,
2235 air_density,
2236 speed_of_sound,
2237 );
2238
2239 *state = calculate_combined_angular_motion(
2241 ¶ms,
2242 state,
2243 time,
2244 self.time_step,
2245 0.001, );
2247
2248 if state.yaw_angle.abs() > max_yaw_angle {
2250 max_yaw_angle = state.yaw_angle.abs();
2251 }
2252 if state.precession_angle.abs() > max_precession_angle {
2253 max_precession_angle = state.precession_angle.abs();
2254 }
2255 }
2256 }
2257
2258 let acceleration = self.calculate_acceleration(
2265 &position,
2266 &velocity,
2267 &wind_vector,
2268 (resolved_temp_c, resolved_press_hpa, base_ratio),
2269 );
2270
2271 velocity += acceleration * self.time_step;
2273 position += velocity * self.time_step;
2274 time += self.time_step;
2275 self.validate_integration_state(&position, &velocity, time)?;
2276 }
2277
2278 let termination =
2279 self.append_terminal_endpoint(&mut points, position, velocity, time, &mut max_height)?;
2280
2281 self.annotate_drag_coefficients(&mut points, speed_of_sound);
2286
2287 let last_point = points.last().ok_or("No trajectory points generated")?;
2288
2289 let sampled_points = if self.inputs.enable_trajectory_sampling {
2291 let trajectory_data = TrajectoryData {
2292 times: points.iter().map(|p| p.time).collect(),
2293 positions: points.iter().map(|p| p.position).collect(),
2294 velocities: points
2295 .iter()
2296 .map(|p| {
2297 Vector3::new(0.0, 0.0, p.velocity_magnitude)
2299 })
2300 .collect(),
2301 transonic_distances, mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2303 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2304 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2305 };
2306
2307 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
2312 let outputs = TrajectoryOutputs {
2313 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
2315 time_of_flight_s: last_point.time,
2316 max_ord_dist_horiz_m: max_height,
2317 sight_height_m: sight_position_m,
2318 };
2319
2320 let samples = sample_trajectory(
2322 &trajectory_data,
2323 &outputs,
2324 self.inputs.sample_interval,
2325 self.inputs.bullet_mass,
2326 )?;
2327 Some(samples)
2328 } else {
2329 None
2330 };
2331
2332 Ok(TrajectoryResult {
2333 max_range: last_point.position.x, max_height,
2335 time_of_flight: last_point.time,
2336 impact_velocity: last_point.velocity_magnitude,
2337 impact_energy: last_point.kinetic_energy,
2338 projectile_mass_kg: self.inputs.bullet_mass,
2339 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
2340 station_speed_of_sound_mps: speed_of_sound,
2341 termination,
2342 points,
2343 sampled_points,
2344 min_pitch_damping: if self.inputs.enable_pitch_damping {
2345 Some(min_pitch_damping)
2346 } else {
2347 None
2348 },
2349 transonic_mach,
2350 angular_state,
2351 max_yaw_angle: if self.inputs.enable_precession_nutation {
2352 Some(max_yaw_angle)
2353 } else {
2354 None
2355 },
2356 max_precession_angle: if self.inputs.enable_precession_nutation {
2357 Some(max_precession_angle)
2358 } else {
2359 None
2360 },
2361 aerodynamic_jump: aj_components,
2362 mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2363 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2364 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2365 })
2366 }
2367
2368 fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
2369 let mut time = 0.0;
2371 let mut position = self.initial_position();
2376
2377 let aj_components = self.aerodynamic_jump_components();
2383 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
2384 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
2385 let mut velocity = Vector3::new(
2386 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
2390
2391 let mut points = Vec::new();
2392 let mut max_height = position.y;
2393 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
2399 let mut mach_transitions = MachTransitionTracker::default();
2400
2401 let mut angular_state = if self.inputs.enable_precession_nutation {
2403 Some(AngularState {
2404 pitch_angle: 0.001, yaw_angle: 0.001,
2406 pitch_rate: 0.0,
2407 yaw_rate: 0.0,
2408 precession_angle: 0.0,
2409 nutation_phase: 0.0,
2410 })
2411 } else {
2412 None
2413 };
2414 let mut max_yaw_angle = 0.0;
2415 let mut max_precession_angle = 0.0;
2416
2417 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2419 self.resolved_atmosphere();
2420 let base_ratio = air_density / 1.225;
2425
2426 let wind_vector =
2432 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
2433
2434 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2437 self.inputs.bullet_model.as_deref().unwrap_or("default"),
2438 );
2439
2440 while position.x < self.max_range
2442 && position.y > self.inputs.ground_threshold
2443 && time < TRAJECTORY_TIME_LIMIT_S
2444 {
2445 let velocity_magnitude = velocity.magnitude();
2447 let kinetic_energy =
2448 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
2449
2450 self.push_trajectory_point(
2451 &mut points,
2452 TrajectoryPoint {
2453 time,
2454 position,
2455 velocity_magnitude,
2456 kinetic_energy,
2457 drag_coefficient: None,
2458 },
2459 )?;
2460
2461 {
2464 let mach_here = if speed_of_sound > 0.0 {
2465 velocity_magnitude / speed_of_sound
2466 } else {
2467 0.0
2468 };
2469 mach_transitions.record_downward_crossings(
2470 mach_here,
2471 position.x,
2472 &mut transonic_distances,
2473 );
2474 }
2475
2476 if position.y > max_height {
2477 max_height = position.y;
2478 }
2479
2480 if self.inputs.enable_pitch_damping {
2482 let mach = velocity_magnitude / speed_of_sound;
2483
2484 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2486 transonic_mach = Some(mach);
2487 }
2488
2489 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2491
2492 if pitch_damping < min_pitch_damping {
2494 min_pitch_damping = pitch_damping;
2495 }
2496 }
2497
2498 if self.inputs.enable_precession_nutation {
2500 if let Some(ref mut state) = angular_state {
2501 let velocity_magnitude = velocity.magnitude();
2502 let params = self.precession_nutation_params(
2503 velocity_magnitude,
2504 air_density,
2505 speed_of_sound,
2506 );
2507
2508 *state = calculate_combined_angular_motion(
2510 ¶ms,
2511 state,
2512 time,
2513 self.time_step,
2514 0.001, );
2516
2517 if state.yaw_angle.abs() > max_yaw_angle {
2519 max_yaw_angle = state.yaw_angle.abs();
2520 }
2521 if state.precession_angle.abs() > max_precession_angle {
2522 max_precession_angle = state.precession_angle.abs();
2523 }
2524 }
2525 }
2526
2527 let dt = self.time_step;
2529
2530 let acc1 = self.calculate_acceleration(
2532 &position,
2533 &velocity,
2534 &wind_vector,
2535 (resolved_temp_c, resolved_press_hpa, base_ratio),
2536 );
2537
2538 let pos2 = position + velocity * (dt * 0.5);
2540 let vel2 = velocity + acc1 * (dt * 0.5);
2541 let acc2 = self.calculate_acceleration(
2542 &pos2,
2543 &vel2,
2544 &wind_vector,
2545 (resolved_temp_c, resolved_press_hpa, base_ratio),
2546 );
2547
2548 let pos3 = position + vel2 * (dt * 0.5);
2550 let vel3 = velocity + acc2 * (dt * 0.5);
2551 let acc3 = self.calculate_acceleration(
2552 &pos3,
2553 &vel3,
2554 &wind_vector,
2555 (resolved_temp_c, resolved_press_hpa, base_ratio),
2556 );
2557
2558 let pos4 = position + vel3 * dt;
2560 let vel4 = velocity + acc3 * dt;
2561 let acc4 = self.calculate_acceleration(
2562 &pos4,
2563 &vel4,
2564 &wind_vector,
2565 (resolved_temp_c, resolved_press_hpa, base_ratio),
2566 );
2567
2568 position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
2570 velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
2571 time += dt;
2572 self.validate_integration_state(&position, &velocity, time)?;
2573 }
2574
2575 let termination =
2576 self.append_terminal_endpoint(&mut points, position, velocity, time, &mut max_height)?;
2577
2578 self.annotate_drag_coefficients(&mut points, speed_of_sound);
2583
2584 let last_point = points.last().ok_or("No trajectory points generated")?;
2585
2586 let sampled_points = if self.inputs.enable_trajectory_sampling {
2588 let trajectory_data = TrajectoryData {
2589 times: points.iter().map(|p| p.time).collect(),
2590 positions: points.iter().map(|p| p.position).collect(),
2591 velocities: points
2592 .iter()
2593 .map(|p| {
2594 Vector3::new(0.0, 0.0, p.velocity_magnitude)
2596 })
2597 .collect(),
2598 transonic_distances, mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2600 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2601 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2602 };
2603
2604 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
2609 let outputs = TrajectoryOutputs {
2610 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
2612 time_of_flight_s: last_point.time,
2613 max_ord_dist_horiz_m: max_height,
2614 sight_height_m: sight_position_m,
2615 };
2616
2617 let samples = sample_trajectory(
2619 &trajectory_data,
2620 &outputs,
2621 self.inputs.sample_interval,
2622 self.inputs.bullet_mass,
2623 )?;
2624 Some(samples)
2625 } else {
2626 None
2627 };
2628
2629 Ok(TrajectoryResult {
2630 max_range: last_point.position.x, max_height,
2632 time_of_flight: last_point.time,
2633 impact_velocity: last_point.velocity_magnitude,
2634 impact_energy: last_point.kinetic_energy,
2635 projectile_mass_kg: self.inputs.bullet_mass,
2636 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
2637 station_speed_of_sound_mps: speed_of_sound,
2638 termination,
2639 points,
2640 sampled_points,
2641 min_pitch_damping: if self.inputs.enable_pitch_damping {
2642 Some(min_pitch_damping)
2643 } else {
2644 None
2645 },
2646 transonic_mach,
2647 angular_state,
2648 max_yaw_angle: if self.inputs.enable_precession_nutation {
2649 Some(max_yaw_angle)
2650 } else {
2651 None
2652 },
2653 max_precession_angle: if self.inputs.enable_precession_nutation {
2654 Some(max_precession_angle)
2655 } else {
2656 None
2657 },
2658 aerodynamic_jump: aj_components,
2659 mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2660 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2661 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2662 })
2663 }
2664
2665 fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
2666 let mut time = 0.0;
2668 let mut position = self.initial_position();
2672
2673 let aj_components = self.aerodynamic_jump_components();
2679 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
2680 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
2681 let mut velocity = Vector3::new(
2682 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
2686
2687 let mut points = Vec::new();
2688 let mut max_height = position.y;
2689 let mut dt = 0.001; let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2694 self.resolved_atmosphere();
2695 let base_ratio = air_density / 1.225;
2700 let wind_vector =
2705 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
2706
2707 let mut transonic_distances: Vec<f64> = Vec::new();
2709 let mut mach_transitions = MachTransitionTracker::default();
2710
2711 let mut min_pitch_damping = f64::INFINITY;
2716 let mut transonic_mach: Option<f64> = None;
2717 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2718 self.inputs.bullet_model.as_deref().unwrap_or("default"),
2719 );
2720 let mut angular_state = if self.inputs.enable_precession_nutation {
2721 Some(AngularState {
2722 pitch_angle: 0.001,
2723 yaw_angle: 0.001,
2724 pitch_rate: 0.0,
2725 yaw_rate: 0.0,
2726 precession_angle: 0.0,
2727 nutation_phase: 0.0,
2728 })
2729 } else {
2730 None
2731 };
2732 let mut max_yaw_angle = 0.0;
2733 let mut max_precession_angle = 0.0;
2734
2735 while position.x < self.max_range
2736 && position.y > self.inputs.ground_threshold
2737 && time < TRAJECTORY_TIME_LIMIT_S
2738 {
2739 let velocity_magnitude = velocity.magnitude();
2741 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
2742
2743 self.push_trajectory_point(
2744 &mut points,
2745 TrajectoryPoint {
2746 time,
2747 position,
2748 velocity_magnitude,
2749 kinetic_energy,
2750 drag_coefficient: None,
2751 },
2752 )?;
2753
2754 {
2757 let mach_here = if speed_of_sound > 0.0 {
2758 velocity_magnitude / speed_of_sound
2759 } else {
2760 0.0
2761 };
2762 mach_transitions.record_downward_crossings(
2763 mach_here,
2764 position.x,
2765 &mut transonic_distances,
2766 );
2767 }
2768
2769 if position.y > max_height {
2770 max_height = position.y;
2771 }
2772
2773 if self.inputs.enable_pitch_damping {
2776 let mach = velocity_magnitude / speed_of_sound;
2777 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2778 transonic_mach = Some(mach);
2779 }
2780 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2781 if pitch_damping < min_pitch_damping {
2782 min_pitch_damping = pitch_damping;
2783 }
2784 }
2785
2786 let accepted_step = self.adaptive_rk45_step(
2789 &position,
2790 &velocity,
2791 dt,
2792 &wind_vector,
2793 (resolved_temp_c, resolved_press_hpa, base_ratio),
2794 );
2795 debug_assert!(
2796 accepted_step.error <= RK45_TOLERANCE || accepted_step.used_dt <= RK45_MIN_DT
2797 );
2798
2799 if self.inputs.enable_precession_nutation {
2803 if let Some(ref mut state) = angular_state {
2804 let params = self.precession_nutation_params(
2805 velocity_magnitude,
2806 air_density,
2807 speed_of_sound,
2808 );
2809
2810 *state = calculate_combined_angular_motion(
2811 ¶ms,
2812 state,
2813 time,
2814 accepted_step.used_dt,
2815 0.001,
2816 );
2817
2818 if state.yaw_angle.abs() > max_yaw_angle {
2819 max_yaw_angle = state.yaw_angle.abs();
2820 }
2821 if state.precession_angle.abs() > max_precession_angle {
2822 max_precession_angle = state.precession_angle.abs();
2823 }
2824 }
2825 }
2826
2827 position = accepted_step.position;
2828 velocity = accepted_step.velocity;
2829 time += accepted_step.used_dt;
2830 self.validate_integration_state(&position, &velocity, time)?;
2831
2832 dt = accepted_step.next_dt;
2834 }
2835
2836 if points.is_empty() {
2838 return Err(BallisticsError::from("No trajectory points calculated"));
2839 }
2840
2841 let termination =
2843 self.append_terminal_endpoint(&mut points, position, velocity, time, &mut max_height)?;
2844
2845 self.annotate_drag_coefficients(&mut points, speed_of_sound);
2847
2848 let last_point = points.last().unwrap();
2849
2850 let sampled_points = if self.inputs.enable_trajectory_sampling {
2852 let trajectory_data = TrajectoryData {
2854 times: points.iter().map(|p| p.time).collect(),
2855 positions: points.iter().map(|p| p.position).collect(),
2856 velocities: points
2857 .iter()
2858 .map(|p| {
2859 Vector3::new(0.0, 0.0, p.velocity_magnitude)
2861 })
2862 .collect(),
2863 transonic_distances, mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2865 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2866 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2867 };
2868
2869 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
2874 let outputs = TrajectoryOutputs {
2875 target_distance_horiz_m: last_point.position.x,
2876 target_vertical_height_m: sight_position_m,
2877 time_of_flight_s: last_point.time,
2878 max_ord_dist_horiz_m: max_height,
2879 sight_height_m: sight_position_m,
2880 };
2881
2882 let samples = sample_trajectory(
2883 &trajectory_data,
2884 &outputs,
2885 self.inputs.sample_interval,
2886 self.inputs.bullet_mass,
2887 )?;
2888 Some(samples)
2889 } else {
2890 None
2891 };
2892
2893 Ok(TrajectoryResult {
2894 max_range: last_point.position.x, max_height,
2896 time_of_flight: last_point.time,
2897 impact_velocity: last_point.velocity_magnitude,
2898 impact_energy: last_point.kinetic_energy,
2899 projectile_mass_kg: self.inputs.bullet_mass,
2900 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
2901 station_speed_of_sound_mps: speed_of_sound,
2902 termination,
2903 points,
2904 sampled_points,
2905 min_pitch_damping: if self.inputs.enable_pitch_damping {
2906 Some(min_pitch_damping)
2907 } else {
2908 None
2909 },
2910 transonic_mach,
2911 angular_state,
2912 max_yaw_angle: if self.inputs.enable_precession_nutation {
2913 Some(max_yaw_angle)
2914 } else {
2915 None
2916 },
2917 max_precession_angle: if self.inputs.enable_precession_nutation {
2918 Some(max_precession_angle)
2919 } else {
2920 None
2921 },
2922 aerodynamic_jump: aj_components,
2923 mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2924 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2925 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2926 })
2927 }
2928
2929 fn adaptive_rk45_step(
2930 &self,
2931 position: &Vector3<f64>,
2932 velocity: &Vector3<f64>,
2933 initial_dt: f64,
2934 wind_vector: &Vector3<f64>,
2935 resolved_atmo: (f64, f64, f64),
2936 ) -> Rk45AcceptedStep {
2937 let mut trial_dt = initial_dt;
2938
2939 loop {
2940 let trial = self.rk45_step(
2941 position,
2942 velocity,
2943 trial_dt,
2944 wind_vector,
2945 RK45_TOLERANCE,
2946 resolved_atmo,
2947 );
2948 let next_dt = if trial.suggested_dt.is_finite() {
2953 (RK45_SAFETY_FACTOR * trial.suggested_dt).clamp(RK45_MIN_DT, RK45_MAX_DT)
2954 } else {
2955 RK45_MIN_DT
2956 };
2957
2958 if trial.error <= RK45_TOLERANCE || trial_dt <= RK45_MIN_DT {
2959 return Rk45AcceptedStep {
2960 position: trial.position,
2961 velocity: trial.velocity,
2962 used_dt: trial_dt,
2963 next_dt,
2964 error: trial.error,
2965 };
2966 }
2967
2968 trial_dt = next_dt;
2969 }
2970 }
2971
2972 fn rk45_step(
2973 &self,
2974 position: &Vector3<f64>,
2975 velocity: &Vector3<f64>,
2976 dt: f64,
2977 wind_vector: &Vector3<f64>,
2978 tolerance: f64,
2979 resolved_atmo: (f64, f64, f64), ) -> Rk45Trial {
2981 const A21: f64 = 1.0 / 5.0;
2983 const A31: f64 = 3.0 / 40.0;
2984 const A32: f64 = 9.0 / 40.0;
2985 const A41: f64 = 44.0 / 45.0;
2986 const A42: f64 = -56.0 / 15.0;
2987 const A43: f64 = 32.0 / 9.0;
2988 const A51: f64 = 19372.0 / 6561.0;
2989 const A52: f64 = -25360.0 / 2187.0;
2990 const A53: f64 = 64448.0 / 6561.0;
2991 const A54: f64 = -212.0 / 729.0;
2992 const A61: f64 = 9017.0 / 3168.0;
2993 const A62: f64 = -355.0 / 33.0;
2994 const A63: f64 = 46732.0 / 5247.0;
2995 const A64: f64 = 49.0 / 176.0;
2996 const A65: f64 = -5103.0 / 18656.0;
2997 const A71: f64 = 35.0 / 384.0;
2998 const A73: f64 = 500.0 / 1113.0;
2999 const A74: f64 = 125.0 / 192.0;
3000 const A75: f64 = -2187.0 / 6784.0;
3001 const A76: f64 = 11.0 / 84.0;
3002
3003 const B1: f64 = 35.0 / 384.0;
3005 const B3: f64 = 500.0 / 1113.0;
3006 const B4: f64 = 125.0 / 192.0;
3007 const B5: f64 = -2187.0 / 6784.0;
3008 const B6: f64 = 11.0 / 84.0;
3009
3010 const B1_ERR: f64 = 5179.0 / 57600.0;
3012 const B3_ERR: f64 = 7571.0 / 16695.0;
3013 const B4_ERR: f64 = 393.0 / 640.0;
3014 const B5_ERR: f64 = -92097.0 / 339200.0;
3015 const B6_ERR: f64 = 187.0 / 2100.0;
3016 const B7_ERR: f64 = 1.0 / 40.0;
3017
3018 let k1_v = self.calculate_acceleration(position, velocity, wind_vector, resolved_atmo);
3020 let k1_p = *velocity;
3021
3022 let p2 = position + dt * A21 * k1_p;
3023 let v2 = velocity + dt * A21 * k1_v;
3024 let k2_v = self.calculate_acceleration(&p2, &v2, wind_vector, resolved_atmo);
3025 let k2_p = v2;
3026
3027 let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
3028 let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
3029 let k3_v = self.calculate_acceleration(&p3, &v3, wind_vector, resolved_atmo);
3030 let k3_p = v3;
3031
3032 let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
3033 let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
3034 let k4_v = self.calculate_acceleration(&p4, &v4, wind_vector, resolved_atmo);
3035 let k4_p = v4;
3036
3037 let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
3038 let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
3039 let k5_v = self.calculate_acceleration(&p5, &v5, wind_vector, resolved_atmo);
3040 let k5_p = v5;
3041
3042 let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
3043 let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
3044 let k6_v = self.calculate_acceleration(&p6, &v6, wind_vector, resolved_atmo);
3045 let k6_p = v6;
3046
3047 let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
3048 let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
3049 let k7_v = self.calculate_acceleration(&p7, &v7, wind_vector, resolved_atmo);
3050 let k7_p = v7;
3051
3052 let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
3054 let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
3055
3056 let pos_err = position
3058 + dt * (B1_ERR * k1_p
3059 + B3_ERR * k3_p
3060 + B4_ERR * k4_p
3061 + B5_ERR * k5_p
3062 + B6_ERR * k6_p
3063 + B7_ERR * k7_p);
3064 let vel_err = velocity
3065 + dt * (B1_ERR * k1_v
3066 + B3_ERR * k3_v
3067 + B4_ERR * k4_v
3068 + B5_ERR * k5_v
3069 + B6_ERR * k6_v
3070 + B7_ERR * k7_v);
3071
3072 let error = cli_rk45_error_norm(position, velocity, &new_pos, &new_vel, &pos_err, &vel_err);
3074
3075 let dt_new = if error < tolerance {
3077 dt * (tolerance / error).powf(0.2).min(2.0)
3078 } else {
3079 dt * (tolerance / error).powf(0.25).max(0.1)
3080 };
3081
3082 Rk45Trial {
3083 position: new_pos,
3084 velocity: new_vel,
3085 suggested_dt: dt_new,
3086 error,
3087 }
3088 }
3089
3090 fn apply_cluster_bc_correction(&self, base_bc: f64, velocity_fps: f64) -> f64 {
3091 if let Some(ref cluster_bc) = self.cluster_bc {
3092 cluster_bc.apply_correction_for_drag_model(
3093 base_bc,
3094 self.inputs.caliber_inches,
3095 self.inputs.weight_grains,
3096 velocity_fps,
3097 self.inputs.bc_type,
3098 )
3099 } else {
3100 base_bc
3101 }
3102 }
3103
3104 fn calculate_acceleration(
3105 &self,
3106 position: &Vector3<f64>,
3107 velocity: &Vector3<f64>,
3108 wind_vector: &Vector3<f64>,
3109 resolved_atmo: (f64, f64, f64), ) -> Vector3<f64> {
3111 let actual_wind = if let Some(ref sock) = self.wind_sock {
3117 sock.vector_for_range_stateless(position.x)
3118 } else if self.inputs.enable_wind_shear {
3119 self.get_wind_at_altitude(position.y)
3120 } else {
3121 *wind_vector
3122 };
3123 let actual_wind =
3124 crate::derivatives::level_vector_to_shot_frame(actual_wind, self.inputs.shooting_angle);
3125
3126 let relative_velocity = velocity - actual_wind;
3127 let velocity_magnitude = relative_velocity.magnitude();
3128
3129 if velocity_magnitude < 0.001 {
3130 return self.gravity_acceleration();
3131 }
3132
3133 let (base_temp_c, base_press_hpa, station_ratio) = resolved_atmo;
3144
3145 let (drag_base_temp_c, drag_base_press_hpa, drag_base_ratio, drag_humidity_percent) =
3153 if let Some(ref sock) = self.atmo_sock {
3154 let (zone_temp_c, zone_press_hpa, zone_humidity) = sock.atmo_for_range(position.x);
3155 let zone_base_ratio = crate::atmosphere::calculate_air_density_cimp(
3156 zone_temp_c,
3157 zone_press_hpa,
3158 zone_humidity,
3159 ) / 1.225;
3160 (zone_temp_c, zone_press_hpa, zone_base_ratio, zone_humidity)
3161 } else {
3162 (
3163 base_temp_c,
3164 base_press_hpa,
3165 station_ratio,
3166 self.atmosphere.humidity,
3167 )
3168 };
3169 let local_alt = crate::atmosphere::shot_frame_altitude(
3170 self.atmosphere.altitude,
3171 position.x,
3172 position.y,
3173 self.inputs.shooting_angle,
3174 );
3175 let (air_density, speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
3176 local_alt,
3177 self.atmosphere.altitude,
3178 drag_base_temp_c,
3179 drag_base_press_hpa,
3180 drag_base_ratio,
3181 drag_humidity_percent,
3182 );
3183
3184 let (cd, retard_denom) = self.drag_terms(velocity_magnitude, speed_of_sound);
3188
3189 let velocity_fps = velocity_magnitude * 3.28084;
3191
3192 let cd_to_retard = crate::constants::CD_TO_RETARD;
3197 let standard_factor = cd * cd_to_retard;
3198 let density_scale = air_density / 1.225; let a_drag_ft_s2 =
3202 (velocity_fps * velocity_fps) * standard_factor * density_scale / retard_denom;
3203 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
3207
3208 let mut accel = drag_acceleration + self.gravity_acceleration();
3211
3212 if self.inputs.enable_coriolis {
3215 if let Some(lat_deg) = self.inputs.latitude {
3216 let omega_earth = 7.2921159e-5_f64; let lat = lat_deg.to_radians();
3218 let az = self.inputs.shot_azimuth; let omega = Vector3::new(
3225 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), );
3229 let omega = crate::derivatives::level_vector_to_shot_frame(
3230 omega,
3231 self.inputs.shooting_angle,
3232 );
3233 accel += -2.0 * omega.cross(velocity);
3238 }
3239 }
3240
3241 if self.inputs.enable_magnus
3248 && !self.inputs.use_enhanced_spin_drift
3249 && self.inputs.bullet_diameter > 0.0
3250 && self.inputs.twist_rate > 0.0
3251 {
3252 let diameter_m = self.inputs.bullet_diameter;
3253 let (spin_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
3254 self.inputs.muzzle_velocity,
3255 velocity_magnitude,
3256 self.inputs.twist_rate,
3257 diameter_m,
3258 );
3259 let mach = velocity_magnitude / speed_of_sound;
3261
3262 let d_in = self.inputs.bullet_diameter / 0.0254;
3264 let m_gr = self.inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
3265 let l_in = if self.inputs.bullet_length > 0.0 {
3266 self.inputs.bullet_length / 0.0254
3267 } else {
3268 let est_m = crate::stability::estimate_bullet_length_m(
3270 self.inputs.bullet_diameter,
3271 self.inputs.bullet_mass,
3272 );
3273 if est_m > 0.0 {
3274 est_m / 0.0254
3275 } else {
3276 4.5 * d_in
3277 }
3278 };
3279 let sg = crate::spin_drift::calculate_dynamic_stability(
3283 m_gr,
3284 velocity_magnitude,
3285 spin_rad_s,
3286 d_in,
3287 l_in,
3288 air_density,
3289 );
3290
3291 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
3293 sg,
3294 velocity_magnitude,
3295 spin_rad_s,
3296 0.0, 0.0, air_density,
3299 d_in,
3300 l_in,
3301 m_gr,
3302 mach,
3303 "match",
3304 false,
3305 );
3306
3307 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
3309 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
3310 let magnus_force = 0.5
3311 * air_density
3312 * velocity_magnitude.powi(2)
3313 * area
3314 * c_np
3315 * spin_param
3316 * yaw_rad.sin();
3317
3318 if magnus_force.abs() > 1e-12 {
3322 if let Some(dir) = crate::derivatives::yaw_of_repose_magnus_direction(
3323 relative_velocity,
3324 self.gravity_acceleration(),
3325 self.inputs.is_twist_right,
3326 ) {
3327 accel += (magnus_force / self.inputs.bullet_mass) * dir;
3328 }
3329 }
3330 }
3331
3332 accel
3333 }
3334
3335 fn drag_terms(&self, velocity_magnitude: f64, speed_of_sound: f64) -> (f64, f64) {
3347 let cd = self.calculate_drag_coefficient(velocity_magnitude, speed_of_sound);
3348
3349 let velocity_fps = velocity_magnitude * 3.28084;
3350
3351 let (base_bc, bc_from_segments) = if let Some(segments) = self
3356 .inputs
3357 .bc_segments_data
3358 .as_ref()
3359 .filter(|segments| self.inputs.use_bc_segments && !segments.is_empty())
3360 {
3361 (
3363 crate::bc_estimation::velocity_segment_bc(
3364 velocity_fps,
3365 segments,
3366 self.inputs.bc_value,
3367 ),
3368 true,
3369 )
3370 } else if let Some(segments) = self
3371 .inputs
3372 .bc_segments
3373 .as_ref()
3374 .filter(|segments| !segments.is_empty())
3375 {
3376 (
3377 crate::derivatives::interpolated_bc(
3378 velocity_magnitude / speed_of_sound,
3379 segments,
3380 Some(&self.inputs),
3381 ),
3382 true,
3383 )
3384 } else {
3385 (self.inputs.bc_value, false)
3386 };
3387
3388 let effective_bc = if bc_from_segments {
3393 base_bc
3394 } else {
3395 self.apply_cluster_bc_correction(base_bc, velocity_fps)
3396 };
3397 let effective_bc = effective_bc.max(1e-6);
3400
3401 let retard_denom = if self.inputs.custom_drag_table.is_some() {
3406 self.inputs.custom_drag_denominator(effective_bc)
3407 } else {
3408 effective_bc
3409 };
3410
3411 (cd, retard_denom)
3412 }
3413
3414 pub fn effective_drag_coefficient(
3435 &self,
3436 velocity_magnitude: f64,
3437 speed_of_sound: f64,
3438 ) -> Option<f64> {
3439 if !velocity_magnitude.is_finite() || speed_of_sound <= 1e-9 {
3440 return None;
3441 }
3442 let sectional_density = self.inputs.sectional_density_lb_in2()?;
3443 let (cd, retard_denom) = self.drag_terms(velocity_magnitude, speed_of_sound);
3444 if retard_denom <= 0.0 {
3445 return None;
3446 }
3447 let effective = cd * sectional_density / retard_denom;
3448 effective.is_finite().then_some(effective)
3449 }
3450
3451 fn annotate_drag_coefficients(&self, points: &mut [TrajectoryPoint], speed_of_sound: f64) {
3461 for point in points.iter_mut() {
3462 point.drag_coefficient =
3463 self.effective_drag_coefficient(point.velocity_magnitude, speed_of_sound);
3464 }
3465 }
3466
3467 fn calculate_drag_coefficient(&self, velocity: f64, speed_of_sound: f64) -> f64 {
3468 let mach = velocity / speed_of_sound;
3469
3470 if let Some(ref table) = self.inputs.custom_drag_table {
3474 return table.interpolate(mach) * self.inputs.cd_scale;
3479 }
3480
3481 crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type)
3484 }
3485}
3486
3487#[derive(Debug, Clone)]
3489pub struct MonteCarloParams {
3490 pub num_simulations: usize,
3491 pub velocity_std_dev: f64,
3492 pub angle_std_dev: f64,
3493 pub bc_std_dev: f64,
3494 pub wind_speed_std_dev: f64,
3495 pub target_distance: Option<f64>,
3496 pub base_wind_speed: f64,
3497 pub base_wind_direction: f64,
3498 pub azimuth_std_dev: f64, }
3500
3501impl Default for MonteCarloParams {
3502 fn default() -> Self {
3503 Self {
3504 num_simulations: 1000,
3505 velocity_std_dev: 1.0,
3506 angle_std_dev: 0.001,
3507 bc_std_dev: 0.01,
3508 wind_speed_std_dev: 1.0,
3509 target_distance: None,
3510 base_wind_speed: 0.0,
3511 base_wind_direction: 0.0,
3512 azimuth_std_dev: 0.001, }
3514 }
3515}
3516
3517#[derive(Debug, Clone)]
3519pub struct MonteCarloResults {
3520 pub ranges: Vec<f64>,
3521 pub impact_velocities: Vec<f64>,
3522 pub impact_positions: Vec<Vector3<f64>>,
3528}
3529
3530pub const DEFAULT_HIT_RADIUS_M: f64 = 0.3;
3533
3534pub const TARGET_NOT_REACHED_SENTINEL_M: f64 = -1.0e9;
3540
3541impl MonteCarloResults {
3542 pub fn position_reached_target(position: &Vector3<f64>) -> bool {
3544 position.iter().all(|component| component.is_finite())
3545 && position.y != TARGET_NOT_REACHED_SENTINEL_M
3546 }
3547
3548 pub fn target_arrival_count(&self) -> usize {
3550 self.impact_positions
3551 .iter()
3552 .filter(|position| Self::position_reached_target(position))
3553 .count()
3554 }
3555
3556 pub fn target_shortfall_fraction(&self) -> f64 {
3559 if self.impact_positions.is_empty() {
3560 return 0.0;
3561 }
3562 (self.impact_positions.len() - self.target_arrival_count()) as f64
3563 / self.impact_positions.len() as f64
3564 }
3565
3566 pub fn target_plane_cep(&self) -> Option<f64> {
3572 let mut radial_misses: Vec<f64> = self
3573 .impact_positions
3574 .iter()
3575 .filter(|position| Self::position_reached_target(position))
3576 .map(Vector3::norm)
3577 .filter(|miss| miss.is_finite())
3578 .collect();
3579 radial_misses.sort_by(f64::total_cmp);
3580 if radial_misses.is_empty() {
3581 None
3582 } else {
3583 Some(radial_misses[radial_misses.len() / 2])
3584 }
3585 }
3586
3587 pub fn hit_probability(&self, hit_radius_m: f64) -> f64 {
3596 if self.impact_positions.is_empty() {
3597 return 0.0;
3598 }
3599 let hits = self
3600 .impact_positions
3601 .iter()
3602 .filter(|position| {
3603 Self::position_reached_target(position) && position.norm() < hit_radius_m
3604 })
3605 .count();
3606 hits as f64 / self.impact_positions.len() as f64
3607 }
3608
3609 pub fn rect_hit_probability(&self, width_m: f64, height_m: f64) -> f64 {
3621 let dimensions_invalid = width_m.is_nan()
3622 || width_m <= 0.0
3623 || height_m.is_nan()
3624 || height_m <= 0.0;
3625 if self.impact_positions.is_empty() || dimensions_invalid {
3626 return 0.0;
3627 }
3628 let half_width = width_m / 2.0;
3629 let half_height = height_m / 2.0;
3630 let hits = self
3631 .impact_positions
3632 .iter()
3633 .filter(|position| {
3634 Self::position_reached_target(position)
3635 && position.z.abs() <= half_width
3636 && position.y.abs() <= half_height
3637 })
3638 .count();
3639 hits as f64 / self.impact_positions.len() as f64
3640 }
3641}
3642
3643fn wind_from_signed_speed_sample(
3644 signed_speed: f64,
3645 sampled_direction: f64,
3646 vertical_speed: f64,
3647) -> WindConditions {
3648 if signed_speed < 0.0 {
3653 WindConditions {
3654 speed: -signed_speed,
3655 direction: sampled_direction + std::f64::consts::PI,
3656 vertical_speed,
3657 }
3658 } else {
3659 WindConditions {
3660 speed: signed_speed,
3661 direction: sampled_direction,
3662 vertical_speed,
3663 }
3664 }
3665}
3666
3667struct MonteCarloWindSampler {
3668 speed: rand_distr::Normal<f64>,
3669 direction: rand_distr::Normal<f64>,
3670 vertical_speed: f64,
3672}
3673
3674impl MonteCarloWindSampler {
3675 fn new(
3676 base_wind: &WindConditions,
3677 wind_speed_std_dev: f64,
3678 wind_direction_std_dev: f64,
3679 ) -> Result<Self, BallisticsError> {
3680 use rand_distr::Normal;
3681
3682 if !wind_direction_std_dev.is_finite() || wind_direction_std_dev < 0.0 {
3683 return Err("Wind direction standard deviation must be finite and non-negative".into());
3684 }
3685
3686 let speed = Normal::new(base_wind.speed, wind_speed_std_dev)
3687 .map_err(|e| format!("Invalid wind speed distribution: {e}"))?;
3688 let direction = Normal::new(base_wind.direction, wind_direction_std_dev)
3689 .map_err(|e| format!("Invalid wind direction distribution: {e}"))?;
3690 Ok(Self { speed, direction, vertical_speed: base_wind.vertical_speed })
3691 }
3692
3693 fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> WindConditions {
3694 use rand_distr::Distribution;
3695
3696 wind_from_signed_speed_sample(
3697 self.speed.sample(rng),
3698 self.direction.sample(rng),
3699 self.vertical_speed,
3700 )
3701 }
3702}
3703
3704pub fn run_monte_carlo(
3706 base_inputs: BallisticInputs,
3707 params: MonteCarloParams,
3708) -> Result<MonteCarloResults, BallisticsError> {
3709 run_monte_carlo_with_direction_std_dev(base_inputs, params, 0.0)
3710}
3711
3712pub fn run_monte_carlo_with_direction_std_dev(
3717 base_inputs: BallisticInputs,
3718 params: MonteCarloParams,
3719 wind_direction_std_dev: f64,
3720) -> Result<MonteCarloResults, BallisticsError> {
3721 let base_wind = WindConditions {
3722 speed: params.base_wind_speed,
3723 direction: params.base_wind_direction,
3724 vertical_speed: 0.0,
3725 };
3726 run_monte_carlo_with_wind_and_direction_std_dev(
3727 base_inputs,
3728 base_wind,
3729 params,
3730 wind_direction_std_dev,
3731 )
3732}
3733
3734pub fn run_monte_carlo_with_wind(
3736 base_inputs: BallisticInputs,
3737 base_wind: WindConditions,
3738 params: MonteCarloParams,
3739) -> Result<MonteCarloResults, BallisticsError> {
3740 run_monte_carlo_with_wind_and_direction_std_dev(base_inputs, base_wind, params, 0.0)
3741}
3742
3743pub fn run_monte_carlo_with_wind_and_direction_std_dev(
3748 base_inputs: BallisticInputs,
3749 base_wind: WindConditions,
3750 params: MonteCarloParams,
3751 wind_direction_std_dev: f64,
3752) -> Result<MonteCarloResults, BallisticsError> {
3753 let mut rng = rand::rng();
3754 run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
3755 base_inputs,
3756 base_wind,
3757 params,
3758 wind_direction_std_dev,
3759 &mut rng,
3760 )
3761}
3762
3763pub fn run_monte_carlo_with_wind_and_direction_std_dev_seeded(
3770 base_inputs: BallisticInputs,
3771 base_wind: WindConditions,
3772 params: MonteCarloParams,
3773 wind_direction_std_dev: f64,
3774 seed: u64,
3775) -> Result<MonteCarloResults, BallisticsError> {
3776 use rand::{rngs::StdRng, SeedableRng};
3777 let mut rng = StdRng::seed_from_u64(seed);
3778 run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
3779 base_inputs,
3780 base_wind,
3781 params,
3782 wind_direction_std_dev,
3783 &mut rng,
3784 )
3785}
3786
3787fn run_monte_carlo_with_wind_and_direction_std_dev_using_rng<R: rand::Rng + ?Sized>(
3788 base_inputs: BallisticInputs,
3789 base_wind: WindConditions,
3790 params: MonteCarloParams,
3791 wind_direction_std_dev: f64,
3792 rng: &mut R,
3793) -> Result<MonteCarloResults, BallisticsError> {
3794 use rand_distr::{Distribution, Normal};
3795
3796 let mut ranges = Vec::new();
3797 let mut impact_velocities = Vec::new();
3798 let mut impact_positions = Vec::new();
3799
3800 let atmosphere = AtmosphericConditions {
3801 temperature: base_inputs.temperature,
3802 pressure: base_inputs.pressure,
3803 humidity: base_inputs.humidity_percent(),
3804 altitude: base_inputs.altitude,
3805 };
3806 let target_hint = params
3807 .target_distance
3808 .unwrap_or(base_inputs.target_distance);
3809 let solver_max_range = target_hint.max(1000.0) * 2.0;
3810
3811 let mut baseline_solver =
3813 TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), atmosphere.clone());
3814 baseline_solver.set_max_range(solver_max_range);
3815 let baseline_result = baseline_solver.solve()?;
3816
3817 let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
3819
3820 let baseline_at_target = baseline_result
3822 .position_at_range(target_distance)
3823 .ok_or("Could not interpolate baseline at target distance")?;
3824
3825 let velocity_delta_dist = Normal::new(0.0, params.velocity_std_dev)
3830 .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
3831 let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
3832 .map_err(|e| format!("Invalid angle distribution: {}", e))?;
3833 let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
3834 .map_err(|e| format!("Invalid BC distribution: {}", e))?;
3835 let wind_sampler = MonteCarloWindSampler::new(
3838 &base_wind,
3839 params.wind_speed_std_dev,
3840 wind_direction_std_dev,
3841 )?;
3842 let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
3843 .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
3844
3845 for _ in 0..params.num_simulations {
3846 let mut inputs = base_inputs.clone();
3848 let muzzle_velocity_delta = velocity_delta_dist.sample(&mut *rng);
3849 inputs.muzzle_angle = angle_dist.sample(&mut *rng);
3850 inputs.bc_value = bc_dist.sample(&mut *rng).max(0.01);
3851 inputs.azimuth_angle = azimuth_dist.sample(&mut *rng); let wind = wind_sampler.sample(&mut *rng);
3855
3856 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere.clone());
3858 solver.inputs.muzzle_velocity =
3859 (solver.inputs.muzzle_velocity + muzzle_velocity_delta).max(0.0);
3860 solver.set_max_range(solver_max_range);
3861 match solver.solve() {
3862 Ok(result) => {
3863 let deviation = if result.max_range < target_distance {
3869 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)
3872 } else {
3873 let pos_at_target = match result.position_at_range(target_distance) {
3874 Some(p) => p,
3875 None => continue, };
3877 Vector3::new(
3882 0.0,
3883 pos_at_target.y - baseline_at_target.y,
3884 pos_at_target.z - baseline_at_target.z,
3885 )
3886 };
3887
3888 ranges.push(result.max_range);
3889 impact_velocities.push(result.impact_velocity);
3890 impact_positions.push(deviation);
3891 }
3892 Err(_) => {
3893 continue;
3895 }
3896 }
3897 }
3898
3899 if ranges.is_empty() {
3900 return Err("No successful simulations".into());
3901 }
3902
3903 Ok(MonteCarloResults {
3904 ranges,
3905 impact_velocities,
3906 impact_positions,
3907 })
3908}
3909
3910pub fn calculate_zero_angle(
3912 inputs: BallisticInputs,
3913 target_distance: f64,
3914 target_height: f64,
3915) -> Result<f64, BallisticsError> {
3916 calculate_zero_angle_with_conditions(
3917 inputs,
3918 target_distance,
3919 target_height,
3920 WindConditions::default(),
3921 AtmosphericConditions::default(),
3922 )
3923}
3924
3925pub fn calculate_zero_angle_with_conditions(
3926 inputs: BallisticInputs,
3927 target_distance: f64,
3928 target_height: f64,
3929 wind: WindConditions,
3930 atmosphere: AtmosphericConditions,
3931) -> Result<f64, BallisticsError> {
3932 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
3933 solver.calculate_and_set_zero_angle(target_distance, target_height, ZeroTargetFrame::SightLine)
3934}
3935
3936pub fn calculate_zero_angle_with_resolved_conditions(
3942 inputs: BallisticInputs,
3943 target_distance: f64,
3944 target_height: f64,
3945 wind: WindConditions,
3946 atmosphere: AtmosphericConditions,
3947) -> Result<f64, BallisticsError> {
3948 let mut solver = TrajectorySolver::new_with_resolved_station_atmosphere(inputs, wind, atmosphere);
3949 solver.calculate_and_set_zero_angle(target_distance, target_height, ZeroTargetFrame::SightLine)
3950}
3951
3952pub const ZERO_RANGE_FROM_ANGLE_MAX_RANGE_M: f64 = 2000.0;
3960
3961pub fn calculate_zero_range_from_angle_with_conditions(
3976 inputs: BallisticInputs,
3977 zero_angle_rad: f64,
3978 target_height: f64,
3979 wind: WindConditions,
3980 atmosphere: AtmosphericConditions,
3981) -> Result<ZeroCrossings, BallisticsError> {
3982 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
3983 solver.set_max_range(ZERO_RANGE_FROM_ANGLE_MAX_RANGE_M);
3984 solver.find_zero_range(zero_angle_rad, target_height, ZeroTargetFrame::SightLine)
3985}
3986
3987pub fn calculate_zero_range_from_angle_with_resolved_conditions(
3993 inputs: BallisticInputs,
3994 zero_angle_rad: f64,
3995 target_height: f64,
3996 wind: WindConditions,
3997 atmosphere: AtmosphericConditions,
3998) -> Result<ZeroCrossings, BallisticsError> {
3999 let mut solver = TrajectorySolver::new_with_resolved_station_atmosphere(inputs, wind, atmosphere);
4000 solver.set_max_range(ZERO_RANGE_FROM_ANGLE_MAX_RANGE_M);
4001 solver.find_zero_range(zero_angle_rad, target_height, ZeroTargetFrame::SightLine)
4002}
4003
4004#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4006pub enum BcFitMode {
4007 Drop,
4009 Velocity,
4012}
4013
4014#[derive(Debug, Clone, Copy)]
4016pub struct BcEstimate {
4017 pub bc: f64,
4019 pub rms_error: f64,
4021 pub drag_model: DragModel,
4023 pub mode: BcFitMode,
4025 pub at_bound: bool,
4029}
4030
4031fn fit_value_at(
4039 points: &[TrajectoryPoint],
4040 target_dist: f64,
4041 mode: BcFitMode,
4042 drop_offset: f64,
4043) -> Option<f64> {
4044 let val = |p: &TrajectoryPoint| match mode {
4045 BcFitMode::Drop => drop_offset - p.position.y,
4046 BcFitMode::Velocity => p.velocity_magnitude,
4047 };
4048 for i in 0..points.len() {
4049 if points[i].position.x >= target_dist {
4050 if i == 0 {
4051 return Some(val(&points[0]));
4052 }
4053 let p1 = &points[i - 1];
4054 let p2 = &points[i];
4055 let dx = p2.position.x - p1.position.x;
4056 if dx.abs() < 1e-9 {
4057 return Some(val(p2));
4058 }
4059 let t = (target_dist - p1.position.x) / dx;
4060 return Some(val(p1) + t * (val(p2) - val(p1)));
4061 }
4062 }
4063 None
4064}
4065
4066fn fit_residual_sse(
4067 trajectory: &[TrajectoryPoint],
4068 observations: &[(f64, f64)],
4069 mode: BcFitMode,
4070 drop_offset: f64,
4071) -> Option<f64> {
4072 if observations.is_empty() {
4073 return None;
4074 }
4075 let mut total = 0.0;
4076 for (target_dist, target_val) in observations {
4077 let value = fit_value_at(trajectory, *target_dist, mode, drop_offset)?;
4080 let error = value - target_val;
4081 total += error * error;
4082 }
4083 Some(total)
4084}
4085
4086#[allow(clippy::too_many_arguments)] pub fn estimate_bc_fit(
4104 velocity: f64,
4105 mass: f64,
4106 diameter: f64,
4107 points: &[(f64, f64)],
4108 drag_model: DragModel,
4109 mode: BcFitMode,
4110 atmosphere: AtmosphericConditions,
4111 zero_range: Option<f64>,
4112 sight_height: f64,
4113) -> Result<BcEstimate, BallisticsError> {
4114 if points.is_empty() {
4115 return Err(BallisticsError::from(
4116 "No data points provided for BC estimation.".to_string(),
4117 ));
4118 }
4119 let max_dist = points.iter().map(|(d, _)| *d).fold(0.0_f64, f64::max);
4120 let drop_offset = if zero_range.is_some() { sight_height } else { 0.0 };
4123
4124 let sse = |bc_value: f64| -> Option<f64> {
4126 let mut inputs = BallisticInputs {
4127 muzzle_velocity: velocity,
4128 bc_value,
4129 bc_type: drag_model,
4130 bullet_mass: mass,
4131 bullet_diameter: diameter,
4132 sight_height,
4133 ..Default::default()
4134 };
4135 if let Some(zr) = zero_range {
4138 let za = calculate_zero_angle_with_conditions(
4144 inputs.clone(),
4145 zr,
4146 sight_height,
4147 WindConditions::default(),
4148 atmosphere.clone(),
4149 )
4150 .ok()?;
4151 inputs.muzzle_angle = za;
4152 }
4153 let mut solver =
4154 TrajectorySolver::new(inputs, WindConditions::default(), atmosphere.clone());
4155 solver.set_max_range(max_dist * 1.5);
4156 let result = solver.solve().ok()?;
4157 fit_residual_sse(&result.points, points, mode, drop_offset)
4158 };
4159
4160 let (bc_min, bc_max) = match drag_model {
4164 DragModel::G7 => (0.05, 0.70),
4165 _ => (0.10, 1.20),
4166 };
4167
4168 let mut best_bc = f64::NAN;
4170 let mut best_sse = f64::MAX;
4171 let mut bc = bc_min;
4172 while bc <= bc_max + 1e-9 {
4173 if let Some(s) = sse(bc) {
4174 if s < best_sse {
4175 best_sse = s;
4176 best_bc = bc;
4177 }
4178 }
4179 bc += 0.01;
4180 }
4181 if !best_bc.is_finite() {
4182 return Err(BallisticsError::from(
4183 "Unable to estimate BC from provided data. Check that the values and units are correct."
4184 .to_string(),
4185 ));
4186 }
4187
4188 let lo = (best_bc - 0.01).max(bc_min);
4190 let hi = (best_bc + 0.01).min(bc_max);
4191 let mut bc = lo;
4192 while bc <= hi + 1e-9 {
4193 if let Some(s) = sse(bc) {
4194 if s < best_sse {
4195 best_sse = s;
4196 best_bc = bc;
4197 }
4198 }
4199 bc += 0.001;
4200 }
4201
4202 let at_bound = best_bc <= bc_min + 0.011 || best_bc >= bc_max - 0.011;
4205 let rms_error = (best_sse / points.len() as f64).sqrt();
4208 Ok(BcEstimate {
4209 bc: best_bc,
4210 rms_error,
4211 drag_model,
4212 mode,
4213 at_bound,
4214 })
4215}
4216
4217pub fn estimate_bc_from_trajectory(
4220 velocity: f64,
4221 mass: f64,
4222 diameter: f64,
4223 points: &[(f64, f64)], ) -> Result<f64, BallisticsError> {
4225 estimate_bc_fit(
4226 velocity,
4227 mass,
4228 diameter,
4229 points,
4230 DragModel::G1,
4231 BcFitMode::Drop,
4232 AtmosphericConditions::default(),
4233 None,
4234 0.05,
4235 )
4236 .map(|e| e.bc)
4237}
4238
4239use rand;
4241use rand_distr;
4242
4243#[cfg(test)]
4244mod mba737_powder_resolution_tests {
4245 use super::*;
4246
4247 #[test]
4248 fn linear_model_cold_powder_subtracts() {
4249 let v = resolve_powder_adjusted_velocity(823.0, 11.1, true, 0.5486, 21.1, None, None);
4251 assert!((v - (823.0 + 0.5486 * (11.1 - 21.1))).abs() < 1e-12);
4252 assert!(v < 823.0);
4253 }
4254
4255 #[test]
4256 fn linear_model_hot_powder_adds() {
4257 let v = resolve_powder_adjusted_velocity(823.0, 31.1, true, 0.5486, 21.1, None, None);
4258 assert!((v - (823.0 + 0.5486 * 10.0)).abs() < 1e-12);
4259 }
4260
4261 #[test]
4262 fn disabled_flag_is_passthrough() {
4263 let v = resolve_powder_adjusted_velocity(823.0, 40.0, false, 0.5486, 21.1, None, None);
4264 assert_eq!(v, 823.0);
4265 }
4266
4267 #[test]
4268 fn curve_overrides_linear_and_interpolates_at_powder_temp() {
4269 let curve = [(4.4, 798.6), (21.1, 823.0), (37.8, 841.2)];
4270 let v = resolve_powder_adjusted_velocity(823.0, 30.0, true, 99.0, 21.1, Some(&curve), Some(4.4));
4272 assert!((v - 798.6).abs() < 1e-9);
4273 }
4274
4275 #[test]
4276 fn curve_falls_back_to_ambient_and_clamps() {
4277 let curve = [(4.4, 798.6), (37.8, 841.2)];
4278 let v = resolve_powder_adjusted_velocity(823.0, -40.0, true, 1.0, 21.1, Some(&curve), None);
4280 assert!((v - 798.6).abs() < 1e-9);
4281 let v_hot = resolve_powder_adjusted_velocity(823.0, 60.0, true, 1.0, 21.1, Some(&curve), None);
4282 assert!((v_hot - 841.2).abs() < 1e-9);
4283 }
4284
4285 #[test]
4286 fn empty_curve_suppresses_linear_fallback() {
4287 let v = resolve_powder_adjusted_velocity(823.0, 40.0, true, 0.5486, 21.1, Some(&[]), None);
4289 assert_eq!(v, 823.0);
4290 }
4291
4292 #[test]
4293 fn sweep_huge_range_errors_instead_of_overflowing() {
4294 assert!(parse_powder_sweep("0:1e20:1").is_err());
4297 assert!(parse_powder_sweep("0:1e308:1e-3").is_err());
4298 }
4299
4300 #[test]
4301 fn sweep_fractional_step_keeps_end_row() {
4302 let rows = parse_powder_sweep("0:0.3:0.1").unwrap();
4304 assert_eq!(rows.len(), 4);
4305 assert!((rows[3] - 0.3).abs() < 1e-9);
4306 }
4307
4308 #[test]
4309 fn solver_and_helper_agree_on_linear_model() {
4310 let inputs = BallisticInputs {
4312 use_powder_sensitivity: true,
4313 powder_temp_sensitivity: 0.5486,
4314 powder_temp: 21.1,
4315 temperature: 4.4,
4316 ..Default::default()
4317 };
4318 let expected = resolve_powder_adjusted_velocity(
4319 inputs.muzzle_velocity,
4320 inputs.temperature,
4321 true,
4322 0.5486,
4323 21.1,
4324 None,
4325 None,
4326 );
4327 let solver = TrajectorySolver::new(
4328 inputs,
4329 WindConditions::default(),
4330 AtmosphericConditions::default(),
4331 );
4332 assert!((solver.inputs.muzzle_velocity - expected).abs() < 1e-12);
4333 }
4334}
4335
4336#[cfg(test)]
4337mod mba1302_solver_seam_tests {
4338 use super::*;
4339 use crate::wind::WindSegment;
4340
4341 #[test]
4342 fn authoritative_station_atmosphere_preserves_explicit_standard_values_at_altitude() {
4343 let atmosphere = AtmosphericConditions {
4344 temperature: 15.0,
4345 pressure: 1013.25,
4346 humidity: 50.0,
4347 altitude: 2_000.0,
4348 };
4349 let legacy = TrajectorySolver::new(
4350 BallisticInputs::default(),
4351 WindConditions::default(),
4352 atmosphere.clone(),
4353 );
4354 let authoritative = TrajectorySolver::new_with_resolved_station_atmosphere(
4355 BallisticInputs::default(),
4356 WindConditions::default(),
4357 atmosphere,
4358 );
4359
4360 let (legacy_density, _, legacy_temp_c, legacy_pressure_hpa) = legacy.resolved_atmosphere();
4361 let (authoritative_density, _, authoritative_temp_c, authoritative_pressure_hpa) =
4362 authoritative.resolved_atmosphere();
4363 let (icao_temp_k, icao_pressure_pa) =
4364 crate::atmosphere::calculate_icao_standard_atmosphere(2_000.0);
4365 let (expected_authoritative_density, _) =
4366 crate::atmosphere::calculate_atmosphere(2_000.0, Some(15.0), Some(1013.25), 50.0);
4367
4368 assert!((legacy_temp_c - (icao_temp_k - 273.15)).abs() < 1e-12);
4369 assert!((legacy_pressure_hpa - icao_pressure_pa / 100.0).abs() < 1e-12);
4370 assert_eq!(authoritative_temp_c.to_bits(), 15.0_f64.to_bits());
4371 assert_eq!(authoritative_pressure_hpa.to_bits(), 1013.25_f64.to_bits());
4372 assert_eq!(
4373 authoritative_density.to_bits(),
4374 expected_authoritative_density.to_bits()
4375 );
4376 assert!(
4377 (authoritative_density - legacy_density).abs() > 0.1,
4378 "explicit standard values at altitude must differ from ICAO-at-altitude: explicit={authoritative_density}, ICAO={legacy_density}"
4379 );
4380 }
4381
4382 #[test]
4391 fn precomputed_absolute_resolution_via_authoritative_matches_legacy_new() {
4392 for (temperature, pressure, altitude) in [
4393 (15.0, 1013.25, 0.0), (15.0, 1013.25, 2000.0), (-5.0, 850.0, 2000.0), (22.0, 950.0, 500.0),
4397 ] {
4398 let atmosphere = AtmosphericConditions {
4399 temperature,
4400 pressure,
4401 humidity: 50.0,
4402 altitude,
4403 };
4404 let legacy = TrajectorySolver::new(
4405 BallisticInputs::default(),
4406 WindConditions::default(),
4407 atmosphere.clone(),
4408 );
4409
4410 let (resolved_temp_c, resolved_pressure_hpa) =
4411 crate::atmosphere::resolve_station_conditions_with_pressure_mode(
4412 temperature,
4413 pressure,
4414 altitude,
4415 crate::atmosphere::PressureReferenceMode::Absolute,
4416 );
4417 let precomputed_atmosphere = AtmosphericConditions {
4418 temperature: resolved_temp_c,
4419 pressure: resolved_pressure_hpa,
4420 humidity: 50.0,
4421 altitude,
4422 };
4423 let precomputed = TrajectorySolver::new_with_resolved_station_atmosphere(
4424 BallisticInputs::default(),
4425 WindConditions::default(),
4426 precomputed_atmosphere,
4427 );
4428
4429 let (legacy_density, legacy_sos, legacy_temp_c, legacy_pressure_hpa) =
4430 legacy.resolved_atmosphere();
4431 let (pre_density, pre_sos, pre_temp_c, pre_pressure_hpa) =
4432 precomputed.resolved_atmosphere();
4433
4434 assert_eq!(
4435 legacy_temp_c.to_bits(),
4436 pre_temp_c.to_bits(),
4437 "temperature=({temperature}, {pressure}, {altitude})"
4438 );
4439 assert_eq!(
4440 legacy_pressure_hpa.to_bits(),
4441 pre_pressure_hpa.to_bits(),
4442 "pressure=({temperature}, {pressure}, {altitude})"
4443 );
4444 assert_eq!(legacy_density.to_bits(), pre_density.to_bits());
4445 assert_eq!(legacy_sos.to_bits(), pre_sos.to_bits());
4446 }
4447 }
4448
4449 fn configured_euler_zero(vertical_wind_mps: f64, time_step_s: f64) -> TrajectorySolver {
4450 let inputs = BallisticInputs {
4451 muzzle_velocity: 800.0,
4452 bc_value: 0.5,
4453 bc_type: DragModel::G7,
4454 bullet_mass: 0.0109,
4455 bullet_diameter: 0.00782,
4456 bullet_length: 0.0309,
4457 sight_height: 0.05,
4458 ground_threshold: -100.0,
4459 use_rk4: false,
4460 use_adaptive_rk45: false,
4461 ..BallisticInputs::default()
4462 };
4463 let mut solver = TrajectorySolver::new_with_resolved_station_atmosphere(
4464 inputs,
4465 WindConditions::default(),
4466 AtmosphericConditions::default(),
4467 );
4468 solver.set_max_range(300.0);
4469 solver.set_time_step(time_step_s);
4470 if vertical_wind_mps != 0.0 {
4471 solver.set_wind_segments(vec![WindSegment {
4472 speed_kmh: 0.0,
4473 angle_deg: 0.0,
4474 until_m: 400.0,
4475 vertical_mps: vertical_wind_mps,
4476 }]);
4477 }
4478 solver
4479 }
4480
4481 #[test]
4482 fn inclined_shot_zeroes_like_a_level_rifle() {
4483 const ZERO_DISTANCE_M: f64 = 91.44; const SIGHT_HEIGHT_M: f64 = 0.0381; let inputs = BallisticInputs {
4492 bc_value: 0.5,
4493 bullet_mass: 150.0 * 0.06479891 / 1000.0,
4494 muzzle_velocity: 2700.0 * 0.3048,
4495 sight_height: SIGHT_HEIGHT_M,
4496 ..Default::default()
4497 };
4498
4499 let mut level = inputs.clone();
4500 level.shooting_angle = 0.0;
4501 let level_angle = TrajectorySolver::new(level, Default::default(), Default::default())
4502 .find_zero_angle(ZERO_DISTANCE_M, SIGHT_HEIGHT_M, ZeroTargetFrame::SightLine)
4503 .expect("level zero must solve");
4504
4505 let mut inclined = inputs;
4506 inclined.shooting_angle = 5.71_f64.to_radians();
4507 let inclined_angle =
4508 TrajectorySolver::new(inclined, Default::default(), Default::default())
4509 .find_zero_angle(ZERO_DISTANCE_M, SIGHT_HEIGHT_M, ZeroTargetFrame::SightLine)
4510 .expect("MBA-1412: a 5.71 deg incline at a 100 yd zero must be solvable");
4511
4512 assert!(
4513 (inclined_angle - level_angle).abs() < 1e-9,
4514 "zeroing is level-rifle sight geometry; incline must not move the solved zero: \
4515 level={level_angle}, inclined={inclined_angle}"
4516 );
4517 }
4518
4519 #[test]
4520 fn configured_zero_keeps_segments_method_and_time_step_then_sets_base_angle() {
4521 const TARGET_DISTANCE_M: f64 = 150.0;
4522 const TARGET_HEIGHT_M: f64 = 0.05;
4523
4524 let mut segmented = configured_euler_zero(-10.0, 0.02);
4527 let coarse_height = segmented
4528 .zero_trial_height_at(0.0, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
4529 .expect("coarse configured trial")
4530 .expect("coarse trial reaches target");
4531 let mut fine = segmented.clone();
4532 fine.set_time_step(0.001);
4533 let fine_height = fine
4534 .zero_trial_height_at(0.0, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
4535 .expect("fine configured trial")
4536 .expect("fine trial reaches target");
4537 assert!(
4538 (coarse_height - fine_height).abs() > 1e-5,
4539 "configured Euler step must affect zero trials: coarse={coarse_height}, fine={fine_height}"
4540 );
4541
4542 let segmented_angle = segmented
4543 .calculate_and_set_zero_angle(TARGET_DISTANCE_M, TARGET_HEIGHT_M, ZeroTargetFrame::SightLine)
4544 .expect("segmented zero");
4545 assert_eq!(
4546 segmented.inputs.muzzle_angle.to_bits(),
4547 segmented_angle.to_bits(),
4548 "successful zero must install its angle on the configured solver"
4549 );
4550 assert_eq!(segmented.time_step.to_bits(), 0.02_f64.to_bits());
4551 assert_eq!(segmented.max_range.to_bits(), 300.0_f64.to_bits());
4552 assert!(segmented.wind_sock.is_some());
4553 assert_eq!(
4554 segmented.station_atmosphere_resolution,
4555 StationAtmosphereResolution::Authoritative
4556 );
4557 let zero_height = segmented
4558 .zero_trial_height_at(segmented_angle, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
4559 .expect("verify segmented zero")
4560 .expect("zeroed trial reaches target");
4561 assert!(
4562 (zero_height - TARGET_HEIGHT_M).abs() < 0.0001,
4563 "configured zero missed target: height={zero_height}"
4564 );
4565
4566 let mut calm = configured_euler_zero(0.0, 0.02);
4567 let calm_angle = calm
4568 .calculate_and_set_zero_angle(TARGET_DISTANCE_M, TARGET_HEIGHT_M, ZeroTargetFrame::SightLine)
4569 .expect("calm zero");
4570 assert!(
4571 (segmented_angle - calm_angle).abs() > 1e-5,
4572 "segmented vertical wind must participate in zero trials: segmented={segmented_angle}, calm={calm_angle}"
4573 );
4574 }
4575}
4576
4577#[cfg(test)]
4578mod result_sanity_tests {
4579 use super::*;
4580
4581 fn default_solver() -> TrajectorySolver {
4582 TrajectorySolver::new(
4583 BallisticInputs::default(),
4584 WindConditions::default(),
4585 AtmosphericConditions::default(),
4586 )
4587 }
4588
4589 fn minimal_result() -> TrajectoryResult {
4590 TrajectoryResult {
4591 max_range: 100.0,
4592 max_height: 1.0,
4593 time_of_flight: 0.5,
4594 impact_velocity: 700.0,
4595 impact_energy: 2450.0,
4596 projectile_mass_kg: 0.01,
4597 line_of_sight_height_m: 1.5,
4598 station_speed_of_sound_mps: 340.0,
4599 termination: TrajectoryTermination::MaxRange,
4600 points: vec![],
4601 sampled_points: None,
4602 min_pitch_damping: None,
4603 transonic_mach: None,
4604 angular_state: None,
4605 max_yaw_angle: None,
4606 max_precession_angle: None,
4607 aerodynamic_jump: None,
4608 mach_1_2_distance_m: None,
4609 mach_1_0_distance_m: None,
4610 mach_0_9_distance_m: None,
4611 }
4612 }
4613
4614 #[test]
4615 fn mba1293_negative_scalars_fail_the_result_postcondition() {
4616 let solver = default_solver();
4617 solver
4618 .validate_result_sanity(&minimal_result())
4619 .expect("a sane result must pass");
4620
4621 for (name, mutate) in [
4622 ("max_range", (|r| r.max_range = -50.588) as fn(&mut TrajectoryResult)),
4623 ("time_of_flight", |r| r.time_of_flight = -1.0),
4624 ("impact_velocity", |r| r.impact_velocity = -700.0),
4625 ("impact_energy", |r| r.impact_energy = -1.0),
4626 ] {
4627 let mut result = minimal_result();
4628 mutate(&mut result);
4629 let error = solver
4630 .validate_result_sanity(&result)
4631 .expect_err("negative scalar must fail");
4632 assert!(
4633 error.to_string().contains(name),
4634 "error for {name} did not name the field: {error}"
4635 );
4636 }
4637 }
4638
4639 #[test]
4640 fn mba1293_speed_budget_bounds_legitimate_states_and_rejects_divergence() {
4641 let solver = default_solver();
4642 let mv = solver.inputs.muzzle_velocity;
4643
4644 let position = Vector3::new(10.0, 0.0, 0.0);
4646 solver
4647 .validate_integration_state(&position, &Vector3::new(mv, 0.0, 0.0), 0.01)
4648 .expect("muzzle-speed state must pass");
4649
4650 let error = solver
4652 .validate_integration_state(&position, &Vector3::new(-13.0 * mv, 0.0, 0.0), 0.01)
4653 .expect_err("13x muzzle speed must fail the budget");
4654 assert!(error.to_string().contains("diverged"), "{error}");
4655
4656 let after_fall = mv + crate::constants::G_ACCEL_MPS2 * 60.0;
4658 solver
4659 .validate_integration_state(&position, &Vector3::new(0.0, -after_fall, 0.0), 60.0)
4660 .expect("gravity-accelerated speed within g*t must pass");
4661 }
4662}
4663
4664#[cfg(test)]
4665mod trajectory_point_budget_tests {
4666 use super::*;
4667 use crate::MAX_TRAJECTORY_SAMPLES;
4668
4669 fn solver_with_budget(
4670 use_rk4: bool,
4671 use_adaptive_rk45: bool,
4672 point_budget: usize,
4673 max_range: f64,
4674 ) -> TrajectorySolver {
4675 let inputs = BallisticInputs {
4676 use_rk4,
4677 use_adaptive_rk45,
4678 ground_threshold: f64::NEG_INFINITY,
4679 ..BallisticInputs::default()
4680 };
4681 let mut solver = TrajectorySolver::new(
4682 inputs,
4683 WindConditions::default(),
4684 AtmosphericConditions::default(),
4685 );
4686 solver.max_trajectory_points = point_budget;
4687 solver.set_max_range(max_range);
4688 solver.set_time_step(0.001);
4689 solver
4690 }
4691
4692 #[test]
4693 fn mba1283_every_solver_errors_instead_of_exceeding_point_budget() {
4694 for (mode, use_rk4, use_adaptive_rk45) in [
4695 ("Euler", false, false),
4696 ("RK4", true, false),
4697 ("RK45", true, true),
4698 ] {
4699 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 3, 10.0)
4700 .solve()
4701 .expect_err("a solve requiring more than three points must fail");
4702 assert!(
4703 error.to_string().contains("point limit of 3"),
4704 "unexpected {mode} point-budget error: {error}"
4705 );
4706 }
4707 }
4708
4709 #[test]
4710 fn mba1283_interpolated_endpoint_counts_toward_point_budget() {
4711 for (mode, use_rk4, use_adaptive_rk45) in [
4712 ("Euler", false, false),
4713 ("RK4", true, false),
4714 ("RK45", true, true),
4715 ] {
4716 let result = solver_with_budget(use_rk4, use_adaptive_rk45, 2, 0.1)
4717 .solve()
4718 .expect("the initial point plus exact endpoint fit a two-point budget");
4719 assert_eq!(result.points.len(), 2, "unexpected {mode} point count");
4720
4721 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 1, 0.1)
4722 .solve()
4723 .expect_err("the exact endpoint must not exceed a one-point budget");
4724 assert!(
4725 error.to_string().contains("point limit of 1"),
4726 "unexpected {mode} endpoint-budget error: {error}"
4727 );
4728 }
4729 }
4730
4731 #[test]
4732 fn mba1299_every_solver_preflights_the_sample_budget() {
4733 for (mode, use_rk4, use_adaptive_rk45) in [
4734 ("Euler", false, false),
4735 ("RK4", true, false),
4736 ("RK45", true, true),
4737 ] {
4738 let inputs = BallisticInputs {
4739 use_rk4,
4740 use_adaptive_rk45,
4741 enable_trajectory_sampling: true,
4742 sample_interval: 1.0,
4743 ground_threshold: f64::NEG_INFINITY,
4744 ..BallisticInputs::default()
4745 };
4746 let mut solver = TrajectorySolver::new(
4747 inputs,
4748 WindConditions::default(),
4749 AtmosphericConditions::default(),
4750 );
4751 solver.set_max_range(MAX_TRAJECTORY_SAMPLES as f64);
4752 solver.max_trajectory_points = 0;
4755
4756 let error = solver
4757 .solve()
4758 .expect_err("an over-limit sample grid must fail before integration");
4759 assert!(
4760 error
4761 .to_string()
4762 .contains("trajectory sample limit of 250000 exceeded"),
4763 "unexpected {mode} sample-budget error: {error}"
4764 );
4765 }
4766 }
4767
4768 #[test]
4769 fn mba1299_normal_sampling_does_not_change_solver_results() {
4770 for (mode, use_rk4, use_adaptive_rk45) in [
4771 ("Euler", false, false),
4772 ("RK4", true, false),
4773 ("RK45", true, true),
4774 ] {
4775 let solve = |enable_trajectory_sampling| {
4776 let inputs = BallisticInputs {
4777 use_rk4,
4778 use_adaptive_rk45,
4779 enable_trajectory_sampling,
4780 sample_interval: 0.5,
4781 ground_threshold: f64::NEG_INFINITY,
4782 ..BallisticInputs::default()
4783 };
4784 let mut solver = TrajectorySolver::new(
4785 inputs,
4786 WindConditions::default(),
4787 AtmosphericConditions::default(),
4788 );
4789 solver.set_max_range(2.0);
4790 solver.solve().expect("normal short-range solve")
4791 };
4792
4793 let baseline = solve(false);
4794 let sampled = solve(true);
4795 for (field, left, right) in [
4796 ("max_range", baseline.max_range, sampled.max_range),
4797 ("max_height", baseline.max_height, sampled.max_height),
4798 (
4799 "time_of_flight",
4800 baseline.time_of_flight,
4801 sampled.time_of_flight,
4802 ),
4803 (
4804 "impact_velocity",
4805 baseline.impact_velocity,
4806 sampled.impact_velocity,
4807 ),
4808 (
4809 "impact_energy",
4810 baseline.impact_energy,
4811 sampled.impact_energy,
4812 ),
4813 ] {
4814 assert_eq!(
4815 left.to_bits(),
4816 right.to_bits(),
4817 "{mode} sampling changed {field}"
4818 );
4819 }
4820 assert_eq!(baseline.points.len(), sampled.points.len());
4821 for (index, (left, right)) in baseline
4822 .points
4823 .iter()
4824 .zip(&sampled.points)
4825 .enumerate()
4826 {
4827 assert_eq!(left.time.to_bits(), right.time.to_bits(), "{mode} point {index}");
4828 assert_eq!(
4829 left.position.map(f64::to_bits),
4830 right.position.map(f64::to_bits),
4831 "{mode} point {index} position"
4832 );
4833 assert_eq!(
4834 left.velocity_magnitude.to_bits(),
4835 right.velocity_magnitude.to_bits(),
4836 "{mode} point {index} velocity"
4837 );
4838 assert_eq!(
4839 left.kinetic_energy.to_bits(),
4840 right.kinetic_energy.to_bits(),
4841 "{mode} point {index} energy"
4842 );
4843 }
4844 assert!(baseline.sampled_points.is_none());
4845 let samples = sampled
4846 .sampled_points
4847 .expect("sampling-enabled solve should return observations");
4848 assert_eq!(
4849 samples
4850 .iter()
4851 .map(|sample| sample.distance_m)
4852 .collect::<Vec<_>>(),
4853 vec![0.0, 0.5, 1.0, 1.5, 2.0],
4854 "{mode} normal sampling grid changed"
4855 );
4856 }
4857 }
4858}
4859
4860#[cfg(test)]
4861mod monte_carlo_result_tests {
4862 use super::*;
4863
4864 fn make_results(impact_positions: Vec<Vector3<f64>>) -> MonteCarloResults {
4865 let count = impact_positions.len();
4866 MonteCarloResults {
4867 ranges: vec![500.0; count],
4868 impact_velocities: vec![300.0; count],
4869 impact_positions,
4870 }
4871 }
4872
4873 #[test]
4874 fn target_plane_cep_excludes_shortfall_markers() {
4875 let mut positions: Vec<Vector3<f64>> = (1..=5)
4876 .map(|radius| Vector3::new(0.0, radius as f64, 0.0))
4877 .collect();
4878 positions.extend(
4879 (0..5).map(|_| Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)),
4880 );
4881 let results = make_results(positions);
4882
4883 assert_eq!(results.target_arrival_count(), 5);
4884 assert_eq!(results.target_shortfall_fraction(), 0.5);
4885 assert_eq!(results.target_plane_cep(), Some(3.0));
4886
4887 let one_shortfall = make_results(vec![
4888 Vector3::new(0.0, 1.0, 0.0),
4889 Vector3::new(0.0, 2.0, 0.0),
4890 Vector3::new(0.0, 3.0, 0.0),
4891 Vector3::new(0.0, 4.0, 0.0),
4892 Vector3::new(0.0, 5.0, 0.0),
4893 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4894 ]);
4895 assert_eq!(one_shortfall.target_plane_cep(), Some(3.0));
4896 }
4897
4898 #[test]
4899 fn all_shortfalls_have_no_cep_but_still_count_as_misses() {
4900 let all_shortfalls = make_results(vec![
4901 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4902 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4903 ]);
4904 assert_eq!(all_shortfalls.target_arrival_count(), 0);
4905 assert_eq!(all_shortfalls.target_shortfall_fraction(), 1.0);
4906 assert_eq!(all_shortfalls.target_plane_cep(), None);
4907 assert_eq!(all_shortfalls.hit_probability(0.3), 0.0);
4908
4909 let one_hit_one_shortfall = make_results(vec![
4910 Vector3::new(0.0, 0.1, 0.0),
4911 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4912 ]);
4913 assert_eq!(one_hit_one_shortfall.hit_probability(0.3), 0.5);
4914 }
4915
4916 #[test]
4918 fn rect_hit_probability_checks_independent_axis_halves() {
4919 let results = make_results(vec![
4920 Vector3::new(0.0, 0.1, 0.1),
4922 Vector3::new(0.0, 0.0, 0.2),
4924 Vector3::new(0.0, 0.0, 0.201),
4926 Vector3::new(0.0, 0.301, 0.0),
4928 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4930 ]);
4931 assert!((results.rect_hit_probability(0.4, 0.6) - 0.4).abs() < 1e-12);
4933 }
4934
4935 #[test]
4936 fn rect_hit_probability_matches_circular_hit_probability_for_a_centered_hit() {
4937 let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
4938 assert_eq!(results.rect_hit_probability(0.5, 0.5), 1.0);
4939 assert_eq!(results.hit_probability(0.3), 1.0);
4940 }
4941
4942 #[test]
4943 fn rect_hit_probability_is_zero_for_empty_or_nonpositive_dimensions() {
4944 let empty = make_results(vec![]);
4945 assert_eq!(empty.rect_hit_probability(1.0, 1.0), 0.0);
4946
4947 let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
4948 assert_eq!(results.rect_hit_probability(0.0, 1.0), 0.0);
4949 assert_eq!(results.rect_hit_probability(1.0, 0.0), 0.0);
4950 assert_eq!(results.rect_hit_probability(-1.0, 1.0), 0.0);
4951 }
4952}
4953
4954#[cfg(test)]
4955mod monte_carlo_seeded_tests {
4956 use super::*;
4957
4958 #[test]
4959 fn seeded_runs_are_deterministic_and_match_the_using_rng_path() {
4960 let inputs = BallisticInputs {
4961 muzzle_velocity: 800.0,
4962 ..BallisticInputs::default()
4963 };
4964 let params = MonteCarloParams {
4965 num_simulations: 64,
4966 target_distance: Some(200.0),
4967 ..MonteCarloParams::default()
4968 };
4969
4970 let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4971 inputs.clone(),
4972 WindConditions::default(),
4973 params.clone(),
4974 0.01,
4975 42,
4976 )
4977 .expect("seeded run a");
4978 let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4979 inputs,
4980 WindConditions::default(),
4981 params,
4982 0.01,
4983 42,
4984 )
4985 .expect("seeded run b");
4986
4987 assert_eq!(a.ranges.len(), b.ranges.len());
4988 for (ra, rb) in a.ranges.iter().zip(b.ranges.iter()) {
4989 assert_eq!(ra.to_bits(), rb.to_bits());
4990 }
4991 for (pa, pb) in a.impact_positions.iter().zip(b.impact_positions.iter()) {
4992 assert_eq!(pa.x.to_bits(), pb.x.to_bits());
4993 assert_eq!(pa.y.to_bits(), pb.y.to_bits());
4994 assert_eq!(pa.z.to_bits(), pb.z.to_bits());
4995 }
4996 }
4997
4998 #[test]
4999 fn different_seeds_generally_produce_different_draws() {
5000 let inputs = BallisticInputs {
5001 muzzle_velocity: 800.0,
5002 ..BallisticInputs::default()
5003 };
5004 let params = MonteCarloParams {
5005 num_simulations: 32,
5006 velocity_std_dev: 5.0,
5007 target_distance: Some(200.0),
5008 ..MonteCarloParams::default()
5009 };
5010
5011 let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
5012 inputs.clone(),
5013 WindConditions::default(),
5014 params.clone(),
5015 0.0,
5016 1,
5017 )
5018 .expect("seeded run a");
5019 let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
5020 inputs,
5021 WindConditions::default(),
5022 params,
5023 0.0,
5024 2,
5025 )
5026 .expect("seeded run b");
5027
5028 assert_ne!(a.impact_velocities, b.impact_velocities);
5029 }
5030}
5031
5032#[cfg(test)]
5033mod monte_carlo_powder_curve_tests {
5034 use super::*;
5035 use rand::{rngs::StdRng, SeedableRng};
5036
5037 #[test]
5038 fn powder_curve_preserves_sampled_muzzle_velocity_dispersion() {
5039 let inputs = BallisticInputs {
5040 muzzle_velocity: 700.0,
5041 powder_temp_curve: Some(vec![(15.0, 800.0)]),
5042 powder_curve_temp_c: Some(15.0),
5043 ..BallisticInputs::default()
5044 };
5045 let params = MonteCarloParams {
5046 num_simulations: 16,
5047 velocity_std_dev: 20.0,
5048 angle_std_dev: 1e-12,
5049 bc_std_dev: 1e-12,
5050 wind_speed_std_dev: 1e-12,
5051 target_distance: Some(100.0),
5052 azimuth_std_dev: 1e-12,
5053 ..MonteCarloParams::default()
5054 };
5055
5056 let mut rng = StdRng::seed_from_u64(0x5EED_1176);
5057 let results = run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
5058 inputs,
5059 WindConditions::default(),
5060 params,
5061 0.0,
5062 &mut rng,
5063 )
5064 .expect("Monte Carlo solve");
5065 let min_velocity = results
5066 .impact_velocities
5067 .iter()
5068 .copied()
5069 .fold(f64::INFINITY, f64::min);
5070 let max_velocity = results
5071 .impact_velocities
5072 .iter()
5073 .copied()
5074 .fold(f64::NEG_INFINITY, f64::max);
5075
5076 assert!(
5077 max_velocity - min_velocity > 1.0,
5078 "20 m/s muzzle spread collapsed after curve resolution: impact-velocity span={} m/s",
5079 max_velocity - min_velocity
5080 );
5081 }
5082}
5083
5084#[cfg(test)]
5085mod monte_carlo_wind_sampling_tests {
5086 use super::*;
5087 use rand::{rngs::StdRng, SeedableRng};
5088
5089 #[test]
5090 fn wind_speed_sigma_does_not_change_seeded_direction_draws() {
5091 let base_wind = WindConditions {
5092 speed: 100.0,
5093 direction: 0.37,
5094 vertical_speed: 0.0,
5095 };
5096 let narrow_speed = MonteCarloWindSampler::new(&base_wind, 0.5, 0.2).unwrap();
5097 let wide_speed = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
5098 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
5099 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
5100 let mut speed_changed = false;
5101
5102 for _ in 0..32 {
5103 let narrow = narrow_speed.sample(&mut narrow_rng);
5104 let wide = wide_speed.sample(&mut wide_rng);
5105 assert!(narrow.speed > 0.0 && wide.speed > 0.0);
5106 assert_eq!(narrow.direction.to_bits(), wide.direction.to_bits());
5107 speed_changed |= narrow.speed.to_bits() != wide.speed.to_bits();
5108 }
5109 assert!(
5110 speed_changed,
5111 "different speed sigmas must still vary speed draws"
5112 );
5113 }
5114
5115 #[test]
5116 fn zero_direction_sigma_has_no_angular_jitter() {
5117 let base_wind = WindConditions {
5118 speed: 100.0,
5119 direction: 0.37,
5120 vertical_speed: 0.0,
5121 };
5122 let sampler = MonteCarloWindSampler::new(&base_wind, 4.0, 0.0).unwrap();
5123 let mut rng = StdRng::seed_from_u64(0x5EED_1223);
5124 let mut speed_changed = false;
5125
5126 for _ in 0..32 {
5127 let wind = sampler.sample(&mut rng);
5128 speed_changed |= wind.speed.to_bits() != base_wind.speed.to_bits();
5129 assert_eq!(wind.direction.to_bits(), base_wind.direction.to_bits());
5130 }
5131 assert!(speed_changed, "speed uncertainty should remain active");
5132 }
5133
5134 #[test]
5135 fn direction_sigma_controls_seeded_angular_spread_in_radians() {
5136 let base_wind = WindConditions {
5137 speed: 100.0,
5138 direction: 0.37,
5139 vertical_speed: 0.0,
5140 };
5141 let narrow = MonteCarloWindSampler::new(&base_wind, 4.0, 0.1).unwrap();
5142 let wide = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
5143 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
5144 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
5145 let mut nonzero_direction_draw = false;
5146
5147 for _ in 0..32 {
5148 let narrow_wind = narrow.sample(&mut narrow_rng);
5149 let wide_wind = wide.sample(&mut wide_rng);
5150 assert_eq!(narrow_wind.speed.to_bits(), wide_wind.speed.to_bits());
5151
5152 let narrow_delta = narrow_wind.direction - base_wind.direction;
5153 let wide_delta = wide_wind.direction - base_wind.direction;
5154 assert!((wide_delta - 2.0 * narrow_delta).abs() < 1e-12);
5155 nonzero_direction_draw |= narrow_delta.abs() > 1e-6;
5156 }
5157 assert!(
5158 nonzero_direction_draw,
5159 "positive radians sigma must vary direction"
5160 );
5161 }
5162
5163 #[test]
5164 fn direction_sigma_rejects_negative_or_nonfinite_values() {
5165 let base_wind = WindConditions::default();
5166 for sigma in [-0.1, f64::NAN, f64::INFINITY] {
5167 assert!(MonteCarloWindSampler::new(&base_wind, 1.0, sigma).is_err());
5168 }
5169 }
5170
5171 #[test]
5172 fn base_vertical_wind_rides_into_every_mc_sample() {
5173 use rand::SeedableRng;
5177 let base_wind = WindConditions { vertical_speed: 4.2, ..Default::default() };
5178 let sampler = MonteCarloWindSampler::new(&base_wind, 1.0, 0.2).unwrap();
5179 let mut rng = rand::rngs::StdRng::seed_from_u64(7);
5180 for _ in 0..32 {
5181 let w = sampler.sample(&mut rng);
5182 assert_eq!(w.vertical_speed, 4.2);
5183 }
5184 }
5185
5186 #[test]
5187 fn negative_speed_sample_reverses_wind_direction() {
5188 let direction = 0.25;
5189 let signed_speed = -2.5;
5190 let wind = wind_from_signed_speed_sample(signed_speed, direction, 0.0);
5191 let positive_wind = wind_from_signed_speed_sample(2.5, direction, 0.0);
5192
5193 assert_eq!(wind.speed, 2.5);
5194 assert!(
5195 (wind.direction - (direction + std::f64::consts::PI)).abs() < f64::EPSILON,
5196 "negative speed must reverse direction by pi: got {}",
5197 wind.direction
5198 );
5199 assert_eq!(positive_wind.speed, 2.5);
5200 assert_eq!(positive_wind.direction, direction);
5201
5202 let normalized_x = -wind.speed * wind.direction.cos();
5203 let normalized_z = -wind.speed * wind.direction.sin();
5204 let signed_x = -signed_speed * direction.cos();
5205 let signed_z = -signed_speed * direction.sin();
5206 assert!((normalized_x - signed_x).abs() < 1e-12);
5207 assert!((normalized_z - signed_z).abs() < 1e-12);
5208 }
5209}
5210
5211#[cfg(test)]
5212mod bc_fit_objective_tests {
5213 use super::*;
5214
5215 fn velocity_point(range_m: f64, velocity_mps: f64) -> TrajectoryPoint {
5216 TrajectoryPoint {
5217 time: 0.0,
5218 position: Vector3::new(range_m, 0.0, 0.0),
5219 velocity_magnitude: velocity_mps,
5220 kinetic_energy: 0.0,
5221 drag_coefficient: None,
5222 }
5223 }
5224
5225 #[test]
5226 fn candidate_that_misses_an_observation_has_no_score() {
5227 let trajectory = vec![velocity_point(0.0, 800.0), velocity_point(100.0, 700.0)];
5228 let observations = vec![(50.0, 750.0), (150.0, 600.0)];
5229
5230 assert!(
5231 fit_residual_sse(&trajectory, &observations, BcFitMode::Velocity, 0.0).is_none(),
5232 "a candidate that reaches only one of two observations must not compete on partial SSE"
5233 );
5234
5235 let complete_observations = vec![(50.0, 740.0), (100.0, 680.0)];
5236 assert_eq!(
5237 fit_residual_sse(
5238 &trajectory,
5239 &complete_observations,
5240 BcFitMode::Velocity,
5241 0.0,
5242 ),
5243 Some(500.0)
5244 );
5245 }
5246}
5247
5248#[cfg(test)]
5249mod cluster_bc_reference_space_tests {
5250 use super::*;
5251
5252 fn acceleration_at_1100_fps(inputs: BallisticInputs) -> Vector3<f64> {
5253 let solver = TrajectorySolver::new(
5254 inputs,
5255 WindConditions::default(),
5256 AtmosphericConditions::default(),
5257 );
5258 let position = Vector3::zeros();
5259 let velocity = Vector3::new(1100.0 / 3.28084, 0.0, 0.0);
5260 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5261 solver.calculate_acceleration(
5262 &position,
5263 &velocity,
5264 &Vector3::zeros(),
5265 (temp_c, pressure_hpa, density / 1.225),
5266 )
5267 }
5268
5269 #[test]
5270 fn solver_passes_g7_reference_model_to_cluster_classifier() {
5271 let inputs = BallisticInputs {
5272 bc_value: 0.190,
5273 bc_type: DragModel::G7,
5274 bullet_mass: 77.0 * crate::constants::GRAINS_TO_KG,
5275 bullet_diameter: 0.224 * 0.0254,
5276 use_cluster_bc: true,
5277 ..BallisticInputs::default()
5278 };
5279
5280 let solver = TrajectorySolver::new(
5281 inputs,
5282 WindConditions::default(),
5283 AtmosphericConditions::default(),
5284 );
5285 let corrected = solver.apply_cluster_bc_correction(0.190, 2800.0);
5286
5287 assert!(
5288 (corrected / 0.190 - 1.004).abs() < 1e-12,
5289 "solver selected the wrong G7 cluster multiplier: {}",
5290 corrected / 0.190
5291 );
5292 }
5293
5294 #[test]
5295 fn velocity_bc_segments_are_not_cluster_corrected_twice() {
5296 let segmented_clustered = BallisticInputs {
5297 bc_value: 0.5,
5298 bc_type: DragModel::G7,
5299 use_bc_segments: true,
5300 bc_segments_data: Some(vec![
5301 crate::BCSegmentData {
5302 velocity_min: 0.0,
5303 velocity_max: 1_600.0,
5304 bc_value: 0.4,
5305 },
5306 crate::BCSegmentData {
5307 velocity_min: 1_600.0,
5308 velocity_max: 5_000.0,
5309 bc_value: 0.45,
5310 },
5311 ]),
5312 use_cluster_bc: true,
5313 ..BallisticInputs::default()
5314 };
5315 let mut segmented_only = segmented_clustered.clone();
5316 segmented_only.use_cluster_bc = false;
5317 let mut constant_clustered = segmented_clustered.clone();
5318 constant_clustered.bc_value = 0.4;
5319 constant_clustered.bc_segments_data = None;
5320
5321 let stacked = acceleration_at_1100_fps(segmented_clustered);
5322 let segment_only = acceleration_at_1100_fps(segmented_only);
5323 let cluster_only = acceleration_at_1100_fps(constant_clustered);
5324
5325 assert!(
5326 (stacked.x - segment_only.x).abs() < 1e-12,
5327 "segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
5328 stacked.x,
5329 segment_only.x
5330 );
5331 assert!(
5332 (cluster_only.x - segment_only.x).abs() > 1e-6,
5333 "cluster correction must remain active for a constant BC"
5334 );
5335 }
5336
5337 #[test]
5338 fn mach_bc_segments_are_not_cluster_corrected_twice() {
5339 let mach_segmented_clustered = BallisticInputs {
5340 bc_value: 0.5,
5341 bc_type: DragModel::G7,
5342 use_bc_segments: false,
5343 bc_segments: Some(vec![(0.5, 0.3), (1.5, 0.5)]),
5344 use_cluster_bc: true,
5345 ..BallisticInputs::default()
5346 };
5347 let mut mach_segmented_only = mach_segmented_clustered.clone();
5348 mach_segmented_only.use_cluster_bc = false;
5349
5350 let stacked = acceleration_at_1100_fps(mach_segmented_clustered);
5351 let segment_only = acceleration_at_1100_fps(mach_segmented_only);
5352
5353 assert!(
5354 (stacked.x - segment_only.x).abs() < 1e-12,
5355 "Mach segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
5356 stacked.x,
5357 segment_only.x
5358 );
5359 }
5360}
5361
5362#[cfg(test)]
5363mod velocity_bc_flag_tests {
5364 use super::*;
5365
5366 fn acceleration_at_600_mps(inputs: BallisticInputs) -> Vector3<f64> {
5367 let solver = TrajectorySolver::new(
5368 inputs,
5369 WindConditions::default(),
5370 AtmosphericConditions::default(),
5371 );
5372 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5373 solver.calculate_acceleration(
5374 &Vector3::zeros(),
5375 &Vector3::new(600.0, 0.0, 0.0),
5376 &Vector3::zeros(),
5377 (temp_c, pressure_hpa, density / 1.225),
5378 )
5379 }
5380
5381 #[test]
5382 fn velocity_bc_data_requires_opt_in_in_trajectory_solver() {
5383 let scalar_inputs = BallisticInputs {
5384 bc_value: 0.5,
5385 bc_type: DragModel::G7,
5386 ..BallisticInputs::default()
5387 };
5388 let mut disabled_inputs = scalar_inputs.clone();
5389 disabled_inputs.bc_segments_data = Some(vec![crate::BCSegmentData {
5390 velocity_min: 0.0,
5391 velocity_max: 4_000.0,
5392 bc_value: 0.46,
5393 }]);
5394 disabled_inputs.use_bc_segments = false;
5395 let mut enabled_inputs = disabled_inputs.clone();
5396 enabled_inputs.use_bc_segments = true;
5397 let mut mach_only_inputs = scalar_inputs.clone();
5398 mach_only_inputs.bc_segments = Some(vec![(0.0, 0.4), (3.0, 0.4)]);
5399 let mut disabled_with_both = mach_only_inputs.clone();
5400 disabled_with_both.bc_segments_data = disabled_inputs.bc_segments_data.clone();
5401
5402 let scalar = acceleration_at_600_mps(scalar_inputs);
5403 let disabled = acceleration_at_600_mps(disabled_inputs);
5404 let enabled = acceleration_at_600_mps(enabled_inputs);
5405 let mach_only = acceleration_at_600_mps(mach_only_inputs);
5406 let disabled_with_both = acceleration_at_600_mps(disabled_with_both);
5407
5408 assert_eq!(
5409 disabled.x.to_bits(),
5410 scalar.x.to_bits(),
5411 "a populated velocity table must not change drag while use_bc_segments is false"
5412 );
5413 assert!(
5414 enabled.x < disabled.x - 1.0,
5415 "enabling the lower BC table must increase drag: disabled ax={} enabled ax={}",
5416 disabled.x,
5417 enabled.x
5418 );
5419 assert_eq!(
5420 disabled_with_both.x.to_bits(),
5421 mach_only.x.to_bits(),
5422 "disabling velocity data must fall through to an explicit Mach table"
5423 );
5424 }
5425}
5426
5427#[cfg(test)]
5428mod mach_bc_segment_tests {
5429 use super::*;
5430
5431 #[test]
5432 fn trajectory_solver_interpolates_explicit_mach_bc_segments() {
5433 let segmented_inputs = BallisticInputs {
5434 bc_value: 0.8,
5435 use_bc_segments: false,
5436 bc_segments: Some(vec![(1.0, 0.2), (2.0, 0.4)]),
5437 bc_segments_data: None,
5438 ..BallisticInputs::default()
5439 };
5440
5441 let mut expected_inputs = segmented_inputs.clone();
5442 expected_inputs.bc_value = 0.3;
5443 expected_inputs.bc_segments = None;
5444
5445 let atmosphere = AtmosphericConditions::default();
5446 let segmented_solver = TrajectorySolver::new(
5447 segmented_inputs,
5448 WindConditions::default(),
5449 atmosphere.clone(),
5450 );
5451 let expected_solver = TrajectorySolver::new(
5452 expected_inputs,
5453 WindConditions::default(),
5454 atmosphere,
5455 );
5456 let position = Vector3::zeros();
5457 let (density, _, temp_c, pressure_hpa) = segmented_solver.resolved_atmosphere();
5458 let (_, local_speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
5459 segmented_solver.atmosphere.altitude,
5460 segmented_solver.atmosphere.altitude,
5461 temp_c,
5462 pressure_hpa,
5463 density / 1.225,
5464 segmented_solver.atmosphere.humidity,
5465 );
5466 let velocity = Vector3::new(1.5 * local_speed_of_sound, 0.0, 0.0);
5467 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
5468
5469 let segmented_acceleration = segmented_solver.calculate_acceleration(
5470 &position,
5471 &velocity,
5472 &Vector3::zeros(),
5473 resolved_atmo,
5474 );
5475 let expected_acceleration = expected_solver.calculate_acceleration(
5476 &position,
5477 &velocity,
5478 &Vector3::zeros(),
5479 resolved_atmo,
5480 );
5481
5482 assert!(
5483 (segmented_acceleration.x - expected_acceleration.x).abs() < 1e-12,
5484 "Mach 1.5 must interpolate BC 0.3: segmented ax={} expected ax={}",
5485 segmented_acceleration.x,
5486 expected_acceleration.x
5487 );
5488 }
5489}
5490
5491#[cfg(test)]
5492mod custom_drag_table_validation_tests {
5493 use super::*;
5494
5495 #[test]
5496 fn solve_accepts_zero_bc_when_custom_table_present() {
5497 let inputs = BallisticInputs {
5498 bc_value: 0.0, bullet_mass: 0.0106,
5500 bullet_diameter: 0.00782,
5501 muzzle_velocity: 850.0,
5502 custom_drag_table: Some(crate::drag::DragTable::new(
5503 vec![0.5, 1.0, 2.0, 3.0],
5504 vec![0.23, 0.40, 0.30, 0.26],
5505 )),
5506 ..BallisticInputs::default()
5507 };
5508 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
5509 assert!(solver.solve().is_ok());
5511 }
5512
5513 #[test]
5514 fn solve_still_requires_bc_without_table() {
5515 let inputs = BallisticInputs {
5516 bc_value: 0.0,
5517 bullet_mass: 0.0106,
5518 bullet_diameter: 0.00782,
5519 muzzle_velocity: 850.0,
5520 ..BallisticInputs::default()
5521 };
5522 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
5523 assert!(solver.solve().is_err());
5524 }
5525}
5526
5527#[cfg(test)]
5529mod cd_scale_tests {
5530 use super::*;
5531
5532 fn deck() -> crate::drag::DragTable {
5533 crate::drag::DragTable::new(vec![0.5, 1.0, 2.0, 3.0], vec![0.23, 0.40, 0.30, 0.26])
5534 }
5535
5536 fn deck_inputs(cd_scale: f64) -> BallisticInputs {
5537 BallisticInputs {
5538 bullet_mass: 0.0106,
5539 bullet_diameter: 0.00782,
5540 muzzle_velocity: 850.0,
5541 custom_drag_table: Some(deck()),
5542 cd_scale,
5543 ..BallisticInputs::default()
5544 }
5545 }
5546
5547 #[test]
5548 fn default_cd_scale_is_one() {
5549 assert_eq!(BallisticInputs::default().cd_scale, 1.0);
5550 }
5551
5552 #[test]
5555 fn cd_scale_absent_is_byte_identical_to_explicit_one() {
5556 let omitted = BallisticInputs {
5557 bullet_mass: 0.0106,
5558 bullet_diameter: 0.00782,
5559 muzzle_velocity: 850.0,
5560 custom_drag_table: Some(deck()),
5561 ..BallisticInputs::default()
5562 };
5563 let explicit = BallisticInputs {
5564 cd_scale: 1.0,
5565 ..omitted.clone()
5566 };
5567
5568 let solver_omitted =
5569 TrajectorySolver::new(omitted, WindConditions::default(), AtmosphericConditions::default());
5570 let solver_explicit =
5571 TrajectorySolver::new(explicit, WindConditions::default(), AtmosphericConditions::default());
5572
5573 let cd_omitted = solver_omitted.calculate_drag_coefficient(700.0, 340.0);
5574 let cd_explicit = solver_explicit.calculate_drag_coefficient(700.0, 340.0);
5575 assert_eq!(
5576 cd_omitted.to_bits(),
5577 cd_explicit.to_bits(),
5578 "default cd_scale must be bit-identical to an explicit 1.0"
5579 );
5580
5581 let result = solver_omitted.solve();
5584 assert!(result.is_ok(), "existing custom-deck solves must pass unchanged");
5585 }
5586
5587 #[test]
5589 fn cd_scale_multiplies_the_interpolated_cd_exactly() {
5590 let velocity = 700.0;
5591 let speed_of_sound = 340.0;
5592 let mach = velocity / speed_of_sound;
5593 let expected_unscaled = deck().interpolate(mach);
5594
5595 for &scale in &[0.90, 1.0, 1.10, 1.5] {
5596 let solver = TrajectorySolver::new(
5597 deck_inputs(scale),
5598 WindConditions::default(),
5599 AtmosphericConditions::default(),
5600 );
5601 let cd = solver.calculate_drag_coefficient(velocity, speed_of_sound);
5602 assert!(
5603 (cd - expected_unscaled * scale).abs() < 1e-12,
5604 "scale={scale}: cd={cd} expected={}",
5605 expected_unscaled * scale
5606 );
5607 }
5608 }
5609
5610 #[test]
5614 fn cd_scale_direction_on_cli_api_solver() {
5615 let solve = |scale: f64| {
5616 TrajectorySolver::new(
5617 deck_inputs(scale),
5618 WindConditions::default(),
5619 AtmosphericConditions::default(),
5620 )
5621 .solve()
5622 .expect("custom-deck solve should succeed")
5623 };
5624
5625 let baseline = solve(1.0);
5626 let scaled_up = solve(1.10);
5627 let scaled_down = solve(0.90);
5628
5629 assert!(
5630 scaled_up.impact_velocity < baseline.impact_velocity,
5631 "cd_scale=1.10 must increase drag -> lower impact velocity: base={} up={}",
5632 baseline.impact_velocity,
5633 scaled_up.impact_velocity
5634 );
5635 assert!(
5636 scaled_down.impact_velocity > baseline.impact_velocity,
5637 "cd_scale=0.90 must decrease drag -> higher impact velocity: base={} down={}",
5638 baseline.impact_velocity,
5639 scaled_down.impact_velocity
5640 );
5641 }
5642
5643 #[test]
5645 fn validate_for_solve_rejects_invalid_cd_scale() {
5646 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
5647 let solver = TrajectorySolver::new(
5648 deck_inputs(bad),
5649 WindConditions::default(),
5650 AtmosphericConditions::default(),
5651 );
5652 assert!(
5653 solver.solve().is_err(),
5654 "cd_scale={bad} must be rejected by validate_for_solve"
5655 );
5656 }
5657 }
5658
5659 #[test]
5666 fn validate_for_solve_rejects_invalid_cd_scale_without_a_custom_drag_table() {
5667 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
5668 let inputs = BallisticInputs {
5669 bc_value: 0.5,
5670 bc_type: crate::DragModel::G1,
5671 bullet_mass: 0.0106,
5672 bullet_diameter: 0.00782,
5673 muzzle_velocity: 850.0,
5674 cd_scale: bad,
5675 ..BallisticInputs::default()
5676 };
5677 assert!(inputs.custom_drag_table.is_none(), "precondition: no custom deck");
5678 let solver = TrajectorySolver::new(
5679 inputs,
5680 WindConditions::default(),
5681 AtmosphericConditions::default(),
5682 );
5683 assert!(
5684 solver.solve().is_err(),
5685 "cd_scale={bad} must be rejected by validate_for_solve even without a custom \
5686 drag table"
5687 );
5688 }
5689 }
5690
5691 #[test]
5695 fn cd_scale_is_inert_without_a_custom_drag_table() {
5696 let make = |cd_scale: f64| BallisticInputs {
5697 bc_value: 0.5,
5698 bc_type: crate::DragModel::G1,
5699 bullet_mass: 0.0106,
5700 bullet_diameter: 0.00782,
5701 muzzle_velocity: 850.0,
5702 cd_scale,
5703 ..BallisticInputs::default()
5704 };
5705 let solver_neutral = TrajectorySolver::new(
5706 make(1.0),
5707 WindConditions::default(),
5708 AtmosphericConditions::default(),
5709 );
5710 let solver_far = TrajectorySolver::new(
5711 make(1.5),
5712 WindConditions::default(),
5713 AtmosphericConditions::default(),
5714 );
5715 let cd_neutral = solver_neutral.calculate_drag_coefficient(700.0, 340.0);
5716 let cd_far = solver_far.calculate_drag_coefficient(700.0, 340.0);
5717 assert_eq!(
5718 cd_neutral.to_bits(),
5719 cd_far.to_bits(),
5720 "cd_scale must not affect the G-model/BC drag path"
5721 );
5722 }
5723
5724 #[test]
5727 fn cd_scale_shifts_all_three_solver_paths_in_the_same_direction() {
5728 let cli_solve = |scale: f64| {
5730 TrajectorySolver::new(
5731 deck_inputs(scale),
5732 WindConditions::default(),
5733 AtmosphericConditions::default(),
5734 )
5735 .solve()
5736 .expect("cli_api custom-deck solve should succeed")
5737 };
5738 let cli_baseline = cli_solve(1.0);
5739 let cli_scaled = cli_solve(1.10);
5740 assert!(
5741 cli_scaled.impact_velocity < cli_baseline.impact_velocity,
5742 "cli_api: cd_scale=1.10 must lower impact velocity"
5743 );
5744
5745 let derivatives_accel_x = |scale: f64| {
5747 let inputs = deck_inputs(scale);
5748 crate::derivatives::compute_derivatives(
5749 nalgebra::Vector3::zeros(),
5750 nalgebra::Vector3::new(700.0, 0.0, 0.0),
5751 &inputs,
5752 nalgebra::Vector3::zeros(),
5753 (1.225, 340.0, 0.0, 0.0),
5754 inputs.bc_value,
5755 None,
5756 0.0,
5757 None,
5758 )[3]
5759 };
5760 let deriv_baseline = derivatives_accel_x(1.0);
5761 let deriv_scaled = derivatives_accel_x(1.10);
5762 assert!(
5763 deriv_scaled < deriv_baseline,
5764 "derivatives: cd_scale=1.10 must make x-acceleration more negative (more drag): \
5765 base={deriv_baseline} scaled={deriv_scaled}"
5766 );
5767
5768 let fast_final_speed = |scale: f64| {
5770 let inputs = deck_inputs(scale);
5771 let wind_sock = crate::wind::WindSock::new(vec![]);
5772 let params = crate::fast_trajectory::FastIntegrationParams {
5773 horiz: 500.0,
5774 vert: 0.0,
5775 initial_state: [0.0, 0.0, 0.0, 850.0, 0.0, 0.0],
5776 t_span: (0.0, 5.0),
5777 atmo_params: (0.0, 15.0, 1013.25, 1.0),
5778 atmo_sock: None,
5779 };
5780 let solution = crate::fast_trajectory::fast_integrate(&inputs, &wind_sock, params);
5781 assert!(solution.success, "fast_integrate must succeed for scale={scale}");
5782 let last = solution.t.len() - 1;
5783 let (vx, vy, vz) = (
5784 solution.y[3][last],
5785 solution.y[4][last],
5786 solution.y[5][last],
5787 );
5788 (vx * vx + vy * vy + vz * vz).sqrt()
5789 };
5790 let fast_baseline = fast_final_speed(1.0);
5791 let fast_scaled = fast_final_speed(1.10);
5792 assert!(
5793 fast_scaled < fast_baseline,
5794 "fast_trajectory: cd_scale=1.10 must lower final speed: base={fast_baseline} scaled={fast_scaled}"
5795 );
5796 }
5797}
5798
5799#[cfg(test)]
5800mod humid_local_mach_tests {
5801 use super::*;
5802
5803 fn solver_with_station_humidity(humidity_percent: f64) -> TrajectorySolver {
5804 let inputs = BallisticInputs {
5805 custom_drag_table: Some(crate::drag::DragTable::new(vec![0.5, 1.5], vec![0.1, 1.1])),
5806 ..BallisticInputs::default()
5807 };
5808 TrajectorySolver::new(
5809 inputs,
5810 WindConditions::default(),
5811 AtmosphericConditions {
5812 temperature: 30.0,
5813 pressure: 1013.25,
5814 humidity: humidity_percent,
5815 altitude: 0.0,
5816 },
5817 )
5818 }
5819
5820 fn acceleration(solver: &TrajectorySolver, base_ratio: f64) -> Vector3<f64> {
5821 solver.calculate_acceleration(
5822 &Vector3::zeros(),
5823 &Vector3::new(350.0, 0.0, 0.0),
5824 &Vector3::zeros(),
5825 (30.0, 1013.25, base_ratio),
5826 )
5827 }
5828
5829 #[test]
5830 fn local_mach_uses_station_humidity_when_density_is_held_constant() {
5831 let dry = acceleration(&solver_with_station_humidity(0.0), 1.0);
5832 let humid = acceleration(&solver_with_station_humidity(100.0), 1.0);
5833
5834 assert!(
5835 humid.x > dry.x,
5836 "humid sound speed should lower Mach and drag on the rising test curve: dry ax={} humid ax={}",
5837 dry.x,
5838 humid.x
5839 );
5840 }
5841
5842 #[test]
5843 fn active_atmosphere_zone_uses_zone_humidity_instead_of_station_humidity() {
5844 let zone_humidity = 80.0;
5845 let zone_ratio =
5846 crate::atmosphere::calculate_air_density_cimp(30.0, 1013.25, zone_humidity) / 1.225;
5847 let station_solver = solver_with_station_humidity(zone_humidity);
5848 let mut zoned_solver = solver_with_station_humidity(0.0);
5849 zoned_solver.set_atmo_segments(vec![(30.0, 1013.25, zone_humidity, 1_000.0)]);
5850
5851 let station = acceleration(&station_solver, zone_ratio);
5852 let zoned = acceleration(&zoned_solver, zone_ratio);
5853
5854 assert!(
5855 (zoned - station).norm() < 1e-12,
5856 "active zone T/P/RH should override the station atmosphere: station={station:?} zoned={zoned:?}"
5857 );
5858 }
5859}
5860
5861#[cfg(test)]
5862mod inclined_atmosphere_frame_tests {
5863 use super::*;
5864
5865 fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
5866 let (sin_angle, cos_angle) = angle.sin_cos();
5867 Vector3::new(
5868 level.x * cos_angle + level.y * sin_angle,
5869 -level.x * sin_angle + level.y * cos_angle,
5870 level.z,
5871 )
5872 }
5873
5874 #[test]
5875 fn inclined_positions_at_same_world_altitude_have_same_solver_acceleration() {
5876 let angle = std::f64::consts::FRAC_PI_6;
5877 let inputs = BallisticInputs {
5878 shooting_angle: angle,
5879 ..BallisticInputs::default()
5880 };
5881 let atmosphere = AtmosphericConditions {
5882 altitude: 100.0,
5883 ..AtmosphericConditions::default()
5884 };
5885 let solver = TrajectorySolver::new(inputs, WindConditions::default(), atmosphere);
5886 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5887 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
5888 let velocity = Vector3::new(600.0, 0.0, 0.0);
5889 let along_slant = Vector3::new(1_000.0, 0.0, 0.0);
5890 let across_slant = Vector3::new(0.0, 500.0 / angle.cos(), 0.0);
5891
5892 let a = solver.calculate_acceleration(
5893 &along_slant,
5894 &velocity,
5895 &Vector3::zeros(),
5896 resolved_atmo,
5897 );
5898 let b = solver.calculate_acceleration(
5899 &across_slant,
5900 &velocity,
5901 &Vector3::zeros(),
5902 resolved_atmo,
5903 );
5904
5905 assert!(
5906 (a - b).norm() < 1e-10,
5907 "solver acceleration differs at equal world altitude: {a:?} vs {b:?}"
5908 );
5909 }
5910
5911 #[test]
5912 fn inclined_headwind_is_rotated_into_solver_frame() {
5913 let angle = std::f64::consts::FRAC_PI_6;
5914 let inputs = BallisticInputs {
5915 shooting_angle: angle,
5916 ..BallisticInputs::default()
5917 };
5918 let solver = TrajectorySolver::new(
5919 inputs,
5920 WindConditions::default(),
5921 AtmosphericConditions::default(),
5922 );
5923 let level_headwind = Vector3::new(-100.0, 0.0, 0.0);
5924 let velocity = expected_shot_frame_vector(level_headwind, angle);
5925 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5926 let actual = solver.calculate_acceleration(
5927 &Vector3::zeros(),
5928 &velocity,
5929 &level_headwind,
5930 (temp_c, pressure_hpa, density / 1.225),
5931 );
5932
5933 assert!(
5934 (actual - solver.gravity_acceleration()).norm() < 1e-12,
5935 "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
5936 );
5937 }
5938
5939 #[test]
5940 fn inclined_coriolis_is_rotated_into_solver_frame() {
5941 let angle = std::f64::consts::FRAC_PI_6;
5942 let latitude_deg = 45.0_f64;
5943 let shot_azimuth = 0.4_f64;
5944 let velocity = Vector3::new(600.0, 20.0, 5.0);
5945 let base_inputs = BallisticInputs {
5946 shooting_angle: angle,
5947 latitude: Some(latitude_deg),
5948 shot_azimuth,
5949 ..BallisticInputs::default()
5950 };
5951 let acceleration = |enable_coriolis| {
5952 let mut inputs = base_inputs.clone();
5953 inputs.enable_coriolis = enable_coriolis;
5954 let solver = TrajectorySolver::new(
5955 inputs,
5956 WindConditions::default(),
5957 AtmosphericConditions::default(),
5958 );
5959 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5960 solver.calculate_acceleration(
5961 &Vector3::zeros(),
5962 &velocity,
5963 &Vector3::zeros(),
5964 (temp_c, pressure_hpa, density / 1.225),
5965 )
5966 };
5967
5968 let omega_earth = 7.2921159e-5_f64;
5969 let latitude = latitude_deg.to_radians();
5970 let level_omega = Vector3::new(
5971 omega_earth * latitude.cos() * shot_azimuth.cos(),
5972 omega_earth * latitude.sin(),
5973 -omega_earth * latitude.cos() * shot_azimuth.sin(),
5974 );
5975 let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
5976 let actual = acceleration(true) - acceleration(false);
5977
5978 assert!(
5979 (actual - expected).norm() < 1e-12,
5980 "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
5981 );
5982 }
5983}
5984
5985#[cfg(test)]
5986mod terminal_range_interpolation_tests {
5987 use super::*;
5988
5989 #[test]
5990 fn terminal_finalizer_selects_the_earliest_crossed_boundary() {
5991 let inputs = BallisticInputs {
5992 ground_threshold: 0.0,
5993 ..BallisticInputs::default()
5994 };
5995 let mut solver = TrajectorySolver::new(
5996 inputs,
5997 WindConditions::default(),
5998 AtmosphericConditions::default(),
5999 );
6000 solver.set_max_range(120.0);
6001
6002 let previous_speed = 700.0;
6003 let mut points = vec![TrajectoryPoint {
6004 time: 99.0,
6005 position: Vector3::new(90.0, 1.0, -1.0),
6006 velocity_magnitude: previous_speed,
6007 kinetic_energy: 0.5 * solver.inputs.bullet_mass * previous_speed.powi(2),
6008 drag_coefficient: None,
6009 }];
6010 let mut max_height = 1.0;
6011 let termination = solver
6012 .append_terminal_endpoint(
6013 &mut points,
6014 Vector3::new(130.0, -3.0, 3.0),
6015 Vector3::new(600.0, 0.0, 0.0),
6016 101.0,
6017 &mut max_height,
6018 )
6019 .expect("the final step brackets supported boundaries");
6020
6021 assert_eq!(termination, TrajectoryTermination::GroundThreshold);
6022 assert_eq!(points.len(), 2);
6023 let terminal = points.last().expect("terminal point");
6024 assert_eq!(terminal.time, 99.5);
6025 assert_eq!(terminal.position, Vector3::new(100.0, 0.0, 0.0));
6026 assert_eq!(terminal.velocity_magnitude, 675.0);
6027 assert_eq!(
6028 terminal.kinetic_energy,
6029 0.5 * solver.inputs.bullet_mass * 675.0_f64.powi(2)
6030 );
6031
6032 solver.set_max_range(100.0);
6034 let mut tied_points = vec![points[0].clone()];
6035 assert_eq!(
6036 solver
6037 .append_terminal_endpoint(
6038 &mut tied_points,
6039 Vector3::new(130.0, -3.0, 3.0),
6040 Vector3::new(600.0, 0.0, 0.0),
6041 101.0,
6042 &mut max_height,
6043 )
6044 .expect("tied boundaries remain a valid terminal"),
6045 TrajectoryTermination::GroundThreshold
6046 );
6047 }
6048
6049 #[test]
6050 fn sub_ulp_terminal_crossing_replaces_instead_of_duplicating_range() {
6051 let ground_threshold = f64::from_bits(1.0_f64.to_bits() - 1);
6052 let inputs = BallisticInputs {
6053 ground_threshold,
6054 ..BallisticInputs::default()
6055 };
6056 let mut solver = TrajectorySolver::new(
6057 inputs,
6058 WindConditions::default(),
6059 AtmosphericConditions::default(),
6060 );
6061 solver.set_max_range(1_000.0);
6062
6063 let speed = 700.0;
6064 let mut points = vec![TrajectoryPoint {
6065 time: 0.0,
6066 position: Vector3::new(100.0, 1.0, 0.0),
6067 velocity_magnitude: speed,
6068 kinetic_energy: 0.5 * solver.inputs.bullet_mass * speed.powi(2),
6069 drag_coefficient: None,
6070 }];
6071 let mut max_height = 1.0;
6072 let termination = solver
6073 .append_terminal_endpoint(
6074 &mut points,
6075 Vector3::new(101.0, 0.0, 0.0),
6076 Vector3::new(699.0, 0.0, 0.0),
6077 1.0,
6078 &mut max_height,
6079 )
6080 .expect("sub-ULP ground crossing remains representable as one terminal state");
6081
6082 assert_eq!(termination, TrajectoryTermination::GroundThreshold);
6083 assert_eq!(points.len(), 1);
6084 assert_eq!(points[0].position.x, 100.0);
6085 assert_eq!(points[0].position.y.to_bits(), ground_threshold.to_bits());
6086 assert!(points[0].time > 0.0);
6087 }
6088
6089 #[test]
6090 fn every_solver_appends_an_exact_max_range_endpoint() {
6091 let target_range = 0.1;
6092 let modes = [
6093 ("Euler", false, false),
6094 ("RK4", true, false),
6095 ("RK45", true, true),
6096 ];
6097
6098 for (name, use_rk4, use_adaptive_rk45) in modes {
6099 let inputs = BallisticInputs {
6100 use_rk4,
6101 use_adaptive_rk45,
6102 ground_threshold: f64::NEG_INFINITY,
6103 enable_trajectory_sampling: true,
6104 sample_interval: target_range,
6105 ..BallisticInputs::default()
6106 };
6107 let mut solver = TrajectorySolver::new(
6108 inputs,
6109 WindConditions::default(),
6110 AtmosphericConditions::default(),
6111 );
6112 solver.set_max_range(target_range);
6113
6114 let result = solver.solve().expect("short-range solve should succeed");
6115 let terminal = result.points.last().expect("terminal point is missing");
6116 let muzzle = result.points.first().expect("muzzle point is missing");
6117
6118 assert_eq!(result.termination, TrajectoryTermination::MaxRange);
6119 assert_eq!(
6120 terminal.position.x.to_bits(),
6121 target_range.to_bits(),
6122 "{name} did not terminate exactly at max_range"
6123 );
6124 assert_eq!(result.max_range.to_bits(), target_range.to_bits());
6125 assert!(
6126 result.time_of_flight > 0.0 && result.time_of_flight < solver.time_step,
6127 "{name} terminal time was not interpolated within the crossing step: {}",
6128 result.time_of_flight
6129 );
6130 assert_eq!(result.time_of_flight.to_bits(), terminal.time.to_bits());
6131 assert_eq!(
6132 result.impact_velocity.to_bits(),
6133 terminal.velocity_magnitude.to_bits()
6134 );
6135 assert_eq!(
6136 result.impact_energy.to_bits(),
6137 terminal.kinetic_energy.to_bits()
6138 );
6139 let expected_energy = 0.5 * solver.inputs.bullet_mass * result.impact_velocity.powi(2);
6140 assert!((result.impact_energy - expected_energy).abs() < 1e-12);
6141 assert!(terminal.velocity_magnitude < muzzle.velocity_magnitude);
6142 assert!(terminal.kinetic_energy < muzzle.kinetic_energy);
6143
6144 let terminal_sample = result
6145 .sampled_points
6146 .as_ref()
6147 .and_then(|samples| samples.last())
6148 .expect("terminal trajectory sample is missing");
6149 assert_eq!(
6150 terminal_sample.distance_m.to_bits(),
6151 target_range.to_bits(),
6152 "{name} sampling did not include max_range"
6153 );
6154 assert_eq!(
6155 terminal_sample.time_s.to_bits(),
6156 result.time_of_flight.to_bits()
6157 );
6158 assert_eq!(
6159 terminal_sample.velocity_mps.to_bits(),
6160 result.impact_velocity.to_bits()
6161 );
6162 assert!((terminal_sample.energy_j - result.impact_energy).abs() < 1e-12);
6163 }
6164 }
6165}
6166
6167#[cfg(test)]
6168mod precession_inertia_wiring_tests {
6169 use super::*;
6170
6171 #[test]
6172 fn solver_uses_projectile_specific_moments_of_inertia() {
6173 let mass_kg = 55.0 * crate::constants::GRAINS_TO_KG;
6174 let caliber_m = 0.224 * 0.0254;
6175 let length_m = 0.75 * 0.0254;
6176 let inputs = BallisticInputs {
6177 bullet_mass: mass_kg,
6178 bullet_diameter: caliber_m,
6179 bullet_length: length_m,
6180 muzzle_velocity: 800.0,
6181 twist_rate: 7.0,
6182 enable_precession_nutation: true,
6183 use_rk4: false,
6184 use_adaptive_rk45: false,
6185 ..BallisticInputs::default()
6186 };
6187 let mut solver = TrajectorySolver::new(
6188 inputs,
6189 WindConditions::default(),
6190 AtmosphericConditions::default(),
6191 );
6192 solver.set_max_range(0.1);
6193
6194 let (air_density, speed_of_sound, _, _) = solver.resolved_atmosphere();
6195 let velocity_mps = solver.inputs.muzzle_velocity;
6196 let velocity_fps = velocity_mps * 3.28084;
6197 let twist_rate_ft = solver.inputs.twist_rate / 12.0;
6198 let spin_rate_rad_s = (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI;
6199 let initial_state = AngularState {
6200 pitch_angle: 0.001,
6201 yaw_angle: 0.001,
6202 pitch_rate: 0.0,
6203 yaw_rate: 0.0,
6204 precession_angle: 0.0,
6205 nutation_phase: 0.0,
6206 };
6207 let params = PrecessionNutationParams {
6208 mass_kg,
6209 caliber_m,
6210 length_m,
6211 spin_rate_rad_s,
6212 spin_inertia: crate::spin_decay::calculate_moment_of_inertia(
6213 mass_kg, caliber_m, length_m, "ogive",
6214 ),
6215 transverse_inertia: crate::pitch_damping::calculate_transverse_moment_of_inertia(
6216 mass_kg, caliber_m, length_m, "ogive",
6217 ),
6218 velocity_mps,
6219 air_density_kg_m3: air_density,
6220 mach: velocity_mps / speed_of_sound,
6221 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
6222 nutation_damping_factor: 0.05,
6223 };
6224 let expected = calculate_combined_angular_motion(
6225 ¶ms,
6226 &initial_state,
6227 0.0,
6228 solver.time_step,
6229 0.001,
6230 );
6231 let actual = solver
6232 .solve()
6233 .expect("one-step solve should succeed")
6234 .angular_state
6235 .expect("precession/nutation was enabled");
6236
6237 assert!(
6238 (actual.precession_angle - expected.precession_angle).abs() < 1e-15,
6239 "precession phase used the wrong inertia: actual={}, expected={}",
6240 actual.precession_angle,
6241 expected.precession_angle
6242 );
6243 assert!(
6244 (actual.nutation_phase - expected.nutation_phase).abs() < 1e-15,
6245 "nutation phase used the wrong inertia: actual={}, expected={}",
6246 actual.nutation_phase,
6247 expected.nutation_phase
6248 );
6249 }
6250}
6251
6252#[cfg(test)]
6253mod form_factor_drag_tests {
6254 use super::*;
6255
6256 fn acceleration_with_form_factor_flag(enabled: bool) -> Vector3<f64> {
6257 let inputs = BallisticInputs {
6258 bc_value: 0.462,
6259 bc_type: DragModel::G1,
6260 bullet_model: Some("168gr SMK Match".to_string()),
6261 use_form_factor: enabled,
6262 ..BallisticInputs::default()
6263 };
6264 let solver = TrajectorySolver::new(
6265 inputs,
6266 WindConditions::default(),
6267 AtmosphericConditions::default(),
6268 );
6269 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
6270 solver.calculate_acceleration(
6271 &Vector3::zeros(),
6272 &Vector3::new(600.0, 0.0, 0.0),
6273 &Vector3::zeros(),
6274 (temp_c, pressure_hpa, density / 1.225),
6275 )
6276 }
6277
6278 #[test]
6279 fn measured_bc_drag_does_not_apply_name_based_form_factor_again() {
6280 let baseline = acceleration_with_form_factor_flag(false);
6281 let flagged = acceleration_with_form_factor_flag(true);
6282
6283 assert!(
6284 (flagged - baseline).norm() < 1e-12,
6285 "published BC already encodes form factor: baseline={baseline:?} flagged={flagged:?}"
6286 );
6287 }
6288}
6289
6290#[cfg(test)]
6291mod rk45_adaptivity_tests {
6292 use super::*;
6293
6294 #[test]
6295 fn cli_rk45_error_norm_scales_components_independently() {
6296 let position = Vector3::new(1.0e9, 0.0, 0.0);
6297 let velocity = Vector3::new(800.0, 0.0, 0.0);
6298 let fifth_position = position;
6299 let fifth_velocity = velocity;
6300 let fourth_position = position;
6301 let fourth_velocity = Vector3::new(800.0, 1.0e-3, 0.0);
6302
6303 let error = cli_rk45_error_norm(
6304 &position,
6305 &velocity,
6306 &fifth_position,
6307 &fifth_velocity,
6308 &fourth_position,
6309 &fourth_velocity,
6310 );
6311 let expected = 1.0e-3 / 6.0_f64.sqrt();
6312
6313 assert!(
6314 (error - expected).abs() <= 1e-15,
6315 "large downrange position masked a velocity-component error: {error}"
6316 );
6317 }
6318
6319 fn discontinuous_wind_solver() -> TrajectorySolver {
6320 let inputs = BallisticInputs::default();
6321 let mut solver = TrajectorySolver::new(
6322 inputs,
6323 WindConditions::default(),
6324 AtmosphericConditions::default(),
6325 );
6326 solver.set_wind_segments(vec![
6327 crate::wind::WindSegment::new(0.0, 90.0, 4.0),
6328 crate::wind::WindSegment::new(1_000.0, 90.0, 10_000.0),
6329 ]);
6330 solver
6331 }
6332
6333 #[test]
6334 fn rk45_retries_discontinuous_trial_before_advancing() {
6335 let solver = discontinuous_wind_solver();
6336 let position = Vector3::new(0.0, solver.inputs.muzzle_height, 0.0);
6337 let velocity = Vector3::new(solver.inputs.muzzle_velocity, 0.0, 0.0);
6338 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
6339 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
6340 let dt = 0.01;
6341
6342 let rejected_trial = solver.rk45_step(
6343 &position,
6344 &velocity,
6345 dt,
6346 &Vector3::zeros(),
6347 RK45_TOLERANCE,
6348 resolved_atmo,
6349 );
6350 assert!(
6351 rejected_trial.error > RK45_TOLERANCE,
6352 "discontinuous full step must exceed tolerance, got {}",
6353 rejected_trial.error
6354 );
6355
6356 let accepted = solver.adaptive_rk45_step(
6357 &position,
6358 &velocity,
6359 dt,
6360 &Vector3::zeros(),
6361 resolved_atmo,
6362 );
6363 assert!(accepted.used_dt < dt, "oversized trial was not retried");
6364 assert!(
6365 accepted.error <= RK45_TOLERANCE || accepted.used_dt <= RK45_MIN_DT,
6366 "accepted error {} exceeds tolerance at dt {}",
6367 accepted.error,
6368 accepted.used_dt
6369 );
6370
6371 let accepted_trial = solver.rk45_step(
6372 &position,
6373 &velocity,
6374 accepted.used_dt,
6375 &Vector3::zeros(),
6376 RK45_TOLERANCE,
6377 resolved_atmo,
6378 );
6379 assert_eq!(accepted.position, accepted_trial.position);
6380 assert_eq!(accepted.velocity, accepted_trial.velocity);
6381 assert!((RK45_MIN_DT..=RK45_MAX_DT).contains(&accepted.next_dt));
6382 }
6383}
6384
6385#[cfg(test)]
6386mod ground_termination_tests {
6387 use super::*;
6388 use crate::trajectory_observation::TrajectoryObservationFlag;
6389
6390 #[test]
6391 fn every_solver_reports_one_exact_early_ground_endpoint() {
6392 for (name, use_rk4, use_adaptive_rk45) in [
6393 ("Euler", false, false),
6394 ("RK4", true, false),
6395 ("RK45", true, true),
6396 ] {
6397 let inputs = BallisticInputs {
6398 muzzle_height: 1.0,
6399 muzzle_angle: -0.2,
6400 ground_threshold: 0.0,
6401 use_rk4,
6402 use_adaptive_rk45,
6403 ..BallisticInputs::default()
6404 };
6405 let mut solver = TrajectorySolver::new(
6406 inputs,
6407 WindConditions::default(),
6408 AtmosphericConditions::default(),
6409 );
6410 solver.set_max_range(1_000.0);
6411
6412 let result = solver.solve().expect("early-ground solve should succeed");
6413 let terminal = result.points.last().expect("terminal point is missing");
6414
6415 assert_eq!(result.termination, TrajectoryTermination::GroundThreshold);
6416 assert_eq!(terminal.position.y.to_bits(), 0.0_f64.to_bits());
6417 assert!(
6418 terminal.position.x < 1_000.0,
6419 "{name} incorrectly reached max range"
6420 );
6421 assert_eq!(result.max_range.to_bits(), terminal.position.x.to_bits());
6422 assert_eq!(
6423 result
6424 .points
6425 .iter()
6426 .filter(|point| point.position.y == 0.0)
6427 .count(),
6428 1,
6429 "{name} did not retain exactly one ground endpoint"
6430 );
6431
6432 let observations = result
6433 .sample_observations(1.0, 100)
6434 .expect("checked early-ground sampling should succeed");
6435 assert!(observations[..observations.len() - 1]
6436 .iter()
6437 .all(|observation| observation.distance_m < terminal.position.x));
6438 let terminal_observation = observations.last().expect("terminal observation");
6439 assert_eq!(
6440 terminal_observation.distance_m.to_bits(),
6441 terminal.position.x.to_bits()
6442 );
6443 assert!(terminal_observation
6444 .flags
6445 .contains(&TrajectoryObservationFlag::Terminal));
6446 assert!(terminal_observation
6447 .flags
6448 .contains(&TrajectoryObservationFlag::GroundThreshold));
6449 assert_eq!(
6450 observations
6451 .iter()
6452 .filter(|observation| observation
6453 .flags
6454 .contains(&TrajectoryObservationFlag::Terminal))
6455 .count(),
6456 1,
6457 "{name} repeated the terminal observation"
6458 );
6459 }
6460 }
6461
6462 #[test]
6467 fn rk4_and_rk45_descend_to_ground_threshold() {
6468 for adaptive in [false, true] {
6469 let inputs = BallisticInputs {
6470 muzzle_angle: 0.1, use_rk4: true,
6472 use_adaptive_rk45: adaptive,
6473 ..BallisticInputs::default()
6474 };
6475 assert_eq!(
6476 inputs.ground_threshold, -100.0,
6477 "default ground_threshold is -100 m"
6478 );
6479
6480 let mut solver = TrajectorySolver::new(
6481 inputs,
6482 WindConditions::default(),
6483 AtmosphericConditions::default(),
6484 );
6485 solver.set_max_range(1.0e7);
6487
6488 let result = solver.solve().expect("solve should succeed");
6489 let final_y = result
6490 .points
6491 .last()
6492 .expect("trajectory has points")
6493 .position
6494 .y;
6495 assert!(
6496 final_y < -1.0,
6497 "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
6498 past launch level toward the ground_threshold floor, not stop at y = 0"
6499 );
6500 }
6501 }
6502}
6503
6504#[cfg(test)]
6505mod magnus_stability_tests {
6506 use super::*;
6507
6508 #[test]
6509 fn yaw_of_repose_magnus_force_is_vertical_and_twist_signed() {
6510 let acceleration = |enable_magnus, is_twist_right| {
6511 let inputs = BallisticInputs {
6512 muzzle_velocity: 822.96,
6513 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
6514 bullet_diameter: 0.308 * 0.0254,
6515 bullet_length: 1.215 * 0.0254,
6516 twist_rate: 10.0,
6517 is_twist_right,
6518 enable_magnus,
6519 ..BallisticInputs::default()
6520 };
6521 let solver = TrajectorySolver::new(
6522 inputs,
6523 WindConditions::default(),
6524 AtmosphericConditions::default(),
6525 );
6526 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
6527 solver.calculate_acceleration(
6528 &Vector3::zeros(),
6529 &Vector3::new(822.96, 0.0, 0.0),
6530 &Vector3::zeros(),
6531 (temp_c, pressure_hpa, density / 1.225),
6532 )
6533 };
6534
6535 let baseline = acceleration(false, true);
6536 let right_twist = acceleration(true, true) - baseline;
6537 let left_twist = acceleration(true, false) - baseline;
6538
6539 assert!(
6540 right_twist.y < 0.0,
6541 "right-hand Magnus must point down, got {right_twist:?}"
6542 );
6543 assert!(
6544 left_twist.y > 0.0,
6545 "left-hand Magnus must point up, got {left_twist:?}"
6546 );
6547 assert!((right_twist.y + left_twist.y).abs() < 1e-12);
6548 assert!(right_twist.x.abs() < 1e-12 && right_twist.z.abs() < 1e-12);
6549 assert!(left_twist.x.abs() < 1e-12 && left_twist.z.abs() < 1e-12);
6550 }
6551
6552 #[test]
6553 fn magnus_uses_velocity_corrected_muzzle_stability_gate() {
6554 let muzzle_velocity = 1_400.0 / 3.28084;
6555 let inputs = BallisticInputs {
6556 muzzle_velocity,
6557 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
6558 bullet_diameter: 0.308 * 0.0254,
6559 bullet_length: 1.215 * 0.0254,
6560 twist_rate: 15.0,
6561 enable_magnus: true,
6562 ..BallisticInputs::default()
6563 };
6564 let solver = TrajectorySolver::new(
6565 inputs.clone(),
6566 WindConditions::default(),
6567 AtmosphericConditions::default(),
6568 );
6569
6570 let bare_sg = crate::spin_drift::miller_stability(0.308, 168.0, 15.0, 1.215);
6571 let canonical_sg = solver.effective_spin_drift_sg();
6572 assert!(bare_sg > 1.0, "test requires bare Sg above the Magnus gate");
6573 assert!(
6574 canonical_sg < 1.0,
6575 "velocity-corrected Sg must be below the gate, got {canonical_sg}"
6576 );
6577
6578 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
6579 let acceleration = solver.calculate_acceleration(
6580 &Vector3::zeros(),
6581 &Vector3::new(muzzle_velocity, 0.0, 0.0),
6582 &Vector3::zeros(),
6583 (temp_c, pressure_hpa, density / 1.225),
6584 );
6585 let mut baseline_inputs = inputs;
6586 baseline_inputs.enable_magnus = false;
6587 let baseline_solver = TrajectorySolver::new(
6588 baseline_inputs,
6589 WindConditions::default(),
6590 AtmosphericConditions::default(),
6591 );
6592 let baseline = baseline_solver.calculate_acceleration(
6593 &Vector3::zeros(),
6594 &Vector3::new(muzzle_velocity, 0.0, 0.0),
6595 &Vector3::zeros(),
6596 (temp_c, pressure_hpa, density / 1.225),
6597 );
6598
6599 assert_eq!(
6600 acceleration, baseline,
6601 "canonical Sg below 1 must suppress every Magnus acceleration component"
6602 );
6603 }
6604
6605 #[test]
6606 fn magnus_force_grows_as_fixed_spin_projectile_slows() {
6607 let inputs = BallisticInputs {
6608 muzzle_velocity: 800.0,
6609 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
6610 bullet_diameter: 0.308 * 0.0254,
6611 bullet_length: 1.215 * 0.0254,
6612 twist_rate: 12.0,
6613 enable_magnus: true,
6614 ..BallisticInputs::default()
6615 };
6616
6617 let magnus_acceleration = |speed_mps| {
6618 let evaluate = |enable_magnus| {
6619 let mut run_inputs = inputs.clone();
6620 run_inputs.enable_magnus = enable_magnus;
6621 let solver = TrajectorySolver::new(
6622 run_inputs,
6623 WindConditions::default(),
6624 AtmosphericConditions::default(),
6625 );
6626 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
6627 solver
6628 .calculate_acceleration(
6629 &Vector3::zeros(),
6630 &Vector3::new(speed_mps, 0.0, 0.0),
6631 &Vector3::zeros(),
6632 (temp_c, pressure_hpa, density / 1.225),
6633 )
6634 .y
6635 };
6636 (evaluate(true) - evaluate(false)).abs()
6637 };
6638
6639 let fast = magnus_acceleration(200.0);
6640 let slow = magnus_acceleration(100.0);
6641 let ratio = slow / fast;
6642 let expected_ratio = 2.0_f64.powf(5.0 / 3.0);
6643
6644 assert!(fast > 0.0 && slow > 0.0, "fast={fast}, slow={slow}");
6645 assert!(
6646 (ratio - expected_ratio).abs() < 1e-3,
6647 "fixed-spin Magnus acceleration must grow downrange; slow/fast={ratio}, \
6648 expected={expected_ratio}"
6649 );
6650 }
6651}
6652
6653#[cfg(test)]
6654mod coriolis_direction_tests {
6655 use super::*;
6656 use std::f64::consts::FRAC_PI_2;
6657
6658 #[test]
6659 fn supersonic_crossing_flags_a_positive_range_sample() {
6660 use crate::trajectory_sampling::TrajectoryFlag;
6664
6665 for (solver_name, use_rk4, use_adaptive_rk45) in [
6666 ("Euler", false, false),
6667 ("RK4", true, false),
6668 ("RK45", true, true),
6669 ] {
6670 let inputs = BallisticInputs {
6671 muzzle_velocity: 850.0,
6672 bc_value: 0.2,
6673 bc_type: DragModel::G7,
6674 muzzle_angle: 0.03,
6675 enable_trajectory_sampling: true,
6676 sample_interval: 50.0,
6677 use_rk4,
6678 use_adaptive_rk45,
6679 ..BallisticInputs::default()
6680 };
6681 let mut solver = TrajectorySolver::new(
6682 inputs,
6683 WindConditions::default(),
6684 AtmosphericConditions::default(),
6685 );
6686 solver.set_max_range(2000.0);
6687 let samples = solver
6688 .solve()
6689 .expect("supersonic solve should succeed")
6690 .sampled_points
6691 .expect("sampling was enabled");
6692 let flagged_distances: Vec<_> = samples
6693 .iter()
6694 .filter(|sample| sample.flags.contains(&TrajectoryFlag::MachTransition))
6695 .map(|sample| sample.distance_m)
6696 .collect();
6697
6698 assert!(
6699 !flagged_distances.is_empty()
6700 && flagged_distances.iter().all(|distance| *distance > 0.0),
6701 "{solver_name} must flag genuine crossings only at positive range: {flagged_distances:?}"
6702 );
6703 }
6704 }
6705
6706 #[test]
6707 fn subsonic_launch_does_not_flag_a_muzzle_transition() {
6708 use crate::trajectory_sampling::TrajectoryFlag;
6709
6710 for (solver_name, use_rk4, use_adaptive_rk45) in [
6711 ("Euler", false, false),
6712 ("RK4", true, false),
6713 ("RK45", true, true),
6714 ] {
6715 let inputs = BallisticInputs {
6716 muzzle_velocity: 250.0,
6717 muzzle_angle: 0.02,
6718 enable_trajectory_sampling: true,
6719 sample_interval: 25.0,
6720 use_rk4,
6721 use_adaptive_rk45,
6722 ..BallisticInputs::default()
6723 };
6724 let mut solver = TrajectorySolver::new(
6725 inputs,
6726 WindConditions::default(),
6727 AtmosphericConditions::default(),
6728 );
6729 solver.set_max_range(300.0);
6730 let samples = solver
6731 .solve()
6732 .expect("subsonic solve should succeed")
6733 .sampled_points
6734 .expect("sampling was enabled");
6735
6736 assert!(
6737 samples
6738 .iter()
6739 .all(|sample| !sample.flags.contains(&TrajectoryFlag::MachTransition)),
6740 "{solver_name} marked a Mach transition for a launch already below Mach 1"
6741 );
6742 }
6743 }
6744
6745 #[test]
6746 fn mach_transition_tracker_requires_a_downward_crossing() {
6747 fn record(mach_values: &[f64]) -> Vec<f64> {
6748 let mut tracker = MachTransitionTracker::default();
6749 let mut distances = Vec::new();
6750 for (index, mach) in mach_values.iter().copied().enumerate() {
6751 tracker.record_downward_crossings(mach, index as f64 * 10.0, &mut distances);
6752 }
6753 distances
6754 }
6755
6756 assert!(record(&[0.9, 0.8, 0.7]).is_empty());
6757 assert_eq!(record(&[1.1, 1.05, 0.99]), vec![20.0]);
6758 assert_eq!(record(&[1.2, 1.19, 1.0, 0.99]), vec![10.0, 30.0]);
6759 assert_eq!(record(&[0.9, 1.3, 1.1, 0.9, 1.3, 0.8]), vec![20.0, 30.0]);
6760 assert!(record(&[1.3, f64::NAN, 1.1]).is_empty());
6761 }
6762
6763 #[test]
6764 fn mach_transition_tracker_labels_0_9_without_touching_the_flat_vec() {
6765 fn record(mach_values: &[f64]) -> (Vec<f64>, MachTransitionTracker) {
6771 let mut tracker = MachTransitionTracker::default();
6772 let mut distances = Vec::new();
6773 for (index, mach) in mach_values.iter().copied().enumerate() {
6774 tracker.record_downward_crossings(mach, index as f64 * 10.0, &mut distances);
6775 }
6776 (distances, tracker)
6777 }
6778
6779 let (distances, tracker) = record(&[0.9, 0.8, 0.7]);
6782 assert!(distances.is_empty()); assert_eq!(tracker.mach_1_2_distance_m, None);
6784 assert_eq!(tracker.mach_1_0_distance_m, None);
6785 assert_eq!(tracker.mach_0_9_distance_m, Some(10.0));
6786
6787 let (distances, tracker) = record(&[1.1, 1.05, 0.99]);
6789 assert_eq!(distances, vec![20.0]);
6790 assert_eq!(tracker.mach_1_2_distance_m, None);
6791 assert_eq!(tracker.mach_1_0_distance_m, Some(20.0));
6792 assert_eq!(tracker.mach_0_9_distance_m, None);
6793
6794 let (distances, tracker) = record(&[1.2, 1.19, 1.0, 0.99]);
6796 assert_eq!(distances, vec![10.0, 30.0]); assert_eq!(tracker.mach_1_2_distance_m, Some(10.0));
6798 assert_eq!(tracker.mach_1_0_distance_m, Some(30.0));
6799 assert_eq!(tracker.mach_0_9_distance_m, None);
6800
6801 let (distances, tracker) = record(&[0.9, 1.3, 1.1, 0.9, 1.3, 0.8]);
6804 assert_eq!(distances, vec![20.0, 30.0]); assert_eq!(tracker.mach_1_2_distance_m, Some(20.0));
6806 assert_eq!(tracker.mach_1_0_distance_m, Some(30.0));
6807 assert_eq!(tracker.mach_0_9_distance_m, Some(50.0));
6808 assert!(
6809 tracker.mach_1_2_distance_m < tracker.mach_1_0_distance_m
6810 && tracker.mach_1_0_distance_m < tracker.mach_0_9_distance_m,
6811 "labeled crossings must be strictly increasing downrange"
6812 );
6813
6814 let (distances, tracker) = record(&[1.3, f64::NAN, 1.1]);
6816 assert!(distances.is_empty());
6817 assert_eq!(tracker.mach_1_2_distance_m, None);
6818 assert_eq!(tracker.mach_1_0_distance_m, None);
6819 assert_eq!(tracker.mach_0_9_distance_m, None);
6820 }
6821
6822 #[test]
6823 fn humidity_percent_converts_and_clamps() {
6824 let mut i = BallisticInputs {
6826 humidity: 0.5,
6827 ..BallisticInputs::default()
6828 };
6829 assert!((i.humidity_percent() - 50.0).abs() < 1e-9, "0.5 -> 50%");
6830 i.humidity = 0.0;
6831 assert_eq!(i.humidity_percent(), 0.0);
6832 i.humidity = 1.0;
6833 assert_eq!(i.humidity_percent(), 100.0);
6834 i.humidity = 1.5; assert_eq!(i.humidity_percent(), 100.0);
6836 }
6837
6838 fn vertical_at(shot_azimuth: f64, range_m: f64) -> f64 {
6841 let inputs = BallisticInputs {
6842 muzzle_velocity: 800.0,
6843 bc_value: 0.5,
6844 bc_type: DragModel::G7,
6845 muzzle_angle: 0.02, enable_coriolis: true,
6847 latitude: Some(45.0),
6848 shot_azimuth,
6849 ground_threshold: f64::NEG_INFINITY, ..BallisticInputs::default()
6851 };
6852 let mut solver = TrajectorySolver::new(
6853 inputs,
6854 WindConditions::default(),
6855 AtmosphericConditions::default(),
6856 );
6857 solver.set_max_range(range_m + 50.0);
6858 let r = solver.solve().expect("solve");
6859 let pts = &r.points;
6860 for i in 1..pts.len() {
6861 if pts[i].position.x >= range_m {
6862 let p1 = &pts[i - 1];
6863 let p2 = &pts[i];
6864 let t = (range_m - p1.position.x) / (p2.position.x - p1.position.x);
6865 return p1.position.y + t * (p2.position.y - p1.position.y);
6866 }
6867 }
6868 panic!("range {range_m} not reached");
6869 }
6870
6871 #[test]
6876 fn eotvos_east_higher_than_west() {
6877 let range = 600.0;
6878 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!(
6882 east > west,
6883 "east ({east:.5}) must be higher than west ({west:.5}) at {range} m (Eötvös)"
6884 );
6885 assert!(
6886 east > north && north > west,
6887 "north ({north:.5}) must lie between east ({east:.5}) and west ({west:.5})"
6888 );
6889 assert!(
6890 (east - west) > 1e-3,
6891 "E-W vertical separation ({:.6} m) should be physically meaningful, not FP noise",
6892 east - west
6893 );
6894 }
6895
6896 #[test]
6904 fn labeled_mach_crossings_match_pinned_pre_change_flat_vec_across_solvers() {
6905 let cases = [
6907 ("Euler", false, false, 670.9878683238721_f64, 805.5274119916264_f64),
6908 ("RK4", true, false, 671.7257336844475_f64, 805.933409072171_f64),
6909 ("RK45", true, true, 672.4905711917901_f64, 806.5709746782849_f64),
6910 ];
6911
6912 for (solver_name, use_rk4, use_adaptive_rk45, expected_1_2, expected_1_0) in cases {
6913 let inputs = BallisticInputs {
6914 muzzle_velocity: 850.0,
6915 bc_value: 0.2,
6916 bc_type: DragModel::G7,
6917 muzzle_angle: 0.03,
6918 use_rk4,
6919 use_adaptive_rk45,
6920 ..BallisticInputs::default()
6921 };
6922 let mut solver = TrajectorySolver::new(
6923 inputs,
6924 WindConditions::default(),
6925 AtmosphericConditions::default(),
6926 );
6927 solver.set_max_range(2000.0);
6928 let result = solver.solve().expect("solve should succeed");
6929
6930 assert_eq!(
6931 result.mach_1_2_distance_m,
6932 Some(expected_1_2),
6933 "{solver_name}: mach_1_2_distance_m must match the pinned pre-change flat-Vec value"
6934 );
6935 assert_eq!(
6936 result.mach_1_0_distance_m,
6937 Some(expected_1_0),
6938 "{solver_name}: mach_1_0_distance_m must match the pinned pre-change flat-Vec value"
6939 );
6940
6941 let mach_1_2 = result.mach_1_2_distance_m.expect("crosses 1.2");
6942 let mach_1_0 = result.mach_1_0_distance_m.expect("crosses 1.0");
6943 let mach_0_9 = result
6944 .mach_0_9_distance_m
6945 .expect("this trajectory also goes past 0.9 within 2000 m");
6946 assert!(
6947 mach_1_2 < mach_1_0 && mach_1_0 < mach_0_9,
6948 "{solver_name}: labeled crossings must be strictly increasing downrange \
6949 (1.2={mach_1_2}, 1.0={mach_1_0}, 0.9={mach_0_9})"
6950 );
6951 }
6952 }
6953
6954 #[test]
6957 fn labeled_mach_crossings_are_none_for_a_fully_supersonic_trajectory() {
6958 for (solver_name, use_rk4, use_adaptive_rk45) in [
6959 ("Euler", false, false),
6960 ("RK4", true, false),
6961 ("RK45", true, true),
6962 ] {
6963 let inputs = BallisticInputs {
6964 muzzle_velocity: 850.0,
6965 bc_value: 0.2,
6966 bc_type: DragModel::G7,
6967 muzzle_angle: 0.03,
6968 use_rk4,
6969 use_adaptive_rk45,
6970 ..BallisticInputs::default()
6971 };
6972 let mut solver = TrajectorySolver::new(
6973 inputs,
6974 WindConditions::default(),
6975 AtmosphericConditions::default(),
6976 );
6977 solver.set_max_range(200.0);
6979 let result = solver.solve().expect("solve should succeed");
6980
6981 assert_eq!(
6982 result.mach_1_2_distance_m, None,
6983 "{solver_name}: must not report a 1.2 crossing that never happens"
6984 );
6985 assert_eq!(
6986 result.mach_1_0_distance_m, None,
6987 "{solver_name}: must not report a 1.0 crossing that never happens"
6988 );
6989 assert_eq!(
6990 result.mach_0_9_distance_m, None,
6991 "{solver_name}: must not report a 0.9 crossing that never happens"
6992 );
6993 }
6994 }
6995}
6996
6997#[cfg(test)]
6998mod cant_tests {
6999 use super::*;
7000
7001 fn base_inputs() -> BallisticInputs {
7002 BallisticInputs {
7003 muzzle_velocity: 800.0,
7004 bc_value: 0.5,
7005 bc_type: DragModel::G7,
7006 bullet_mass: 0.0109,
7007 bullet_diameter: 0.00782,
7008 bullet_length: 0.0309,
7009 sight_height: 0.05,
7010 twist_rate: 10.0,
7011 use_rk4: true,
7012 ..BallisticInputs::default()
7013 }
7014 }
7015
7016 fn solve_with(inputs: BallisticInputs, max_range: f64) -> TrajectoryResult {
7017 let mut s = TrajectorySolver::new(
7018 inputs,
7019 WindConditions::default(),
7020 AtmosphericConditions::default(),
7021 );
7022 s.set_max_range(max_range);
7023 s.solve().expect("solve")
7024 }
7025
7026 fn yz_at(result: &TrajectoryResult, x: f64) -> (f64, f64) {
7028 let pts = &result.points;
7029 for i in 1..pts.len() {
7030 if pts[i].position.x >= x {
7031 let (p1, p2) = (&pts[i - 1], &pts[i]);
7032 let dx = p2.position.x - p1.position.x;
7033 let t = if dx.abs() < 1e-12 { 0.0 } else { (x - p1.position.x) / dx };
7034 return (
7035 p1.position.y + t * (p2.position.y - p1.position.y),
7036 p1.position.z + t * (p2.position.z - p1.position.z),
7037 );
7038 }
7039 }
7040 panic!("trajectory never reached {x} m");
7041 }
7042
7043 #[test]
7044 fn cant_sign_clockwise_up_offset_goes_right_and_low() {
7045 let mut level = base_inputs();
7047 level.muzzle_angle = 0.003; let mut canted = level.clone();
7049 canted.cant_angle = 10f64.to_radians();
7050
7051 let (y0, z0) = yz_at(&solve_with(level, 400.0), 300.0);
7052 let (y1, z1) = yz_at(&solve_with(canted, 400.0), 300.0);
7053 assert!(z1 > z0 + 0.01, "clockwise cant must move POI right: z0={z0} z1={z1}");
7054 assert!(y1 < y0 - 0.001, "clockwise cant must move POI low: y0={y0} y1={y1}");
7055 }
7056
7057 #[test]
7058 fn pure_cant_shows_bore_offset_near_range() {
7059 let mut i = base_inputs();
7062 i.muzzle_angle = 0.0;
7063 i.cant_angle = 10f64.to_radians();
7064 let sh = i.sight_height;
7065 let r = solve_with(i, 60.0);
7066 let first = &r.points[1]; let expected = -sh * 10f64.to_radians().sin();
7068 assert!(
7069 (first.position.z - expected).abs() < 0.005,
7070 "near-muzzle lateral {} should be ~bore offset {expected}",
7071 first.position.z
7072 );
7073 }
7074
7075 #[test]
7076 fn zero_angle_is_independent_of_cant() {
7077 let a = base_inputs();
7078 let mut b = base_inputs();
7079 b.cant_angle = 15f64.to_radians();
7080 let za = calculate_zero_angle(a.clone(), 100.0, 0.0).expect("zero a");
7081 let zb = calculate_zero_angle(b.clone(), 100.0, 0.0).expect("zero b");
7082 assert_eq!(za.to_bits(), zb.to_bits(), "zeroing must ignore cant: {za} vs {zb}");
7083 let _ = (a.cant_angle, b.cant_angle);
7085 }
7086
7087 #[test]
7088 fn nonfinite_cant_is_rejected() {
7089 let mut i = base_inputs();
7090 i.cant_angle = f64::NAN;
7091 let s = TrajectorySolver::new(i, WindConditions::default(), AtmosphericConditions::default());
7092 assert!(s.solve().is_err());
7093 }
7094
7095 #[test]
7096 fn incline_and_cant_compose_without_breaking() {
7097 let mut flat = base_inputs();
7099 flat.muzzle_angle = 0.003;
7100 flat.shooting_angle = 15f64.to_radians();
7101 let mut canted = flat.clone();
7102 canted.cant_angle = 10f64.to_radians();
7103 let (_, z_flat) = yz_at(&solve_with(flat, 400.0), 300.0);
7104 let (_, z_cant) = yz_at(&solve_with(canted, 400.0), 300.0);
7105 assert!(z_cant > z_flat, "cant must still deflect right on an incline");
7106 }
7107}
7108
7109#[cfg(test)]
7110mod vertical_wind_tests {
7111 use super::*;
7112
7113 fn base_inputs() -> BallisticInputs {
7114 BallisticInputs {
7115 muzzle_velocity: 800.0,
7116 bc_value: 0.5,
7117 bc_type: DragModel::G7,
7118 bullet_mass: 0.0109,
7119 bullet_diameter: 0.00782,
7120 bullet_length: 0.0309,
7121 sight_height: 0.05,
7122 twist_rate: 10.0,
7123 use_rk4: true,
7124 ..BallisticInputs::default()
7125 }
7126 }
7127
7128 fn y_at(result: &TrajectoryResult, x: f64) -> f64 {
7130 let pts = &result.points;
7131 for i in 1..pts.len() {
7132 if pts[i].position.x >= x {
7133 let (p1, p2) = (&pts[i - 1], &pts[i]);
7134 let dx = p2.position.x - p1.position.x;
7135 let t = if dx.abs() < 1e-12 { 0.0 } else { (x - p1.position.x) / dx };
7136 return p1.position.y + t * (p2.position.y - p1.position.y);
7137 }
7138 }
7139 panic!("trajectory never reached {x} m");
7140 }
7141
7142 fn solve_with(inputs: BallisticInputs, wind: WindConditions, max_range: f64) -> TrajectoryResult {
7143 let mut s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
7144 s.set_max_range(max_range);
7145 s.solve().expect("solve")
7146 }
7147
7148 #[test]
7149 fn updraft_raises_poi_downrange() {
7150 let calm_inputs = base_inputs();
7153 let calm_wind = WindConditions::default();
7154 let updraft = WindConditions {
7155 vertical_speed: 5.0,
7156 ..Default::default()
7157 };
7158
7159 let calm = solve_with(calm_inputs.clone(), calm_wind, 500.0);
7160 let updraft_result = solve_with(calm_inputs, updraft, 500.0);
7161
7162 let y_calm = y_at(&calm, 400.0);
7163 let y_updraft = y_at(&updraft_result, 400.0);
7164 assert!(
7165 y_updraft > y_calm,
7166 "5 m/s updraft must raise POI at 400m: calm={y_calm}, updraft={y_updraft}"
7167 );
7168 }
7169
7170 #[test]
7171 fn zero_vertical_is_default_and_finite_required() {
7172 assert_eq!(WindConditions::default().vertical_speed, 0.0);
7173
7174 let inputs = base_inputs();
7175 let wind = WindConditions {
7176 vertical_speed: f64::NAN,
7177 ..Default::default()
7178 };
7179 let s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
7180 assert!(
7181 s.solve().is_err(),
7182 "NaN wind.vertical_speed must be rejected by validate_for_solve"
7183 );
7184 }
7185}
7186
7187#[cfg(test)]
7189mod bc_reference_standard_tests {
7190 use super::*;
7191
7192 fn base_inputs() -> BallisticInputs {
7193 BallisticInputs {
7194 muzzle_velocity: 800.0,
7195 bc_value: 0.5,
7196 bc_type: DragModel::G7,
7197 bullet_mass: 0.0109,
7198 bullet_diameter: 0.00782,
7199 bullet_length: 0.0309,
7200 sight_height: 0.05,
7201 twist_rate: 10.0,
7202 use_rk4: true,
7203 ..BallisticInputs::default()
7204 }
7205 }
7206
7207 fn y_and_speed_at(result: &TrajectoryResult, x: f64) -> (f64, f64) {
7209 let pts = &result.points;
7210 for i in 1..pts.len() {
7211 if pts[i].position.x >= x {
7212 let (p1, p2) = (&pts[i - 1], &pts[i]);
7213 let dx = p2.position.x - p1.position.x;
7214 let t = if dx.abs() < 1e-12 {
7215 0.0
7216 } else {
7217 (x - p1.position.x) / dx
7218 };
7219 return (
7220 p1.position.y + t * (p2.position.y - p1.position.y),
7221 p1.velocity_magnitude + t * (p2.velocity_magnitude - p1.velocity_magnitude),
7222 );
7223 }
7224 }
7225 panic!("trajectory never reached {x} m");
7226 }
7227
7228 #[test]
7231 fn asm_to_icao_ratio_matches_documented_value() {
7232 assert!(
7233 (crate::constants::ASM_TO_ICAO_BC - 0.98237).abs() < 1e-5,
7234 "ASM_TO_ICAO_BC = {} must equal 0.98237 to 5 decimal places",
7235 crate::constants::ASM_TO_ICAO_BC
7236 );
7237 assert_eq!(
7242 crate::constants::ASM_TO_ICAO_BC,
7243 crate::constants::ASM_DENSITY_LB_FT3 / crate::constants::ICAO_DENSITY_LB_FT3
7244 );
7245 }
7246
7247 #[test]
7250 fn default_bc_reference_standard_is_icao() {
7251 assert_eq!(
7252 BallisticInputs::default().bc_reference_standard,
7253 BcReferenceStandard::Icao
7254 );
7255 }
7256
7257 #[test]
7262 fn icao_reference_leaves_bc_value_bit_identical() {
7263 let raw_bc: f64 = 0.4372911; let inputs = BallisticInputs {
7265 bc_value: raw_bc,
7266 bc_reference_standard: BcReferenceStandard::Icao,
7267 ..base_inputs()
7268 };
7269 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
7270 assert_eq!(solver.inputs.bc_value.to_bits(), raw_bc.to_bits());
7271 }
7272
7273 #[test]
7274 fn default_inputs_solve_is_unaffected_by_the_new_field_existing() {
7275 let a = TrajectorySolver::new(base_inputs(), WindConditions::default(), AtmosphericConditions::default())
7279 .solve()
7280 .expect("solve a");
7281 let b = TrajectorySolver::new(
7282 BallisticInputs { ..base_inputs() },
7283 WindConditions::default(),
7284 AtmosphericConditions::default(),
7285 )
7286 .solve()
7287 .expect("solve b");
7288 assert_eq!(a.impact_velocity.to_bits(), b.impact_velocity.to_bits());
7289 assert_eq!(a.max_range.to_bits(), b.max_range.to_bits());
7290 }
7291
7292 #[test]
7295 fn army_standard_metro_scales_bc_value_by_exactly_the_derived_ratio() {
7296 let raw_bc = 0.5;
7297 let inputs = BallisticInputs {
7298 bc_value: raw_bc,
7299 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
7300 ..base_inputs()
7301 };
7302 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
7303 assert_eq!(
7304 solver.inputs.bc_value,
7305 raw_bc * crate::constants::ASM_TO_ICAO_BC
7306 );
7307 }
7308
7309 #[test]
7310 fn army_standard_metro_scales_mach_keyed_bc_segments() {
7311 let inputs = BallisticInputs {
7312 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
7313 bc_segments: Some(vec![(0.5, 0.40), (1.5, 0.30)]),
7314 ..base_inputs()
7315 };
7316 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
7317 let segments = solver.inputs.bc_segments.as_ref().expect("segments");
7318 assert_eq!(segments[0], (0.5, 0.40 * crate::constants::ASM_TO_ICAO_BC));
7319 assert_eq!(segments[1], (1.5, 0.30 * crate::constants::ASM_TO_ICAO_BC));
7320 }
7321
7322 #[test]
7323 fn army_standard_metro_scales_velocity_keyed_bc_segments_data() {
7324 let inputs = BallisticInputs {
7325 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
7326 bc_segments_data: Some(vec![
7327 crate::BCSegmentData {
7328 velocity_min: 0.0,
7329 velocity_max: 500.0,
7330 bc_value: 0.40,
7331 },
7332 crate::BCSegmentData {
7333 velocity_min: 500.0,
7334 velocity_max: 900.0,
7335 bc_value: 0.45,
7336 },
7337 ]),
7338 ..base_inputs()
7339 };
7340 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
7341 let segments = solver.inputs.bc_segments_data.as_ref().expect("segments");
7342 assert_eq!(segments[0].bc_value, 0.40 * crate::constants::ASM_TO_ICAO_BC);
7343 assert_eq!(segments[1].bc_value, 0.45 * crate::constants::ASM_TO_ICAO_BC);
7344 assert_eq!(segments[0].velocity_min, 0.0);
7346 assert_eq!(segments[1].velocity_max, 900.0);
7347 }
7348
7349 #[test]
7354 fn army_standard_metro_moves_impact_in_the_more_drag_direction() {
7355 let solve_at = |standard: BcReferenceStandard| {
7356 let inputs = BallisticInputs {
7357 bc_value: 0.475,
7358 bc_reference_standard: standard,
7359 ..base_inputs()
7360 };
7361 let mut solver =
7362 TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
7363 solver.set_max_range(500.0);
7364 solver.solve().expect("solve")
7365 };
7366
7367 let icao = solve_at(BcReferenceStandard::Icao);
7368 let asm = solve_at(BcReferenceStandard::ArmyStandardMetro);
7369
7370 let (y_icao, v_icao) = y_and_speed_at(&icao, 400.0);
7371 let (y_asm, v_asm) = y_and_speed_at(&asm, 400.0);
7372
7373 assert!(
7374 y_asm < y_icao,
7375 "ArmyStandardMetro must drop MORE (lower y) at 400m than Icao for the same raw \
7376 bc_value: icao_y={y_icao}, asm_y={y_asm}"
7377 );
7378 assert!(
7379 v_asm < v_icao,
7380 "ArmyStandardMetro must retain LESS velocity at 400m than Icao for the same raw \
7381 bc_value: icao_v={v_icao}, asm_v={v_asm}"
7382 );
7383 }
7384
7385 #[test]
7392 fn monte_carlo_inherits_the_normalized_bc_reference() {
7393 let base_inputs_asm = BallisticInputs {
7394 bc_value: 0.475,
7395 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
7396 ..base_inputs()
7397 };
7398 let wind = WindConditions::default();
7399
7400 let mut direct_solver =
7406 TrajectorySolver::new(base_inputs_asm.clone(), wind.clone(), AtmosphericConditions::default());
7407 direct_solver.set_max_range(base_inputs_asm.target_distance.max(1000.0) * 2.0);
7408 let direct = direct_solver.solve().expect("direct solve");
7409
7410 let mc_params = MonteCarloParams {
7411 num_simulations: 1,
7412 velocity_std_dev: 0.0,
7413 angle_std_dev: 0.0,
7414 bc_std_dev: 0.0,
7415 wind_speed_std_dev: 0.0,
7416 target_distance: None,
7417 base_wind_speed: 0.0,
7418 base_wind_direction: 0.0,
7419 azimuth_std_dev: 0.0,
7420 };
7421 let mc = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
7422 base_inputs_asm,
7423 wind,
7424 mc_params,
7425 0.0,
7426 42,
7427 )
7428 .expect("monte carlo");
7429
7430 assert_eq!(mc.ranges.len(), 1);
7431 assert_eq!(
7432 mc.ranges[0].to_bits(),
7433 direct.max_range.to_bits(),
7434 "a zero-dispersion single MC sample must match a plain solve of the same \
7435 ASM-referenced inputs bit-for-bit"
7436 );
7437 assert_eq!(
7438 mc.impact_velocities[0].to_bits(),
7439 direct.impact_velocity.to_bits()
7440 );
7441 }
7442
7443 #[test]
7451 fn estimate_bc_fit_recovers_an_icao_referenced_bc() {
7452 let known_bc = 0.475;
7453 let velocity = 800.0;
7454 let mass = 0.0109;
7455 let diameter = 0.00782;
7456 let atmosphere = AtmosphericConditions::default();
7457
7458 let synth_inputs = BallisticInputs {
7459 muzzle_velocity: velocity,
7460 bc_value: known_bc,
7461 bc_type: DragModel::G7,
7462 bullet_mass: mass,
7463 bullet_diameter: diameter,
7464 bullet_length: 0.0309,
7465 sight_height: 0.05,
7466 twist_rate: 10.0,
7467 use_rk4: true,
7468 bc_reference_standard: BcReferenceStandard::Icao,
7469 ..BallisticInputs::default()
7470 };
7471 let mut solver = TrajectorySolver::new(synth_inputs, WindConditions::default(), atmosphere.clone());
7472 solver.set_max_range(500.0);
7473 let trajectory = solver.solve().expect("synthetic solve");
7474
7475 let points: Vec<(f64, f64)> = [100.0, 200.0, 300.0, 400.0]
7476 .iter()
7477 .map(|&d| {
7478 let (y, _) = {
7479 let pts = &trajectory.points;
7480 let mut found = None;
7481 for i in 1..pts.len() {
7482 if pts[i].position.x >= d {
7483 let (p1, p2) = (&pts[i - 1], &pts[i]);
7484 let dx = p2.position.x - p1.position.x;
7485 let t = if dx.abs() < 1e-12 {
7486 0.0
7487 } else {
7488 (d - p1.position.x) / dx
7489 };
7490 found = Some((
7491 p1.position.y + t * (p2.position.y - p1.position.y),
7492 0.0,
7493 ));
7494 break;
7495 }
7496 }
7497 found.expect("trajectory reached observation distance")
7498 };
7499 (d, -y) })
7501 .collect();
7502
7503 let estimate = estimate_bc_fit(
7504 velocity,
7505 mass,
7506 diameter,
7507 &points,
7508 DragModel::G7,
7509 BcFitMode::Drop,
7510 atmosphere,
7511 None,
7512 0.05,
7513 )
7514 .expect("fit should converge");
7515
7516 assert!(
7517 (estimate.bc - known_bc).abs() < 0.02,
7518 "fit should recover the known ICAO-referenced bc={known_bc}, got {}",
7519 estimate.bc
7520 );
7521 }
7522
7523 #[test]
7526 fn custom_drag_table_makes_bc_reference_standard_numerically_inert() {
7527 let table = crate::drag::DragTable::try_new(vec![0.5, 1.0, 2.0, 3.0], vec![0.3, 0.4, 0.3, 0.2])
7528 .expect("valid table");
7529
7530 let solve_with = |standard: BcReferenceStandard| {
7531 let inputs = BallisticInputs {
7532 bc_value: 0.5, bc_reference_standard: standard,
7534 custom_drag_table: Some(table.clone()),
7535 ..base_inputs()
7536 };
7537 let mut solver =
7538 TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
7539 solver.set_max_range(500.0);
7540 solver.solve().expect("solve")
7541 };
7542
7543 let icao = solve_with(BcReferenceStandard::Icao);
7544 let asm = solve_with(BcReferenceStandard::ArmyStandardMetro);
7545
7546 assert_eq!(
7547 icao.impact_velocity.to_bits(),
7548 asm.impact_velocity.to_bits(),
7549 "a custom drag table must make bc_reference_standard fully inert"
7550 );
7551 assert_eq!(icao.max_range.to_bits(), asm.max_range.to_bits());
7552 }
7553
7554 #[test]
7555 fn custom_drag_table_inert_warning_fires_only_for_army_standard_metro_with_a_table() {
7556 let table = crate::drag::DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.3, 0.4, 0.3])
7557 .expect("valid table");
7558
7559 let no_table_icao = base_inputs();
7561 assert!(no_table_icao.bc_reference_standard_inert_warning().is_none());
7562 let no_table_asm = BallisticInputs {
7563 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
7564 ..base_inputs()
7565 };
7566 assert!(no_table_asm.bc_reference_standard_inert_warning().is_none());
7567
7568 let table_icao = BallisticInputs {
7570 custom_drag_table: Some(table.clone()),
7571 ..base_inputs()
7572 };
7573 assert!(table_icao.bc_reference_standard_inert_warning().is_none());
7574
7575 let table_asm = BallisticInputs {
7577 custom_drag_table: Some(table),
7578 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
7579 ..base_inputs()
7580 };
7581 let warning = table_asm
7582 .bc_reference_standard_inert_warning()
7583 .expect("must warn");
7584 assert!(warning.contains("--bc-reference"));
7585 assert!(warning.contains("--drag-table"));
7586 }
7587}
7588
7589#[cfg(test)]
7595mod effective_drag_coefficient_tests {
7596 use super::*;
7597
7598 fn inputs_175gr_g7() -> BallisticInputs {
7599 let mut inputs = BallisticInputs {
7600 bc_value: 0.243,
7601 bc_type: DragModel::G7,
7602 muzzle_velocity: 823.0,
7603 ..Default::default()
7604 };
7605 inputs.bullet_mass = 175.0 * crate::constants::GRAINS_TO_KG;
7609 inputs.bullet_diameter = 0.308 * 0.0254;
7610 inputs.weight_grains = 175.0;
7611 inputs.caliber_inches = 0.308;
7612 inputs
7613 }
7614
7615 fn solver(inputs: BallisticInputs) -> TrajectorySolver {
7616 TrajectorySolver::new(
7617 inputs,
7618 WindConditions::default(),
7619 AtmosphericConditions::default(),
7620 )
7621 }
7622
7623 #[test]
7627 fn reports_the_projectiles_own_cd_not_the_reference_tables() {
7628 let inputs = inputs_175gr_g7();
7629 let sd = inputs.sectional_density_lb_in2().expect("SD");
7630 let solver = solver(inputs);
7631
7632 let sos = 340.0;
7633 let velocity = 800.0;
7634 let mach = velocity / sos;
7635
7636 let reference = crate::drag::get_drag_coefficient(mach, &DragModel::G7);
7637 let reported = solver
7638 .effective_drag_coefficient(velocity, sos)
7639 .expect("mass and diameter are set");
7640
7641 let expected = reference * sd / 0.243;
7642 assert!(
7643 (reported - expected).abs() < 1e-12,
7644 "reported {reported} != Cd_ref * SD / BC {expected}"
7645 );
7646 assert!(
7649 (reported - reference).abs() > 1e-6,
7650 "form factor collapsed to 1; this fixture no longer distinguishes the two values"
7651 );
7652 }
7653
7654 #[test]
7657 fn a_custom_drag_table_passes_through_unscaled() {
7658 let mut inputs = inputs_175gr_g7();
7659 inputs.custom_drag_table = Some(crate::drag::DragTable::new(
7660 vec![0.5, 3.0],
7661 vec![0.15, 0.40],
7662 ));
7663 let solver = solver(inputs);
7664
7665 let sos = 340.0;
7666 let velocity = 0.9 * sos;
7667 let table_value = solver
7668 .inputs
7669 .custom_drag_table
7670 .as_ref()
7671 .expect("table")
7672 .interpolate(0.9);
7673
7674 let reported = solver
7675 .effective_drag_coefficient(velocity, sos)
7676 .expect("mass and diameter are set");
7677 assert!(
7678 (reported - table_value).abs() < 1e-12,
7679 "custom table Cd {table_value} was rescaled to {reported}"
7680 );
7681 }
7682
7683 #[test]
7686 fn a_velocity_segmented_bc_steps_the_reported_cd() {
7687 let mut inputs = inputs_175gr_g7();
7688 inputs.use_bc_segments = true;
7689 inputs.bc_segments_data = Some(vec![
7690 crate::BCSegmentData { velocity_min: 2400.0, velocity_max: 4000.0, bc_value: 0.243 },
7691 crate::BCSegmentData { velocity_min: 0.0, velocity_max: 2400.0, bc_value: 0.200 },
7692 ]);
7693 let solver = solver(inputs);
7694
7695 let sos = 340.0;
7696 let above = solver.effective_drag_coefficient(2500.0 / 3.28084, sos).expect("cd");
7698 let below = solver.effective_drag_coefficient(2300.0 / 3.28084, sos).expect("cd");
7699
7700 assert!(
7702 below > above,
7703 "expected the 0.200 band to report a higher Cd than the 0.243 band; got {below} vs {above}"
7704 );
7705 }
7706
7707 #[test]
7710 fn is_absent_when_sectional_density_is_unknown() {
7711 let mut inputs = inputs_175gr_g7();
7712 inputs.weight_grains = 0.0;
7713 inputs.bullet_mass = 0.0;
7714 let solver = solver(inputs);
7715 assert!(solver.effective_drag_coefficient(800.0, 340.0).is_none());
7716 }
7717
7718 #[test]
7723 fn the_json_emit_rule_is_flag_gated_and_absent_when_cd_is_unknown() {
7724 let mut point = TrajectoryPoint {
7725 time: 0.0,
7726 position: nalgebra::Vector3::new(0.0, 0.0, 0.0),
7727 velocity_magnitude: 800.0,
7728 kinetic_energy: 3000.0,
7729 drag_coefficient: Some(0.31),
7730 };
7731 assert_eq!(point.drag_coefficient_json_value(true), Some(0.31));
7732 assert_eq!(
7733 point.drag_coefficient_json_value(false),
7734 None,
7735 "without the flag the key must not exist, so default JSON stays byte-identical"
7736 );
7737 point.drag_coefficient = None;
7738 assert_eq!(
7739 point.drag_coefficient_json_value(true),
7740 None,
7741 "unknown sectional density must yield an ABSENT key, not null"
7742 );
7743 }
7744
7745 #[test]
7747 fn every_point_of_a_solved_trajectory_carries_the_value() {
7748 let mut solver = solver(inputs_175gr_g7());
7749 solver.set_max_range(300.0);
7750 let result = solver.solve().expect("solve");
7751 assert!(!result.points.is_empty());
7752 assert!(
7753 result.points.iter().all(|p| p.drag_coefficient.is_some()),
7754 "the post-integration pass missed at least one point"
7755 );
7756 }
7757}