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
317impl Default for BallisticInputs {
318 fn default() -> Self {
319 let mass_kg = 0.01;
320 let diameter_m = 0.00762;
321 let bc = 0.5;
322 let muzzle_angle_rad = 0.0;
323 let bc_type = DragModel::G1;
324
325 Self {
326 bc_value: bc,
328 bc_type,
329 bc_reference_standard: BcReferenceStandard::Icao,
330 bullet_mass: mass_kg,
331 muzzle_velocity: 800.0,
332 bullet_diameter: diameter_m,
333 bullet_length: crate::stability::estimate_bullet_length_m(diameter_m, mass_kg),
337
338 muzzle_angle: muzzle_angle_rad,
340 target_distance: 100.0,
341 azimuth_angle: 0.0,
342 shot_azimuth: 0.0,
343 shooting_angle: 0.0,
344 cant_angle: 0.0,
345 sight_height: 0.05,
346 muzzle_height: 0.0, target_height: 0.0, ground_threshold: -100.0, altitude: 0.0,
352 temperature: 15.0,
353 pressure: 1013.25, humidity: 0.5, latitude: None,
356
357 wind_speed: 0.0,
359 wind_angle: 0.0,
360
361 twist_rate: 12.0, is_twist_right: true,
364 caliber_inches: diameter_m / 0.0254, weight_grains: mass_kg / crate::constants::GRAINS_TO_KG, manufacturer: None,
367 bullet_model: None,
368 bullet_id: None,
369 bullet_cluster: None,
370
371 use_rk4: true, use_adaptive_rk45: true, enable_advanced_effects: false,
377 enable_magnus: false,
378 enable_coriolis: false,
379 use_powder_sensitivity: false,
380 powder_temp_sensitivity: 0.0,
381 powder_temp: 15.0,
382 powder_temp_curve: None,
383 powder_curve_temp_c: None,
384 tipoff_yaw: 0.0,
385 tipoff_decay_distance: 50.0,
386 use_bc_segments: false,
387 bc_segments: None,
388 bc_segments_data: None,
389 use_enhanced_spin_drift: false,
390 use_form_factor: false,
391 enable_wind_shear: false,
392 wind_shear_model: "none".to_string(),
393 enable_trajectory_sampling: false,
394 sample_interval: 10.0, enable_pitch_damping: false,
396 enable_precession_nutation: false,
397 enable_aerodynamic_jump: false,
398 use_cluster_bc: false, custom_drag_table: None,
402 cd_scale: 1.0,
403
404 bc_type_str: None,
406 }
407 }
408}
409
410pub fn interpolate_powder_temp_curve(curve: &[(f64, f64)], temp_c: f64) -> f64 {
416 debug_assert!(!curve.is_empty());
417 if curve.is_empty() {
418 return 0.0;
419 }
420 let mut sorted;
423 let pts: &[(f64, f64)] = if curve.windows(2).all(|w| w[0].0 <= w[1].0) {
424 curve
425 } else {
426 sorted = curve.to_vec();
427 sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
428 &sorted
429 };
430 let n = pts.len();
431 if temp_c <= pts[0].0 {
432 return pts[0].1; }
434 if temp_c >= pts[n - 1].0 {
435 return pts[n - 1].1; }
437 for i in 1..n {
438 let (t0, v0) = pts[i - 1];
439 let (t1, v1) = pts[i];
440 if temp_c <= t1 {
441 let span = t1 - t0;
442 if span.abs() < f64::EPSILON {
443 return v1; }
445 let f = (temp_c - t0) / span;
446 return v0 + f * (v1 - v0);
447 }
448 }
449 pts[n - 1].1
450}
451
452pub fn parse_powder_sweep(s: &str) -> Result<Vec<f64>, String> {
458 const MAX_SWEEP_ROWS: usize = 500;
459 let parts: Vec<&str> = s.split(':').collect();
460 if parts.len() != 3 {
461 return Err(format!(
462 "Invalid --sweep '{}': expected START:END:STEP (e.g. \"20:110:10\")",
463 s
464 ));
465 }
466 let parse = |p: &str, name: &str| -> Result<f64, String> {
467 p.trim()
468 .parse::<f64>()
469 .map_err(|_| format!("Invalid --sweep {}: '{}' is not a number", name, p.trim()))
470 };
471 let start = parse(parts[0], "START")?;
472 let end = parse(parts[1], "END")?;
473 let step = parse(parts[2], "STEP")?;
474 if !step.is_finite() || step <= 0.0 {
475 return Err(format!("Invalid --sweep STEP {}: must be positive", step));
476 }
477 if !start.is_finite() || !end.is_finite() || end < start {
478 return Err(format!(
479 "Invalid --sweep range {}:{}: END must be >= START",
480 start, end
481 ));
482 }
483 let n_f = ((end - start) / step + 1e-9).floor();
489 if !n_f.is_finite() || n_f + 1.0 > MAX_SWEEP_ROWS as f64 {
490 return Err(format!(
491 "--sweep would produce more than {} rows; use a larger STEP",
492 MAX_SWEEP_ROWS
493 ));
494 }
495 let n = n_f as usize + 1;
496 Ok((0..n).map(|i| start + step * i as f64).collect())
498}
499
500pub fn resolve_powder_adjusted_velocity(
509 nominal_velocity_mps: f64,
510 ambient_temperature_c: f64,
511 use_powder_sensitivity: bool,
512 powder_temp_sensitivity_mps_per_c: f64,
513 powder_reference_temp_c: f64,
514 powder_temp_curve: Option<&[(f64, f64)]>,
515 powder_curve_temp_c: Option<f64>,
516) -> f64 {
517 if let Some(curve) = powder_temp_curve {
518 if !curve.is_empty() {
519 let lookup_c = powder_curve_temp_c.unwrap_or(ambient_temperature_c);
520 return interpolate_powder_temp_curve(curve, lookup_c);
521 }
522 return nominal_velocity_mps;
525 }
526 if use_powder_sensitivity {
527 let temp_delta_c = ambient_temperature_c - powder_reference_temp_c;
528 return nominal_velocity_mps + powder_temp_sensitivity_mps_per_c * temp_delta_c;
529 }
530 nominal_velocity_mps
531}
532
533#[derive(Debug, Clone)]
535pub struct WindConditions {
536 pub speed: f64, pub direction: f64,
540 pub vertical_speed: f64,
548}
549
550impl Default for WindConditions {
551 fn default() -> Self {
552 Self {
553 speed: 0.0,
554 direction: 0.0,
555 vertical_speed: 0.0,
556 }
557 }
558}
559
560#[derive(Debug, Clone)]
562pub struct AtmosphericConditions {
563 pub temperature: f64, pub pressure: f64, pub humidity: f64,
569 pub altitude: f64, }
571
572impl Default for AtmosphericConditions {
573 fn default() -> Self {
574 Self {
575 temperature: 15.0,
576 pressure: 1013.25,
577 humidity: 50.0,
578 altitude: 0.0,
579 }
580 }
581}
582
583#[derive(Debug, Clone)]
585pub struct TrajectoryPoint {
586 pub time: f64,
587 pub position: Vector3<f64>,
588 pub velocity_magnitude: f64,
589 pub kinetic_energy: f64,
590}
591
592#[derive(Debug, Clone)]
594pub struct TrajectoryResult {
595 pub max_range: f64,
596 pub max_height: f64,
597 pub time_of_flight: f64,
598 pub impact_velocity: f64,
599 pub impact_energy: f64,
600 pub projectile_mass_kg: f64,
602 pub line_of_sight_height_m: f64,
604 pub station_speed_of_sound_mps: f64,
606 pub termination: TrajectoryTermination,
608 pub points: Vec<TrajectoryPoint>,
609 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>,
618 pub mach_1_2_distance_m: Option<f64>,
623 pub mach_1_0_distance_m: Option<f64>,
627 pub mach_0_9_distance_m: Option<f64>,
635}
636
637const RK45_TOLERANCE: f64 = 1e-6;
638const RK45_SAFETY_FACTOR: f64 = 0.9;
639const RK45_MAX_DT: f64 = 0.01;
640const RK45_MIN_DT: f64 = 1e-6;
641const TRAJECTORY_TIME_LIMIT_S: f64 = 100.0;
642
643pub const MAX_TRAJECTORY_POINTS: usize = 250_000;
649
650fn cli_rk45_error_norm(
652 position: &Vector3<f64>,
653 velocity: &Vector3<f64>,
654 fifth_position: &Vector3<f64>,
655 fifth_velocity: &Vector3<f64>,
656 fourth_position: &Vector3<f64>,
657 fourth_velocity: &Vector3<f64>,
658) -> f64 {
659 let pack_state = |position: &Vector3<f64>, velocity: &Vector3<f64>| {
660 Vector6::new(
661 position.x, position.y, position.z, velocity.x, velocity.y, velocity.z,
662 )
663 };
664 let state = pack_state(position, velocity);
665 let fifth_order = pack_state(fifth_position, fifth_velocity);
666 let fourth_order = pack_state(fourth_position, fourth_velocity);
667
668 crate::trajectory_integration::rk45_error_norm(&state, &fifth_order, &fourth_order)
669}
670
671struct Rk45Trial {
672 position: Vector3<f64>,
673 velocity: Vector3<f64>,
674 suggested_dt: f64,
675 error: f64,
676}
677
678struct Rk45AcceptedStep {
679 position: Vector3<f64>,
680 velocity: Vector3<f64>,
681 used_dt: f64,
682 next_dt: f64,
683 error: f64,
684}
685
686#[derive(Default)]
700struct MachTransitionTracker {
701 previous_mach: Option<f64>,
702 crossed_transonic: bool,
703 crossed_subsonic: bool,
704 crossed_narrow: bool,
705 mach_1_2_distance_m: Option<f64>,
708 mach_1_0_distance_m: Option<f64>,
711 mach_0_9_distance_m: Option<f64>,
714}
715
716impl MachTransitionTracker {
717 fn record_downward_crossings(&mut self, mach: f64, downrange_m: f64, distances: &mut Vec<f64>) {
718 if !mach.is_finite() {
719 self.previous_mach = None;
720 return;
721 }
722
723 if let Some(previous_mach) = self.previous_mach {
724 if !self.crossed_transonic && previous_mach >= 1.2 && mach < 1.2 {
725 self.crossed_transonic = true;
726 distances.push(downrange_m);
727 self.mach_1_2_distance_m = Some(downrange_m);
728 }
729 if !self.crossed_subsonic && previous_mach >= 1.0 && mach < 1.0 {
730 self.crossed_subsonic = true;
731 distances.push(downrange_m);
732 self.mach_1_0_distance_m = Some(downrange_m);
733 }
734 if !self.crossed_narrow && previous_mach >= 0.9 && mach < 0.9 {
735 self.crossed_narrow = true;
736 self.mach_0_9_distance_m = Some(downrange_m);
738 }
739 }
740 self.previous_mach = Some(mach);
741 }
742}
743
744impl TrajectoryResult {
745 pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
749 if self.points.is_empty() {
750 return None;
751 }
752
753 for i in 0..self.points.len() - 1 {
755 let p1 = &self.points[i];
756 let p2 = &self.points[i + 1];
757
758 if p1.position.x <= target_range && p2.position.x >= target_range {
760 let dx = p2.position.x - p1.position.x;
762 if dx.abs() < 1e-10 {
763 return Some(p1.position);
764 }
765 let t = (target_range - p1.position.x) / dx;
766
767 return Some(Vector3::new(
769 target_range,
770 p1.position.y + t * (p2.position.y - p1.position.y),
771 p1.position.z + t * (p2.position.z - p1.position.z),
772 ));
773 }
774 }
775
776 self.points.last().map(|p| p.position)
778 }
779}
780
781#[derive(Debug, Clone, Copy, PartialEq, Eq)]
783enum StationAtmosphereResolution {
784 LegacyDefaultSentinels,
787 Authoritative,
790}
791
792#[derive(Clone)]
793pub struct TrajectorySolver {
794 inputs: BallisticInputs,
795 wind: WindConditions,
796 atmosphere: AtmosphericConditions,
797 station_atmosphere_resolution: StationAtmosphereResolution,
798 max_range: f64,
799 time_step: f64,
800 max_trajectory_points: usize,
801 cluster_bc: Option<ClusterBCDegradation>,
802 precession_nutation_inertias: (f64, f64),
804 wind_sock: Option<crate::wind::WindSock>,
809 atmo_sock: Option<crate::atmosphere::AtmoSock>,
816}
817
818#[derive(Debug, Clone, Copy, PartialEq, Eq)]
830pub(crate) enum ZeroTargetFrame {
831 SightLine,
832 WorldVertical,
833}
834
835#[derive(Debug, Clone, Copy, PartialEq)]
848pub struct ZeroCrossings {
849 pub near_m: Option<f64>,
852 pub far_m: Option<f64>,
855}
856
857impl TrajectorySolver {
858 pub fn new(
859 inputs: BallisticInputs,
860 wind: WindConditions,
861 atmosphere: AtmosphericConditions,
862 ) -> Self {
863 Self::new_with_station_atmosphere_resolution(
864 inputs,
865 wind,
866 atmosphere,
867 StationAtmosphereResolution::LegacyDefaultSentinels,
868 )
869 }
870
871 pub fn new_with_resolved_station_atmosphere(
882 inputs: BallisticInputs,
883 wind: WindConditions,
884 atmosphere: AtmosphericConditions,
885 ) -> Self {
886 Self::new_with_station_atmosphere_resolution(
887 inputs,
888 wind,
889 atmosphere,
890 StationAtmosphereResolution::Authoritative,
891 )
892 }
893
894 fn new_with_station_atmosphere_resolution(
895 mut inputs: BallisticInputs,
896 wind: WindConditions,
897 atmosphere: AtmosphericConditions,
898 station_atmosphere_resolution: StationAtmosphereResolution,
899 ) -> Self {
900 if matches!(
914 inputs.bc_reference_standard,
915 BcReferenceStandard::ArmyStandardMetro
916 ) {
917 inputs.bc_value *= crate::constants::ASM_TO_ICAO_BC;
918 if let Some(segments) = inputs.bc_segments.as_mut() {
919 for (_mach, bc) in segments.iter_mut() {
920 *bc *= crate::constants::ASM_TO_ICAO_BC;
921 }
922 }
923 if let Some(segments) = inputs.bc_segments_data.as_mut() {
924 for segment in segments.iter_mut() {
925 segment.bc_value *= crate::constants::ASM_TO_ICAO_BC;
926 }
927 }
928 }
929
930 inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
932 inputs.weight_grains = inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
933
934 inputs.muzzle_velocity = resolve_powder_adjusted_velocity(
946 inputs.muzzle_velocity,
947 inputs.temperature,
948 inputs.use_powder_sensitivity,
949 inputs.powder_temp_sensitivity,
950 inputs.powder_temp,
951 inputs.powder_temp_curve.as_deref(),
952 inputs.powder_curve_temp_c,
953 );
954
955 let cluster_bc = if inputs.use_cluster_bc {
957 Some(ClusterBCDegradation::new())
958 } else {
959 None
960 };
961 let precession_nutation_inertias = projectile_moments_of_inertia(
962 inputs.bullet_mass,
963 inputs.bullet_diameter,
964 inputs.bullet_length,
965 );
966
967 Self {
968 inputs,
969 wind,
970 atmosphere,
971 station_atmosphere_resolution,
972 max_range: 1000.0,
973 time_step: 0.001,
974 max_trajectory_points: MAX_TRAJECTORY_POINTS,
975 cluster_bc,
976 precession_nutation_inertias,
977 wind_sock: None,
978 atmo_sock: None,
979 }
980 }
981
982 pub fn set_max_range(&mut self, range: f64) {
983 self.max_range = range;
984 }
985
986 pub fn set_time_step(&mut self, step: f64) {
987 self.time_step = step;
988 }
989
990 pub(crate) fn calculate_and_set_zero_angle(
994 &mut self,
995 target_distance_m: f64,
996 target_height_m: f64,
997 frame: ZeroTargetFrame,
998 ) -> Result<f64, BallisticsError> {
999 let angle = self.find_zero_angle(target_distance_m, target_height_m, frame)?;
1000 self.inputs.muzzle_angle = angle;
1001 Ok(angle)
1002 }
1003
1004 fn find_zero_angle(
1005 &self,
1006 target_distance_m: f64,
1007 target_height_m: f64,
1008 frame: ZeroTargetFrame,
1009 ) -> Result<f64, BallisticsError> {
1010 let mut low_angle = 0.0;
1013 let mut high_angle = 0.2; let tolerance = 1e-7;
1015 let max_iterations = 60;
1016
1017 let low_height = self.zero_trial_height_at(low_angle, target_distance_m, frame)?;
1019 let high_height = self.zero_trial_height_at(high_angle, target_distance_m, frame)?;
1020
1021 match (low_height, high_height) {
1022 (Some(low_height), Some(high_height)) => {
1023 let low_error = low_height - target_height_m;
1024 let high_error = high_height - target_height_m;
1025
1026 if low_error > 0.0 && high_error > 0.0 {
1027 } else if low_error < 0.0 && high_error < 0.0 {
1030 let mut expanded = false;
1032 for multiplier in [2.0, 3.0, 4.0] {
1033 let new_high = (high_angle * multiplier).min(0.785);
1034 if let Ok(Some(height)) =
1035 self.zero_trial_height_at(new_high, target_distance_m, frame)
1036 {
1037 if height - target_height_m > 0.0 {
1038 high_angle = new_high;
1039 expanded = true;
1040 break;
1041 }
1042 }
1043 if new_high >= 0.785 {
1044 break;
1045 }
1046 }
1047 if !expanded {
1048 return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
1049 }
1050 }
1051 }
1052 (None, Some(_)) => {
1053 }
1056 (Some(_), None) => {
1057 return Err(
1058 "Cannot find zero angle: high angle trajectory doesn't reach target distance"
1059 .into(),
1060 );
1061 }
1062 (None, None) => {
1063 return Err(
1064 "Cannot find zero angle: trajectory cannot reach target distance at any angle"
1065 .into(),
1066 );
1067 }
1068 }
1069
1070 for _ in 0..max_iterations {
1071 let mid_angle = (low_angle + high_angle) / 2.0;
1072 match self.zero_trial_height_at(mid_angle, target_distance_m, frame)? {
1073 Some(height) => {
1074 let error = height - target_height_m;
1075 if error.abs() < 0.0001 {
1078 return Ok(mid_angle);
1079 }
1080
1081 if (high_angle - low_angle).abs() < tolerance {
1084 if error.abs() < 0.01 {
1085 return Ok(mid_angle);
1086 }
1087 return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
1088 }
1089
1090 if error > 0.0 {
1091 high_angle = mid_angle;
1092 } else {
1093 low_angle = mid_angle;
1094 }
1095 }
1096 None => {
1097 low_angle = mid_angle;
1098 if (high_angle - low_angle).abs() < tolerance {
1099 return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
1100 }
1101 }
1102 }
1103 }
1104
1105 Err("Failed to find zero angle".into())
1106 }
1107
1108 fn zero_trial_height_at(
1111 &self,
1112 angle_rad: f64,
1113 target_distance_m: f64,
1114 frame: ZeroTargetFrame,
1115 ) -> Result<Option<f64>, BallisticsError> {
1116 let mut trial = self.clone();
1117 trial.inputs.muzzle_angle = angle_rad;
1118 trial.inputs.enable_aerodynamic_jump = false;
1121 trial.inputs.cant_angle = 0.0;
1124 if frame == ZeroTargetFrame::SightLine {
1131 trial.inputs.shooting_angle = 0.0;
1132 }
1133 trial.set_max_range(target_distance_m * 2.0);
1134 let result = trial.solve()?;
1135
1136 for (index, point) in result.points.iter().enumerate() {
1137 if point.position.x >= target_distance_m {
1138 let shot_y_m = if index == 0 {
1139 point.position.y
1140 } else {
1141 let previous = &result.points[index - 1];
1142 let span = point.position.x - previous.position.x;
1143 let fraction = (target_distance_m - previous.position.x) / span;
1144 previous.position.y + fraction * (point.position.y - previous.position.y)
1145 };
1146 return Ok(Some(crate::atmosphere::shot_frame_altitude(
1147 0.0,
1148 target_distance_m,
1149 shot_y_m,
1150 trial.inputs.shooting_angle,
1151 )));
1152 }
1153 }
1154 Ok(None)
1155 }
1156
1157 fn find_zero_range(
1184 &self,
1185 angle_rad: f64,
1186 target_height_m: f64,
1187 frame: ZeroTargetFrame,
1188 ) -> Result<ZeroCrossings, BallisticsError> {
1189 let mut trial = self.clone();
1190 trial.inputs.muzzle_angle = angle_rad;
1191 trial.inputs.enable_aerodynamic_jump = false;
1194 trial.inputs.cant_angle = 0.0;
1195 if frame == ZeroTargetFrame::SightLine {
1196 trial.inputs.shooting_angle = 0.0;
1197 }
1198 let result = trial.solve()?;
1199
1200 let mut near_crossing: Option<f64> = None;
1208 let mut far_crossing: Option<f64> = None;
1209 let mut previous: Option<(f64, f64)> = None; for point in &result.points {
1211 let height = crate::atmosphere::shot_frame_altitude(
1212 0.0,
1213 point.position.x,
1214 point.position.y,
1215 trial.inputs.shooting_angle,
1216 );
1217 let error = height - target_height_m;
1218 if let Some((prev_x, prev_error)) = previous {
1219 if prev_error == 0.0 {
1220 if near_crossing.is_none() {
1223 near_crossing = Some(prev_x);
1224 } else {
1225 far_crossing = Some(prev_x);
1226 }
1227 }
1228 if prev_error * error < 0.0 {
1229 let fraction = prev_error / (prev_error - error);
1230 let crossing = prev_x + fraction * (point.position.x - prev_x);
1231 if prev_error < 0.0 && error > 0.0 {
1232 if near_crossing.is_none() {
1234 near_crossing = Some(crossing);
1235 }
1236 } else {
1237 far_crossing = Some(crossing);
1239 }
1240 }
1241 }
1242 previous = Some((point.position.x, error));
1243 }
1244 if let Some((last_x, last_error)) = previous {
1247 if last_error == 0.0 {
1248 if near_crossing.is_none() {
1249 near_crossing = Some(last_x);
1250 } else {
1251 far_crossing = Some(last_x);
1252 }
1253 }
1254 }
1255
1256 if near_crossing.is_none() && far_crossing.is_none() {
1257 return Err(BallisticsError::from(
1258 "Cannot find zero range: this angle never crosses the target height within the \
1259 solved range (angle too shallow to reach it, or both crossings lie beyond \
1260 the solver's max range)."
1261 .to_string(),
1262 ));
1263 }
1264
1265 Ok(ZeroCrossings {
1266 near_m: near_crossing,
1267 far_m: far_crossing,
1268 })
1269 }
1270
1271 fn validate_for_solve(&self) -> Result<(), BallisticsError> {
1277 let require_finite = |name: &str, value: f64| {
1278 if value.is_finite() {
1279 Ok(())
1280 } else {
1281 Err(BallisticsError::from(format!("{name} must be finite")))
1282 }
1283 };
1284 let require_positive = |name: &str, value: f64| {
1285 if value.is_finite() && value > 0.0 {
1286 Ok(())
1287 } else {
1288 Err(BallisticsError::from(format!(
1289 "{name} must be finite and greater than zero"
1290 )))
1291 }
1292 };
1293
1294 if self.inputs.custom_drag_table.is_none() {
1300 require_positive("bc_value", self.inputs.bc_value)?;
1301 }
1302 require_positive("bullet_mass", self.inputs.bullet_mass)?;
1303 require_positive("bullet_diameter", self.inputs.bullet_diameter)?;
1304 require_positive("muzzle_velocity", self.inputs.muzzle_velocity)?;
1305 require_positive("cd_scale", self.inputs.cd_scale)?;
1311
1312 require_finite("muzzle_angle", self.inputs.muzzle_angle)?;
1313 require_finite("azimuth_angle", self.inputs.azimuth_angle)?;
1314 require_finite("shooting_angle", self.inputs.shooting_angle)?;
1315 require_finite("cant_angle", self.inputs.cant_angle)?;
1316 require_finite("muzzle_height", self.inputs.muzzle_height)?;
1317
1318 if !(self.inputs.ground_threshold.is_finite()
1321 || self.inputs.ground_threshold == f64::NEG_INFINITY)
1322 {
1323 return Err(BallisticsError::from(
1324 "ground_threshold must be finite or negative infinity",
1325 ));
1326 }
1327
1328 match &self.wind_sock {
1329 Some(wind_sock) => wind_sock
1330 .validate_segments()
1331 .map_err(BallisticsError::from)?,
1332 None => {
1333 require_finite("wind.speed", self.wind.speed)?;
1334 require_finite("wind.direction", self.wind.direction)?;
1335 require_finite("wind.vertical_speed", self.wind.vertical_speed)?;
1336 }
1337 }
1338
1339 require_finite("atmosphere.temperature", self.atmosphere.temperature)?;
1340 require_finite("atmosphere.pressure", self.atmosphere.pressure)?;
1341 require_finite("atmosphere.humidity", self.atmosphere.humidity)?;
1342 require_finite("atmosphere.altitude", self.atmosphere.altitude)?;
1343
1344 require_positive("max_range", self.max_range)?;
1345 if !self.inputs.use_rk4 || !self.inputs.use_adaptive_rk45 {
1348 require_positive("time_step", self.time_step)?;
1349 }
1350
1351 if self.inputs.enable_trajectory_sampling {
1352 require_finite("sight_height", self.inputs.sight_height)?;
1353 require_positive("sample_interval", self.inputs.sample_interval)?;
1354 projected_sample_count(self.max_range, self.inputs.sample_interval)?;
1355 }
1356
1357 if self.inputs.enable_coriolis {
1358 require_finite("shot_azimuth", self.inputs.shot_azimuth)?;
1359 if let Some(latitude) = self.inputs.latitude {
1360 require_finite("latitude", latitude)?;
1361 }
1362 }
1363
1364 Ok(())
1365 }
1366
1367 fn validate_result_sanity(&self, result: &TrajectoryResult) -> Result<(), BallisticsError> {
1374 let require_finite = |name: &str, value: f64| {
1375 if value.is_finite() {
1376 Ok(())
1377 } else {
1378 Err(BallisticsError::from(format!(
1379 "trajectory result contains non-finite {name}"
1380 )))
1381 }
1382 };
1383 let require_non_negative = |name: &str, value: f64| {
1384 if value >= 0.0 {
1385 Ok(())
1386 } else {
1387 Err(BallisticsError::from(format!(
1388 "trajectory result contains non-physical negative {name} ({value})"
1389 )))
1390 }
1391 };
1392 let require_indexed_finite = |collection: &str, index: usize, field: &str, value: f64| {
1393 if value.is_finite() {
1394 Ok(())
1395 } else {
1396 Err(BallisticsError::from(format!(
1397 "trajectory result contains non-finite {collection}[{index}].{field}"
1398 )))
1399 }
1400 };
1401 let require_indexed_non_negative =
1402 |collection: &str, index: usize, field: &str, value: f64| {
1403 if value >= 0.0 {
1404 Ok(())
1405 } else {
1406 Err(BallisticsError::from(format!(
1407 "trajectory result contains non-physical negative {collection}[{index}].{field} ({value})"
1408 )))
1409 }
1410 };
1411
1412 require_finite("max_range", result.max_range)?;
1413 require_finite("max_height", result.max_height)?;
1414 require_finite("time_of_flight", result.time_of_flight)?;
1415 require_finite("impact_velocity", result.impact_velocity)?;
1416 require_finite("impact_energy", result.impact_energy)?;
1417 require_finite("projectile_mass_kg", result.projectile_mass_kg)?;
1418 require_finite(
1419 "line_of_sight_height_m",
1420 result.line_of_sight_height_m,
1421 )?;
1422 require_finite(
1423 "station_speed_of_sound_mps",
1424 result.station_speed_of_sound_mps,
1425 )?;
1426
1427 require_non_negative("max_range", result.max_range)?;
1431 require_non_negative("time_of_flight", result.time_of_flight)?;
1432 require_non_negative("impact_velocity", result.impact_velocity)?;
1433 require_non_negative("impact_energy", result.impact_energy)?;
1434 require_non_negative("projectile_mass_kg", result.projectile_mass_kg)?;
1435 require_non_negative(
1436 "station_speed_of_sound_mps",
1437 result.station_speed_of_sound_mps,
1438 )?;
1439
1440 for (index, point) in result.points.iter().enumerate() {
1441 require_indexed_finite("points", index, "time", point.time)?;
1442 require_indexed_finite("points", index, "position.x", point.position.x)?;
1443 require_indexed_finite("points", index, "position.y", point.position.y)?;
1444 require_indexed_finite("points", index, "position.z", point.position.z)?;
1445 require_indexed_finite(
1446 "points",
1447 index,
1448 "velocity_magnitude",
1449 point.velocity_magnitude,
1450 )?;
1451 require_indexed_finite("points", index, "kinetic_energy", point.kinetic_energy)?;
1452 require_indexed_non_negative("points", index, "time", point.time)?;
1453 require_indexed_non_negative(
1454 "points",
1455 index,
1456 "velocity_magnitude",
1457 point.velocity_magnitude,
1458 )?;
1459 require_indexed_non_negative("points", index, "kinetic_energy", point.kinetic_energy)?;
1460 }
1461
1462 if let Some(samples) = &result.sampled_points {
1463 for (index, sample) in samples.iter().enumerate() {
1464 require_indexed_finite("sampled_points", index, "distance_m", sample.distance_m)?;
1465 require_indexed_finite("sampled_points", index, "drop_m", sample.drop_m)?;
1466 require_indexed_finite(
1467 "sampled_points",
1468 index,
1469 "wind_drift_m",
1470 sample.wind_drift_m,
1471 )?;
1472 require_indexed_finite(
1473 "sampled_points",
1474 index,
1475 "velocity_mps",
1476 sample.velocity_mps,
1477 )?;
1478 require_indexed_finite("sampled_points", index, "energy_j", sample.energy_j)?;
1479 require_indexed_finite("sampled_points", index, "time_s", sample.time_s)?;
1480 }
1481 }
1482
1483 for (name, value) in [
1484 ("min_pitch_damping", result.min_pitch_damping),
1485 ("transonic_mach", result.transonic_mach),
1486 ("max_yaw_angle", result.max_yaw_angle),
1487 ("max_precession_angle", result.max_precession_angle),
1488 ] {
1489 if let Some(value) = value {
1490 require_finite(name, value)?;
1491 }
1492 }
1493
1494 if let Some(state) = result.angular_state {
1495 for (name, value) in [
1496 ("angular_state.pitch_angle", state.pitch_angle),
1497 ("angular_state.yaw_angle", state.yaw_angle),
1498 ("angular_state.pitch_rate", state.pitch_rate),
1499 ("angular_state.yaw_rate", state.yaw_rate),
1500 ("angular_state.precession_angle", state.precession_angle),
1501 ("angular_state.nutation_phase", state.nutation_phase),
1502 ] {
1503 require_finite(name, value)?;
1504 }
1505 }
1506
1507 if let Some(jump) = result.aerodynamic_jump {
1508 for (name, value) in [
1509 ("aerodynamic_jump.vertical_jump_moa", jump.vertical_jump_moa),
1510 (
1511 "aerodynamic_jump.horizontal_jump_moa",
1512 jump.horizontal_jump_moa,
1513 ),
1514 ("aerodynamic_jump.jump_angle_rad", jump.jump_angle_rad),
1515 (
1516 "aerodynamic_jump.magnus_component_moa",
1517 jump.magnus_component_moa,
1518 ),
1519 ("aerodynamic_jump.yaw_component_moa", jump.yaw_component_moa),
1520 (
1521 "aerodynamic_jump.stabilization_factor",
1522 jump.stabilization_factor,
1523 ),
1524 ] {
1525 require_finite(name, value)?;
1526 }
1527 }
1528
1529 Ok(())
1530 }
1531
1532 fn validate_integration_state(
1545 &self,
1546 position: &Vector3<f64>,
1547 velocity: &Vector3<f64>,
1548 time: f64,
1549 ) -> Result<(), BallisticsError> {
1550 if !(position.iter().all(|value| value.is_finite())
1551 && velocity.iter().all(|value| value.is_finite())
1552 && time.is_finite())
1553 {
1554 return Err(BallisticsError::from(
1555 "trajectory integration produced a non-finite state (often from physically \
1556 extreme inputs — e.g. an absurd bore/muzzle height placing the launch far \
1557 from sea level, or a degenerate atmosphere; check those inputs, or set \
1558 --altitude explicitly)",
1559 ));
1560 }
1561
1562 let speed = velocity.magnitude();
1563 let budget = self.speed_budget(time);
1564 if speed > budget {
1565 return Err(BallisticsError::from(format!(
1566 "trajectory integration diverged: speed {speed:.3e} m/s at t={time:.6}s exceeds \
1567 the physical budget of {budget:.3e} m/s"
1568 )));
1569 }
1570 Ok(())
1571 }
1572
1573 fn speed_budget(&self, time: f64) -> f64 {
1578 let scalar_wind = self.wind.speed.abs() + self.wind.vertical_speed.abs();
1579 let wind_bound = match &self.wind_sock {
1580 Some(sock) => scalar_wind.max(sock.max_speed_mps()),
1581 None => scalar_wind,
1582 };
1583 2.0 * (self.inputs.muzzle_velocity + wind_bound + 10.0)
1584 + crate::constants::G_ACCEL_MPS2 * time
1585 }
1586
1587 fn push_trajectory_point(
1589 &self,
1590 points: &mut Vec<TrajectoryPoint>,
1591 point: TrajectoryPoint,
1592 ) -> Result<(), BallisticsError> {
1593 if points.len() >= self.max_trajectory_points {
1594 return Err(BallisticsError::from(format!(
1595 "trajectory point limit of {} exceeded",
1596 self.max_trajectory_points
1597 )));
1598 }
1599 points.push(point);
1600 Ok(())
1601 }
1602
1603 pub fn set_wind_segments(&mut self, segments: Vec<crate::wind::WindSegment>) {
1610 self.wind_sock = if segments.is_empty() {
1611 None
1612 } else {
1613 Some(crate::wind::WindSock::new(segments))
1614 };
1615 }
1616
1617 pub fn set_atmo_segments(&mut self, segments: Vec<crate::atmosphere::AtmoSegment>) {
1626 self.atmo_sock = if segments.is_empty() {
1627 None
1628 } else {
1629 Some(crate::atmosphere::AtmoSock::new(segments))
1630 };
1631 }
1632
1633 fn launch_angles_from(
1642 &self,
1643 aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
1644 ) -> (f64, f64) {
1645 let (mut elev, mut azim) = (self.inputs.muzzle_angle, self.inputs.azimuth_angle);
1646 if self.inputs.cant_angle != 0.0 {
1653 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
1654 let (e0, a0) = (elev, azim);
1655 elev = e0 * cos_c - a0 * sin_c;
1656 azim = a0 * cos_c + e0 * sin_c;
1657 }
1658 match aj {
1659 Some(c) => {
1660 const MOA_PER_RAD: f64 = 3437.7467707849;
1662 (
1663 elev + c.vertical_jump_moa / MOA_PER_RAD,
1664 azim + c.horizontal_jump_moa / MOA_PER_RAD,
1665 )
1666 }
1667 None => (elev, azim),
1668 }
1669 }
1670
1671 fn aerodynamic_jump_components(
1679 &self,
1680 ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
1681 if !self.inputs.enable_aerodynamic_jump {
1682 return None;
1683 }
1684 let diameter_m = self.inputs.bullet_diameter;
1688 if !(self.inputs.twist_rate.is_finite()
1689 && self.inputs.twist_rate != 0.0
1690 && diameter_m.is_finite()
1691 && diameter_m > 0.0
1692 && self.inputs.bullet_length.is_finite()
1693 && self.inputs.bullet_length > 0.0
1694 && self.inputs.muzzle_velocity.is_finite())
1695 {
1696 return None;
1697 }
1698
1699 let (_, _, temp_c, pressure_hpa) = self.resolved_atmosphere();
1701 let sg = crate::stability::compute_stability_coefficient(
1702 &self.inputs,
1703 (self.atmosphere.altitude, temp_c, pressure_hpa, 0.0),
1704 );
1705 if !(sg.is_finite() && sg > 0.0) {
1706 return None;
1707 }
1708 let length_calibers = self.inputs.bullet_length / diameter_m;
1709
1710 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
1716 let crosswind_from_right_mps = if let Some(sock) = &self.wind_sock {
1717 -sock.vector_for_range_stateless(0.0)[2]
1718 } else {
1719 self.wind.speed * self.wind.direction.sin()
1720 };
1721 let crosswind_from_right_mph = crosswind_from_right_mps * MS_TO_MPH;
1722
1723 let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
1724 sg,
1725 length_calibers,
1726 crosswind_from_right_mph,
1727 self.inputs.is_twist_right,
1728 );
1729 if !vertical_jump_moa.is_finite() {
1730 return None;
1731 }
1732
1733 const MOA_PER_RAD: f64 = 3437.7467707849;
1734 Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
1735 vertical_jump_moa,
1736 horizontal_jump_moa: 0.0,
1738 jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
1739 magnus_component_moa: 0.0,
1740 yaw_component_moa: 0.0,
1741 stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
1742 })
1743 }
1744
1745 fn resolved_atmosphere(&self) -> (f64, f64, f64, f64) {
1746 let (temp_c, pressure_hpa) = match self.station_atmosphere_resolution {
1747 StationAtmosphereResolution::LegacyDefaultSentinels => {
1748 crate::atmosphere::resolve_station_conditions(
1749 self.atmosphere.temperature,
1750 self.atmosphere.pressure,
1751 self.atmosphere.altitude,
1752 )
1753 }
1754 StationAtmosphereResolution::Authoritative => {
1755 (self.atmosphere.temperature, self.atmosphere.pressure)
1756 }
1757 };
1758 let (density, speed_of_sound) = crate::atmosphere::calculate_atmosphere(
1759 self.atmosphere.altitude,
1760 Some(temp_c),
1761 Some(pressure_hpa),
1762 self.atmosphere.humidity,
1763 );
1764 (density, speed_of_sound, temp_c, pressure_hpa)
1765 }
1766
1767 fn precession_nutation_params(
1768 &self,
1769 velocity_mps: f64,
1770 air_density_kg_m3: f64,
1771 speed_of_sound_mps: f64,
1772 ) -> PrecessionNutationParams {
1773 let (spin_inertia, transverse_inertia) = self.precession_nutation_inertias;
1774 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
1775 let velocity_fps = velocity_mps * 3.28084;
1776 let twist_rate_ft = self.inputs.twist_rate / 12.0;
1777 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
1778 } else {
1779 0.0
1780 };
1781
1782 PrecessionNutationParams {
1783 mass_kg: self.inputs.bullet_mass,
1784 caliber_m: self.inputs.bullet_diameter,
1785 length_m: self.inputs.bullet_length,
1786 spin_rate_rad_s,
1787 spin_inertia,
1788 transverse_inertia,
1789 velocity_mps,
1790 air_density_kg_m3,
1791 mach: velocity_mps / speed_of_sound_mps,
1792 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
1793 nutation_damping_factor: 0.05,
1794 }
1795 }
1796
1797 fn append_terminal_endpoint(
1804 &self,
1805 points: &mut Vec<TrajectoryPoint>,
1806 post_position: Vector3<f64>,
1807 post_velocity: Vector3<f64>,
1808 post_time: f64,
1809 max_height: &mut f64,
1810 ) -> Result<TrajectoryTermination, BallisticsError> {
1811 let previous = points
1812 .last()
1813 .cloned()
1814 .ok_or_else(|| BallisticsError::from("No trajectory points generated"))?;
1815
1816 let mut crossings = Vec::with_capacity(3);
1817 if previous.position.x < self.max_range && post_position.x >= self.max_range {
1818 let span = post_position.x - previous.position.x;
1819 if span.is_finite() && span > 0.0 {
1820 crossings.push((
1821 (self.max_range - previous.position.x) / span,
1822 TrajectoryTermination::MaxRange,
1823 ));
1824 }
1825 }
1826 if self.inputs.ground_threshold.is_finite()
1827 && previous.position.y > self.inputs.ground_threshold
1828 && post_position.y <= self.inputs.ground_threshold
1829 {
1830 let span = post_position.y - previous.position.y;
1831 if span.is_finite() && span < 0.0 {
1832 crossings.push((
1833 (self.inputs.ground_threshold - previous.position.y) / span,
1834 TrajectoryTermination::GroundThreshold,
1835 ));
1836 }
1837 }
1838 if previous.time < TRAJECTORY_TIME_LIMIT_S && post_time >= TRAJECTORY_TIME_LIMIT_S {
1839 let span = post_time - previous.time;
1840 if span.is_finite() && span > 0.0 {
1841 crossings.push((
1842 (TRAJECTORY_TIME_LIMIT_S - previous.time) / span,
1843 TrajectoryTermination::TimeLimit,
1844 ));
1845 }
1846 }
1847
1848 let (fraction, termination) = crossings
1849 .into_iter()
1850 .filter(|(fraction, _)| fraction.is_finite() && (0.0..=1.0).contains(fraction))
1851 .min_by(|left, right| {
1852 let priority = |termination: TrajectoryTermination| match termination {
1853 TrajectoryTermination::GroundThreshold => 0,
1854 TrajectoryTermination::MaxRange => 1,
1855 TrajectoryTermination::TimeLimit => 2,
1856 TrajectoryTermination::VelocityFloor => 3,
1857 };
1858 left.0
1859 .total_cmp(&right.0)
1860 .then_with(|| priority(left.1).cmp(&priority(right.1)))
1861 })
1862 .ok_or_else(|| {
1863 BallisticsError::from(
1864 "trajectory integration stopped without crossing a supported boundary",
1865 )
1866 })?;
1867
1868 let mut position = previous.position + (post_position - previous.position) * fraction;
1869 match termination {
1870 TrajectoryTermination::MaxRange => position.x = self.max_range,
1871 TrajectoryTermination::GroundThreshold => {
1872 position.y = self.inputs.ground_threshold;
1873 }
1874 TrajectoryTermination::TimeLimit | TrajectoryTermination::VelocityFloor => {}
1875 }
1876 let velocity_magnitude = previous.velocity_magnitude
1877 + (post_velocity.magnitude() - previous.velocity_magnitude) * fraction;
1878 let mut time = previous.time + (post_time - previous.time) * fraction;
1879 if termination == TrajectoryTermination::TimeLimit {
1880 time = TRAJECTORY_TIME_LIMIT_S;
1881 }
1882 let kinetic_energy =
1883 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1884
1885 if position.y > *max_height {
1886 *max_height = position.y;
1887 }
1888 let terminal_point = TrajectoryPoint {
1889 time,
1890 position,
1891 velocity_magnitude,
1892 kinetic_energy,
1893 };
1894 if terminal_point.position.x < previous.position.x {
1895 return Err(BallisticsError::from(
1896 "trajectory terminal state reversed downrange before the crossed boundary",
1897 ));
1898 }
1899 if terminal_point.position.x == previous.position.x {
1900 let last = points.last_mut().ok_or_else(|| {
1905 BallisticsError::from("trajectory points disappeared during terminal finalization")
1906 })?;
1907 *last = terminal_point;
1908 } else {
1909 self.push_trajectory_point(points, terminal_point)?;
1910 }
1911 Ok(termination)
1912 }
1913
1914 fn gravity_acceleration(&self) -> Vector3<f64> {
1915 let theta = self.inputs.shooting_angle;
1916 Vector3::new(
1917 -crate::constants::G_ACCEL_MPS2 * theta.sin(),
1918 -crate::constants::G_ACCEL_MPS2 * theta.cos(),
1919 0.0,
1920 )
1921 }
1922
1923 fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
1924 let model = match self.inputs.wind_shear_model.as_str() {
1939 "logarithmic" => WindShearModel::Logarithmic,
1940 "power_law" | "powerlaw" | "exponential" => WindShearModel::PowerLaw,
1941 "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
1942 "custom_layers" | "custom" => WindShearModel::CustomLayers,
1943 _ => WindShearModel::PowerLaw,
1944 };
1945 let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
1946
1947 crate::wind::wind_vector(self.wind.speed, self.wind.direction, 0.0) * speed_ratio
1954 + Vector3::new(0.0, self.wind.vertical_speed, 0.0)
1955 }
1956
1957 pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
1958 self.validate_for_solve()?;
1959 let mut result = if self.inputs.use_rk4 {
1960 if self.inputs.use_adaptive_rk45 {
1961 self.solve_rk45()?
1962 } else {
1963 self.solve_rk4()?
1964 }
1965 } else {
1966 self.solve_euler()?
1967 };
1968 self.apply_spin_drift(&mut result);
1969 self.validate_result_sanity(&result)?;
1970 Ok(result)
1971 }
1972
1973 fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
1979 if !self.inputs.use_enhanced_spin_drift {
1980 return;
1981 }
1982 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 {
1986 return;
1987 }
1988
1989 let sg = self.effective_spin_drift_sg();
1996
1997 for p in result.points.iter_mut() {
1998 if p.time <= 0.0 {
1999 continue;
2000 }
2001 p.position.z +=
2003 crate::spin_drift::litz_drift_meters(sg, p.time, self.inputs.is_twist_right);
2004 }
2005
2006 if let Some(samples) = result.sampled_points.as_mut() {
2010 for s in samples.iter_mut() {
2011 if s.time_s <= 0.0 {
2012 continue;
2013 }
2014 s.wind_drift_m +=
2015 crate::spin_drift::litz_drift_meters(sg, s.time_s, self.inputs.is_twist_right);
2016 }
2017 }
2018 }
2019
2020 fn effective_spin_drift_sg(&self) -> f64 {
2025 let (_, _, temp_c, press_hpa) = self.resolved_atmosphere();
2026 crate::spin_drift::effective_sg_from_inputs(&self.inputs, temp_c, press_hpa)
2027 }
2028
2029 fn initial_position(&self) -> Vector3<f64> {
2035 if self.inputs.cant_angle == 0.0 {
2036 return Vector3::new(0.0, self.inputs.muzzle_height, 0.0);
2037 }
2038 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
2039 let sh = self.inputs.sight_height;
2040 Vector3::new(
2041 0.0,
2042 self.inputs.muzzle_height + sh * (1.0 - cos_c),
2043 -sh * sin_c,
2044 )
2045 }
2046
2047 fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
2048 let mut time = 0.0;
2050 let mut position = self.initial_position();
2054 let aj_components = self.aerodynamic_jump_components();
2060 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
2061 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
2062 let mut velocity = Vector3::new(
2063 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
2067
2068 let mut points = Vec::new();
2069 let mut max_height = position.y;
2070 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
2076 let mut mach_transitions = MachTransitionTracker::default();
2077
2078 let mut angular_state = if self.inputs.enable_precession_nutation {
2080 Some(AngularState {
2081 pitch_angle: 0.001, yaw_angle: 0.001,
2083 pitch_rate: 0.0,
2084 yaw_rate: 0.0,
2085 precession_angle: 0.0,
2086 nutation_phase: 0.0,
2087 })
2088 } else {
2089 None
2090 };
2091 let mut max_yaw_angle = 0.0;
2092 let mut max_precession_angle = 0.0;
2093
2094 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2096 self.resolved_atmosphere();
2097 let base_ratio = air_density / 1.225;
2102
2103 let wind_vector =
2109 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
2110
2111 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2114 self.inputs.bullet_model.as_deref().unwrap_or("default"),
2115 );
2116
2117 while position.x < self.max_range
2119 && position.y > self.inputs.ground_threshold
2120 && time < TRAJECTORY_TIME_LIMIT_S
2121 {
2122 let velocity_magnitude = velocity.magnitude();
2124 let kinetic_energy =
2125 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
2126
2127 self.push_trajectory_point(
2128 &mut points,
2129 TrajectoryPoint {
2130 time,
2131 position,
2132 velocity_magnitude,
2133 kinetic_energy,
2134 },
2135 )?;
2136
2137 {
2140 let mach_here = if speed_of_sound > 0.0 {
2141 velocity_magnitude / speed_of_sound
2142 } else {
2143 0.0
2144 };
2145 mach_transitions.record_downward_crossings(
2146 mach_here,
2147 position.x,
2148 &mut transonic_distances,
2149 );
2150 }
2151
2152 if position.y > max_height {
2154 max_height = position.y;
2155 }
2156
2157 if self.inputs.enable_pitch_damping {
2159 let mach = velocity_magnitude / speed_of_sound;
2160
2161 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2163 transonic_mach = Some(mach);
2164 }
2165
2166 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2168
2169 if pitch_damping < min_pitch_damping {
2171 min_pitch_damping = pitch_damping;
2172 }
2173 }
2174
2175 if self.inputs.enable_precession_nutation {
2177 if let Some(ref mut state) = angular_state {
2178 let velocity_magnitude = velocity.magnitude();
2179 let params = self.precession_nutation_params(
2180 velocity_magnitude,
2181 air_density,
2182 speed_of_sound,
2183 );
2184
2185 *state = calculate_combined_angular_motion(
2187 ¶ms,
2188 state,
2189 time,
2190 self.time_step,
2191 0.001, );
2193
2194 if state.yaw_angle.abs() > max_yaw_angle {
2196 max_yaw_angle = state.yaw_angle.abs();
2197 }
2198 if state.precession_angle.abs() > max_precession_angle {
2199 max_precession_angle = state.precession_angle.abs();
2200 }
2201 }
2202 }
2203
2204 let acceleration = self.calculate_acceleration(
2211 &position,
2212 &velocity,
2213 &wind_vector,
2214 (resolved_temp_c, resolved_press_hpa, base_ratio),
2215 );
2216
2217 velocity += acceleration * self.time_step;
2219 position += velocity * self.time_step;
2220 time += self.time_step;
2221 self.validate_integration_state(&position, &velocity, time)?;
2222 }
2223
2224 let termination =
2225 self.append_terminal_endpoint(&mut points, position, velocity, time, &mut max_height)?;
2226
2227 let last_point = points.last().ok_or("No trajectory points generated")?;
2229
2230 let sampled_points = if self.inputs.enable_trajectory_sampling {
2232 let trajectory_data = TrajectoryData {
2233 times: points.iter().map(|p| p.time).collect(),
2234 positions: points.iter().map(|p| p.position).collect(),
2235 velocities: points
2236 .iter()
2237 .map(|p| {
2238 Vector3::new(0.0, 0.0, p.velocity_magnitude)
2240 })
2241 .collect(),
2242 transonic_distances, mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2244 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2245 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2246 };
2247
2248 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
2253 let outputs = TrajectoryOutputs {
2254 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
2256 time_of_flight_s: last_point.time,
2257 max_ord_dist_horiz_m: max_height,
2258 sight_height_m: sight_position_m,
2259 };
2260
2261 let samples = sample_trajectory(
2263 &trajectory_data,
2264 &outputs,
2265 self.inputs.sample_interval,
2266 self.inputs.bullet_mass,
2267 )?;
2268 Some(samples)
2269 } else {
2270 None
2271 };
2272
2273 Ok(TrajectoryResult {
2274 max_range: last_point.position.x, max_height,
2276 time_of_flight: last_point.time,
2277 impact_velocity: last_point.velocity_magnitude,
2278 impact_energy: last_point.kinetic_energy,
2279 projectile_mass_kg: self.inputs.bullet_mass,
2280 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
2281 station_speed_of_sound_mps: speed_of_sound,
2282 termination,
2283 points,
2284 sampled_points,
2285 min_pitch_damping: if self.inputs.enable_pitch_damping {
2286 Some(min_pitch_damping)
2287 } else {
2288 None
2289 },
2290 transonic_mach,
2291 angular_state,
2292 max_yaw_angle: if self.inputs.enable_precession_nutation {
2293 Some(max_yaw_angle)
2294 } else {
2295 None
2296 },
2297 max_precession_angle: if self.inputs.enable_precession_nutation {
2298 Some(max_precession_angle)
2299 } else {
2300 None
2301 },
2302 aerodynamic_jump: aj_components,
2303 mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2304 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2305 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2306 })
2307 }
2308
2309 fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
2310 let mut time = 0.0;
2312 let mut position = self.initial_position();
2317
2318 let aj_components = self.aerodynamic_jump_components();
2324 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
2325 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
2326 let mut velocity = Vector3::new(
2327 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
2331
2332 let mut points = Vec::new();
2333 let mut max_height = position.y;
2334 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
2340 let mut mach_transitions = MachTransitionTracker::default();
2341
2342 let mut angular_state = if self.inputs.enable_precession_nutation {
2344 Some(AngularState {
2345 pitch_angle: 0.001, yaw_angle: 0.001,
2347 pitch_rate: 0.0,
2348 yaw_rate: 0.0,
2349 precession_angle: 0.0,
2350 nutation_phase: 0.0,
2351 })
2352 } else {
2353 None
2354 };
2355 let mut max_yaw_angle = 0.0;
2356 let mut max_precession_angle = 0.0;
2357
2358 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2360 self.resolved_atmosphere();
2361 let base_ratio = air_density / 1.225;
2366
2367 let wind_vector =
2373 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
2374
2375 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2378 self.inputs.bullet_model.as_deref().unwrap_or("default"),
2379 );
2380
2381 while position.x < self.max_range
2383 && position.y > self.inputs.ground_threshold
2384 && time < TRAJECTORY_TIME_LIMIT_S
2385 {
2386 let velocity_magnitude = velocity.magnitude();
2388 let kinetic_energy =
2389 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
2390
2391 self.push_trajectory_point(
2392 &mut points,
2393 TrajectoryPoint {
2394 time,
2395 position,
2396 velocity_magnitude,
2397 kinetic_energy,
2398 },
2399 )?;
2400
2401 {
2404 let mach_here = if speed_of_sound > 0.0 {
2405 velocity_magnitude / speed_of_sound
2406 } else {
2407 0.0
2408 };
2409 mach_transitions.record_downward_crossings(
2410 mach_here,
2411 position.x,
2412 &mut transonic_distances,
2413 );
2414 }
2415
2416 if position.y > max_height {
2417 max_height = position.y;
2418 }
2419
2420 if self.inputs.enable_pitch_damping {
2422 let mach = velocity_magnitude / speed_of_sound;
2423
2424 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2426 transonic_mach = Some(mach);
2427 }
2428
2429 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2431
2432 if pitch_damping < min_pitch_damping {
2434 min_pitch_damping = pitch_damping;
2435 }
2436 }
2437
2438 if self.inputs.enable_precession_nutation {
2440 if let Some(ref mut state) = angular_state {
2441 let velocity_magnitude = velocity.magnitude();
2442 let params = self.precession_nutation_params(
2443 velocity_magnitude,
2444 air_density,
2445 speed_of_sound,
2446 );
2447
2448 *state = calculate_combined_angular_motion(
2450 ¶ms,
2451 state,
2452 time,
2453 self.time_step,
2454 0.001, );
2456
2457 if state.yaw_angle.abs() > max_yaw_angle {
2459 max_yaw_angle = state.yaw_angle.abs();
2460 }
2461 if state.precession_angle.abs() > max_precession_angle {
2462 max_precession_angle = state.precession_angle.abs();
2463 }
2464 }
2465 }
2466
2467 let dt = self.time_step;
2469
2470 let acc1 = self.calculate_acceleration(
2472 &position,
2473 &velocity,
2474 &wind_vector,
2475 (resolved_temp_c, resolved_press_hpa, base_ratio),
2476 );
2477
2478 let pos2 = position + velocity * (dt * 0.5);
2480 let vel2 = velocity + acc1 * (dt * 0.5);
2481 let acc2 = self.calculate_acceleration(
2482 &pos2,
2483 &vel2,
2484 &wind_vector,
2485 (resolved_temp_c, resolved_press_hpa, base_ratio),
2486 );
2487
2488 let pos3 = position + vel2 * (dt * 0.5);
2490 let vel3 = velocity + acc2 * (dt * 0.5);
2491 let acc3 = self.calculate_acceleration(
2492 &pos3,
2493 &vel3,
2494 &wind_vector,
2495 (resolved_temp_c, resolved_press_hpa, base_ratio),
2496 );
2497
2498 let pos4 = position + vel3 * dt;
2500 let vel4 = velocity + acc3 * dt;
2501 let acc4 = self.calculate_acceleration(
2502 &pos4,
2503 &vel4,
2504 &wind_vector,
2505 (resolved_temp_c, resolved_press_hpa, base_ratio),
2506 );
2507
2508 position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
2510 velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
2511 time += dt;
2512 self.validate_integration_state(&position, &velocity, time)?;
2513 }
2514
2515 let termination =
2516 self.append_terminal_endpoint(&mut points, position, velocity, time, &mut max_height)?;
2517
2518 let last_point = points.last().ok_or("No trajectory points generated")?;
2520
2521 let sampled_points = if self.inputs.enable_trajectory_sampling {
2523 let trajectory_data = TrajectoryData {
2524 times: points.iter().map(|p| p.time).collect(),
2525 positions: points.iter().map(|p| p.position).collect(),
2526 velocities: points
2527 .iter()
2528 .map(|p| {
2529 Vector3::new(0.0, 0.0, p.velocity_magnitude)
2531 })
2532 .collect(),
2533 transonic_distances, mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2535 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2536 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2537 };
2538
2539 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
2544 let outputs = TrajectoryOutputs {
2545 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
2547 time_of_flight_s: last_point.time,
2548 max_ord_dist_horiz_m: max_height,
2549 sight_height_m: sight_position_m,
2550 };
2551
2552 let samples = sample_trajectory(
2554 &trajectory_data,
2555 &outputs,
2556 self.inputs.sample_interval,
2557 self.inputs.bullet_mass,
2558 )?;
2559 Some(samples)
2560 } else {
2561 None
2562 };
2563
2564 Ok(TrajectoryResult {
2565 max_range: last_point.position.x, max_height,
2567 time_of_flight: last_point.time,
2568 impact_velocity: last_point.velocity_magnitude,
2569 impact_energy: last_point.kinetic_energy,
2570 projectile_mass_kg: self.inputs.bullet_mass,
2571 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
2572 station_speed_of_sound_mps: speed_of_sound,
2573 termination,
2574 points,
2575 sampled_points,
2576 min_pitch_damping: if self.inputs.enable_pitch_damping {
2577 Some(min_pitch_damping)
2578 } else {
2579 None
2580 },
2581 transonic_mach,
2582 angular_state,
2583 max_yaw_angle: if self.inputs.enable_precession_nutation {
2584 Some(max_yaw_angle)
2585 } else {
2586 None
2587 },
2588 max_precession_angle: if self.inputs.enable_precession_nutation {
2589 Some(max_precession_angle)
2590 } else {
2591 None
2592 },
2593 aerodynamic_jump: aj_components,
2594 mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2595 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2596 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2597 })
2598 }
2599
2600 fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
2601 let mut time = 0.0;
2603 let mut position = self.initial_position();
2607
2608 let aj_components = self.aerodynamic_jump_components();
2614 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
2615 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
2616 let mut velocity = Vector3::new(
2617 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
2621
2622 let mut points = Vec::new();
2623 let mut max_height = position.y;
2624 let mut dt = 0.001; let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2629 self.resolved_atmosphere();
2630 let base_ratio = air_density / 1.225;
2635 let wind_vector =
2640 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
2641
2642 let mut transonic_distances: Vec<f64> = Vec::new();
2644 let mut mach_transitions = MachTransitionTracker::default();
2645
2646 let mut min_pitch_damping = f64::INFINITY;
2651 let mut transonic_mach: Option<f64> = None;
2652 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2653 self.inputs.bullet_model.as_deref().unwrap_or("default"),
2654 );
2655 let mut angular_state = if self.inputs.enable_precession_nutation {
2656 Some(AngularState {
2657 pitch_angle: 0.001,
2658 yaw_angle: 0.001,
2659 pitch_rate: 0.0,
2660 yaw_rate: 0.0,
2661 precession_angle: 0.0,
2662 nutation_phase: 0.0,
2663 })
2664 } else {
2665 None
2666 };
2667 let mut max_yaw_angle = 0.0;
2668 let mut max_precession_angle = 0.0;
2669
2670 while position.x < self.max_range
2671 && position.y > self.inputs.ground_threshold
2672 && time < TRAJECTORY_TIME_LIMIT_S
2673 {
2674 let velocity_magnitude = velocity.magnitude();
2676 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
2677
2678 self.push_trajectory_point(
2679 &mut points,
2680 TrajectoryPoint {
2681 time,
2682 position,
2683 velocity_magnitude,
2684 kinetic_energy,
2685 },
2686 )?;
2687
2688 {
2691 let mach_here = if speed_of_sound > 0.0 {
2692 velocity_magnitude / speed_of_sound
2693 } else {
2694 0.0
2695 };
2696 mach_transitions.record_downward_crossings(
2697 mach_here,
2698 position.x,
2699 &mut transonic_distances,
2700 );
2701 }
2702
2703 if position.y > max_height {
2704 max_height = position.y;
2705 }
2706
2707 if self.inputs.enable_pitch_damping {
2710 let mach = velocity_magnitude / speed_of_sound;
2711 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2712 transonic_mach = Some(mach);
2713 }
2714 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2715 if pitch_damping < min_pitch_damping {
2716 min_pitch_damping = pitch_damping;
2717 }
2718 }
2719
2720 let accepted_step = self.adaptive_rk45_step(
2723 &position,
2724 &velocity,
2725 dt,
2726 &wind_vector,
2727 (resolved_temp_c, resolved_press_hpa, base_ratio),
2728 );
2729 debug_assert!(
2730 accepted_step.error <= RK45_TOLERANCE || accepted_step.used_dt <= RK45_MIN_DT
2731 );
2732
2733 if self.inputs.enable_precession_nutation {
2737 if let Some(ref mut state) = angular_state {
2738 let params = self.precession_nutation_params(
2739 velocity_magnitude,
2740 air_density,
2741 speed_of_sound,
2742 );
2743
2744 *state = calculate_combined_angular_motion(
2745 ¶ms,
2746 state,
2747 time,
2748 accepted_step.used_dt,
2749 0.001,
2750 );
2751
2752 if state.yaw_angle.abs() > max_yaw_angle {
2753 max_yaw_angle = state.yaw_angle.abs();
2754 }
2755 if state.precession_angle.abs() > max_precession_angle {
2756 max_precession_angle = state.precession_angle.abs();
2757 }
2758 }
2759 }
2760
2761 position = accepted_step.position;
2762 velocity = accepted_step.velocity;
2763 time += accepted_step.used_dt;
2764 self.validate_integration_state(&position, &velocity, time)?;
2765
2766 dt = accepted_step.next_dt;
2768 }
2769
2770 if points.is_empty() {
2772 return Err(BallisticsError::from("No trajectory points calculated"));
2773 }
2774
2775 let termination =
2777 self.append_terminal_endpoint(&mut points, position, velocity, time, &mut max_height)?;
2778
2779 let last_point = points.last().unwrap();
2780
2781 let sampled_points = if self.inputs.enable_trajectory_sampling {
2783 let trajectory_data = TrajectoryData {
2785 times: points.iter().map(|p| p.time).collect(),
2786 positions: points.iter().map(|p| p.position).collect(),
2787 velocities: points
2788 .iter()
2789 .map(|p| {
2790 Vector3::new(0.0, 0.0, p.velocity_magnitude)
2792 })
2793 .collect(),
2794 transonic_distances, mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2796 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2797 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2798 };
2799
2800 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
2805 let outputs = TrajectoryOutputs {
2806 target_distance_horiz_m: last_point.position.x,
2807 target_vertical_height_m: sight_position_m,
2808 time_of_flight_s: last_point.time,
2809 max_ord_dist_horiz_m: max_height,
2810 sight_height_m: sight_position_m,
2811 };
2812
2813 let samples = sample_trajectory(
2814 &trajectory_data,
2815 &outputs,
2816 self.inputs.sample_interval,
2817 self.inputs.bullet_mass,
2818 )?;
2819 Some(samples)
2820 } else {
2821 None
2822 };
2823
2824 Ok(TrajectoryResult {
2825 max_range: last_point.position.x, max_height,
2827 time_of_flight: last_point.time,
2828 impact_velocity: last_point.velocity_magnitude,
2829 impact_energy: last_point.kinetic_energy,
2830 projectile_mass_kg: self.inputs.bullet_mass,
2831 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
2832 station_speed_of_sound_mps: speed_of_sound,
2833 termination,
2834 points,
2835 sampled_points,
2836 min_pitch_damping: if self.inputs.enable_pitch_damping {
2837 Some(min_pitch_damping)
2838 } else {
2839 None
2840 },
2841 transonic_mach,
2842 angular_state,
2843 max_yaw_angle: if self.inputs.enable_precession_nutation {
2844 Some(max_yaw_angle)
2845 } else {
2846 None
2847 },
2848 max_precession_angle: if self.inputs.enable_precession_nutation {
2849 Some(max_precession_angle)
2850 } else {
2851 None
2852 },
2853 aerodynamic_jump: aj_components,
2854 mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2855 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2856 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2857 })
2858 }
2859
2860 fn adaptive_rk45_step(
2861 &self,
2862 position: &Vector3<f64>,
2863 velocity: &Vector3<f64>,
2864 initial_dt: f64,
2865 wind_vector: &Vector3<f64>,
2866 resolved_atmo: (f64, f64, f64),
2867 ) -> Rk45AcceptedStep {
2868 let mut trial_dt = initial_dt;
2869
2870 loop {
2871 let trial = self.rk45_step(
2872 position,
2873 velocity,
2874 trial_dt,
2875 wind_vector,
2876 RK45_TOLERANCE,
2877 resolved_atmo,
2878 );
2879 let next_dt = if trial.suggested_dt.is_finite() {
2884 (RK45_SAFETY_FACTOR * trial.suggested_dt).clamp(RK45_MIN_DT, RK45_MAX_DT)
2885 } else {
2886 RK45_MIN_DT
2887 };
2888
2889 if trial.error <= RK45_TOLERANCE || trial_dt <= RK45_MIN_DT {
2890 return Rk45AcceptedStep {
2891 position: trial.position,
2892 velocity: trial.velocity,
2893 used_dt: trial_dt,
2894 next_dt,
2895 error: trial.error,
2896 };
2897 }
2898
2899 trial_dt = next_dt;
2900 }
2901 }
2902
2903 fn rk45_step(
2904 &self,
2905 position: &Vector3<f64>,
2906 velocity: &Vector3<f64>,
2907 dt: f64,
2908 wind_vector: &Vector3<f64>,
2909 tolerance: f64,
2910 resolved_atmo: (f64, f64, f64), ) -> Rk45Trial {
2912 const A21: f64 = 1.0 / 5.0;
2914 const A31: f64 = 3.0 / 40.0;
2915 const A32: f64 = 9.0 / 40.0;
2916 const A41: f64 = 44.0 / 45.0;
2917 const A42: f64 = -56.0 / 15.0;
2918 const A43: f64 = 32.0 / 9.0;
2919 const A51: f64 = 19372.0 / 6561.0;
2920 const A52: f64 = -25360.0 / 2187.0;
2921 const A53: f64 = 64448.0 / 6561.0;
2922 const A54: f64 = -212.0 / 729.0;
2923 const A61: f64 = 9017.0 / 3168.0;
2924 const A62: f64 = -355.0 / 33.0;
2925 const A63: f64 = 46732.0 / 5247.0;
2926 const A64: f64 = 49.0 / 176.0;
2927 const A65: f64 = -5103.0 / 18656.0;
2928 const A71: f64 = 35.0 / 384.0;
2929 const A73: f64 = 500.0 / 1113.0;
2930 const A74: f64 = 125.0 / 192.0;
2931 const A75: f64 = -2187.0 / 6784.0;
2932 const A76: f64 = 11.0 / 84.0;
2933
2934 const B1: f64 = 35.0 / 384.0;
2936 const B3: f64 = 500.0 / 1113.0;
2937 const B4: f64 = 125.0 / 192.0;
2938 const B5: f64 = -2187.0 / 6784.0;
2939 const B6: f64 = 11.0 / 84.0;
2940
2941 const B1_ERR: f64 = 5179.0 / 57600.0;
2943 const B3_ERR: f64 = 7571.0 / 16695.0;
2944 const B4_ERR: f64 = 393.0 / 640.0;
2945 const B5_ERR: f64 = -92097.0 / 339200.0;
2946 const B6_ERR: f64 = 187.0 / 2100.0;
2947 const B7_ERR: f64 = 1.0 / 40.0;
2948
2949 let k1_v = self.calculate_acceleration(position, velocity, wind_vector, resolved_atmo);
2951 let k1_p = *velocity;
2952
2953 let p2 = position + dt * A21 * k1_p;
2954 let v2 = velocity + dt * A21 * k1_v;
2955 let k2_v = self.calculate_acceleration(&p2, &v2, wind_vector, resolved_atmo);
2956 let k2_p = v2;
2957
2958 let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
2959 let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
2960 let k3_v = self.calculate_acceleration(&p3, &v3, wind_vector, resolved_atmo);
2961 let k3_p = v3;
2962
2963 let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
2964 let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
2965 let k4_v = self.calculate_acceleration(&p4, &v4, wind_vector, resolved_atmo);
2966 let k4_p = v4;
2967
2968 let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
2969 let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
2970 let k5_v = self.calculate_acceleration(&p5, &v5, wind_vector, resolved_atmo);
2971 let k5_p = v5;
2972
2973 let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
2974 let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
2975 let k6_v = self.calculate_acceleration(&p6, &v6, wind_vector, resolved_atmo);
2976 let k6_p = v6;
2977
2978 let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
2979 let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
2980 let k7_v = self.calculate_acceleration(&p7, &v7, wind_vector, resolved_atmo);
2981 let k7_p = v7;
2982
2983 let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
2985 let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
2986
2987 let pos_err = position
2989 + dt * (B1_ERR * k1_p
2990 + B3_ERR * k3_p
2991 + B4_ERR * k4_p
2992 + B5_ERR * k5_p
2993 + B6_ERR * k6_p
2994 + B7_ERR * k7_p);
2995 let vel_err = velocity
2996 + dt * (B1_ERR * k1_v
2997 + B3_ERR * k3_v
2998 + B4_ERR * k4_v
2999 + B5_ERR * k5_v
3000 + B6_ERR * k6_v
3001 + B7_ERR * k7_v);
3002
3003 let error = cli_rk45_error_norm(position, velocity, &new_pos, &new_vel, &pos_err, &vel_err);
3005
3006 let dt_new = if error < tolerance {
3008 dt * (tolerance / error).powf(0.2).min(2.0)
3009 } else {
3010 dt * (tolerance / error).powf(0.25).max(0.1)
3011 };
3012
3013 Rk45Trial {
3014 position: new_pos,
3015 velocity: new_vel,
3016 suggested_dt: dt_new,
3017 error,
3018 }
3019 }
3020
3021 fn apply_cluster_bc_correction(&self, base_bc: f64, velocity_fps: f64) -> f64 {
3022 if let Some(ref cluster_bc) = self.cluster_bc {
3023 cluster_bc.apply_correction_for_drag_model(
3024 base_bc,
3025 self.inputs.caliber_inches,
3026 self.inputs.weight_grains,
3027 velocity_fps,
3028 self.inputs.bc_type,
3029 )
3030 } else {
3031 base_bc
3032 }
3033 }
3034
3035 fn calculate_acceleration(
3036 &self,
3037 position: &Vector3<f64>,
3038 velocity: &Vector3<f64>,
3039 wind_vector: &Vector3<f64>,
3040 resolved_atmo: (f64, f64, f64), ) -> Vector3<f64> {
3042 let actual_wind = if let Some(ref sock) = self.wind_sock {
3048 sock.vector_for_range_stateless(position.x)
3049 } else if self.inputs.enable_wind_shear {
3050 self.get_wind_at_altitude(position.y)
3051 } else {
3052 *wind_vector
3053 };
3054 let actual_wind =
3055 crate::derivatives::level_vector_to_shot_frame(actual_wind, self.inputs.shooting_angle);
3056
3057 let relative_velocity = velocity - actual_wind;
3058 let velocity_magnitude = relative_velocity.magnitude();
3059
3060 if velocity_magnitude < 0.001 {
3061 return self.gravity_acceleration();
3062 }
3063
3064 let (base_temp_c, base_press_hpa, station_ratio) = resolved_atmo;
3075
3076 let (drag_base_temp_c, drag_base_press_hpa, drag_base_ratio, drag_humidity_percent) =
3084 if let Some(ref sock) = self.atmo_sock {
3085 let (zone_temp_c, zone_press_hpa, zone_humidity) = sock.atmo_for_range(position.x);
3086 let zone_base_ratio = crate::atmosphere::calculate_air_density_cimp(
3087 zone_temp_c,
3088 zone_press_hpa,
3089 zone_humidity,
3090 ) / 1.225;
3091 (zone_temp_c, zone_press_hpa, zone_base_ratio, zone_humidity)
3092 } else {
3093 (
3094 base_temp_c,
3095 base_press_hpa,
3096 station_ratio,
3097 self.atmosphere.humidity,
3098 )
3099 };
3100 let local_alt = crate::atmosphere::shot_frame_altitude(
3101 self.atmosphere.altitude,
3102 position.x,
3103 position.y,
3104 self.inputs.shooting_angle,
3105 );
3106 let (air_density, speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
3107 local_alt,
3108 self.atmosphere.altitude,
3109 drag_base_temp_c,
3110 drag_base_press_hpa,
3111 drag_base_ratio,
3112 drag_humidity_percent,
3113 );
3114
3115 let cd = self.calculate_drag_coefficient(velocity_magnitude, speed_of_sound);
3117
3118 let velocity_fps = velocity_magnitude * 3.28084;
3120
3121 let (base_bc, bc_from_segments) = if let Some(segments) = self
3126 .inputs
3127 .bc_segments_data
3128 .as_ref()
3129 .filter(|segments| self.inputs.use_bc_segments && !segments.is_empty())
3130 {
3131 (
3133 crate::bc_estimation::velocity_segment_bc(
3134 velocity_fps,
3135 segments,
3136 self.inputs.bc_value,
3137 ),
3138 true,
3139 )
3140 } else if let Some(segments) = self
3141 .inputs
3142 .bc_segments
3143 .as_ref()
3144 .filter(|segments| !segments.is_empty())
3145 {
3146 (
3147 crate::derivatives::interpolated_bc(
3148 velocity_magnitude / speed_of_sound,
3149 segments,
3150 Some(&self.inputs),
3151 ),
3152 true,
3153 )
3154 } else {
3155 (self.inputs.bc_value, false)
3156 };
3157
3158 let effective_bc = if bc_from_segments {
3163 base_bc
3164 } else {
3165 self.apply_cluster_bc_correction(base_bc, velocity_fps)
3166 };
3167 let effective_bc = effective_bc.max(1e-6);
3170
3171 let retard_denom = if self.inputs.custom_drag_table.is_some() {
3176 self.inputs.custom_drag_denominator(effective_bc)
3177 } else {
3178 effective_bc
3179 };
3180
3181 let cd_to_retard = crate::constants::CD_TO_RETARD;
3186 let standard_factor = cd * cd_to_retard;
3187 let density_scale = air_density / 1.225; let a_drag_ft_s2 =
3191 (velocity_fps * velocity_fps) * standard_factor * density_scale / retard_denom;
3192 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
3196
3197 let mut accel = drag_acceleration + self.gravity_acceleration();
3200
3201 if self.inputs.enable_coriolis {
3204 if let Some(lat_deg) = self.inputs.latitude {
3205 let omega_earth = 7.2921159e-5_f64; let lat = lat_deg.to_radians();
3207 let az = self.inputs.shot_azimuth; let omega = Vector3::new(
3214 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), );
3218 let omega = crate::derivatives::level_vector_to_shot_frame(
3219 omega,
3220 self.inputs.shooting_angle,
3221 );
3222 accel += -2.0 * omega.cross(velocity);
3227 }
3228 }
3229
3230 if self.inputs.enable_magnus
3237 && !self.inputs.use_enhanced_spin_drift
3238 && self.inputs.bullet_diameter > 0.0
3239 && self.inputs.twist_rate > 0.0
3240 {
3241 let diameter_m = self.inputs.bullet_diameter;
3242 let (spin_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
3243 self.inputs.muzzle_velocity,
3244 velocity_magnitude,
3245 self.inputs.twist_rate,
3246 diameter_m,
3247 );
3248 let mach = velocity_magnitude / speed_of_sound;
3250
3251 let d_in = self.inputs.bullet_diameter / 0.0254;
3253 let m_gr = self.inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
3254 let l_in = if self.inputs.bullet_length > 0.0 {
3255 self.inputs.bullet_length / 0.0254
3256 } else {
3257 let est_m = crate::stability::estimate_bullet_length_m(
3259 self.inputs.bullet_diameter,
3260 self.inputs.bullet_mass,
3261 );
3262 if est_m > 0.0 {
3263 est_m / 0.0254
3264 } else {
3265 4.5 * d_in
3266 }
3267 };
3268 let sg = crate::spin_drift::calculate_dynamic_stability(
3272 m_gr,
3273 velocity_magnitude,
3274 spin_rad_s,
3275 d_in,
3276 l_in,
3277 air_density,
3278 );
3279
3280 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
3282 sg,
3283 velocity_magnitude,
3284 spin_rad_s,
3285 0.0, 0.0, air_density,
3288 d_in,
3289 l_in,
3290 m_gr,
3291 mach,
3292 "match",
3293 false,
3294 );
3295
3296 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
3298 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
3299 let magnus_force = 0.5
3300 * air_density
3301 * velocity_magnitude.powi(2)
3302 * area
3303 * c_np
3304 * spin_param
3305 * yaw_rad.sin();
3306
3307 if magnus_force.abs() > 1e-12 {
3311 if let Some(dir) = crate::derivatives::yaw_of_repose_magnus_direction(
3312 relative_velocity,
3313 self.gravity_acceleration(),
3314 self.inputs.is_twist_right,
3315 ) {
3316 accel += (magnus_force / self.inputs.bullet_mass) * dir;
3317 }
3318 }
3319 }
3320
3321 accel
3322 }
3323
3324 fn calculate_drag_coefficient(&self, velocity: f64, speed_of_sound: f64) -> f64 {
3325 let mach = velocity / speed_of_sound;
3326
3327 if let Some(ref table) = self.inputs.custom_drag_table {
3331 return table.interpolate(mach) * self.inputs.cd_scale;
3336 }
3337
3338 crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type)
3341 }
3342}
3343
3344#[derive(Debug, Clone)]
3346pub struct MonteCarloParams {
3347 pub num_simulations: usize,
3348 pub velocity_std_dev: f64,
3349 pub angle_std_dev: f64,
3350 pub bc_std_dev: f64,
3351 pub wind_speed_std_dev: f64,
3352 pub target_distance: Option<f64>,
3353 pub base_wind_speed: f64,
3354 pub base_wind_direction: f64,
3355 pub azimuth_std_dev: f64, }
3357
3358impl Default for MonteCarloParams {
3359 fn default() -> Self {
3360 Self {
3361 num_simulations: 1000,
3362 velocity_std_dev: 1.0,
3363 angle_std_dev: 0.001,
3364 bc_std_dev: 0.01,
3365 wind_speed_std_dev: 1.0,
3366 target_distance: None,
3367 base_wind_speed: 0.0,
3368 base_wind_direction: 0.0,
3369 azimuth_std_dev: 0.001, }
3371 }
3372}
3373
3374#[derive(Debug, Clone)]
3376pub struct MonteCarloResults {
3377 pub ranges: Vec<f64>,
3378 pub impact_velocities: Vec<f64>,
3379 pub impact_positions: Vec<Vector3<f64>>,
3385}
3386
3387pub const DEFAULT_HIT_RADIUS_M: f64 = 0.3;
3390
3391pub const TARGET_NOT_REACHED_SENTINEL_M: f64 = -1.0e9;
3397
3398impl MonteCarloResults {
3399 pub fn position_reached_target(position: &Vector3<f64>) -> bool {
3401 position.iter().all(|component| component.is_finite())
3402 && position.y != TARGET_NOT_REACHED_SENTINEL_M
3403 }
3404
3405 pub fn target_arrival_count(&self) -> usize {
3407 self.impact_positions
3408 .iter()
3409 .filter(|position| Self::position_reached_target(position))
3410 .count()
3411 }
3412
3413 pub fn target_shortfall_fraction(&self) -> f64 {
3416 if self.impact_positions.is_empty() {
3417 return 0.0;
3418 }
3419 (self.impact_positions.len() - self.target_arrival_count()) as f64
3420 / self.impact_positions.len() as f64
3421 }
3422
3423 pub fn target_plane_cep(&self) -> Option<f64> {
3429 let mut radial_misses: Vec<f64> = self
3430 .impact_positions
3431 .iter()
3432 .filter(|position| Self::position_reached_target(position))
3433 .map(Vector3::norm)
3434 .filter(|miss| miss.is_finite())
3435 .collect();
3436 radial_misses.sort_by(f64::total_cmp);
3437 if radial_misses.is_empty() {
3438 None
3439 } else {
3440 Some(radial_misses[radial_misses.len() / 2])
3441 }
3442 }
3443
3444 pub fn hit_probability(&self, hit_radius_m: f64) -> f64 {
3453 if self.impact_positions.is_empty() {
3454 return 0.0;
3455 }
3456 let hits = self
3457 .impact_positions
3458 .iter()
3459 .filter(|position| {
3460 Self::position_reached_target(position) && position.norm() < hit_radius_m
3461 })
3462 .count();
3463 hits as f64 / self.impact_positions.len() as f64
3464 }
3465
3466 pub fn rect_hit_probability(&self, width_m: f64, height_m: f64) -> f64 {
3478 let dimensions_invalid = width_m.is_nan()
3479 || width_m <= 0.0
3480 || height_m.is_nan()
3481 || height_m <= 0.0;
3482 if self.impact_positions.is_empty() || dimensions_invalid {
3483 return 0.0;
3484 }
3485 let half_width = width_m / 2.0;
3486 let half_height = height_m / 2.0;
3487 let hits = self
3488 .impact_positions
3489 .iter()
3490 .filter(|position| {
3491 Self::position_reached_target(position)
3492 && position.z.abs() <= half_width
3493 && position.y.abs() <= half_height
3494 })
3495 .count();
3496 hits as f64 / self.impact_positions.len() as f64
3497 }
3498}
3499
3500fn wind_from_signed_speed_sample(
3501 signed_speed: f64,
3502 sampled_direction: f64,
3503 vertical_speed: f64,
3504) -> WindConditions {
3505 if signed_speed < 0.0 {
3510 WindConditions {
3511 speed: -signed_speed,
3512 direction: sampled_direction + std::f64::consts::PI,
3513 vertical_speed,
3514 }
3515 } else {
3516 WindConditions {
3517 speed: signed_speed,
3518 direction: sampled_direction,
3519 vertical_speed,
3520 }
3521 }
3522}
3523
3524struct MonteCarloWindSampler {
3525 speed: rand_distr::Normal<f64>,
3526 direction: rand_distr::Normal<f64>,
3527 vertical_speed: f64,
3529}
3530
3531impl MonteCarloWindSampler {
3532 fn new(
3533 base_wind: &WindConditions,
3534 wind_speed_std_dev: f64,
3535 wind_direction_std_dev: f64,
3536 ) -> Result<Self, BallisticsError> {
3537 use rand_distr::Normal;
3538
3539 if !wind_direction_std_dev.is_finite() || wind_direction_std_dev < 0.0 {
3540 return Err("Wind direction standard deviation must be finite and non-negative".into());
3541 }
3542
3543 let speed = Normal::new(base_wind.speed, wind_speed_std_dev)
3544 .map_err(|e| format!("Invalid wind speed distribution: {e}"))?;
3545 let direction = Normal::new(base_wind.direction, wind_direction_std_dev)
3546 .map_err(|e| format!("Invalid wind direction distribution: {e}"))?;
3547 Ok(Self { speed, direction, vertical_speed: base_wind.vertical_speed })
3548 }
3549
3550 fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> WindConditions {
3551 use rand_distr::Distribution;
3552
3553 wind_from_signed_speed_sample(
3554 self.speed.sample(rng),
3555 self.direction.sample(rng),
3556 self.vertical_speed,
3557 )
3558 }
3559}
3560
3561pub fn run_monte_carlo(
3563 base_inputs: BallisticInputs,
3564 params: MonteCarloParams,
3565) -> Result<MonteCarloResults, BallisticsError> {
3566 run_monte_carlo_with_direction_std_dev(base_inputs, params, 0.0)
3567}
3568
3569pub fn run_monte_carlo_with_direction_std_dev(
3574 base_inputs: BallisticInputs,
3575 params: MonteCarloParams,
3576 wind_direction_std_dev: f64,
3577) -> Result<MonteCarloResults, BallisticsError> {
3578 let base_wind = WindConditions {
3579 speed: params.base_wind_speed,
3580 direction: params.base_wind_direction,
3581 vertical_speed: 0.0,
3582 };
3583 run_monte_carlo_with_wind_and_direction_std_dev(
3584 base_inputs,
3585 base_wind,
3586 params,
3587 wind_direction_std_dev,
3588 )
3589}
3590
3591pub fn run_monte_carlo_with_wind(
3593 base_inputs: BallisticInputs,
3594 base_wind: WindConditions,
3595 params: MonteCarloParams,
3596) -> Result<MonteCarloResults, BallisticsError> {
3597 run_monte_carlo_with_wind_and_direction_std_dev(base_inputs, base_wind, params, 0.0)
3598}
3599
3600pub fn run_monte_carlo_with_wind_and_direction_std_dev(
3605 base_inputs: BallisticInputs,
3606 base_wind: WindConditions,
3607 params: MonteCarloParams,
3608 wind_direction_std_dev: f64,
3609) -> Result<MonteCarloResults, BallisticsError> {
3610 let mut rng = rand::rng();
3611 run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
3612 base_inputs,
3613 base_wind,
3614 params,
3615 wind_direction_std_dev,
3616 &mut rng,
3617 )
3618}
3619
3620pub fn run_monte_carlo_with_wind_and_direction_std_dev_seeded(
3627 base_inputs: BallisticInputs,
3628 base_wind: WindConditions,
3629 params: MonteCarloParams,
3630 wind_direction_std_dev: f64,
3631 seed: u64,
3632) -> Result<MonteCarloResults, BallisticsError> {
3633 use rand::{rngs::StdRng, SeedableRng};
3634 let mut rng = StdRng::seed_from_u64(seed);
3635 run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
3636 base_inputs,
3637 base_wind,
3638 params,
3639 wind_direction_std_dev,
3640 &mut rng,
3641 )
3642}
3643
3644fn run_monte_carlo_with_wind_and_direction_std_dev_using_rng<R: rand::Rng + ?Sized>(
3645 base_inputs: BallisticInputs,
3646 base_wind: WindConditions,
3647 params: MonteCarloParams,
3648 wind_direction_std_dev: f64,
3649 rng: &mut R,
3650) -> Result<MonteCarloResults, BallisticsError> {
3651 use rand_distr::{Distribution, Normal};
3652
3653 let mut ranges = Vec::new();
3654 let mut impact_velocities = Vec::new();
3655 let mut impact_positions = Vec::new();
3656
3657 let atmosphere = AtmosphericConditions {
3658 temperature: base_inputs.temperature,
3659 pressure: base_inputs.pressure,
3660 humidity: base_inputs.humidity_percent(),
3661 altitude: base_inputs.altitude,
3662 };
3663 let target_hint = params
3664 .target_distance
3665 .unwrap_or(base_inputs.target_distance);
3666 let solver_max_range = target_hint.max(1000.0) * 2.0;
3667
3668 let mut baseline_solver =
3670 TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), atmosphere.clone());
3671 baseline_solver.set_max_range(solver_max_range);
3672 let baseline_result = baseline_solver.solve()?;
3673
3674 let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
3676
3677 let baseline_at_target = baseline_result
3679 .position_at_range(target_distance)
3680 .ok_or("Could not interpolate baseline at target distance")?;
3681
3682 let velocity_delta_dist = Normal::new(0.0, params.velocity_std_dev)
3687 .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
3688 let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
3689 .map_err(|e| format!("Invalid angle distribution: {}", e))?;
3690 let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
3691 .map_err(|e| format!("Invalid BC distribution: {}", e))?;
3692 let wind_sampler = MonteCarloWindSampler::new(
3695 &base_wind,
3696 params.wind_speed_std_dev,
3697 wind_direction_std_dev,
3698 )?;
3699 let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
3700 .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
3701
3702 for _ in 0..params.num_simulations {
3703 let mut inputs = base_inputs.clone();
3705 let muzzle_velocity_delta = velocity_delta_dist.sample(&mut *rng);
3706 inputs.muzzle_angle = angle_dist.sample(&mut *rng);
3707 inputs.bc_value = bc_dist.sample(&mut *rng).max(0.01);
3708 inputs.azimuth_angle = azimuth_dist.sample(&mut *rng); let wind = wind_sampler.sample(&mut *rng);
3712
3713 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere.clone());
3715 solver.inputs.muzzle_velocity =
3716 (solver.inputs.muzzle_velocity + muzzle_velocity_delta).max(0.0);
3717 solver.set_max_range(solver_max_range);
3718 match solver.solve() {
3719 Ok(result) => {
3720 let deviation = if result.max_range < target_distance {
3726 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)
3729 } else {
3730 let pos_at_target = match result.position_at_range(target_distance) {
3731 Some(p) => p,
3732 None => continue, };
3734 Vector3::new(
3739 0.0,
3740 pos_at_target.y - baseline_at_target.y,
3741 pos_at_target.z - baseline_at_target.z,
3742 )
3743 };
3744
3745 ranges.push(result.max_range);
3746 impact_velocities.push(result.impact_velocity);
3747 impact_positions.push(deviation);
3748 }
3749 Err(_) => {
3750 continue;
3752 }
3753 }
3754 }
3755
3756 if ranges.is_empty() {
3757 return Err("No successful simulations".into());
3758 }
3759
3760 Ok(MonteCarloResults {
3761 ranges,
3762 impact_velocities,
3763 impact_positions,
3764 })
3765}
3766
3767pub fn calculate_zero_angle(
3769 inputs: BallisticInputs,
3770 target_distance: f64,
3771 target_height: f64,
3772) -> Result<f64, BallisticsError> {
3773 calculate_zero_angle_with_conditions(
3774 inputs,
3775 target_distance,
3776 target_height,
3777 WindConditions::default(),
3778 AtmosphericConditions::default(),
3779 )
3780}
3781
3782pub fn calculate_zero_angle_with_conditions(
3783 inputs: BallisticInputs,
3784 target_distance: f64,
3785 target_height: f64,
3786 wind: WindConditions,
3787 atmosphere: AtmosphericConditions,
3788) -> Result<f64, BallisticsError> {
3789 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
3790 solver.calculate_and_set_zero_angle(target_distance, target_height, ZeroTargetFrame::SightLine)
3791}
3792
3793pub fn calculate_zero_angle_with_resolved_conditions(
3799 inputs: BallisticInputs,
3800 target_distance: f64,
3801 target_height: f64,
3802 wind: WindConditions,
3803 atmosphere: AtmosphericConditions,
3804) -> Result<f64, BallisticsError> {
3805 let mut solver = TrajectorySolver::new_with_resolved_station_atmosphere(inputs, wind, atmosphere);
3806 solver.calculate_and_set_zero_angle(target_distance, target_height, ZeroTargetFrame::SightLine)
3807}
3808
3809const ZERO_RANGE_FROM_ANGLE_MAX_RANGE_M: f64 = 2000.0;
3817
3818pub fn calculate_zero_range_from_angle_with_conditions(
3833 inputs: BallisticInputs,
3834 zero_angle_rad: f64,
3835 target_height: f64,
3836 wind: WindConditions,
3837 atmosphere: AtmosphericConditions,
3838) -> Result<ZeroCrossings, BallisticsError> {
3839 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
3840 solver.set_max_range(ZERO_RANGE_FROM_ANGLE_MAX_RANGE_M);
3841 solver.find_zero_range(zero_angle_rad, target_height, ZeroTargetFrame::SightLine)
3842}
3843
3844pub fn calculate_zero_range_from_angle_with_resolved_conditions(
3850 inputs: BallisticInputs,
3851 zero_angle_rad: f64,
3852 target_height: f64,
3853 wind: WindConditions,
3854 atmosphere: AtmosphericConditions,
3855) -> Result<ZeroCrossings, BallisticsError> {
3856 let mut solver = TrajectorySolver::new_with_resolved_station_atmosphere(inputs, wind, atmosphere);
3857 solver.set_max_range(ZERO_RANGE_FROM_ANGLE_MAX_RANGE_M);
3858 solver.find_zero_range(zero_angle_rad, target_height, ZeroTargetFrame::SightLine)
3859}
3860
3861#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3863pub enum BcFitMode {
3864 Drop,
3866 Velocity,
3869}
3870
3871#[derive(Debug, Clone, Copy)]
3873pub struct BcEstimate {
3874 pub bc: f64,
3876 pub rms_error: f64,
3878 pub drag_model: DragModel,
3880 pub mode: BcFitMode,
3882 pub at_bound: bool,
3886}
3887
3888fn fit_value_at(
3896 points: &[TrajectoryPoint],
3897 target_dist: f64,
3898 mode: BcFitMode,
3899 drop_offset: f64,
3900) -> Option<f64> {
3901 let val = |p: &TrajectoryPoint| match mode {
3902 BcFitMode::Drop => drop_offset - p.position.y,
3903 BcFitMode::Velocity => p.velocity_magnitude,
3904 };
3905 for i in 0..points.len() {
3906 if points[i].position.x >= target_dist {
3907 if i == 0 {
3908 return Some(val(&points[0]));
3909 }
3910 let p1 = &points[i - 1];
3911 let p2 = &points[i];
3912 let dx = p2.position.x - p1.position.x;
3913 if dx.abs() < 1e-9 {
3914 return Some(val(p2));
3915 }
3916 let t = (target_dist - p1.position.x) / dx;
3917 return Some(val(p1) + t * (val(p2) - val(p1)));
3918 }
3919 }
3920 None
3921}
3922
3923fn fit_residual_sse(
3924 trajectory: &[TrajectoryPoint],
3925 observations: &[(f64, f64)],
3926 mode: BcFitMode,
3927 drop_offset: f64,
3928) -> Option<f64> {
3929 if observations.is_empty() {
3930 return None;
3931 }
3932 let mut total = 0.0;
3933 for (target_dist, target_val) in observations {
3934 let value = fit_value_at(trajectory, *target_dist, mode, drop_offset)?;
3937 let error = value - target_val;
3938 total += error * error;
3939 }
3940 Some(total)
3941}
3942
3943#[allow(clippy::too_many_arguments)] pub fn estimate_bc_fit(
3961 velocity: f64,
3962 mass: f64,
3963 diameter: f64,
3964 points: &[(f64, f64)],
3965 drag_model: DragModel,
3966 mode: BcFitMode,
3967 atmosphere: AtmosphericConditions,
3968 zero_range: Option<f64>,
3969 sight_height: f64,
3970) -> Result<BcEstimate, BallisticsError> {
3971 if points.is_empty() {
3972 return Err(BallisticsError::from(
3973 "No data points provided for BC estimation.".to_string(),
3974 ));
3975 }
3976 let max_dist = points.iter().map(|(d, _)| *d).fold(0.0_f64, f64::max);
3977 let drop_offset = if zero_range.is_some() { sight_height } else { 0.0 };
3980
3981 let sse = |bc_value: f64| -> Option<f64> {
3983 let mut inputs = BallisticInputs {
3984 muzzle_velocity: velocity,
3985 bc_value,
3986 bc_type: drag_model,
3987 bullet_mass: mass,
3988 bullet_diameter: diameter,
3989 sight_height,
3990 ..Default::default()
3991 };
3992 if let Some(zr) = zero_range {
3995 let za = calculate_zero_angle_with_conditions(
4001 inputs.clone(),
4002 zr,
4003 sight_height,
4004 WindConditions::default(),
4005 atmosphere.clone(),
4006 )
4007 .ok()?;
4008 inputs.muzzle_angle = za;
4009 }
4010 let mut solver =
4011 TrajectorySolver::new(inputs, WindConditions::default(), atmosphere.clone());
4012 solver.set_max_range(max_dist * 1.5);
4013 let result = solver.solve().ok()?;
4014 fit_residual_sse(&result.points, points, mode, drop_offset)
4015 };
4016
4017 let (bc_min, bc_max) = match drag_model {
4021 DragModel::G7 => (0.05, 0.70),
4022 _ => (0.10, 1.20),
4023 };
4024
4025 let mut best_bc = f64::NAN;
4027 let mut best_sse = f64::MAX;
4028 let mut bc = bc_min;
4029 while bc <= bc_max + 1e-9 {
4030 if let Some(s) = sse(bc) {
4031 if s < best_sse {
4032 best_sse = s;
4033 best_bc = bc;
4034 }
4035 }
4036 bc += 0.01;
4037 }
4038 if !best_bc.is_finite() {
4039 return Err(BallisticsError::from(
4040 "Unable to estimate BC from provided data. Check that the values and units are correct."
4041 .to_string(),
4042 ));
4043 }
4044
4045 let lo = (best_bc - 0.01).max(bc_min);
4047 let hi = (best_bc + 0.01).min(bc_max);
4048 let mut bc = lo;
4049 while bc <= hi + 1e-9 {
4050 if let Some(s) = sse(bc) {
4051 if s < best_sse {
4052 best_sse = s;
4053 best_bc = bc;
4054 }
4055 }
4056 bc += 0.001;
4057 }
4058
4059 let at_bound = best_bc <= bc_min + 0.011 || best_bc >= bc_max - 0.011;
4062 let rms_error = (best_sse / points.len() as f64).sqrt();
4065 Ok(BcEstimate {
4066 bc: best_bc,
4067 rms_error,
4068 drag_model,
4069 mode,
4070 at_bound,
4071 })
4072}
4073
4074pub fn estimate_bc_from_trajectory(
4077 velocity: f64,
4078 mass: f64,
4079 diameter: f64,
4080 points: &[(f64, f64)], ) -> Result<f64, BallisticsError> {
4082 estimate_bc_fit(
4083 velocity,
4084 mass,
4085 diameter,
4086 points,
4087 DragModel::G1,
4088 BcFitMode::Drop,
4089 AtmosphericConditions::default(),
4090 None,
4091 0.05,
4092 )
4093 .map(|e| e.bc)
4094}
4095
4096use rand;
4098use rand_distr;
4099
4100#[cfg(test)]
4101mod mba737_powder_resolution_tests {
4102 use super::*;
4103
4104 #[test]
4105 fn linear_model_cold_powder_subtracts() {
4106 let v = resolve_powder_adjusted_velocity(823.0, 11.1, true, 0.5486, 21.1, None, None);
4108 assert!((v - (823.0 + 0.5486 * (11.1 - 21.1))).abs() < 1e-12);
4109 assert!(v < 823.0);
4110 }
4111
4112 #[test]
4113 fn linear_model_hot_powder_adds() {
4114 let v = resolve_powder_adjusted_velocity(823.0, 31.1, true, 0.5486, 21.1, None, None);
4115 assert!((v - (823.0 + 0.5486 * 10.0)).abs() < 1e-12);
4116 }
4117
4118 #[test]
4119 fn disabled_flag_is_passthrough() {
4120 let v = resolve_powder_adjusted_velocity(823.0, 40.0, false, 0.5486, 21.1, None, None);
4121 assert_eq!(v, 823.0);
4122 }
4123
4124 #[test]
4125 fn curve_overrides_linear_and_interpolates_at_powder_temp() {
4126 let curve = [(4.4, 798.6), (21.1, 823.0), (37.8, 841.2)];
4127 let v = resolve_powder_adjusted_velocity(823.0, 30.0, true, 99.0, 21.1, Some(&curve), Some(4.4));
4129 assert!((v - 798.6).abs() < 1e-9);
4130 }
4131
4132 #[test]
4133 fn curve_falls_back_to_ambient_and_clamps() {
4134 let curve = [(4.4, 798.6), (37.8, 841.2)];
4135 let v = resolve_powder_adjusted_velocity(823.0, -40.0, true, 1.0, 21.1, Some(&curve), None);
4137 assert!((v - 798.6).abs() < 1e-9);
4138 let v_hot = resolve_powder_adjusted_velocity(823.0, 60.0, true, 1.0, 21.1, Some(&curve), None);
4139 assert!((v_hot - 841.2).abs() < 1e-9);
4140 }
4141
4142 #[test]
4143 fn empty_curve_suppresses_linear_fallback() {
4144 let v = resolve_powder_adjusted_velocity(823.0, 40.0, true, 0.5486, 21.1, Some(&[]), None);
4146 assert_eq!(v, 823.0);
4147 }
4148
4149 #[test]
4150 fn sweep_huge_range_errors_instead_of_overflowing() {
4151 assert!(parse_powder_sweep("0:1e20:1").is_err());
4154 assert!(parse_powder_sweep("0:1e308:1e-3").is_err());
4155 }
4156
4157 #[test]
4158 fn sweep_fractional_step_keeps_end_row() {
4159 let rows = parse_powder_sweep("0:0.3:0.1").unwrap();
4161 assert_eq!(rows.len(), 4);
4162 assert!((rows[3] - 0.3).abs() < 1e-9);
4163 }
4164
4165 #[test]
4166 fn solver_and_helper_agree_on_linear_model() {
4167 let inputs = BallisticInputs {
4169 use_powder_sensitivity: true,
4170 powder_temp_sensitivity: 0.5486,
4171 powder_temp: 21.1,
4172 temperature: 4.4,
4173 ..Default::default()
4174 };
4175 let expected = resolve_powder_adjusted_velocity(
4176 inputs.muzzle_velocity,
4177 inputs.temperature,
4178 true,
4179 0.5486,
4180 21.1,
4181 None,
4182 None,
4183 );
4184 let solver = TrajectorySolver::new(
4185 inputs,
4186 WindConditions::default(),
4187 AtmosphericConditions::default(),
4188 );
4189 assert!((solver.inputs.muzzle_velocity - expected).abs() < 1e-12);
4190 }
4191}
4192
4193#[cfg(test)]
4194mod mba1302_solver_seam_tests {
4195 use super::*;
4196 use crate::wind::WindSegment;
4197
4198 #[test]
4199 fn authoritative_station_atmosphere_preserves_explicit_standard_values_at_altitude() {
4200 let atmosphere = AtmosphericConditions {
4201 temperature: 15.0,
4202 pressure: 1013.25,
4203 humidity: 50.0,
4204 altitude: 2_000.0,
4205 };
4206 let legacy = TrajectorySolver::new(
4207 BallisticInputs::default(),
4208 WindConditions::default(),
4209 atmosphere.clone(),
4210 );
4211 let authoritative = TrajectorySolver::new_with_resolved_station_atmosphere(
4212 BallisticInputs::default(),
4213 WindConditions::default(),
4214 atmosphere,
4215 );
4216
4217 let (legacy_density, _, legacy_temp_c, legacy_pressure_hpa) = legacy.resolved_atmosphere();
4218 let (authoritative_density, _, authoritative_temp_c, authoritative_pressure_hpa) =
4219 authoritative.resolved_atmosphere();
4220 let (icao_temp_k, icao_pressure_pa) =
4221 crate::atmosphere::calculate_icao_standard_atmosphere(2_000.0);
4222 let (expected_authoritative_density, _) =
4223 crate::atmosphere::calculate_atmosphere(2_000.0, Some(15.0), Some(1013.25), 50.0);
4224
4225 assert!((legacy_temp_c - (icao_temp_k - 273.15)).abs() < 1e-12);
4226 assert!((legacy_pressure_hpa - icao_pressure_pa / 100.0).abs() < 1e-12);
4227 assert_eq!(authoritative_temp_c.to_bits(), 15.0_f64.to_bits());
4228 assert_eq!(authoritative_pressure_hpa.to_bits(), 1013.25_f64.to_bits());
4229 assert_eq!(
4230 authoritative_density.to_bits(),
4231 expected_authoritative_density.to_bits()
4232 );
4233 assert!(
4234 (authoritative_density - legacy_density).abs() > 0.1,
4235 "explicit standard values at altitude must differ from ICAO-at-altitude: explicit={authoritative_density}, ICAO={legacy_density}"
4236 );
4237 }
4238
4239 #[test]
4248 fn precomputed_absolute_resolution_via_authoritative_matches_legacy_new() {
4249 for (temperature, pressure, altitude) in [
4250 (15.0, 1013.25, 0.0), (15.0, 1013.25, 2000.0), (-5.0, 850.0, 2000.0), (22.0, 950.0, 500.0),
4254 ] {
4255 let atmosphere = AtmosphericConditions {
4256 temperature,
4257 pressure,
4258 humidity: 50.0,
4259 altitude,
4260 };
4261 let legacy = TrajectorySolver::new(
4262 BallisticInputs::default(),
4263 WindConditions::default(),
4264 atmosphere.clone(),
4265 );
4266
4267 let (resolved_temp_c, resolved_pressure_hpa) =
4268 crate::atmosphere::resolve_station_conditions_with_pressure_mode(
4269 temperature,
4270 pressure,
4271 altitude,
4272 crate::atmosphere::PressureReferenceMode::Absolute,
4273 );
4274 let precomputed_atmosphere = AtmosphericConditions {
4275 temperature: resolved_temp_c,
4276 pressure: resolved_pressure_hpa,
4277 humidity: 50.0,
4278 altitude,
4279 };
4280 let precomputed = TrajectorySolver::new_with_resolved_station_atmosphere(
4281 BallisticInputs::default(),
4282 WindConditions::default(),
4283 precomputed_atmosphere,
4284 );
4285
4286 let (legacy_density, legacy_sos, legacy_temp_c, legacy_pressure_hpa) =
4287 legacy.resolved_atmosphere();
4288 let (pre_density, pre_sos, pre_temp_c, pre_pressure_hpa) =
4289 precomputed.resolved_atmosphere();
4290
4291 assert_eq!(
4292 legacy_temp_c.to_bits(),
4293 pre_temp_c.to_bits(),
4294 "temperature=({temperature}, {pressure}, {altitude})"
4295 );
4296 assert_eq!(
4297 legacy_pressure_hpa.to_bits(),
4298 pre_pressure_hpa.to_bits(),
4299 "pressure=({temperature}, {pressure}, {altitude})"
4300 );
4301 assert_eq!(legacy_density.to_bits(), pre_density.to_bits());
4302 assert_eq!(legacy_sos.to_bits(), pre_sos.to_bits());
4303 }
4304 }
4305
4306 fn configured_euler_zero(vertical_wind_mps: f64, time_step_s: f64) -> TrajectorySolver {
4307 let inputs = BallisticInputs {
4308 muzzle_velocity: 800.0,
4309 bc_value: 0.5,
4310 bc_type: DragModel::G7,
4311 bullet_mass: 0.0109,
4312 bullet_diameter: 0.00782,
4313 bullet_length: 0.0309,
4314 sight_height: 0.05,
4315 ground_threshold: -100.0,
4316 use_rk4: false,
4317 use_adaptive_rk45: false,
4318 ..BallisticInputs::default()
4319 };
4320 let mut solver = TrajectorySolver::new_with_resolved_station_atmosphere(
4321 inputs,
4322 WindConditions::default(),
4323 AtmosphericConditions::default(),
4324 );
4325 solver.set_max_range(300.0);
4326 solver.set_time_step(time_step_s);
4327 if vertical_wind_mps != 0.0 {
4328 solver.set_wind_segments(vec![WindSegment {
4329 speed_kmh: 0.0,
4330 angle_deg: 0.0,
4331 until_m: 400.0,
4332 vertical_mps: vertical_wind_mps,
4333 }]);
4334 }
4335 solver
4336 }
4337
4338 #[test]
4339 fn inclined_shot_zeroes_like_a_level_rifle() {
4340 const ZERO_DISTANCE_M: f64 = 91.44; const SIGHT_HEIGHT_M: f64 = 0.0381; let inputs = BallisticInputs {
4349 bc_value: 0.5,
4350 bullet_mass: 150.0 * 0.06479891 / 1000.0,
4351 muzzle_velocity: 2700.0 * 0.3048,
4352 sight_height: SIGHT_HEIGHT_M,
4353 ..Default::default()
4354 };
4355
4356 let mut level = inputs.clone();
4357 level.shooting_angle = 0.0;
4358 let level_angle = TrajectorySolver::new(level, Default::default(), Default::default())
4359 .find_zero_angle(ZERO_DISTANCE_M, SIGHT_HEIGHT_M, ZeroTargetFrame::SightLine)
4360 .expect("level zero must solve");
4361
4362 let mut inclined = inputs;
4363 inclined.shooting_angle = 5.71_f64.to_radians();
4364 let inclined_angle =
4365 TrajectorySolver::new(inclined, Default::default(), Default::default())
4366 .find_zero_angle(ZERO_DISTANCE_M, SIGHT_HEIGHT_M, ZeroTargetFrame::SightLine)
4367 .expect("MBA-1412: a 5.71 deg incline at a 100 yd zero must be solvable");
4368
4369 assert!(
4370 (inclined_angle - level_angle).abs() < 1e-9,
4371 "zeroing is level-rifle sight geometry; incline must not move the solved zero: \
4372 level={level_angle}, inclined={inclined_angle}"
4373 );
4374 }
4375
4376 #[test]
4377 fn configured_zero_keeps_segments_method_and_time_step_then_sets_base_angle() {
4378 const TARGET_DISTANCE_M: f64 = 150.0;
4379 const TARGET_HEIGHT_M: f64 = 0.05;
4380
4381 let mut segmented = configured_euler_zero(-10.0, 0.02);
4384 let coarse_height = segmented
4385 .zero_trial_height_at(0.0, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
4386 .expect("coarse configured trial")
4387 .expect("coarse trial reaches target");
4388 let mut fine = segmented.clone();
4389 fine.set_time_step(0.001);
4390 let fine_height = fine
4391 .zero_trial_height_at(0.0, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
4392 .expect("fine configured trial")
4393 .expect("fine trial reaches target");
4394 assert!(
4395 (coarse_height - fine_height).abs() > 1e-5,
4396 "configured Euler step must affect zero trials: coarse={coarse_height}, fine={fine_height}"
4397 );
4398
4399 let segmented_angle = segmented
4400 .calculate_and_set_zero_angle(TARGET_DISTANCE_M, TARGET_HEIGHT_M, ZeroTargetFrame::SightLine)
4401 .expect("segmented zero");
4402 assert_eq!(
4403 segmented.inputs.muzzle_angle.to_bits(),
4404 segmented_angle.to_bits(),
4405 "successful zero must install its angle on the configured solver"
4406 );
4407 assert_eq!(segmented.time_step.to_bits(), 0.02_f64.to_bits());
4408 assert_eq!(segmented.max_range.to_bits(), 300.0_f64.to_bits());
4409 assert!(segmented.wind_sock.is_some());
4410 assert_eq!(
4411 segmented.station_atmosphere_resolution,
4412 StationAtmosphereResolution::Authoritative
4413 );
4414 let zero_height = segmented
4415 .zero_trial_height_at(segmented_angle, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
4416 .expect("verify segmented zero")
4417 .expect("zeroed trial reaches target");
4418 assert!(
4419 (zero_height - TARGET_HEIGHT_M).abs() < 0.0001,
4420 "configured zero missed target: height={zero_height}"
4421 );
4422
4423 let mut calm = configured_euler_zero(0.0, 0.02);
4424 let calm_angle = calm
4425 .calculate_and_set_zero_angle(TARGET_DISTANCE_M, TARGET_HEIGHT_M, ZeroTargetFrame::SightLine)
4426 .expect("calm zero");
4427 assert!(
4428 (segmented_angle - calm_angle).abs() > 1e-5,
4429 "segmented vertical wind must participate in zero trials: segmented={segmented_angle}, calm={calm_angle}"
4430 );
4431 }
4432}
4433
4434#[cfg(test)]
4435mod result_sanity_tests {
4436 use super::*;
4437
4438 fn default_solver() -> TrajectorySolver {
4439 TrajectorySolver::new(
4440 BallisticInputs::default(),
4441 WindConditions::default(),
4442 AtmosphericConditions::default(),
4443 )
4444 }
4445
4446 fn minimal_result() -> TrajectoryResult {
4447 TrajectoryResult {
4448 max_range: 100.0,
4449 max_height: 1.0,
4450 time_of_flight: 0.5,
4451 impact_velocity: 700.0,
4452 impact_energy: 2450.0,
4453 projectile_mass_kg: 0.01,
4454 line_of_sight_height_m: 1.5,
4455 station_speed_of_sound_mps: 340.0,
4456 termination: TrajectoryTermination::MaxRange,
4457 points: vec![],
4458 sampled_points: None,
4459 min_pitch_damping: None,
4460 transonic_mach: None,
4461 angular_state: None,
4462 max_yaw_angle: None,
4463 max_precession_angle: None,
4464 aerodynamic_jump: None,
4465 mach_1_2_distance_m: None,
4466 mach_1_0_distance_m: None,
4467 mach_0_9_distance_m: None,
4468 }
4469 }
4470
4471 #[test]
4472 fn mba1293_negative_scalars_fail_the_result_postcondition() {
4473 let solver = default_solver();
4474 solver
4475 .validate_result_sanity(&minimal_result())
4476 .expect("a sane result must pass");
4477
4478 for (name, mutate) in [
4479 ("max_range", (|r| r.max_range = -50.588) as fn(&mut TrajectoryResult)),
4480 ("time_of_flight", |r| r.time_of_flight = -1.0),
4481 ("impact_velocity", |r| r.impact_velocity = -700.0),
4482 ("impact_energy", |r| r.impact_energy = -1.0),
4483 ] {
4484 let mut result = minimal_result();
4485 mutate(&mut result);
4486 let error = solver
4487 .validate_result_sanity(&result)
4488 .expect_err("negative scalar must fail");
4489 assert!(
4490 error.to_string().contains(name),
4491 "error for {name} did not name the field: {error}"
4492 );
4493 }
4494 }
4495
4496 #[test]
4497 fn mba1293_speed_budget_bounds_legitimate_states_and_rejects_divergence() {
4498 let solver = default_solver();
4499 let mv = solver.inputs.muzzle_velocity;
4500
4501 let position = Vector3::new(10.0, 0.0, 0.0);
4503 solver
4504 .validate_integration_state(&position, &Vector3::new(mv, 0.0, 0.0), 0.01)
4505 .expect("muzzle-speed state must pass");
4506
4507 let error = solver
4509 .validate_integration_state(&position, &Vector3::new(-13.0 * mv, 0.0, 0.0), 0.01)
4510 .expect_err("13x muzzle speed must fail the budget");
4511 assert!(error.to_string().contains("diverged"), "{error}");
4512
4513 let after_fall = mv + crate::constants::G_ACCEL_MPS2 * 60.0;
4515 solver
4516 .validate_integration_state(&position, &Vector3::new(0.0, -after_fall, 0.0), 60.0)
4517 .expect("gravity-accelerated speed within g*t must pass");
4518 }
4519}
4520
4521#[cfg(test)]
4522mod trajectory_point_budget_tests {
4523 use super::*;
4524 use crate::MAX_TRAJECTORY_SAMPLES;
4525
4526 fn solver_with_budget(
4527 use_rk4: bool,
4528 use_adaptive_rk45: bool,
4529 point_budget: usize,
4530 max_range: f64,
4531 ) -> TrajectorySolver {
4532 let inputs = BallisticInputs {
4533 use_rk4,
4534 use_adaptive_rk45,
4535 ground_threshold: f64::NEG_INFINITY,
4536 ..BallisticInputs::default()
4537 };
4538 let mut solver = TrajectorySolver::new(
4539 inputs,
4540 WindConditions::default(),
4541 AtmosphericConditions::default(),
4542 );
4543 solver.max_trajectory_points = point_budget;
4544 solver.set_max_range(max_range);
4545 solver.set_time_step(0.001);
4546 solver
4547 }
4548
4549 #[test]
4550 fn mba1283_every_solver_errors_instead_of_exceeding_point_budget() {
4551 for (mode, use_rk4, use_adaptive_rk45) in [
4552 ("Euler", false, false),
4553 ("RK4", true, false),
4554 ("RK45", true, true),
4555 ] {
4556 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 3, 10.0)
4557 .solve()
4558 .expect_err("a solve requiring more than three points must fail");
4559 assert!(
4560 error.to_string().contains("point limit of 3"),
4561 "unexpected {mode} point-budget error: {error}"
4562 );
4563 }
4564 }
4565
4566 #[test]
4567 fn mba1283_interpolated_endpoint_counts_toward_point_budget() {
4568 for (mode, use_rk4, use_adaptive_rk45) in [
4569 ("Euler", false, false),
4570 ("RK4", true, false),
4571 ("RK45", true, true),
4572 ] {
4573 let result = solver_with_budget(use_rk4, use_adaptive_rk45, 2, 0.1)
4574 .solve()
4575 .expect("the initial point plus exact endpoint fit a two-point budget");
4576 assert_eq!(result.points.len(), 2, "unexpected {mode} point count");
4577
4578 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 1, 0.1)
4579 .solve()
4580 .expect_err("the exact endpoint must not exceed a one-point budget");
4581 assert!(
4582 error.to_string().contains("point limit of 1"),
4583 "unexpected {mode} endpoint-budget error: {error}"
4584 );
4585 }
4586 }
4587
4588 #[test]
4589 fn mba1299_every_solver_preflights_the_sample_budget() {
4590 for (mode, use_rk4, use_adaptive_rk45) in [
4591 ("Euler", false, false),
4592 ("RK4", true, false),
4593 ("RK45", true, true),
4594 ] {
4595 let inputs = BallisticInputs {
4596 use_rk4,
4597 use_adaptive_rk45,
4598 enable_trajectory_sampling: true,
4599 sample_interval: 1.0,
4600 ground_threshold: f64::NEG_INFINITY,
4601 ..BallisticInputs::default()
4602 };
4603 let mut solver = TrajectorySolver::new(
4604 inputs,
4605 WindConditions::default(),
4606 AtmosphericConditions::default(),
4607 );
4608 solver.set_max_range(MAX_TRAJECTORY_SAMPLES as f64);
4609 solver.max_trajectory_points = 0;
4612
4613 let error = solver
4614 .solve()
4615 .expect_err("an over-limit sample grid must fail before integration");
4616 assert!(
4617 error
4618 .to_string()
4619 .contains("trajectory sample limit of 250000 exceeded"),
4620 "unexpected {mode} sample-budget error: {error}"
4621 );
4622 }
4623 }
4624
4625 #[test]
4626 fn mba1299_normal_sampling_does_not_change_solver_results() {
4627 for (mode, use_rk4, use_adaptive_rk45) in [
4628 ("Euler", false, false),
4629 ("RK4", true, false),
4630 ("RK45", true, true),
4631 ] {
4632 let solve = |enable_trajectory_sampling| {
4633 let inputs = BallisticInputs {
4634 use_rk4,
4635 use_adaptive_rk45,
4636 enable_trajectory_sampling,
4637 sample_interval: 0.5,
4638 ground_threshold: f64::NEG_INFINITY,
4639 ..BallisticInputs::default()
4640 };
4641 let mut solver = TrajectorySolver::new(
4642 inputs,
4643 WindConditions::default(),
4644 AtmosphericConditions::default(),
4645 );
4646 solver.set_max_range(2.0);
4647 solver.solve().expect("normal short-range solve")
4648 };
4649
4650 let baseline = solve(false);
4651 let sampled = solve(true);
4652 for (field, left, right) in [
4653 ("max_range", baseline.max_range, sampled.max_range),
4654 ("max_height", baseline.max_height, sampled.max_height),
4655 (
4656 "time_of_flight",
4657 baseline.time_of_flight,
4658 sampled.time_of_flight,
4659 ),
4660 (
4661 "impact_velocity",
4662 baseline.impact_velocity,
4663 sampled.impact_velocity,
4664 ),
4665 (
4666 "impact_energy",
4667 baseline.impact_energy,
4668 sampled.impact_energy,
4669 ),
4670 ] {
4671 assert_eq!(
4672 left.to_bits(),
4673 right.to_bits(),
4674 "{mode} sampling changed {field}"
4675 );
4676 }
4677 assert_eq!(baseline.points.len(), sampled.points.len());
4678 for (index, (left, right)) in baseline
4679 .points
4680 .iter()
4681 .zip(&sampled.points)
4682 .enumerate()
4683 {
4684 assert_eq!(left.time.to_bits(), right.time.to_bits(), "{mode} point {index}");
4685 assert_eq!(
4686 left.position.map(f64::to_bits),
4687 right.position.map(f64::to_bits),
4688 "{mode} point {index} position"
4689 );
4690 assert_eq!(
4691 left.velocity_magnitude.to_bits(),
4692 right.velocity_magnitude.to_bits(),
4693 "{mode} point {index} velocity"
4694 );
4695 assert_eq!(
4696 left.kinetic_energy.to_bits(),
4697 right.kinetic_energy.to_bits(),
4698 "{mode} point {index} energy"
4699 );
4700 }
4701 assert!(baseline.sampled_points.is_none());
4702 let samples = sampled
4703 .sampled_points
4704 .expect("sampling-enabled solve should return observations");
4705 assert_eq!(
4706 samples
4707 .iter()
4708 .map(|sample| sample.distance_m)
4709 .collect::<Vec<_>>(),
4710 vec![0.0, 0.5, 1.0, 1.5, 2.0],
4711 "{mode} normal sampling grid changed"
4712 );
4713 }
4714 }
4715}
4716
4717#[cfg(test)]
4718mod monte_carlo_result_tests {
4719 use super::*;
4720
4721 fn make_results(impact_positions: Vec<Vector3<f64>>) -> MonteCarloResults {
4722 let count = impact_positions.len();
4723 MonteCarloResults {
4724 ranges: vec![500.0; count],
4725 impact_velocities: vec![300.0; count],
4726 impact_positions,
4727 }
4728 }
4729
4730 #[test]
4731 fn target_plane_cep_excludes_shortfall_markers() {
4732 let mut positions: Vec<Vector3<f64>> = (1..=5)
4733 .map(|radius| Vector3::new(0.0, radius as f64, 0.0))
4734 .collect();
4735 positions.extend(
4736 (0..5).map(|_| Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)),
4737 );
4738 let results = make_results(positions);
4739
4740 assert_eq!(results.target_arrival_count(), 5);
4741 assert_eq!(results.target_shortfall_fraction(), 0.5);
4742 assert_eq!(results.target_plane_cep(), Some(3.0));
4743
4744 let one_shortfall = make_results(vec![
4745 Vector3::new(0.0, 1.0, 0.0),
4746 Vector3::new(0.0, 2.0, 0.0),
4747 Vector3::new(0.0, 3.0, 0.0),
4748 Vector3::new(0.0, 4.0, 0.0),
4749 Vector3::new(0.0, 5.0, 0.0),
4750 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4751 ]);
4752 assert_eq!(one_shortfall.target_plane_cep(), Some(3.0));
4753 }
4754
4755 #[test]
4756 fn all_shortfalls_have_no_cep_but_still_count_as_misses() {
4757 let all_shortfalls = make_results(vec![
4758 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4759 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4760 ]);
4761 assert_eq!(all_shortfalls.target_arrival_count(), 0);
4762 assert_eq!(all_shortfalls.target_shortfall_fraction(), 1.0);
4763 assert_eq!(all_shortfalls.target_plane_cep(), None);
4764 assert_eq!(all_shortfalls.hit_probability(0.3), 0.0);
4765
4766 let one_hit_one_shortfall = make_results(vec![
4767 Vector3::new(0.0, 0.1, 0.0),
4768 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4769 ]);
4770 assert_eq!(one_hit_one_shortfall.hit_probability(0.3), 0.5);
4771 }
4772
4773 #[test]
4775 fn rect_hit_probability_checks_independent_axis_halves() {
4776 let results = make_results(vec![
4777 Vector3::new(0.0, 0.1, 0.1),
4779 Vector3::new(0.0, 0.0, 0.2),
4781 Vector3::new(0.0, 0.0, 0.201),
4783 Vector3::new(0.0, 0.301, 0.0),
4785 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4787 ]);
4788 assert!((results.rect_hit_probability(0.4, 0.6) - 0.4).abs() < 1e-12);
4790 }
4791
4792 #[test]
4793 fn rect_hit_probability_matches_circular_hit_probability_for_a_centered_hit() {
4794 let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
4795 assert_eq!(results.rect_hit_probability(0.5, 0.5), 1.0);
4796 assert_eq!(results.hit_probability(0.3), 1.0);
4797 }
4798
4799 #[test]
4800 fn rect_hit_probability_is_zero_for_empty_or_nonpositive_dimensions() {
4801 let empty = make_results(vec![]);
4802 assert_eq!(empty.rect_hit_probability(1.0, 1.0), 0.0);
4803
4804 let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
4805 assert_eq!(results.rect_hit_probability(0.0, 1.0), 0.0);
4806 assert_eq!(results.rect_hit_probability(1.0, 0.0), 0.0);
4807 assert_eq!(results.rect_hit_probability(-1.0, 1.0), 0.0);
4808 }
4809}
4810
4811#[cfg(test)]
4812mod monte_carlo_seeded_tests {
4813 use super::*;
4814
4815 #[test]
4816 fn seeded_runs_are_deterministic_and_match_the_using_rng_path() {
4817 let inputs = BallisticInputs {
4818 muzzle_velocity: 800.0,
4819 ..BallisticInputs::default()
4820 };
4821 let params = MonteCarloParams {
4822 num_simulations: 64,
4823 target_distance: Some(200.0),
4824 ..MonteCarloParams::default()
4825 };
4826
4827 let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4828 inputs.clone(),
4829 WindConditions::default(),
4830 params.clone(),
4831 0.01,
4832 42,
4833 )
4834 .expect("seeded run a");
4835 let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4836 inputs,
4837 WindConditions::default(),
4838 params,
4839 0.01,
4840 42,
4841 )
4842 .expect("seeded run b");
4843
4844 assert_eq!(a.ranges.len(), b.ranges.len());
4845 for (ra, rb) in a.ranges.iter().zip(b.ranges.iter()) {
4846 assert_eq!(ra.to_bits(), rb.to_bits());
4847 }
4848 for (pa, pb) in a.impact_positions.iter().zip(b.impact_positions.iter()) {
4849 assert_eq!(pa.x.to_bits(), pb.x.to_bits());
4850 assert_eq!(pa.y.to_bits(), pb.y.to_bits());
4851 assert_eq!(pa.z.to_bits(), pb.z.to_bits());
4852 }
4853 }
4854
4855 #[test]
4856 fn different_seeds_generally_produce_different_draws() {
4857 let inputs = BallisticInputs {
4858 muzzle_velocity: 800.0,
4859 ..BallisticInputs::default()
4860 };
4861 let params = MonteCarloParams {
4862 num_simulations: 32,
4863 velocity_std_dev: 5.0,
4864 target_distance: Some(200.0),
4865 ..MonteCarloParams::default()
4866 };
4867
4868 let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4869 inputs.clone(),
4870 WindConditions::default(),
4871 params.clone(),
4872 0.0,
4873 1,
4874 )
4875 .expect("seeded run a");
4876 let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4877 inputs,
4878 WindConditions::default(),
4879 params,
4880 0.0,
4881 2,
4882 )
4883 .expect("seeded run b");
4884
4885 assert_ne!(a.impact_velocities, b.impact_velocities);
4886 }
4887}
4888
4889#[cfg(test)]
4890mod monte_carlo_powder_curve_tests {
4891 use super::*;
4892 use rand::{rngs::StdRng, SeedableRng};
4893
4894 #[test]
4895 fn powder_curve_preserves_sampled_muzzle_velocity_dispersion() {
4896 let inputs = BallisticInputs {
4897 muzzle_velocity: 700.0,
4898 powder_temp_curve: Some(vec![(15.0, 800.0)]),
4899 powder_curve_temp_c: Some(15.0),
4900 ..BallisticInputs::default()
4901 };
4902 let params = MonteCarloParams {
4903 num_simulations: 16,
4904 velocity_std_dev: 20.0,
4905 angle_std_dev: 1e-12,
4906 bc_std_dev: 1e-12,
4907 wind_speed_std_dev: 1e-12,
4908 target_distance: Some(100.0),
4909 azimuth_std_dev: 1e-12,
4910 ..MonteCarloParams::default()
4911 };
4912
4913 let mut rng = StdRng::seed_from_u64(0x5EED_1176);
4914 let results = run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
4915 inputs,
4916 WindConditions::default(),
4917 params,
4918 0.0,
4919 &mut rng,
4920 )
4921 .expect("Monte Carlo solve");
4922 let min_velocity = results
4923 .impact_velocities
4924 .iter()
4925 .copied()
4926 .fold(f64::INFINITY, f64::min);
4927 let max_velocity = results
4928 .impact_velocities
4929 .iter()
4930 .copied()
4931 .fold(f64::NEG_INFINITY, f64::max);
4932
4933 assert!(
4934 max_velocity - min_velocity > 1.0,
4935 "20 m/s muzzle spread collapsed after curve resolution: impact-velocity span={} m/s",
4936 max_velocity - min_velocity
4937 );
4938 }
4939}
4940
4941#[cfg(test)]
4942mod monte_carlo_wind_sampling_tests {
4943 use super::*;
4944 use rand::{rngs::StdRng, SeedableRng};
4945
4946 #[test]
4947 fn wind_speed_sigma_does_not_change_seeded_direction_draws() {
4948 let base_wind = WindConditions {
4949 speed: 100.0,
4950 direction: 0.37,
4951 vertical_speed: 0.0,
4952 };
4953 let narrow_speed = MonteCarloWindSampler::new(&base_wind, 0.5, 0.2).unwrap();
4954 let wide_speed = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
4955 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
4956 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
4957 let mut speed_changed = false;
4958
4959 for _ in 0..32 {
4960 let narrow = narrow_speed.sample(&mut narrow_rng);
4961 let wide = wide_speed.sample(&mut wide_rng);
4962 assert!(narrow.speed > 0.0 && wide.speed > 0.0);
4963 assert_eq!(narrow.direction.to_bits(), wide.direction.to_bits());
4964 speed_changed |= narrow.speed.to_bits() != wide.speed.to_bits();
4965 }
4966 assert!(
4967 speed_changed,
4968 "different speed sigmas must still vary speed draws"
4969 );
4970 }
4971
4972 #[test]
4973 fn zero_direction_sigma_has_no_angular_jitter() {
4974 let base_wind = WindConditions {
4975 speed: 100.0,
4976 direction: 0.37,
4977 vertical_speed: 0.0,
4978 };
4979 let sampler = MonteCarloWindSampler::new(&base_wind, 4.0, 0.0).unwrap();
4980 let mut rng = StdRng::seed_from_u64(0x5EED_1223);
4981 let mut speed_changed = false;
4982
4983 for _ in 0..32 {
4984 let wind = sampler.sample(&mut rng);
4985 speed_changed |= wind.speed.to_bits() != base_wind.speed.to_bits();
4986 assert_eq!(wind.direction.to_bits(), base_wind.direction.to_bits());
4987 }
4988 assert!(speed_changed, "speed uncertainty should remain active");
4989 }
4990
4991 #[test]
4992 fn direction_sigma_controls_seeded_angular_spread_in_radians() {
4993 let base_wind = WindConditions {
4994 speed: 100.0,
4995 direction: 0.37,
4996 vertical_speed: 0.0,
4997 };
4998 let narrow = MonteCarloWindSampler::new(&base_wind, 4.0, 0.1).unwrap();
4999 let wide = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
5000 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
5001 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
5002 let mut nonzero_direction_draw = false;
5003
5004 for _ in 0..32 {
5005 let narrow_wind = narrow.sample(&mut narrow_rng);
5006 let wide_wind = wide.sample(&mut wide_rng);
5007 assert_eq!(narrow_wind.speed.to_bits(), wide_wind.speed.to_bits());
5008
5009 let narrow_delta = narrow_wind.direction - base_wind.direction;
5010 let wide_delta = wide_wind.direction - base_wind.direction;
5011 assert!((wide_delta - 2.0 * narrow_delta).abs() < 1e-12);
5012 nonzero_direction_draw |= narrow_delta.abs() > 1e-6;
5013 }
5014 assert!(
5015 nonzero_direction_draw,
5016 "positive radians sigma must vary direction"
5017 );
5018 }
5019
5020 #[test]
5021 fn direction_sigma_rejects_negative_or_nonfinite_values() {
5022 let base_wind = WindConditions::default();
5023 for sigma in [-0.1, f64::NAN, f64::INFINITY] {
5024 assert!(MonteCarloWindSampler::new(&base_wind, 1.0, sigma).is_err());
5025 }
5026 }
5027
5028 #[test]
5029 fn base_vertical_wind_rides_into_every_mc_sample() {
5030 use rand::SeedableRng;
5034 let base_wind = WindConditions { vertical_speed: 4.2, ..Default::default() };
5035 let sampler = MonteCarloWindSampler::new(&base_wind, 1.0, 0.2).unwrap();
5036 let mut rng = rand::rngs::StdRng::seed_from_u64(7);
5037 for _ in 0..32 {
5038 let w = sampler.sample(&mut rng);
5039 assert_eq!(w.vertical_speed, 4.2);
5040 }
5041 }
5042
5043 #[test]
5044 fn negative_speed_sample_reverses_wind_direction() {
5045 let direction = 0.25;
5046 let signed_speed = -2.5;
5047 let wind = wind_from_signed_speed_sample(signed_speed, direction, 0.0);
5048 let positive_wind = wind_from_signed_speed_sample(2.5, direction, 0.0);
5049
5050 assert_eq!(wind.speed, 2.5);
5051 assert!(
5052 (wind.direction - (direction + std::f64::consts::PI)).abs() < f64::EPSILON,
5053 "negative speed must reverse direction by pi: got {}",
5054 wind.direction
5055 );
5056 assert_eq!(positive_wind.speed, 2.5);
5057 assert_eq!(positive_wind.direction, direction);
5058
5059 let normalized_x = -wind.speed * wind.direction.cos();
5060 let normalized_z = -wind.speed * wind.direction.sin();
5061 let signed_x = -signed_speed * direction.cos();
5062 let signed_z = -signed_speed * direction.sin();
5063 assert!((normalized_x - signed_x).abs() < 1e-12);
5064 assert!((normalized_z - signed_z).abs() < 1e-12);
5065 }
5066}
5067
5068#[cfg(test)]
5069mod bc_fit_objective_tests {
5070 use super::*;
5071
5072 fn velocity_point(range_m: f64, velocity_mps: f64) -> TrajectoryPoint {
5073 TrajectoryPoint {
5074 time: 0.0,
5075 position: Vector3::new(range_m, 0.0, 0.0),
5076 velocity_magnitude: velocity_mps,
5077 kinetic_energy: 0.0,
5078 }
5079 }
5080
5081 #[test]
5082 fn candidate_that_misses_an_observation_has_no_score() {
5083 let trajectory = vec![velocity_point(0.0, 800.0), velocity_point(100.0, 700.0)];
5084 let observations = vec![(50.0, 750.0), (150.0, 600.0)];
5085
5086 assert!(
5087 fit_residual_sse(&trajectory, &observations, BcFitMode::Velocity, 0.0).is_none(),
5088 "a candidate that reaches only one of two observations must not compete on partial SSE"
5089 );
5090
5091 let complete_observations = vec![(50.0, 740.0), (100.0, 680.0)];
5092 assert_eq!(
5093 fit_residual_sse(
5094 &trajectory,
5095 &complete_observations,
5096 BcFitMode::Velocity,
5097 0.0,
5098 ),
5099 Some(500.0)
5100 );
5101 }
5102}
5103
5104#[cfg(test)]
5105mod cluster_bc_reference_space_tests {
5106 use super::*;
5107
5108 fn acceleration_at_1100_fps(inputs: BallisticInputs) -> Vector3<f64> {
5109 let solver = TrajectorySolver::new(
5110 inputs,
5111 WindConditions::default(),
5112 AtmosphericConditions::default(),
5113 );
5114 let position = Vector3::zeros();
5115 let velocity = Vector3::new(1100.0 / 3.28084, 0.0, 0.0);
5116 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5117 solver.calculate_acceleration(
5118 &position,
5119 &velocity,
5120 &Vector3::zeros(),
5121 (temp_c, pressure_hpa, density / 1.225),
5122 )
5123 }
5124
5125 #[test]
5126 fn solver_passes_g7_reference_model_to_cluster_classifier() {
5127 let inputs = BallisticInputs {
5128 bc_value: 0.190,
5129 bc_type: DragModel::G7,
5130 bullet_mass: 77.0 * crate::constants::GRAINS_TO_KG,
5131 bullet_diameter: 0.224 * 0.0254,
5132 use_cluster_bc: true,
5133 ..BallisticInputs::default()
5134 };
5135
5136 let solver = TrajectorySolver::new(
5137 inputs,
5138 WindConditions::default(),
5139 AtmosphericConditions::default(),
5140 );
5141 let corrected = solver.apply_cluster_bc_correction(0.190, 2800.0);
5142
5143 assert!(
5144 (corrected / 0.190 - 1.004).abs() < 1e-12,
5145 "solver selected the wrong G7 cluster multiplier: {}",
5146 corrected / 0.190
5147 );
5148 }
5149
5150 #[test]
5151 fn velocity_bc_segments_are_not_cluster_corrected_twice() {
5152 let segmented_clustered = BallisticInputs {
5153 bc_value: 0.5,
5154 bc_type: DragModel::G7,
5155 use_bc_segments: true,
5156 bc_segments_data: Some(vec![
5157 crate::BCSegmentData {
5158 velocity_min: 0.0,
5159 velocity_max: 1_600.0,
5160 bc_value: 0.4,
5161 },
5162 crate::BCSegmentData {
5163 velocity_min: 1_600.0,
5164 velocity_max: 5_000.0,
5165 bc_value: 0.45,
5166 },
5167 ]),
5168 use_cluster_bc: true,
5169 ..BallisticInputs::default()
5170 };
5171 let mut segmented_only = segmented_clustered.clone();
5172 segmented_only.use_cluster_bc = false;
5173 let mut constant_clustered = segmented_clustered.clone();
5174 constant_clustered.bc_value = 0.4;
5175 constant_clustered.bc_segments_data = None;
5176
5177 let stacked = acceleration_at_1100_fps(segmented_clustered);
5178 let segment_only = acceleration_at_1100_fps(segmented_only);
5179 let cluster_only = acceleration_at_1100_fps(constant_clustered);
5180
5181 assert!(
5182 (stacked.x - segment_only.x).abs() < 1e-12,
5183 "segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
5184 stacked.x,
5185 segment_only.x
5186 );
5187 assert!(
5188 (cluster_only.x - segment_only.x).abs() > 1e-6,
5189 "cluster correction must remain active for a constant BC"
5190 );
5191 }
5192
5193 #[test]
5194 fn mach_bc_segments_are_not_cluster_corrected_twice() {
5195 let mach_segmented_clustered = BallisticInputs {
5196 bc_value: 0.5,
5197 bc_type: DragModel::G7,
5198 use_bc_segments: false,
5199 bc_segments: Some(vec![(0.5, 0.3), (1.5, 0.5)]),
5200 use_cluster_bc: true,
5201 ..BallisticInputs::default()
5202 };
5203 let mut mach_segmented_only = mach_segmented_clustered.clone();
5204 mach_segmented_only.use_cluster_bc = false;
5205
5206 let stacked = acceleration_at_1100_fps(mach_segmented_clustered);
5207 let segment_only = acceleration_at_1100_fps(mach_segmented_only);
5208
5209 assert!(
5210 (stacked.x - segment_only.x).abs() < 1e-12,
5211 "Mach segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
5212 stacked.x,
5213 segment_only.x
5214 );
5215 }
5216}
5217
5218#[cfg(test)]
5219mod velocity_bc_flag_tests {
5220 use super::*;
5221
5222 fn acceleration_at_600_mps(inputs: BallisticInputs) -> Vector3<f64> {
5223 let solver = TrajectorySolver::new(
5224 inputs,
5225 WindConditions::default(),
5226 AtmosphericConditions::default(),
5227 );
5228 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5229 solver.calculate_acceleration(
5230 &Vector3::zeros(),
5231 &Vector3::new(600.0, 0.0, 0.0),
5232 &Vector3::zeros(),
5233 (temp_c, pressure_hpa, density / 1.225),
5234 )
5235 }
5236
5237 #[test]
5238 fn velocity_bc_data_requires_opt_in_in_trajectory_solver() {
5239 let scalar_inputs = BallisticInputs {
5240 bc_value: 0.5,
5241 bc_type: DragModel::G7,
5242 ..BallisticInputs::default()
5243 };
5244 let mut disabled_inputs = scalar_inputs.clone();
5245 disabled_inputs.bc_segments_data = Some(vec![crate::BCSegmentData {
5246 velocity_min: 0.0,
5247 velocity_max: 4_000.0,
5248 bc_value: 0.46,
5249 }]);
5250 disabled_inputs.use_bc_segments = false;
5251 let mut enabled_inputs = disabled_inputs.clone();
5252 enabled_inputs.use_bc_segments = true;
5253 let mut mach_only_inputs = scalar_inputs.clone();
5254 mach_only_inputs.bc_segments = Some(vec![(0.0, 0.4), (3.0, 0.4)]);
5255 let mut disabled_with_both = mach_only_inputs.clone();
5256 disabled_with_both.bc_segments_data = disabled_inputs.bc_segments_data.clone();
5257
5258 let scalar = acceleration_at_600_mps(scalar_inputs);
5259 let disabled = acceleration_at_600_mps(disabled_inputs);
5260 let enabled = acceleration_at_600_mps(enabled_inputs);
5261 let mach_only = acceleration_at_600_mps(mach_only_inputs);
5262 let disabled_with_both = acceleration_at_600_mps(disabled_with_both);
5263
5264 assert_eq!(
5265 disabled.x.to_bits(),
5266 scalar.x.to_bits(),
5267 "a populated velocity table must not change drag while use_bc_segments is false"
5268 );
5269 assert!(
5270 enabled.x < disabled.x - 1.0,
5271 "enabling the lower BC table must increase drag: disabled ax={} enabled ax={}",
5272 disabled.x,
5273 enabled.x
5274 );
5275 assert_eq!(
5276 disabled_with_both.x.to_bits(),
5277 mach_only.x.to_bits(),
5278 "disabling velocity data must fall through to an explicit Mach table"
5279 );
5280 }
5281}
5282
5283#[cfg(test)]
5284mod mach_bc_segment_tests {
5285 use super::*;
5286
5287 #[test]
5288 fn trajectory_solver_interpolates_explicit_mach_bc_segments() {
5289 let segmented_inputs = BallisticInputs {
5290 bc_value: 0.8,
5291 use_bc_segments: false,
5292 bc_segments: Some(vec![(1.0, 0.2), (2.0, 0.4)]),
5293 bc_segments_data: None,
5294 ..BallisticInputs::default()
5295 };
5296
5297 let mut expected_inputs = segmented_inputs.clone();
5298 expected_inputs.bc_value = 0.3;
5299 expected_inputs.bc_segments = None;
5300
5301 let atmosphere = AtmosphericConditions::default();
5302 let segmented_solver = TrajectorySolver::new(
5303 segmented_inputs,
5304 WindConditions::default(),
5305 atmosphere.clone(),
5306 );
5307 let expected_solver = TrajectorySolver::new(
5308 expected_inputs,
5309 WindConditions::default(),
5310 atmosphere,
5311 );
5312 let position = Vector3::zeros();
5313 let (density, _, temp_c, pressure_hpa) = segmented_solver.resolved_atmosphere();
5314 let (_, local_speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
5315 segmented_solver.atmosphere.altitude,
5316 segmented_solver.atmosphere.altitude,
5317 temp_c,
5318 pressure_hpa,
5319 density / 1.225,
5320 segmented_solver.atmosphere.humidity,
5321 );
5322 let velocity = Vector3::new(1.5 * local_speed_of_sound, 0.0, 0.0);
5323 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
5324
5325 let segmented_acceleration = segmented_solver.calculate_acceleration(
5326 &position,
5327 &velocity,
5328 &Vector3::zeros(),
5329 resolved_atmo,
5330 );
5331 let expected_acceleration = expected_solver.calculate_acceleration(
5332 &position,
5333 &velocity,
5334 &Vector3::zeros(),
5335 resolved_atmo,
5336 );
5337
5338 assert!(
5339 (segmented_acceleration.x - expected_acceleration.x).abs() < 1e-12,
5340 "Mach 1.5 must interpolate BC 0.3: segmented ax={} expected ax={}",
5341 segmented_acceleration.x,
5342 expected_acceleration.x
5343 );
5344 }
5345}
5346
5347#[cfg(test)]
5348mod custom_drag_table_validation_tests {
5349 use super::*;
5350
5351 #[test]
5352 fn solve_accepts_zero_bc_when_custom_table_present() {
5353 let inputs = BallisticInputs {
5354 bc_value: 0.0, bullet_mass: 0.0106,
5356 bullet_diameter: 0.00782,
5357 muzzle_velocity: 850.0,
5358 custom_drag_table: Some(crate::drag::DragTable::new(
5359 vec![0.5, 1.0, 2.0, 3.0],
5360 vec![0.23, 0.40, 0.30, 0.26],
5361 )),
5362 ..BallisticInputs::default()
5363 };
5364 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
5365 assert!(solver.solve().is_ok());
5367 }
5368
5369 #[test]
5370 fn solve_still_requires_bc_without_table() {
5371 let inputs = BallisticInputs {
5372 bc_value: 0.0,
5373 bullet_mass: 0.0106,
5374 bullet_diameter: 0.00782,
5375 muzzle_velocity: 850.0,
5376 ..BallisticInputs::default()
5377 };
5378 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
5379 assert!(solver.solve().is_err());
5380 }
5381}
5382
5383#[cfg(test)]
5385mod cd_scale_tests {
5386 use super::*;
5387
5388 fn deck() -> crate::drag::DragTable {
5389 crate::drag::DragTable::new(vec![0.5, 1.0, 2.0, 3.0], vec![0.23, 0.40, 0.30, 0.26])
5390 }
5391
5392 fn deck_inputs(cd_scale: f64) -> BallisticInputs {
5393 BallisticInputs {
5394 bullet_mass: 0.0106,
5395 bullet_diameter: 0.00782,
5396 muzzle_velocity: 850.0,
5397 custom_drag_table: Some(deck()),
5398 cd_scale,
5399 ..BallisticInputs::default()
5400 }
5401 }
5402
5403 #[test]
5404 fn default_cd_scale_is_one() {
5405 assert_eq!(BallisticInputs::default().cd_scale, 1.0);
5406 }
5407
5408 #[test]
5411 fn cd_scale_absent_is_byte_identical_to_explicit_one() {
5412 let omitted = BallisticInputs {
5413 bullet_mass: 0.0106,
5414 bullet_diameter: 0.00782,
5415 muzzle_velocity: 850.0,
5416 custom_drag_table: Some(deck()),
5417 ..BallisticInputs::default()
5418 };
5419 let explicit = BallisticInputs {
5420 cd_scale: 1.0,
5421 ..omitted.clone()
5422 };
5423
5424 let solver_omitted =
5425 TrajectorySolver::new(omitted, WindConditions::default(), AtmosphericConditions::default());
5426 let solver_explicit =
5427 TrajectorySolver::new(explicit, WindConditions::default(), AtmosphericConditions::default());
5428
5429 let cd_omitted = solver_omitted.calculate_drag_coefficient(700.0, 340.0);
5430 let cd_explicit = solver_explicit.calculate_drag_coefficient(700.0, 340.0);
5431 assert_eq!(
5432 cd_omitted.to_bits(),
5433 cd_explicit.to_bits(),
5434 "default cd_scale must be bit-identical to an explicit 1.0"
5435 );
5436
5437 let result = solver_omitted.solve();
5440 assert!(result.is_ok(), "existing custom-deck solves must pass unchanged");
5441 }
5442
5443 #[test]
5445 fn cd_scale_multiplies_the_interpolated_cd_exactly() {
5446 let velocity = 700.0;
5447 let speed_of_sound = 340.0;
5448 let mach = velocity / speed_of_sound;
5449 let expected_unscaled = deck().interpolate(mach);
5450
5451 for &scale in &[0.90, 1.0, 1.10, 1.5] {
5452 let solver = TrajectorySolver::new(
5453 deck_inputs(scale),
5454 WindConditions::default(),
5455 AtmosphericConditions::default(),
5456 );
5457 let cd = solver.calculate_drag_coefficient(velocity, speed_of_sound);
5458 assert!(
5459 (cd - expected_unscaled * scale).abs() < 1e-12,
5460 "scale={scale}: cd={cd} expected={}",
5461 expected_unscaled * scale
5462 );
5463 }
5464 }
5465
5466 #[test]
5470 fn cd_scale_direction_on_cli_api_solver() {
5471 let solve = |scale: f64| {
5472 TrajectorySolver::new(
5473 deck_inputs(scale),
5474 WindConditions::default(),
5475 AtmosphericConditions::default(),
5476 )
5477 .solve()
5478 .expect("custom-deck solve should succeed")
5479 };
5480
5481 let baseline = solve(1.0);
5482 let scaled_up = solve(1.10);
5483 let scaled_down = solve(0.90);
5484
5485 assert!(
5486 scaled_up.impact_velocity < baseline.impact_velocity,
5487 "cd_scale=1.10 must increase drag -> lower impact velocity: base={} up={}",
5488 baseline.impact_velocity,
5489 scaled_up.impact_velocity
5490 );
5491 assert!(
5492 scaled_down.impact_velocity > baseline.impact_velocity,
5493 "cd_scale=0.90 must decrease drag -> higher impact velocity: base={} down={}",
5494 baseline.impact_velocity,
5495 scaled_down.impact_velocity
5496 );
5497 }
5498
5499 #[test]
5501 fn validate_for_solve_rejects_invalid_cd_scale() {
5502 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
5503 let solver = TrajectorySolver::new(
5504 deck_inputs(bad),
5505 WindConditions::default(),
5506 AtmosphericConditions::default(),
5507 );
5508 assert!(
5509 solver.solve().is_err(),
5510 "cd_scale={bad} must be rejected by validate_for_solve"
5511 );
5512 }
5513 }
5514
5515 #[test]
5522 fn validate_for_solve_rejects_invalid_cd_scale_without_a_custom_drag_table() {
5523 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
5524 let inputs = BallisticInputs {
5525 bc_value: 0.5,
5526 bc_type: crate::DragModel::G1,
5527 bullet_mass: 0.0106,
5528 bullet_diameter: 0.00782,
5529 muzzle_velocity: 850.0,
5530 cd_scale: bad,
5531 ..BallisticInputs::default()
5532 };
5533 assert!(inputs.custom_drag_table.is_none(), "precondition: no custom deck");
5534 let solver = TrajectorySolver::new(
5535 inputs,
5536 WindConditions::default(),
5537 AtmosphericConditions::default(),
5538 );
5539 assert!(
5540 solver.solve().is_err(),
5541 "cd_scale={bad} must be rejected by validate_for_solve even without a custom \
5542 drag table"
5543 );
5544 }
5545 }
5546
5547 #[test]
5551 fn cd_scale_is_inert_without_a_custom_drag_table() {
5552 let make = |cd_scale: f64| BallisticInputs {
5553 bc_value: 0.5,
5554 bc_type: crate::DragModel::G1,
5555 bullet_mass: 0.0106,
5556 bullet_diameter: 0.00782,
5557 muzzle_velocity: 850.0,
5558 cd_scale,
5559 ..BallisticInputs::default()
5560 };
5561 let solver_neutral = TrajectorySolver::new(
5562 make(1.0),
5563 WindConditions::default(),
5564 AtmosphericConditions::default(),
5565 );
5566 let solver_far = TrajectorySolver::new(
5567 make(1.5),
5568 WindConditions::default(),
5569 AtmosphericConditions::default(),
5570 );
5571 let cd_neutral = solver_neutral.calculate_drag_coefficient(700.0, 340.0);
5572 let cd_far = solver_far.calculate_drag_coefficient(700.0, 340.0);
5573 assert_eq!(
5574 cd_neutral.to_bits(),
5575 cd_far.to_bits(),
5576 "cd_scale must not affect the G-model/BC drag path"
5577 );
5578 }
5579
5580 #[test]
5583 fn cd_scale_shifts_all_three_solver_paths_in_the_same_direction() {
5584 let cli_solve = |scale: f64| {
5586 TrajectorySolver::new(
5587 deck_inputs(scale),
5588 WindConditions::default(),
5589 AtmosphericConditions::default(),
5590 )
5591 .solve()
5592 .expect("cli_api custom-deck solve should succeed")
5593 };
5594 let cli_baseline = cli_solve(1.0);
5595 let cli_scaled = cli_solve(1.10);
5596 assert!(
5597 cli_scaled.impact_velocity < cli_baseline.impact_velocity,
5598 "cli_api: cd_scale=1.10 must lower impact velocity"
5599 );
5600
5601 let derivatives_accel_x = |scale: f64| {
5603 let inputs = deck_inputs(scale);
5604 crate::derivatives::compute_derivatives(
5605 nalgebra::Vector3::zeros(),
5606 nalgebra::Vector3::new(700.0, 0.0, 0.0),
5607 &inputs,
5608 nalgebra::Vector3::zeros(),
5609 (1.225, 340.0, 0.0, 0.0),
5610 inputs.bc_value,
5611 None,
5612 0.0,
5613 None,
5614 )[3]
5615 };
5616 let deriv_baseline = derivatives_accel_x(1.0);
5617 let deriv_scaled = derivatives_accel_x(1.10);
5618 assert!(
5619 deriv_scaled < deriv_baseline,
5620 "derivatives: cd_scale=1.10 must make x-acceleration more negative (more drag): \
5621 base={deriv_baseline} scaled={deriv_scaled}"
5622 );
5623
5624 let fast_final_speed = |scale: f64| {
5626 let inputs = deck_inputs(scale);
5627 let wind_sock = crate::wind::WindSock::new(vec![]);
5628 let params = crate::fast_trajectory::FastIntegrationParams {
5629 horiz: 500.0,
5630 vert: 0.0,
5631 initial_state: [0.0, 0.0, 0.0, 850.0, 0.0, 0.0],
5632 t_span: (0.0, 5.0),
5633 atmo_params: (0.0, 15.0, 1013.25, 1.0),
5634 atmo_sock: None,
5635 };
5636 let solution = crate::fast_trajectory::fast_integrate(&inputs, &wind_sock, params);
5637 assert!(solution.success, "fast_integrate must succeed for scale={scale}");
5638 let last = solution.t.len() - 1;
5639 let (vx, vy, vz) = (
5640 solution.y[3][last],
5641 solution.y[4][last],
5642 solution.y[5][last],
5643 );
5644 (vx * vx + vy * vy + vz * vz).sqrt()
5645 };
5646 let fast_baseline = fast_final_speed(1.0);
5647 let fast_scaled = fast_final_speed(1.10);
5648 assert!(
5649 fast_scaled < fast_baseline,
5650 "fast_trajectory: cd_scale=1.10 must lower final speed: base={fast_baseline} scaled={fast_scaled}"
5651 );
5652 }
5653}
5654
5655#[cfg(test)]
5656mod humid_local_mach_tests {
5657 use super::*;
5658
5659 fn solver_with_station_humidity(humidity_percent: f64) -> TrajectorySolver {
5660 let inputs = BallisticInputs {
5661 custom_drag_table: Some(crate::drag::DragTable::new(vec![0.5, 1.5], vec![0.1, 1.1])),
5662 ..BallisticInputs::default()
5663 };
5664 TrajectorySolver::new(
5665 inputs,
5666 WindConditions::default(),
5667 AtmosphericConditions {
5668 temperature: 30.0,
5669 pressure: 1013.25,
5670 humidity: humidity_percent,
5671 altitude: 0.0,
5672 },
5673 )
5674 }
5675
5676 fn acceleration(solver: &TrajectorySolver, base_ratio: f64) -> Vector3<f64> {
5677 solver.calculate_acceleration(
5678 &Vector3::zeros(),
5679 &Vector3::new(350.0, 0.0, 0.0),
5680 &Vector3::zeros(),
5681 (30.0, 1013.25, base_ratio),
5682 )
5683 }
5684
5685 #[test]
5686 fn local_mach_uses_station_humidity_when_density_is_held_constant() {
5687 let dry = acceleration(&solver_with_station_humidity(0.0), 1.0);
5688 let humid = acceleration(&solver_with_station_humidity(100.0), 1.0);
5689
5690 assert!(
5691 humid.x > dry.x,
5692 "humid sound speed should lower Mach and drag on the rising test curve: dry ax={} humid ax={}",
5693 dry.x,
5694 humid.x
5695 );
5696 }
5697
5698 #[test]
5699 fn active_atmosphere_zone_uses_zone_humidity_instead_of_station_humidity() {
5700 let zone_humidity = 80.0;
5701 let zone_ratio =
5702 crate::atmosphere::calculate_air_density_cimp(30.0, 1013.25, zone_humidity) / 1.225;
5703 let station_solver = solver_with_station_humidity(zone_humidity);
5704 let mut zoned_solver = solver_with_station_humidity(0.0);
5705 zoned_solver.set_atmo_segments(vec![(30.0, 1013.25, zone_humidity, 1_000.0)]);
5706
5707 let station = acceleration(&station_solver, zone_ratio);
5708 let zoned = acceleration(&zoned_solver, zone_ratio);
5709
5710 assert!(
5711 (zoned - station).norm() < 1e-12,
5712 "active zone T/P/RH should override the station atmosphere: station={station:?} zoned={zoned:?}"
5713 );
5714 }
5715}
5716
5717#[cfg(test)]
5718mod inclined_atmosphere_frame_tests {
5719 use super::*;
5720
5721 fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
5722 let (sin_angle, cos_angle) = angle.sin_cos();
5723 Vector3::new(
5724 level.x * cos_angle + level.y * sin_angle,
5725 -level.x * sin_angle + level.y * cos_angle,
5726 level.z,
5727 )
5728 }
5729
5730 #[test]
5731 fn inclined_positions_at_same_world_altitude_have_same_solver_acceleration() {
5732 let angle = std::f64::consts::FRAC_PI_6;
5733 let inputs = BallisticInputs {
5734 shooting_angle: angle,
5735 ..BallisticInputs::default()
5736 };
5737 let atmosphere = AtmosphericConditions {
5738 altitude: 100.0,
5739 ..AtmosphericConditions::default()
5740 };
5741 let solver = TrajectorySolver::new(inputs, WindConditions::default(), atmosphere);
5742 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5743 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
5744 let velocity = Vector3::new(600.0, 0.0, 0.0);
5745 let along_slant = Vector3::new(1_000.0, 0.0, 0.0);
5746 let across_slant = Vector3::new(0.0, 500.0 / angle.cos(), 0.0);
5747
5748 let a = solver.calculate_acceleration(
5749 &along_slant,
5750 &velocity,
5751 &Vector3::zeros(),
5752 resolved_atmo,
5753 );
5754 let b = solver.calculate_acceleration(
5755 &across_slant,
5756 &velocity,
5757 &Vector3::zeros(),
5758 resolved_atmo,
5759 );
5760
5761 assert!(
5762 (a - b).norm() < 1e-10,
5763 "solver acceleration differs at equal world altitude: {a:?} vs {b:?}"
5764 );
5765 }
5766
5767 #[test]
5768 fn inclined_headwind_is_rotated_into_solver_frame() {
5769 let angle = std::f64::consts::FRAC_PI_6;
5770 let inputs = BallisticInputs {
5771 shooting_angle: angle,
5772 ..BallisticInputs::default()
5773 };
5774 let solver = TrajectorySolver::new(
5775 inputs,
5776 WindConditions::default(),
5777 AtmosphericConditions::default(),
5778 );
5779 let level_headwind = Vector3::new(-100.0, 0.0, 0.0);
5780 let velocity = expected_shot_frame_vector(level_headwind, angle);
5781 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5782 let actual = solver.calculate_acceleration(
5783 &Vector3::zeros(),
5784 &velocity,
5785 &level_headwind,
5786 (temp_c, pressure_hpa, density / 1.225),
5787 );
5788
5789 assert!(
5790 (actual - solver.gravity_acceleration()).norm() < 1e-12,
5791 "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
5792 );
5793 }
5794
5795 #[test]
5796 fn inclined_coriolis_is_rotated_into_solver_frame() {
5797 let angle = std::f64::consts::FRAC_PI_6;
5798 let latitude_deg = 45.0_f64;
5799 let shot_azimuth = 0.4_f64;
5800 let velocity = Vector3::new(600.0, 20.0, 5.0);
5801 let base_inputs = BallisticInputs {
5802 shooting_angle: angle,
5803 latitude: Some(latitude_deg),
5804 shot_azimuth,
5805 ..BallisticInputs::default()
5806 };
5807 let acceleration = |enable_coriolis| {
5808 let mut inputs = base_inputs.clone();
5809 inputs.enable_coriolis = enable_coriolis;
5810 let solver = TrajectorySolver::new(
5811 inputs,
5812 WindConditions::default(),
5813 AtmosphericConditions::default(),
5814 );
5815 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5816 solver.calculate_acceleration(
5817 &Vector3::zeros(),
5818 &velocity,
5819 &Vector3::zeros(),
5820 (temp_c, pressure_hpa, density / 1.225),
5821 )
5822 };
5823
5824 let omega_earth = 7.2921159e-5_f64;
5825 let latitude = latitude_deg.to_radians();
5826 let level_omega = Vector3::new(
5827 omega_earth * latitude.cos() * shot_azimuth.cos(),
5828 omega_earth * latitude.sin(),
5829 -omega_earth * latitude.cos() * shot_azimuth.sin(),
5830 );
5831 let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
5832 let actual = acceleration(true) - acceleration(false);
5833
5834 assert!(
5835 (actual - expected).norm() < 1e-12,
5836 "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
5837 );
5838 }
5839}
5840
5841#[cfg(test)]
5842mod terminal_range_interpolation_tests {
5843 use super::*;
5844
5845 #[test]
5846 fn terminal_finalizer_selects_the_earliest_crossed_boundary() {
5847 let inputs = BallisticInputs {
5848 ground_threshold: 0.0,
5849 ..BallisticInputs::default()
5850 };
5851 let mut solver = TrajectorySolver::new(
5852 inputs,
5853 WindConditions::default(),
5854 AtmosphericConditions::default(),
5855 );
5856 solver.set_max_range(120.0);
5857
5858 let previous_speed = 700.0;
5859 let mut points = vec![TrajectoryPoint {
5860 time: 99.0,
5861 position: Vector3::new(90.0, 1.0, -1.0),
5862 velocity_magnitude: previous_speed,
5863 kinetic_energy: 0.5 * solver.inputs.bullet_mass * previous_speed.powi(2),
5864 }];
5865 let mut max_height = 1.0;
5866 let termination = solver
5867 .append_terminal_endpoint(
5868 &mut points,
5869 Vector3::new(130.0, -3.0, 3.0),
5870 Vector3::new(600.0, 0.0, 0.0),
5871 101.0,
5872 &mut max_height,
5873 )
5874 .expect("the final step brackets supported boundaries");
5875
5876 assert_eq!(termination, TrajectoryTermination::GroundThreshold);
5877 assert_eq!(points.len(), 2);
5878 let terminal = points.last().expect("terminal point");
5879 assert_eq!(terminal.time, 99.5);
5880 assert_eq!(terminal.position, Vector3::new(100.0, 0.0, 0.0));
5881 assert_eq!(terminal.velocity_magnitude, 675.0);
5882 assert_eq!(
5883 terminal.kinetic_energy,
5884 0.5 * solver.inputs.bullet_mass * 675.0_f64.powi(2)
5885 );
5886
5887 solver.set_max_range(100.0);
5889 let mut tied_points = vec![points[0].clone()];
5890 assert_eq!(
5891 solver
5892 .append_terminal_endpoint(
5893 &mut tied_points,
5894 Vector3::new(130.0, -3.0, 3.0),
5895 Vector3::new(600.0, 0.0, 0.0),
5896 101.0,
5897 &mut max_height,
5898 )
5899 .expect("tied boundaries remain a valid terminal"),
5900 TrajectoryTermination::GroundThreshold
5901 );
5902 }
5903
5904 #[test]
5905 fn sub_ulp_terminal_crossing_replaces_instead_of_duplicating_range() {
5906 let ground_threshold = f64::from_bits(1.0_f64.to_bits() - 1);
5907 let inputs = BallisticInputs {
5908 ground_threshold,
5909 ..BallisticInputs::default()
5910 };
5911 let mut solver = TrajectorySolver::new(
5912 inputs,
5913 WindConditions::default(),
5914 AtmosphericConditions::default(),
5915 );
5916 solver.set_max_range(1_000.0);
5917
5918 let speed = 700.0;
5919 let mut points = vec![TrajectoryPoint {
5920 time: 0.0,
5921 position: Vector3::new(100.0, 1.0, 0.0),
5922 velocity_magnitude: speed,
5923 kinetic_energy: 0.5 * solver.inputs.bullet_mass * speed.powi(2),
5924 }];
5925 let mut max_height = 1.0;
5926 let termination = solver
5927 .append_terminal_endpoint(
5928 &mut points,
5929 Vector3::new(101.0, 0.0, 0.0),
5930 Vector3::new(699.0, 0.0, 0.0),
5931 1.0,
5932 &mut max_height,
5933 )
5934 .expect("sub-ULP ground crossing remains representable as one terminal state");
5935
5936 assert_eq!(termination, TrajectoryTermination::GroundThreshold);
5937 assert_eq!(points.len(), 1);
5938 assert_eq!(points[0].position.x, 100.0);
5939 assert_eq!(points[0].position.y.to_bits(), ground_threshold.to_bits());
5940 assert!(points[0].time > 0.0);
5941 }
5942
5943 #[test]
5944 fn every_solver_appends_an_exact_max_range_endpoint() {
5945 let target_range = 0.1;
5946 let modes = [
5947 ("Euler", false, false),
5948 ("RK4", true, false),
5949 ("RK45", true, true),
5950 ];
5951
5952 for (name, use_rk4, use_adaptive_rk45) in modes {
5953 let inputs = BallisticInputs {
5954 use_rk4,
5955 use_adaptive_rk45,
5956 ground_threshold: f64::NEG_INFINITY,
5957 enable_trajectory_sampling: true,
5958 sample_interval: target_range,
5959 ..BallisticInputs::default()
5960 };
5961 let mut solver = TrajectorySolver::new(
5962 inputs,
5963 WindConditions::default(),
5964 AtmosphericConditions::default(),
5965 );
5966 solver.set_max_range(target_range);
5967
5968 let result = solver.solve().expect("short-range solve should succeed");
5969 let terminal = result.points.last().expect("terminal point is missing");
5970 let muzzle = result.points.first().expect("muzzle point is missing");
5971
5972 assert_eq!(result.termination, TrajectoryTermination::MaxRange);
5973 assert_eq!(
5974 terminal.position.x.to_bits(),
5975 target_range.to_bits(),
5976 "{name} did not terminate exactly at max_range"
5977 );
5978 assert_eq!(result.max_range.to_bits(), target_range.to_bits());
5979 assert!(
5980 result.time_of_flight > 0.0 && result.time_of_flight < solver.time_step,
5981 "{name} terminal time was not interpolated within the crossing step: {}",
5982 result.time_of_flight
5983 );
5984 assert_eq!(result.time_of_flight.to_bits(), terminal.time.to_bits());
5985 assert_eq!(
5986 result.impact_velocity.to_bits(),
5987 terminal.velocity_magnitude.to_bits()
5988 );
5989 assert_eq!(
5990 result.impact_energy.to_bits(),
5991 terminal.kinetic_energy.to_bits()
5992 );
5993 let expected_energy = 0.5 * solver.inputs.bullet_mass * result.impact_velocity.powi(2);
5994 assert!((result.impact_energy - expected_energy).abs() < 1e-12);
5995 assert!(terminal.velocity_magnitude < muzzle.velocity_magnitude);
5996 assert!(terminal.kinetic_energy < muzzle.kinetic_energy);
5997
5998 let terminal_sample = result
5999 .sampled_points
6000 .as_ref()
6001 .and_then(|samples| samples.last())
6002 .expect("terminal trajectory sample is missing");
6003 assert_eq!(
6004 terminal_sample.distance_m.to_bits(),
6005 target_range.to_bits(),
6006 "{name} sampling did not include max_range"
6007 );
6008 assert_eq!(
6009 terminal_sample.time_s.to_bits(),
6010 result.time_of_flight.to_bits()
6011 );
6012 assert_eq!(
6013 terminal_sample.velocity_mps.to_bits(),
6014 result.impact_velocity.to_bits()
6015 );
6016 assert!((terminal_sample.energy_j - result.impact_energy).abs() < 1e-12);
6017 }
6018 }
6019}
6020
6021#[cfg(test)]
6022mod precession_inertia_wiring_tests {
6023 use super::*;
6024
6025 #[test]
6026 fn solver_uses_projectile_specific_moments_of_inertia() {
6027 let mass_kg = 55.0 * crate::constants::GRAINS_TO_KG;
6028 let caliber_m = 0.224 * 0.0254;
6029 let length_m = 0.75 * 0.0254;
6030 let inputs = BallisticInputs {
6031 bullet_mass: mass_kg,
6032 bullet_diameter: caliber_m,
6033 bullet_length: length_m,
6034 muzzle_velocity: 800.0,
6035 twist_rate: 7.0,
6036 enable_precession_nutation: true,
6037 use_rk4: false,
6038 use_adaptive_rk45: false,
6039 ..BallisticInputs::default()
6040 };
6041 let mut solver = TrajectorySolver::new(
6042 inputs,
6043 WindConditions::default(),
6044 AtmosphericConditions::default(),
6045 );
6046 solver.set_max_range(0.1);
6047
6048 let (air_density, speed_of_sound, _, _) = solver.resolved_atmosphere();
6049 let velocity_mps = solver.inputs.muzzle_velocity;
6050 let velocity_fps = velocity_mps * 3.28084;
6051 let twist_rate_ft = solver.inputs.twist_rate / 12.0;
6052 let spin_rate_rad_s = (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI;
6053 let initial_state = AngularState {
6054 pitch_angle: 0.001,
6055 yaw_angle: 0.001,
6056 pitch_rate: 0.0,
6057 yaw_rate: 0.0,
6058 precession_angle: 0.0,
6059 nutation_phase: 0.0,
6060 };
6061 let params = PrecessionNutationParams {
6062 mass_kg,
6063 caliber_m,
6064 length_m,
6065 spin_rate_rad_s,
6066 spin_inertia: crate::spin_decay::calculate_moment_of_inertia(
6067 mass_kg, caliber_m, length_m, "ogive",
6068 ),
6069 transverse_inertia: crate::pitch_damping::calculate_transverse_moment_of_inertia(
6070 mass_kg, caliber_m, length_m, "ogive",
6071 ),
6072 velocity_mps,
6073 air_density_kg_m3: air_density,
6074 mach: velocity_mps / speed_of_sound,
6075 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
6076 nutation_damping_factor: 0.05,
6077 };
6078 let expected = calculate_combined_angular_motion(
6079 ¶ms,
6080 &initial_state,
6081 0.0,
6082 solver.time_step,
6083 0.001,
6084 );
6085 let actual = solver
6086 .solve()
6087 .expect("one-step solve should succeed")
6088 .angular_state
6089 .expect("precession/nutation was enabled");
6090
6091 assert!(
6092 (actual.precession_angle - expected.precession_angle).abs() < 1e-15,
6093 "precession phase used the wrong inertia: actual={}, expected={}",
6094 actual.precession_angle,
6095 expected.precession_angle
6096 );
6097 assert!(
6098 (actual.nutation_phase - expected.nutation_phase).abs() < 1e-15,
6099 "nutation phase used the wrong inertia: actual={}, expected={}",
6100 actual.nutation_phase,
6101 expected.nutation_phase
6102 );
6103 }
6104}
6105
6106#[cfg(test)]
6107mod form_factor_drag_tests {
6108 use super::*;
6109
6110 fn acceleration_with_form_factor_flag(enabled: bool) -> Vector3<f64> {
6111 let inputs = BallisticInputs {
6112 bc_value: 0.462,
6113 bc_type: DragModel::G1,
6114 bullet_model: Some("168gr SMK Match".to_string()),
6115 use_form_factor: enabled,
6116 ..BallisticInputs::default()
6117 };
6118 let solver = TrajectorySolver::new(
6119 inputs,
6120 WindConditions::default(),
6121 AtmosphericConditions::default(),
6122 );
6123 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
6124 solver.calculate_acceleration(
6125 &Vector3::zeros(),
6126 &Vector3::new(600.0, 0.0, 0.0),
6127 &Vector3::zeros(),
6128 (temp_c, pressure_hpa, density / 1.225),
6129 )
6130 }
6131
6132 #[test]
6133 fn measured_bc_drag_does_not_apply_name_based_form_factor_again() {
6134 let baseline = acceleration_with_form_factor_flag(false);
6135 let flagged = acceleration_with_form_factor_flag(true);
6136
6137 assert!(
6138 (flagged - baseline).norm() < 1e-12,
6139 "published BC already encodes form factor: baseline={baseline:?} flagged={flagged:?}"
6140 );
6141 }
6142}
6143
6144#[cfg(test)]
6145mod rk45_adaptivity_tests {
6146 use super::*;
6147
6148 #[test]
6149 fn cli_rk45_error_norm_scales_components_independently() {
6150 let position = Vector3::new(1.0e9, 0.0, 0.0);
6151 let velocity = Vector3::new(800.0, 0.0, 0.0);
6152 let fifth_position = position;
6153 let fifth_velocity = velocity;
6154 let fourth_position = position;
6155 let fourth_velocity = Vector3::new(800.0, 1.0e-3, 0.0);
6156
6157 let error = cli_rk45_error_norm(
6158 &position,
6159 &velocity,
6160 &fifth_position,
6161 &fifth_velocity,
6162 &fourth_position,
6163 &fourth_velocity,
6164 );
6165 let expected = 1.0e-3 / 6.0_f64.sqrt();
6166
6167 assert!(
6168 (error - expected).abs() <= 1e-15,
6169 "large downrange position masked a velocity-component error: {error}"
6170 );
6171 }
6172
6173 fn discontinuous_wind_solver() -> TrajectorySolver {
6174 let inputs = BallisticInputs::default();
6175 let mut solver = TrajectorySolver::new(
6176 inputs,
6177 WindConditions::default(),
6178 AtmosphericConditions::default(),
6179 );
6180 solver.set_wind_segments(vec![
6181 crate::wind::WindSegment::new(0.0, 90.0, 4.0),
6182 crate::wind::WindSegment::new(1_000.0, 90.0, 10_000.0),
6183 ]);
6184 solver
6185 }
6186
6187 #[test]
6188 fn rk45_retries_discontinuous_trial_before_advancing() {
6189 let solver = discontinuous_wind_solver();
6190 let position = Vector3::new(0.0, solver.inputs.muzzle_height, 0.0);
6191 let velocity = Vector3::new(solver.inputs.muzzle_velocity, 0.0, 0.0);
6192 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
6193 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
6194 let dt = 0.01;
6195
6196 let rejected_trial = solver.rk45_step(
6197 &position,
6198 &velocity,
6199 dt,
6200 &Vector3::zeros(),
6201 RK45_TOLERANCE,
6202 resolved_atmo,
6203 );
6204 assert!(
6205 rejected_trial.error > RK45_TOLERANCE,
6206 "discontinuous full step must exceed tolerance, got {}",
6207 rejected_trial.error
6208 );
6209
6210 let accepted = solver.adaptive_rk45_step(
6211 &position,
6212 &velocity,
6213 dt,
6214 &Vector3::zeros(),
6215 resolved_atmo,
6216 );
6217 assert!(accepted.used_dt < dt, "oversized trial was not retried");
6218 assert!(
6219 accepted.error <= RK45_TOLERANCE || accepted.used_dt <= RK45_MIN_DT,
6220 "accepted error {} exceeds tolerance at dt {}",
6221 accepted.error,
6222 accepted.used_dt
6223 );
6224
6225 let accepted_trial = solver.rk45_step(
6226 &position,
6227 &velocity,
6228 accepted.used_dt,
6229 &Vector3::zeros(),
6230 RK45_TOLERANCE,
6231 resolved_atmo,
6232 );
6233 assert_eq!(accepted.position, accepted_trial.position);
6234 assert_eq!(accepted.velocity, accepted_trial.velocity);
6235 assert!((RK45_MIN_DT..=RK45_MAX_DT).contains(&accepted.next_dt));
6236 }
6237}
6238
6239#[cfg(test)]
6240mod ground_termination_tests {
6241 use super::*;
6242 use crate::trajectory_observation::TrajectoryObservationFlag;
6243
6244 #[test]
6245 fn every_solver_reports_one_exact_early_ground_endpoint() {
6246 for (name, use_rk4, use_adaptive_rk45) in [
6247 ("Euler", false, false),
6248 ("RK4", true, false),
6249 ("RK45", true, true),
6250 ] {
6251 let inputs = BallisticInputs {
6252 muzzle_height: 1.0,
6253 muzzle_angle: -0.2,
6254 ground_threshold: 0.0,
6255 use_rk4,
6256 use_adaptive_rk45,
6257 ..BallisticInputs::default()
6258 };
6259 let mut solver = TrajectorySolver::new(
6260 inputs,
6261 WindConditions::default(),
6262 AtmosphericConditions::default(),
6263 );
6264 solver.set_max_range(1_000.0);
6265
6266 let result = solver.solve().expect("early-ground solve should succeed");
6267 let terminal = result.points.last().expect("terminal point is missing");
6268
6269 assert_eq!(result.termination, TrajectoryTermination::GroundThreshold);
6270 assert_eq!(terminal.position.y.to_bits(), 0.0_f64.to_bits());
6271 assert!(
6272 terminal.position.x < 1_000.0,
6273 "{name} incorrectly reached max range"
6274 );
6275 assert_eq!(result.max_range.to_bits(), terminal.position.x.to_bits());
6276 assert_eq!(
6277 result
6278 .points
6279 .iter()
6280 .filter(|point| point.position.y == 0.0)
6281 .count(),
6282 1,
6283 "{name} did not retain exactly one ground endpoint"
6284 );
6285
6286 let observations = result
6287 .sample_observations(1.0, 100)
6288 .expect("checked early-ground sampling should succeed");
6289 assert!(observations[..observations.len() - 1]
6290 .iter()
6291 .all(|observation| observation.distance_m < terminal.position.x));
6292 let terminal_observation = observations.last().expect("terminal observation");
6293 assert_eq!(
6294 terminal_observation.distance_m.to_bits(),
6295 terminal.position.x.to_bits()
6296 );
6297 assert!(terminal_observation
6298 .flags
6299 .contains(&TrajectoryObservationFlag::Terminal));
6300 assert!(terminal_observation
6301 .flags
6302 .contains(&TrajectoryObservationFlag::GroundThreshold));
6303 assert_eq!(
6304 observations
6305 .iter()
6306 .filter(|observation| observation
6307 .flags
6308 .contains(&TrajectoryObservationFlag::Terminal))
6309 .count(),
6310 1,
6311 "{name} repeated the terminal observation"
6312 );
6313 }
6314 }
6315
6316 #[test]
6321 fn rk4_and_rk45_descend_to_ground_threshold() {
6322 for adaptive in [false, true] {
6323 let inputs = BallisticInputs {
6324 muzzle_angle: 0.1, use_rk4: true,
6326 use_adaptive_rk45: adaptive,
6327 ..BallisticInputs::default()
6328 };
6329 assert_eq!(
6330 inputs.ground_threshold, -100.0,
6331 "default ground_threshold is -100 m"
6332 );
6333
6334 let mut solver = TrajectorySolver::new(
6335 inputs,
6336 WindConditions::default(),
6337 AtmosphericConditions::default(),
6338 );
6339 solver.set_max_range(1.0e7);
6341
6342 let result = solver.solve().expect("solve should succeed");
6343 let final_y = result
6344 .points
6345 .last()
6346 .expect("trajectory has points")
6347 .position
6348 .y;
6349 assert!(
6350 final_y < -1.0,
6351 "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
6352 past launch level toward the ground_threshold floor, not stop at y = 0"
6353 );
6354 }
6355 }
6356}
6357
6358#[cfg(test)]
6359mod magnus_stability_tests {
6360 use super::*;
6361
6362 #[test]
6363 fn yaw_of_repose_magnus_force_is_vertical_and_twist_signed() {
6364 let acceleration = |enable_magnus, is_twist_right| {
6365 let inputs = BallisticInputs {
6366 muzzle_velocity: 822.96,
6367 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
6368 bullet_diameter: 0.308 * 0.0254,
6369 bullet_length: 1.215 * 0.0254,
6370 twist_rate: 10.0,
6371 is_twist_right,
6372 enable_magnus,
6373 ..BallisticInputs::default()
6374 };
6375 let solver = TrajectorySolver::new(
6376 inputs,
6377 WindConditions::default(),
6378 AtmosphericConditions::default(),
6379 );
6380 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
6381 solver.calculate_acceleration(
6382 &Vector3::zeros(),
6383 &Vector3::new(822.96, 0.0, 0.0),
6384 &Vector3::zeros(),
6385 (temp_c, pressure_hpa, density / 1.225),
6386 )
6387 };
6388
6389 let baseline = acceleration(false, true);
6390 let right_twist = acceleration(true, true) - baseline;
6391 let left_twist = acceleration(true, false) - baseline;
6392
6393 assert!(
6394 right_twist.y < 0.0,
6395 "right-hand Magnus must point down, got {right_twist:?}"
6396 );
6397 assert!(
6398 left_twist.y > 0.0,
6399 "left-hand Magnus must point up, got {left_twist:?}"
6400 );
6401 assert!((right_twist.y + left_twist.y).abs() < 1e-12);
6402 assert!(right_twist.x.abs() < 1e-12 && right_twist.z.abs() < 1e-12);
6403 assert!(left_twist.x.abs() < 1e-12 && left_twist.z.abs() < 1e-12);
6404 }
6405
6406 #[test]
6407 fn magnus_uses_velocity_corrected_muzzle_stability_gate() {
6408 let muzzle_velocity = 1_400.0 / 3.28084;
6409 let inputs = BallisticInputs {
6410 muzzle_velocity,
6411 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
6412 bullet_diameter: 0.308 * 0.0254,
6413 bullet_length: 1.215 * 0.0254,
6414 twist_rate: 15.0,
6415 enable_magnus: true,
6416 ..BallisticInputs::default()
6417 };
6418 let solver = TrajectorySolver::new(
6419 inputs.clone(),
6420 WindConditions::default(),
6421 AtmosphericConditions::default(),
6422 );
6423
6424 let bare_sg = crate::spin_drift::miller_stability(0.308, 168.0, 15.0, 1.215);
6425 let canonical_sg = solver.effective_spin_drift_sg();
6426 assert!(bare_sg > 1.0, "test requires bare Sg above the Magnus gate");
6427 assert!(
6428 canonical_sg < 1.0,
6429 "velocity-corrected Sg must be below the gate, got {canonical_sg}"
6430 );
6431
6432 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
6433 let acceleration = solver.calculate_acceleration(
6434 &Vector3::zeros(),
6435 &Vector3::new(muzzle_velocity, 0.0, 0.0),
6436 &Vector3::zeros(),
6437 (temp_c, pressure_hpa, density / 1.225),
6438 );
6439 let mut baseline_inputs = inputs;
6440 baseline_inputs.enable_magnus = false;
6441 let baseline_solver = TrajectorySolver::new(
6442 baseline_inputs,
6443 WindConditions::default(),
6444 AtmosphericConditions::default(),
6445 );
6446 let baseline = baseline_solver.calculate_acceleration(
6447 &Vector3::zeros(),
6448 &Vector3::new(muzzle_velocity, 0.0, 0.0),
6449 &Vector3::zeros(),
6450 (temp_c, pressure_hpa, density / 1.225),
6451 );
6452
6453 assert_eq!(
6454 acceleration, baseline,
6455 "canonical Sg below 1 must suppress every Magnus acceleration component"
6456 );
6457 }
6458
6459 #[test]
6460 fn magnus_force_grows_as_fixed_spin_projectile_slows() {
6461 let inputs = BallisticInputs {
6462 muzzle_velocity: 800.0,
6463 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
6464 bullet_diameter: 0.308 * 0.0254,
6465 bullet_length: 1.215 * 0.0254,
6466 twist_rate: 12.0,
6467 enable_magnus: true,
6468 ..BallisticInputs::default()
6469 };
6470
6471 let magnus_acceleration = |speed_mps| {
6472 let evaluate = |enable_magnus| {
6473 let mut run_inputs = inputs.clone();
6474 run_inputs.enable_magnus = enable_magnus;
6475 let solver = TrajectorySolver::new(
6476 run_inputs,
6477 WindConditions::default(),
6478 AtmosphericConditions::default(),
6479 );
6480 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
6481 solver
6482 .calculate_acceleration(
6483 &Vector3::zeros(),
6484 &Vector3::new(speed_mps, 0.0, 0.0),
6485 &Vector3::zeros(),
6486 (temp_c, pressure_hpa, density / 1.225),
6487 )
6488 .y
6489 };
6490 (evaluate(true) - evaluate(false)).abs()
6491 };
6492
6493 let fast = magnus_acceleration(200.0);
6494 let slow = magnus_acceleration(100.0);
6495 let ratio = slow / fast;
6496 let expected_ratio = 2.0_f64.powf(5.0 / 3.0);
6497
6498 assert!(fast > 0.0 && slow > 0.0, "fast={fast}, slow={slow}");
6499 assert!(
6500 (ratio - expected_ratio).abs() < 1e-3,
6501 "fixed-spin Magnus acceleration must grow downrange; slow/fast={ratio}, \
6502 expected={expected_ratio}"
6503 );
6504 }
6505}
6506
6507#[cfg(test)]
6508mod coriolis_direction_tests {
6509 use super::*;
6510 use std::f64::consts::FRAC_PI_2;
6511
6512 #[test]
6513 fn supersonic_crossing_flags_a_positive_range_sample() {
6514 use crate::trajectory_sampling::TrajectoryFlag;
6518
6519 for (solver_name, use_rk4, use_adaptive_rk45) in [
6520 ("Euler", false, false),
6521 ("RK4", true, false),
6522 ("RK45", true, true),
6523 ] {
6524 let inputs = BallisticInputs {
6525 muzzle_velocity: 850.0,
6526 bc_value: 0.2,
6527 bc_type: DragModel::G7,
6528 muzzle_angle: 0.03,
6529 enable_trajectory_sampling: true,
6530 sample_interval: 50.0,
6531 use_rk4,
6532 use_adaptive_rk45,
6533 ..BallisticInputs::default()
6534 };
6535 let mut solver = TrajectorySolver::new(
6536 inputs,
6537 WindConditions::default(),
6538 AtmosphericConditions::default(),
6539 );
6540 solver.set_max_range(2000.0);
6541 let samples = solver
6542 .solve()
6543 .expect("supersonic solve should succeed")
6544 .sampled_points
6545 .expect("sampling was enabled");
6546 let flagged_distances: Vec<_> = samples
6547 .iter()
6548 .filter(|sample| sample.flags.contains(&TrajectoryFlag::MachTransition))
6549 .map(|sample| sample.distance_m)
6550 .collect();
6551
6552 assert!(
6553 !flagged_distances.is_empty()
6554 && flagged_distances.iter().all(|distance| *distance > 0.0),
6555 "{solver_name} must flag genuine crossings only at positive range: {flagged_distances:?}"
6556 );
6557 }
6558 }
6559
6560 #[test]
6561 fn subsonic_launch_does_not_flag_a_muzzle_transition() {
6562 use crate::trajectory_sampling::TrajectoryFlag;
6563
6564 for (solver_name, use_rk4, use_adaptive_rk45) in [
6565 ("Euler", false, false),
6566 ("RK4", true, false),
6567 ("RK45", true, true),
6568 ] {
6569 let inputs = BallisticInputs {
6570 muzzle_velocity: 250.0,
6571 muzzle_angle: 0.02,
6572 enable_trajectory_sampling: true,
6573 sample_interval: 25.0,
6574 use_rk4,
6575 use_adaptive_rk45,
6576 ..BallisticInputs::default()
6577 };
6578 let mut solver = TrajectorySolver::new(
6579 inputs,
6580 WindConditions::default(),
6581 AtmosphericConditions::default(),
6582 );
6583 solver.set_max_range(300.0);
6584 let samples = solver
6585 .solve()
6586 .expect("subsonic solve should succeed")
6587 .sampled_points
6588 .expect("sampling was enabled");
6589
6590 assert!(
6591 samples
6592 .iter()
6593 .all(|sample| !sample.flags.contains(&TrajectoryFlag::MachTransition)),
6594 "{solver_name} marked a Mach transition for a launch already below Mach 1"
6595 );
6596 }
6597 }
6598
6599 #[test]
6600 fn mach_transition_tracker_requires_a_downward_crossing() {
6601 fn record(mach_values: &[f64]) -> Vec<f64> {
6602 let mut tracker = MachTransitionTracker::default();
6603 let mut distances = Vec::new();
6604 for (index, mach) in mach_values.iter().copied().enumerate() {
6605 tracker.record_downward_crossings(mach, index as f64 * 10.0, &mut distances);
6606 }
6607 distances
6608 }
6609
6610 assert!(record(&[0.9, 0.8, 0.7]).is_empty());
6611 assert_eq!(record(&[1.1, 1.05, 0.99]), vec![20.0]);
6612 assert_eq!(record(&[1.2, 1.19, 1.0, 0.99]), vec![10.0, 30.0]);
6613 assert_eq!(record(&[0.9, 1.3, 1.1, 0.9, 1.3, 0.8]), vec![20.0, 30.0]);
6614 assert!(record(&[1.3, f64::NAN, 1.1]).is_empty());
6615 }
6616
6617 #[test]
6618 fn mach_transition_tracker_labels_0_9_without_touching_the_flat_vec() {
6619 fn record(mach_values: &[f64]) -> (Vec<f64>, MachTransitionTracker) {
6625 let mut tracker = MachTransitionTracker::default();
6626 let mut distances = Vec::new();
6627 for (index, mach) in mach_values.iter().copied().enumerate() {
6628 tracker.record_downward_crossings(mach, index as f64 * 10.0, &mut distances);
6629 }
6630 (distances, tracker)
6631 }
6632
6633 let (distances, tracker) = record(&[0.9, 0.8, 0.7]);
6636 assert!(distances.is_empty()); assert_eq!(tracker.mach_1_2_distance_m, None);
6638 assert_eq!(tracker.mach_1_0_distance_m, None);
6639 assert_eq!(tracker.mach_0_9_distance_m, Some(10.0));
6640
6641 let (distances, tracker) = record(&[1.1, 1.05, 0.99]);
6643 assert_eq!(distances, vec![20.0]);
6644 assert_eq!(tracker.mach_1_2_distance_m, None);
6645 assert_eq!(tracker.mach_1_0_distance_m, Some(20.0));
6646 assert_eq!(tracker.mach_0_9_distance_m, None);
6647
6648 let (distances, tracker) = record(&[1.2, 1.19, 1.0, 0.99]);
6650 assert_eq!(distances, vec![10.0, 30.0]); assert_eq!(tracker.mach_1_2_distance_m, Some(10.0));
6652 assert_eq!(tracker.mach_1_0_distance_m, Some(30.0));
6653 assert_eq!(tracker.mach_0_9_distance_m, None);
6654
6655 let (distances, tracker) = record(&[0.9, 1.3, 1.1, 0.9, 1.3, 0.8]);
6658 assert_eq!(distances, vec![20.0, 30.0]); assert_eq!(tracker.mach_1_2_distance_m, Some(20.0));
6660 assert_eq!(tracker.mach_1_0_distance_m, Some(30.0));
6661 assert_eq!(tracker.mach_0_9_distance_m, Some(50.0));
6662 assert!(
6663 tracker.mach_1_2_distance_m < tracker.mach_1_0_distance_m
6664 && tracker.mach_1_0_distance_m < tracker.mach_0_9_distance_m,
6665 "labeled crossings must be strictly increasing downrange"
6666 );
6667
6668 let (distances, tracker) = record(&[1.3, f64::NAN, 1.1]);
6670 assert!(distances.is_empty());
6671 assert_eq!(tracker.mach_1_2_distance_m, None);
6672 assert_eq!(tracker.mach_1_0_distance_m, None);
6673 assert_eq!(tracker.mach_0_9_distance_m, None);
6674 }
6675
6676 #[test]
6677 fn humidity_percent_converts_and_clamps() {
6678 let mut i = BallisticInputs {
6680 humidity: 0.5,
6681 ..BallisticInputs::default()
6682 };
6683 assert!((i.humidity_percent() - 50.0).abs() < 1e-9, "0.5 -> 50%");
6684 i.humidity = 0.0;
6685 assert_eq!(i.humidity_percent(), 0.0);
6686 i.humidity = 1.0;
6687 assert_eq!(i.humidity_percent(), 100.0);
6688 i.humidity = 1.5; assert_eq!(i.humidity_percent(), 100.0);
6690 }
6691
6692 fn vertical_at(shot_azimuth: f64, range_m: f64) -> f64 {
6695 let inputs = BallisticInputs {
6696 muzzle_velocity: 800.0,
6697 bc_value: 0.5,
6698 bc_type: DragModel::G7,
6699 muzzle_angle: 0.02, enable_coriolis: true,
6701 latitude: Some(45.0),
6702 shot_azimuth,
6703 ground_threshold: f64::NEG_INFINITY, ..BallisticInputs::default()
6705 };
6706 let mut solver = TrajectorySolver::new(
6707 inputs,
6708 WindConditions::default(),
6709 AtmosphericConditions::default(),
6710 );
6711 solver.set_max_range(range_m + 50.0);
6712 let r = solver.solve().expect("solve");
6713 let pts = &r.points;
6714 for i in 1..pts.len() {
6715 if pts[i].position.x >= range_m {
6716 let p1 = &pts[i - 1];
6717 let p2 = &pts[i];
6718 let t = (range_m - p1.position.x) / (p2.position.x - p1.position.x);
6719 return p1.position.y + t * (p2.position.y - p1.position.y);
6720 }
6721 }
6722 panic!("range {range_m} not reached");
6723 }
6724
6725 #[test]
6730 fn eotvos_east_higher_than_west() {
6731 let range = 600.0;
6732 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!(
6736 east > west,
6737 "east ({east:.5}) must be higher than west ({west:.5}) at {range} m (Eötvös)"
6738 );
6739 assert!(
6740 east > north && north > west,
6741 "north ({north:.5}) must lie between east ({east:.5}) and west ({west:.5})"
6742 );
6743 assert!(
6744 (east - west) > 1e-3,
6745 "E-W vertical separation ({:.6} m) should be physically meaningful, not FP noise",
6746 east - west
6747 );
6748 }
6749
6750 #[test]
6758 fn labeled_mach_crossings_match_pinned_pre_change_flat_vec_across_solvers() {
6759 let cases = [
6761 ("Euler", false, false, 670.9878683238721_f64, 805.5274119916264_f64),
6762 ("RK4", true, false, 671.7257336844475_f64, 805.933409072171_f64),
6763 ("RK45", true, true, 672.4905711917901_f64, 806.5709746782849_f64),
6764 ];
6765
6766 for (solver_name, use_rk4, use_adaptive_rk45, expected_1_2, expected_1_0) in cases {
6767 let inputs = BallisticInputs {
6768 muzzle_velocity: 850.0,
6769 bc_value: 0.2,
6770 bc_type: DragModel::G7,
6771 muzzle_angle: 0.03,
6772 use_rk4,
6773 use_adaptive_rk45,
6774 ..BallisticInputs::default()
6775 };
6776 let mut solver = TrajectorySolver::new(
6777 inputs,
6778 WindConditions::default(),
6779 AtmosphericConditions::default(),
6780 );
6781 solver.set_max_range(2000.0);
6782 let result = solver.solve().expect("solve should succeed");
6783
6784 assert_eq!(
6785 result.mach_1_2_distance_m,
6786 Some(expected_1_2),
6787 "{solver_name}: mach_1_2_distance_m must match the pinned pre-change flat-Vec value"
6788 );
6789 assert_eq!(
6790 result.mach_1_0_distance_m,
6791 Some(expected_1_0),
6792 "{solver_name}: mach_1_0_distance_m must match the pinned pre-change flat-Vec value"
6793 );
6794
6795 let mach_1_2 = result.mach_1_2_distance_m.expect("crosses 1.2");
6796 let mach_1_0 = result.mach_1_0_distance_m.expect("crosses 1.0");
6797 let mach_0_9 = result
6798 .mach_0_9_distance_m
6799 .expect("this trajectory also goes past 0.9 within 2000 m");
6800 assert!(
6801 mach_1_2 < mach_1_0 && mach_1_0 < mach_0_9,
6802 "{solver_name}: labeled crossings must be strictly increasing downrange \
6803 (1.2={mach_1_2}, 1.0={mach_1_0}, 0.9={mach_0_9})"
6804 );
6805 }
6806 }
6807
6808 #[test]
6811 fn labeled_mach_crossings_are_none_for_a_fully_supersonic_trajectory() {
6812 for (solver_name, use_rk4, use_adaptive_rk45) in [
6813 ("Euler", false, false),
6814 ("RK4", true, false),
6815 ("RK45", true, true),
6816 ] {
6817 let inputs = BallisticInputs {
6818 muzzle_velocity: 850.0,
6819 bc_value: 0.2,
6820 bc_type: DragModel::G7,
6821 muzzle_angle: 0.03,
6822 use_rk4,
6823 use_adaptive_rk45,
6824 ..BallisticInputs::default()
6825 };
6826 let mut solver = TrajectorySolver::new(
6827 inputs,
6828 WindConditions::default(),
6829 AtmosphericConditions::default(),
6830 );
6831 solver.set_max_range(200.0);
6833 let result = solver.solve().expect("solve should succeed");
6834
6835 assert_eq!(
6836 result.mach_1_2_distance_m, None,
6837 "{solver_name}: must not report a 1.2 crossing that never happens"
6838 );
6839 assert_eq!(
6840 result.mach_1_0_distance_m, None,
6841 "{solver_name}: must not report a 1.0 crossing that never happens"
6842 );
6843 assert_eq!(
6844 result.mach_0_9_distance_m, None,
6845 "{solver_name}: must not report a 0.9 crossing that never happens"
6846 );
6847 }
6848 }
6849}
6850
6851#[cfg(test)]
6852mod cant_tests {
6853 use super::*;
6854
6855 fn base_inputs() -> BallisticInputs {
6856 BallisticInputs {
6857 muzzle_velocity: 800.0,
6858 bc_value: 0.5,
6859 bc_type: DragModel::G7,
6860 bullet_mass: 0.0109,
6861 bullet_diameter: 0.00782,
6862 bullet_length: 0.0309,
6863 sight_height: 0.05,
6864 twist_rate: 10.0,
6865 use_rk4: true,
6866 ..BallisticInputs::default()
6867 }
6868 }
6869
6870 fn solve_with(inputs: BallisticInputs, max_range: f64) -> TrajectoryResult {
6871 let mut s = TrajectorySolver::new(
6872 inputs,
6873 WindConditions::default(),
6874 AtmosphericConditions::default(),
6875 );
6876 s.set_max_range(max_range);
6877 s.solve().expect("solve")
6878 }
6879
6880 fn yz_at(result: &TrajectoryResult, x: f64) -> (f64, f64) {
6882 let pts = &result.points;
6883 for i in 1..pts.len() {
6884 if pts[i].position.x >= x {
6885 let (p1, p2) = (&pts[i - 1], &pts[i]);
6886 let dx = p2.position.x - p1.position.x;
6887 let t = if dx.abs() < 1e-12 { 0.0 } else { (x - p1.position.x) / dx };
6888 return (
6889 p1.position.y + t * (p2.position.y - p1.position.y),
6890 p1.position.z + t * (p2.position.z - p1.position.z),
6891 );
6892 }
6893 }
6894 panic!("trajectory never reached {x} m");
6895 }
6896
6897 #[test]
6898 fn cant_sign_clockwise_up_offset_goes_right_and_low() {
6899 let mut level = base_inputs();
6901 level.muzzle_angle = 0.003; let mut canted = level.clone();
6903 canted.cant_angle = 10f64.to_radians();
6904
6905 let (y0, z0) = yz_at(&solve_with(level, 400.0), 300.0);
6906 let (y1, z1) = yz_at(&solve_with(canted, 400.0), 300.0);
6907 assert!(z1 > z0 + 0.01, "clockwise cant must move POI right: z0={z0} z1={z1}");
6908 assert!(y1 < y0 - 0.001, "clockwise cant must move POI low: y0={y0} y1={y1}");
6909 }
6910
6911 #[test]
6912 fn pure_cant_shows_bore_offset_near_range() {
6913 let mut i = base_inputs();
6916 i.muzzle_angle = 0.0;
6917 i.cant_angle = 10f64.to_radians();
6918 let sh = i.sight_height;
6919 let r = solve_with(i, 60.0);
6920 let first = &r.points[1]; let expected = -sh * 10f64.to_radians().sin();
6922 assert!(
6923 (first.position.z - expected).abs() < 0.005,
6924 "near-muzzle lateral {} should be ~bore offset {expected}",
6925 first.position.z
6926 );
6927 }
6928
6929 #[test]
6930 fn zero_angle_is_independent_of_cant() {
6931 let a = base_inputs();
6932 let mut b = base_inputs();
6933 b.cant_angle = 15f64.to_radians();
6934 let za = calculate_zero_angle(a.clone(), 100.0, 0.0).expect("zero a");
6935 let zb = calculate_zero_angle(b.clone(), 100.0, 0.0).expect("zero b");
6936 assert_eq!(za.to_bits(), zb.to_bits(), "zeroing must ignore cant: {za} vs {zb}");
6937 let _ = (a.cant_angle, b.cant_angle);
6939 }
6940
6941 #[test]
6942 fn nonfinite_cant_is_rejected() {
6943 let mut i = base_inputs();
6944 i.cant_angle = f64::NAN;
6945 let s = TrajectorySolver::new(i, WindConditions::default(), AtmosphericConditions::default());
6946 assert!(s.solve().is_err());
6947 }
6948
6949 #[test]
6950 fn incline_and_cant_compose_without_breaking() {
6951 let mut flat = base_inputs();
6953 flat.muzzle_angle = 0.003;
6954 flat.shooting_angle = 15f64.to_radians();
6955 let mut canted = flat.clone();
6956 canted.cant_angle = 10f64.to_radians();
6957 let (_, z_flat) = yz_at(&solve_with(flat, 400.0), 300.0);
6958 let (_, z_cant) = yz_at(&solve_with(canted, 400.0), 300.0);
6959 assert!(z_cant > z_flat, "cant must still deflect right on an incline");
6960 }
6961}
6962
6963#[cfg(test)]
6964mod vertical_wind_tests {
6965 use super::*;
6966
6967 fn base_inputs() -> BallisticInputs {
6968 BallisticInputs {
6969 muzzle_velocity: 800.0,
6970 bc_value: 0.5,
6971 bc_type: DragModel::G7,
6972 bullet_mass: 0.0109,
6973 bullet_diameter: 0.00782,
6974 bullet_length: 0.0309,
6975 sight_height: 0.05,
6976 twist_rate: 10.0,
6977 use_rk4: true,
6978 ..BallisticInputs::default()
6979 }
6980 }
6981
6982 fn y_at(result: &TrajectoryResult, x: f64) -> f64 {
6984 let pts = &result.points;
6985 for i in 1..pts.len() {
6986 if pts[i].position.x >= x {
6987 let (p1, p2) = (&pts[i - 1], &pts[i]);
6988 let dx = p2.position.x - p1.position.x;
6989 let t = if dx.abs() < 1e-12 { 0.0 } else { (x - p1.position.x) / dx };
6990 return p1.position.y + t * (p2.position.y - p1.position.y);
6991 }
6992 }
6993 panic!("trajectory never reached {x} m");
6994 }
6995
6996 fn solve_with(inputs: BallisticInputs, wind: WindConditions, max_range: f64) -> TrajectoryResult {
6997 let mut s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
6998 s.set_max_range(max_range);
6999 s.solve().expect("solve")
7000 }
7001
7002 #[test]
7003 fn updraft_raises_poi_downrange() {
7004 let calm_inputs = base_inputs();
7007 let calm_wind = WindConditions::default();
7008 let updraft = WindConditions {
7009 vertical_speed: 5.0,
7010 ..Default::default()
7011 };
7012
7013 let calm = solve_with(calm_inputs.clone(), calm_wind, 500.0);
7014 let updraft_result = solve_with(calm_inputs, updraft, 500.0);
7015
7016 let y_calm = y_at(&calm, 400.0);
7017 let y_updraft = y_at(&updraft_result, 400.0);
7018 assert!(
7019 y_updraft > y_calm,
7020 "5 m/s updraft must raise POI at 400m: calm={y_calm}, updraft={y_updraft}"
7021 );
7022 }
7023
7024 #[test]
7025 fn zero_vertical_is_default_and_finite_required() {
7026 assert_eq!(WindConditions::default().vertical_speed, 0.0);
7027
7028 let inputs = base_inputs();
7029 let wind = WindConditions {
7030 vertical_speed: f64::NAN,
7031 ..Default::default()
7032 };
7033 let s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
7034 assert!(
7035 s.solve().is_err(),
7036 "NaN wind.vertical_speed must be rejected by validate_for_solve"
7037 );
7038 }
7039}
7040
7041#[cfg(test)]
7043mod bc_reference_standard_tests {
7044 use super::*;
7045
7046 fn base_inputs() -> BallisticInputs {
7047 BallisticInputs {
7048 muzzle_velocity: 800.0,
7049 bc_value: 0.5,
7050 bc_type: DragModel::G7,
7051 bullet_mass: 0.0109,
7052 bullet_diameter: 0.00782,
7053 bullet_length: 0.0309,
7054 sight_height: 0.05,
7055 twist_rate: 10.0,
7056 use_rk4: true,
7057 ..BallisticInputs::default()
7058 }
7059 }
7060
7061 fn y_and_speed_at(result: &TrajectoryResult, x: f64) -> (f64, f64) {
7063 let pts = &result.points;
7064 for i in 1..pts.len() {
7065 if pts[i].position.x >= x {
7066 let (p1, p2) = (&pts[i - 1], &pts[i]);
7067 let dx = p2.position.x - p1.position.x;
7068 let t = if dx.abs() < 1e-12 {
7069 0.0
7070 } else {
7071 (x - p1.position.x) / dx
7072 };
7073 return (
7074 p1.position.y + t * (p2.position.y - p1.position.y),
7075 p1.velocity_magnitude + t * (p2.velocity_magnitude - p1.velocity_magnitude),
7076 );
7077 }
7078 }
7079 panic!("trajectory never reached {x} m");
7080 }
7081
7082 #[test]
7085 fn asm_to_icao_ratio_matches_documented_value() {
7086 assert!(
7087 (crate::constants::ASM_TO_ICAO_BC - 0.98237).abs() < 1e-5,
7088 "ASM_TO_ICAO_BC = {} must equal 0.98237 to 5 decimal places",
7089 crate::constants::ASM_TO_ICAO_BC
7090 );
7091 assert_eq!(
7096 crate::constants::ASM_TO_ICAO_BC,
7097 crate::constants::ASM_DENSITY_LB_FT3 / crate::constants::ICAO_DENSITY_LB_FT3
7098 );
7099 }
7100
7101 #[test]
7104 fn default_bc_reference_standard_is_icao() {
7105 assert_eq!(
7106 BallisticInputs::default().bc_reference_standard,
7107 BcReferenceStandard::Icao
7108 );
7109 }
7110
7111 #[test]
7116 fn icao_reference_leaves_bc_value_bit_identical() {
7117 let raw_bc: f64 = 0.4372911; let inputs = BallisticInputs {
7119 bc_value: raw_bc,
7120 bc_reference_standard: BcReferenceStandard::Icao,
7121 ..base_inputs()
7122 };
7123 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
7124 assert_eq!(solver.inputs.bc_value.to_bits(), raw_bc.to_bits());
7125 }
7126
7127 #[test]
7128 fn default_inputs_solve_is_unaffected_by_the_new_field_existing() {
7129 let a = TrajectorySolver::new(base_inputs(), WindConditions::default(), AtmosphericConditions::default())
7133 .solve()
7134 .expect("solve a");
7135 let b = TrajectorySolver::new(
7136 BallisticInputs { ..base_inputs() },
7137 WindConditions::default(),
7138 AtmosphericConditions::default(),
7139 )
7140 .solve()
7141 .expect("solve b");
7142 assert_eq!(a.impact_velocity.to_bits(), b.impact_velocity.to_bits());
7143 assert_eq!(a.max_range.to_bits(), b.max_range.to_bits());
7144 }
7145
7146 #[test]
7149 fn army_standard_metro_scales_bc_value_by_exactly_the_derived_ratio() {
7150 let raw_bc = 0.5;
7151 let inputs = BallisticInputs {
7152 bc_value: raw_bc,
7153 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
7154 ..base_inputs()
7155 };
7156 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
7157 assert_eq!(
7158 solver.inputs.bc_value,
7159 raw_bc * crate::constants::ASM_TO_ICAO_BC
7160 );
7161 }
7162
7163 #[test]
7164 fn army_standard_metro_scales_mach_keyed_bc_segments() {
7165 let inputs = BallisticInputs {
7166 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
7167 bc_segments: Some(vec![(0.5, 0.40), (1.5, 0.30)]),
7168 ..base_inputs()
7169 };
7170 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
7171 let segments = solver.inputs.bc_segments.as_ref().expect("segments");
7172 assert_eq!(segments[0], (0.5, 0.40 * crate::constants::ASM_TO_ICAO_BC));
7173 assert_eq!(segments[1], (1.5, 0.30 * crate::constants::ASM_TO_ICAO_BC));
7174 }
7175
7176 #[test]
7177 fn army_standard_metro_scales_velocity_keyed_bc_segments_data() {
7178 let inputs = BallisticInputs {
7179 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
7180 bc_segments_data: Some(vec![
7181 crate::BCSegmentData {
7182 velocity_min: 0.0,
7183 velocity_max: 500.0,
7184 bc_value: 0.40,
7185 },
7186 crate::BCSegmentData {
7187 velocity_min: 500.0,
7188 velocity_max: 900.0,
7189 bc_value: 0.45,
7190 },
7191 ]),
7192 ..base_inputs()
7193 };
7194 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
7195 let segments = solver.inputs.bc_segments_data.as_ref().expect("segments");
7196 assert_eq!(segments[0].bc_value, 0.40 * crate::constants::ASM_TO_ICAO_BC);
7197 assert_eq!(segments[1].bc_value, 0.45 * crate::constants::ASM_TO_ICAO_BC);
7198 assert_eq!(segments[0].velocity_min, 0.0);
7200 assert_eq!(segments[1].velocity_max, 900.0);
7201 }
7202
7203 #[test]
7208 fn army_standard_metro_moves_impact_in_the_more_drag_direction() {
7209 let solve_at = |standard: BcReferenceStandard| {
7210 let inputs = BallisticInputs {
7211 bc_value: 0.475,
7212 bc_reference_standard: standard,
7213 ..base_inputs()
7214 };
7215 let mut solver =
7216 TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
7217 solver.set_max_range(500.0);
7218 solver.solve().expect("solve")
7219 };
7220
7221 let icao = solve_at(BcReferenceStandard::Icao);
7222 let asm = solve_at(BcReferenceStandard::ArmyStandardMetro);
7223
7224 let (y_icao, v_icao) = y_and_speed_at(&icao, 400.0);
7225 let (y_asm, v_asm) = y_and_speed_at(&asm, 400.0);
7226
7227 assert!(
7228 y_asm < y_icao,
7229 "ArmyStandardMetro must drop MORE (lower y) at 400m than Icao for the same raw \
7230 bc_value: icao_y={y_icao}, asm_y={y_asm}"
7231 );
7232 assert!(
7233 v_asm < v_icao,
7234 "ArmyStandardMetro must retain LESS velocity at 400m than Icao for the same raw \
7235 bc_value: icao_v={v_icao}, asm_v={v_asm}"
7236 );
7237 }
7238
7239 #[test]
7246 fn monte_carlo_inherits_the_normalized_bc_reference() {
7247 let base_inputs_asm = BallisticInputs {
7248 bc_value: 0.475,
7249 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
7250 ..base_inputs()
7251 };
7252 let wind = WindConditions::default();
7253
7254 let mut direct_solver =
7260 TrajectorySolver::new(base_inputs_asm.clone(), wind.clone(), AtmosphericConditions::default());
7261 direct_solver.set_max_range(base_inputs_asm.target_distance.max(1000.0) * 2.0);
7262 let direct = direct_solver.solve().expect("direct solve");
7263
7264 let mc_params = MonteCarloParams {
7265 num_simulations: 1,
7266 velocity_std_dev: 0.0,
7267 angle_std_dev: 0.0,
7268 bc_std_dev: 0.0,
7269 wind_speed_std_dev: 0.0,
7270 target_distance: None,
7271 base_wind_speed: 0.0,
7272 base_wind_direction: 0.0,
7273 azimuth_std_dev: 0.0,
7274 };
7275 let mc = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
7276 base_inputs_asm,
7277 wind,
7278 mc_params,
7279 0.0,
7280 42,
7281 )
7282 .expect("monte carlo");
7283
7284 assert_eq!(mc.ranges.len(), 1);
7285 assert_eq!(
7286 mc.ranges[0].to_bits(),
7287 direct.max_range.to_bits(),
7288 "a zero-dispersion single MC sample must match a plain solve of the same \
7289 ASM-referenced inputs bit-for-bit"
7290 );
7291 assert_eq!(
7292 mc.impact_velocities[0].to_bits(),
7293 direct.impact_velocity.to_bits()
7294 );
7295 }
7296
7297 #[test]
7305 fn estimate_bc_fit_recovers_an_icao_referenced_bc() {
7306 let known_bc = 0.475;
7307 let velocity = 800.0;
7308 let mass = 0.0109;
7309 let diameter = 0.00782;
7310 let atmosphere = AtmosphericConditions::default();
7311
7312 let synth_inputs = BallisticInputs {
7313 muzzle_velocity: velocity,
7314 bc_value: known_bc,
7315 bc_type: DragModel::G7,
7316 bullet_mass: mass,
7317 bullet_diameter: diameter,
7318 bullet_length: 0.0309,
7319 sight_height: 0.05,
7320 twist_rate: 10.0,
7321 use_rk4: true,
7322 bc_reference_standard: BcReferenceStandard::Icao,
7323 ..BallisticInputs::default()
7324 };
7325 let mut solver = TrajectorySolver::new(synth_inputs, WindConditions::default(), atmosphere.clone());
7326 solver.set_max_range(500.0);
7327 let trajectory = solver.solve().expect("synthetic solve");
7328
7329 let points: Vec<(f64, f64)> = [100.0, 200.0, 300.0, 400.0]
7330 .iter()
7331 .map(|&d| {
7332 let (y, _) = {
7333 let pts = &trajectory.points;
7334 let mut found = None;
7335 for i in 1..pts.len() {
7336 if pts[i].position.x >= d {
7337 let (p1, p2) = (&pts[i - 1], &pts[i]);
7338 let dx = p2.position.x - p1.position.x;
7339 let t = if dx.abs() < 1e-12 {
7340 0.0
7341 } else {
7342 (d - p1.position.x) / dx
7343 };
7344 found = Some((
7345 p1.position.y + t * (p2.position.y - p1.position.y),
7346 0.0,
7347 ));
7348 break;
7349 }
7350 }
7351 found.expect("trajectory reached observation distance")
7352 };
7353 (d, -y) })
7355 .collect();
7356
7357 let estimate = estimate_bc_fit(
7358 velocity,
7359 mass,
7360 diameter,
7361 &points,
7362 DragModel::G7,
7363 BcFitMode::Drop,
7364 atmosphere,
7365 None,
7366 0.05,
7367 )
7368 .expect("fit should converge");
7369
7370 assert!(
7371 (estimate.bc - known_bc).abs() < 0.02,
7372 "fit should recover the known ICAO-referenced bc={known_bc}, got {}",
7373 estimate.bc
7374 );
7375 }
7376
7377 #[test]
7380 fn custom_drag_table_makes_bc_reference_standard_numerically_inert() {
7381 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])
7382 .expect("valid table");
7383
7384 let solve_with = |standard: BcReferenceStandard| {
7385 let inputs = BallisticInputs {
7386 bc_value: 0.5, bc_reference_standard: standard,
7388 custom_drag_table: Some(table.clone()),
7389 ..base_inputs()
7390 };
7391 let mut solver =
7392 TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
7393 solver.set_max_range(500.0);
7394 solver.solve().expect("solve")
7395 };
7396
7397 let icao = solve_with(BcReferenceStandard::Icao);
7398 let asm = solve_with(BcReferenceStandard::ArmyStandardMetro);
7399
7400 assert_eq!(
7401 icao.impact_velocity.to_bits(),
7402 asm.impact_velocity.to_bits(),
7403 "a custom drag table must make bc_reference_standard fully inert"
7404 );
7405 assert_eq!(icao.max_range.to_bits(), asm.max_range.to_bits());
7406 }
7407
7408 #[test]
7409 fn custom_drag_table_inert_warning_fires_only_for_army_standard_metro_with_a_table() {
7410 let table = crate::drag::DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.3, 0.4, 0.3])
7411 .expect("valid table");
7412
7413 let no_table_icao = base_inputs();
7415 assert!(no_table_icao.bc_reference_standard_inert_warning().is_none());
7416 let no_table_asm = BallisticInputs {
7417 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
7418 ..base_inputs()
7419 };
7420 assert!(no_table_asm.bc_reference_standard_inert_warning().is_none());
7421
7422 let table_icao = BallisticInputs {
7424 custom_drag_table: Some(table.clone()),
7425 ..base_inputs()
7426 };
7427 assert!(table_icao.bc_reference_standard_inert_warning().is_none());
7428
7429 let table_asm = BallisticInputs {
7431 custom_drag_table: Some(table),
7432 bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
7433 ..base_inputs()
7434 };
7435 let warning = table_asm
7436 .bc_reference_standard_inert_warning()
7437 .expect("must warn");
7438 assert!(warning.contains("--bc-reference"));
7439 assert!(warning.contains("--drag-table"));
7440 }
7441}