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)]
47pub struct BallisticsError {
48 message: String,
49}
50
51impl fmt::Display for BallisticsError {
52 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
53 write!(f, "{}", self.message)
54 }
55}
56
57impl Error for BallisticsError {}
58
59impl From<String> for BallisticsError {
60 fn from(msg: String) -> Self {
61 BallisticsError { message: msg }
62 }
63}
64
65impl From<&str> for BallisticsError {
66 fn from(msg: &str) -> Self {
67 BallisticsError {
68 message: msg.to_string(),
69 }
70 }
71}
72
73#[derive(Debug, Clone)]
77pub struct BallisticInputs {
78 pub bc_value: f64, pub bc_type: DragModel, 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,
95 pub shooting_angle: f64, pub cant_angle: f64,
105 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,
119 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,
141 pub enable_magnus: bool, pub enable_coriolis: bool, pub use_powder_sensitivity: bool,
144 pub powder_temp_sensitivity: f64, pub powder_temp: f64, pub powder_temp_curve: Option<Vec<(f64, f64)>>,
153 pub powder_curve_temp_c: Option<f64>,
157 pub tipoff_yaw: f64, pub tipoff_decay_distance: f64, pub use_bc_segments: bool,
162 pub bc_segments: Option<Vec<(f64, f64)>>, pub bc_segments_data: Option<Vec<crate::BCSegmentData>>, pub use_enhanced_spin_drift: bool,
165 pub use_form_factor: bool,
168 pub enable_wind_shear: bool,
169 pub wind_shear_model: String,
170 pub enable_trajectory_sampling: bool,
171 pub sample_interval: f64, pub enable_pitch_damping: bool,
173 pub enable_precession_nutation: bool,
174 pub enable_aerodynamic_jump: bool,
177 pub use_cluster_bc: bool, pub custom_drag_table: Option<crate::drag::DragTable>,
181
182 pub bc_type_str: Option<String>,
184}
185
186impl BallisticInputs {
187 pub fn humidity_percent(&self) -> f64 {
192 (self.humidity * 100.0).clamp(0.0, 100.0)
193 }
194
195 pub fn sectional_density_lb_in2(&self) -> Option<f64> {
201 let weight_gr = if self.weight_grains > 0.0 {
202 self.weight_grains
203 } else {
204 self.bullet_mass / crate::constants::GRAINS_TO_KG };
206 let diameter_in = if self.caliber_inches > 0.0 {
207 self.caliber_inches
208 } else {
209 self.bullet_diameter / 0.0254 };
211 if weight_gr > 0.0 && diameter_in > 0.0 {
212 Some(weight_gr / 7000.0 / (diameter_in * diameter_in))
213 } else {
214 None
215 }
216 }
217
218 pub fn custom_drag_denominator(&self, fallback_bc: f64) -> f64 {
230 match self.sectional_density_lb_in2() {
231 Some(sd) => sd,
232 None => {
233 static WARN_ONCE: std::sync::Once = std::sync::Once::new();
234 WARN_ONCE.call_once(|| {
235 eprintln!(
236 "Warning: custom drag table active but bullet mass/diameter are \
237 unavailable; falling back to bc_value for the retardation denominator"
238 );
239 });
240 fallback_bc
241 }
242 }
243 }
244}
245
246impl Default for BallisticInputs {
247 fn default() -> Self {
248 let mass_kg = 0.01;
249 let diameter_m = 0.00762;
250 let bc = 0.5;
251 let muzzle_angle_rad = 0.0;
252 let bc_type = DragModel::G1;
253
254 Self {
255 bc_value: bc,
257 bc_type,
258 bullet_mass: mass_kg,
259 muzzle_velocity: 800.0,
260 bullet_diameter: diameter_m,
261 bullet_length: crate::stability::estimate_bullet_length_m(diameter_m, mass_kg),
265
266 muzzle_angle: muzzle_angle_rad,
268 target_distance: 100.0,
269 azimuth_angle: 0.0,
270 shot_azimuth: 0.0,
271 shooting_angle: 0.0,
272 cant_angle: 0.0,
273 sight_height: 0.05,
274 muzzle_height: 0.0, target_height: 0.0, ground_threshold: -100.0, altitude: 0.0,
280 temperature: 15.0,
281 pressure: 1013.25, humidity: 0.5, latitude: None,
284
285 wind_speed: 0.0,
287 wind_angle: 0.0,
288
289 twist_rate: 12.0, is_twist_right: true,
292 caliber_inches: diameter_m / 0.0254, weight_grains: mass_kg / crate::constants::GRAINS_TO_KG, manufacturer: None,
295 bullet_model: None,
296 bullet_id: None,
297 bullet_cluster: None,
298
299 use_rk4: true, use_adaptive_rk45: true, enable_advanced_effects: false,
305 enable_magnus: false,
306 enable_coriolis: false,
307 use_powder_sensitivity: false,
308 powder_temp_sensitivity: 0.0,
309 powder_temp: 15.0,
310 powder_temp_curve: None,
311 powder_curve_temp_c: None,
312 tipoff_yaw: 0.0,
313 tipoff_decay_distance: 50.0,
314 use_bc_segments: false,
315 bc_segments: None,
316 bc_segments_data: None,
317 use_enhanced_spin_drift: false,
318 use_form_factor: false,
319 enable_wind_shear: false,
320 wind_shear_model: "none".to_string(),
321 enable_trajectory_sampling: false,
322 sample_interval: 10.0, enable_pitch_damping: false,
324 enable_precession_nutation: false,
325 enable_aerodynamic_jump: false,
326 use_cluster_bc: false, custom_drag_table: None,
330
331 bc_type_str: None,
333 }
334 }
335}
336
337pub fn interpolate_powder_temp_curve(curve: &[(f64, f64)], temp_c: f64) -> f64 {
343 debug_assert!(!curve.is_empty());
344 if curve.is_empty() {
345 return 0.0;
346 }
347 let mut sorted;
350 let pts: &[(f64, f64)] = if curve.windows(2).all(|w| w[0].0 <= w[1].0) {
351 curve
352 } else {
353 sorted = curve.to_vec();
354 sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
355 &sorted
356 };
357 let n = pts.len();
358 if temp_c <= pts[0].0 {
359 return pts[0].1; }
361 if temp_c >= pts[n - 1].0 {
362 return pts[n - 1].1; }
364 for i in 1..n {
365 let (t0, v0) = pts[i - 1];
366 let (t1, v1) = pts[i];
367 if temp_c <= t1 {
368 let span = t1 - t0;
369 if span.abs() < f64::EPSILON {
370 return v1; }
372 let f = (temp_c - t0) / span;
373 return v0 + f * (v1 - v0);
374 }
375 }
376 pts[n - 1].1
377}
378
379pub fn parse_powder_sweep(s: &str) -> Result<Vec<f64>, String> {
385 const MAX_SWEEP_ROWS: usize = 500;
386 let parts: Vec<&str> = s.split(':').collect();
387 if parts.len() != 3 {
388 return Err(format!(
389 "Invalid --sweep '{}': expected START:END:STEP (e.g. \"20:110:10\")",
390 s
391 ));
392 }
393 let parse = |p: &str, name: &str| -> Result<f64, String> {
394 p.trim()
395 .parse::<f64>()
396 .map_err(|_| format!("Invalid --sweep {}: '{}' is not a number", name, p.trim()))
397 };
398 let start = parse(parts[0], "START")?;
399 let end = parse(parts[1], "END")?;
400 let step = parse(parts[2], "STEP")?;
401 if !step.is_finite() || step <= 0.0 {
402 return Err(format!("Invalid --sweep STEP {}: must be positive", step));
403 }
404 if !start.is_finite() || !end.is_finite() || end < start {
405 return Err(format!(
406 "Invalid --sweep range {}:{}: END must be >= START",
407 start, end
408 ));
409 }
410 let n_f = ((end - start) / step + 1e-9).floor();
416 if !n_f.is_finite() || n_f + 1.0 > MAX_SWEEP_ROWS as f64 {
417 return Err(format!(
418 "--sweep would produce more than {} rows; use a larger STEP",
419 MAX_SWEEP_ROWS
420 ));
421 }
422 let n = n_f as usize + 1;
423 Ok((0..n).map(|i| start + step * i as f64).collect())
425}
426
427pub fn resolve_powder_adjusted_velocity(
436 nominal_velocity_mps: f64,
437 ambient_temperature_c: f64,
438 use_powder_sensitivity: bool,
439 powder_temp_sensitivity_mps_per_c: f64,
440 powder_reference_temp_c: f64,
441 powder_temp_curve: Option<&[(f64, f64)]>,
442 powder_curve_temp_c: Option<f64>,
443) -> f64 {
444 if let Some(curve) = powder_temp_curve {
445 if !curve.is_empty() {
446 let lookup_c = powder_curve_temp_c.unwrap_or(ambient_temperature_c);
447 return interpolate_powder_temp_curve(curve, lookup_c);
448 }
449 return nominal_velocity_mps;
452 }
453 if use_powder_sensitivity {
454 let temp_delta_c = ambient_temperature_c - powder_reference_temp_c;
455 return nominal_velocity_mps + powder_temp_sensitivity_mps_per_c * temp_delta_c;
456 }
457 nominal_velocity_mps
458}
459
460#[derive(Debug, Clone)]
462pub struct WindConditions {
463 pub speed: f64, pub direction: f64,
467 pub vertical_speed: f64,
475}
476
477impl Default for WindConditions {
478 fn default() -> Self {
479 Self {
480 speed: 0.0,
481 direction: 0.0,
482 vertical_speed: 0.0,
483 }
484 }
485}
486
487#[derive(Debug, Clone)]
489pub struct AtmosphericConditions {
490 pub temperature: f64, pub pressure: f64, pub humidity: f64,
496 pub altitude: f64, }
498
499impl Default for AtmosphericConditions {
500 fn default() -> Self {
501 Self {
502 temperature: 15.0,
503 pressure: 1013.25,
504 humidity: 50.0,
505 altitude: 0.0,
506 }
507 }
508}
509
510#[derive(Debug, Clone)]
512pub struct TrajectoryPoint {
513 pub time: f64,
514 pub position: Vector3<f64>,
515 pub velocity_magnitude: f64,
516 pub kinetic_energy: f64,
517}
518
519#[derive(Debug, Clone)]
521pub struct TrajectoryResult {
522 pub max_range: f64,
523 pub max_height: f64,
524 pub time_of_flight: f64,
525 pub impact_velocity: f64,
526 pub impact_energy: f64,
527 pub projectile_mass_kg: f64,
529 pub line_of_sight_height_m: f64,
531 pub station_speed_of_sound_mps: f64,
533 pub termination: TrajectoryTermination,
535 pub points: Vec<TrajectoryPoint>,
536 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>,
545}
546
547const RK45_TOLERANCE: f64 = 1e-6;
548const RK45_SAFETY_FACTOR: f64 = 0.9;
549const RK45_MAX_DT: f64 = 0.01;
550const RK45_MIN_DT: f64 = 1e-6;
551const TRAJECTORY_TIME_LIMIT_S: f64 = 100.0;
552
553pub const MAX_TRAJECTORY_POINTS: usize = 250_000;
559
560fn cli_rk45_error_norm(
562 position: &Vector3<f64>,
563 velocity: &Vector3<f64>,
564 fifth_position: &Vector3<f64>,
565 fifth_velocity: &Vector3<f64>,
566 fourth_position: &Vector3<f64>,
567 fourth_velocity: &Vector3<f64>,
568) -> f64 {
569 let pack_state = |position: &Vector3<f64>, velocity: &Vector3<f64>| {
570 Vector6::new(
571 position.x, position.y, position.z, velocity.x, velocity.y, velocity.z,
572 )
573 };
574 let state = pack_state(position, velocity);
575 let fifth_order = pack_state(fifth_position, fifth_velocity);
576 let fourth_order = pack_state(fourth_position, fourth_velocity);
577
578 crate::trajectory_integration::rk45_error_norm(&state, &fifth_order, &fourth_order)
579}
580
581struct Rk45Trial {
582 position: Vector3<f64>,
583 velocity: Vector3<f64>,
584 suggested_dt: f64,
585 error: f64,
586}
587
588struct Rk45AcceptedStep {
589 position: Vector3<f64>,
590 velocity: Vector3<f64>,
591 used_dt: f64,
592 next_dt: f64,
593 error: f64,
594}
595
596#[derive(Default)]
597struct MachTransitionTracker {
598 previous_mach: Option<f64>,
599 crossed_transonic: bool,
600 crossed_subsonic: bool,
601}
602
603impl MachTransitionTracker {
604 fn record_downward_crossings(&mut self, mach: f64, downrange_m: f64, distances: &mut Vec<f64>) {
605 if !mach.is_finite() {
606 self.previous_mach = None;
607 return;
608 }
609
610 if let Some(previous_mach) = self.previous_mach {
611 if !self.crossed_transonic && previous_mach >= 1.2 && mach < 1.2 {
612 self.crossed_transonic = true;
613 distances.push(downrange_m);
614 }
615 if !self.crossed_subsonic && previous_mach >= 1.0 && mach < 1.0 {
616 self.crossed_subsonic = true;
617 distances.push(downrange_m);
618 }
619 }
620 self.previous_mach = Some(mach);
621 }
622}
623
624impl TrajectoryResult {
625 pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
629 if self.points.is_empty() {
630 return None;
631 }
632
633 for i in 0..self.points.len() - 1 {
635 let p1 = &self.points[i];
636 let p2 = &self.points[i + 1];
637
638 if p1.position.x <= target_range && p2.position.x >= target_range {
640 let dx = p2.position.x - p1.position.x;
642 if dx.abs() < 1e-10 {
643 return Some(p1.position);
644 }
645 let t = (target_range - p1.position.x) / dx;
646
647 return Some(Vector3::new(
649 target_range,
650 p1.position.y + t * (p2.position.y - p1.position.y),
651 p1.position.z + t * (p2.position.z - p1.position.z),
652 ));
653 }
654 }
655
656 self.points.last().map(|p| p.position)
658 }
659}
660
661#[derive(Debug, Clone, Copy, PartialEq, Eq)]
663enum StationAtmosphereResolution {
664 LegacyDefaultSentinels,
667 Authoritative,
670}
671
672#[derive(Clone)]
673pub struct TrajectorySolver {
674 inputs: BallisticInputs,
675 wind: WindConditions,
676 atmosphere: AtmosphericConditions,
677 station_atmosphere_resolution: StationAtmosphereResolution,
678 max_range: f64,
679 time_step: f64,
680 max_trajectory_points: usize,
681 cluster_bc: Option<ClusterBCDegradation>,
682 precession_nutation_inertias: (f64, f64),
684 wind_sock: Option<crate::wind::WindSock>,
689 atmo_sock: Option<crate::atmosphere::AtmoSock>,
696}
697
698impl TrajectorySolver {
699 pub fn new(
700 inputs: BallisticInputs,
701 wind: WindConditions,
702 atmosphere: AtmosphericConditions,
703 ) -> Self {
704 Self::new_with_station_atmosphere_resolution(
705 inputs,
706 wind,
707 atmosphere,
708 StationAtmosphereResolution::LegacyDefaultSentinels,
709 )
710 }
711
712 pub(crate) fn new_with_resolved_station_atmosphere(
716 inputs: BallisticInputs,
717 wind: WindConditions,
718 atmosphere: AtmosphericConditions,
719 ) -> Self {
720 Self::new_with_station_atmosphere_resolution(
721 inputs,
722 wind,
723 atmosphere,
724 StationAtmosphereResolution::Authoritative,
725 )
726 }
727
728 fn new_with_station_atmosphere_resolution(
729 mut inputs: BallisticInputs,
730 wind: WindConditions,
731 atmosphere: AtmosphericConditions,
732 station_atmosphere_resolution: StationAtmosphereResolution,
733 ) -> Self {
734 inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
736 inputs.weight_grains = inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
737
738 inputs.muzzle_velocity = resolve_powder_adjusted_velocity(
750 inputs.muzzle_velocity,
751 inputs.temperature,
752 inputs.use_powder_sensitivity,
753 inputs.powder_temp_sensitivity,
754 inputs.powder_temp,
755 inputs.powder_temp_curve.as_deref(),
756 inputs.powder_curve_temp_c,
757 );
758
759 let cluster_bc = if inputs.use_cluster_bc {
761 Some(ClusterBCDegradation::new())
762 } else {
763 None
764 };
765 let precession_nutation_inertias = projectile_moments_of_inertia(
766 inputs.bullet_mass,
767 inputs.bullet_diameter,
768 inputs.bullet_length,
769 );
770
771 Self {
772 inputs,
773 wind,
774 atmosphere,
775 station_atmosphere_resolution,
776 max_range: 1000.0,
777 time_step: 0.001,
778 max_trajectory_points: MAX_TRAJECTORY_POINTS,
779 cluster_bc,
780 precession_nutation_inertias,
781 wind_sock: None,
782 atmo_sock: None,
783 }
784 }
785
786 pub fn set_max_range(&mut self, range: f64) {
787 self.max_range = range;
788 }
789
790 pub fn set_time_step(&mut self, step: f64) {
791 self.time_step = step;
792 }
793
794 pub(crate) fn calculate_and_set_zero_angle(
798 &mut self,
799 target_distance_m: f64,
800 target_height_m: f64,
801 ) -> Result<f64, BallisticsError> {
802 let angle = self.find_zero_angle(target_distance_m, target_height_m)?;
803 self.inputs.muzzle_angle = angle;
804 Ok(angle)
805 }
806
807 fn find_zero_angle(
808 &self,
809 target_distance_m: f64,
810 target_height_m: f64,
811 ) -> Result<f64, BallisticsError> {
812 let mut low_angle = 0.0;
815 let mut high_angle = 0.2; let tolerance = 1e-7;
817 let max_iterations = 60;
818
819 let low_height = self.zero_trial_height_at(low_angle, target_distance_m)?;
821 let high_height = self.zero_trial_height_at(high_angle, target_distance_m)?;
822
823 match (low_height, high_height) {
824 (Some(low_height), Some(high_height)) => {
825 let low_error = low_height - target_height_m;
826 let high_error = high_height - target_height_m;
827
828 if low_error > 0.0 && high_error > 0.0 {
829 } else if low_error < 0.0 && high_error < 0.0 {
832 let mut expanded = false;
834 for multiplier in [2.0, 3.0, 4.0] {
835 let new_high = (high_angle * multiplier).min(0.785);
836 if let Ok(Some(height)) =
837 self.zero_trial_height_at(new_high, target_distance_m)
838 {
839 if height - target_height_m > 0.0 {
840 high_angle = new_high;
841 expanded = true;
842 break;
843 }
844 }
845 if new_high >= 0.785 {
846 break;
847 }
848 }
849 if !expanded {
850 return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
851 }
852 }
853 }
854 (None, Some(_)) => {
855 }
858 (Some(_), None) => {
859 return Err(
860 "Cannot find zero angle: high angle trajectory doesn't reach target distance"
861 .into(),
862 );
863 }
864 (None, None) => {
865 return Err(
866 "Cannot find zero angle: trajectory cannot reach target distance at any angle"
867 .into(),
868 );
869 }
870 }
871
872 for _ in 0..max_iterations {
873 let mid_angle = (low_angle + high_angle) / 2.0;
874 match self.zero_trial_height_at(mid_angle, target_distance_m)? {
875 Some(height) => {
876 let error = height - target_height_m;
877 if error.abs() < 0.0001 {
880 return Ok(mid_angle);
881 }
882
883 if (high_angle - low_angle).abs() < tolerance {
886 if error.abs() < 0.01 {
887 return Ok(mid_angle);
888 }
889 return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
890 }
891
892 if error > 0.0 {
893 high_angle = mid_angle;
894 } else {
895 low_angle = mid_angle;
896 }
897 }
898 None => {
899 low_angle = mid_angle;
900 if (high_angle - low_angle).abs() < tolerance {
901 return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
902 }
903 }
904 }
905 }
906
907 Err("Failed to find zero angle".into())
908 }
909
910 fn zero_trial_height_at(
913 &self,
914 angle_rad: f64,
915 target_distance_m: f64,
916 ) -> Result<Option<f64>, BallisticsError> {
917 let mut trial = self.clone();
918 trial.inputs.muzzle_angle = angle_rad;
919 trial.inputs.enable_aerodynamic_jump = false;
922 trial.inputs.cant_angle = 0.0;
925 trial.set_max_range(target_distance_m * 2.0);
926 let result = trial.solve()?;
927
928 for (index, point) in result.points.iter().enumerate() {
929 if point.position.x >= target_distance_m {
930 let shot_y_m = if index == 0 {
931 point.position.y
932 } else {
933 let previous = &result.points[index - 1];
934 let span = point.position.x - previous.position.x;
935 let fraction = (target_distance_m - previous.position.x) / span;
936 previous.position.y + fraction * (point.position.y - previous.position.y)
937 };
938 return Ok(Some(crate::atmosphere::shot_frame_altitude(
939 0.0,
940 target_distance_m,
941 shot_y_m,
942 trial.inputs.shooting_angle,
943 )));
944 }
945 }
946 Ok(None)
947 }
948
949 fn validate_for_solve(&self) -> Result<(), BallisticsError> {
955 let require_finite = |name: &str, value: f64| {
956 if value.is_finite() {
957 Ok(())
958 } else {
959 Err(BallisticsError::from(format!("{name} must be finite")))
960 }
961 };
962 let require_positive = |name: &str, value: f64| {
963 if value.is_finite() && value > 0.0 {
964 Ok(())
965 } else {
966 Err(BallisticsError::from(format!(
967 "{name} must be finite and greater than zero"
968 )))
969 }
970 };
971
972 if self.inputs.custom_drag_table.is_none() {
978 require_positive("bc_value", self.inputs.bc_value)?;
979 }
980 require_positive("bullet_mass", self.inputs.bullet_mass)?;
981 require_positive("bullet_diameter", self.inputs.bullet_diameter)?;
982 require_positive("muzzle_velocity", self.inputs.muzzle_velocity)?;
983
984 require_finite("muzzle_angle", self.inputs.muzzle_angle)?;
985 require_finite("azimuth_angle", self.inputs.azimuth_angle)?;
986 require_finite("shooting_angle", self.inputs.shooting_angle)?;
987 require_finite("cant_angle", self.inputs.cant_angle)?;
988 require_finite("muzzle_height", self.inputs.muzzle_height)?;
989
990 if !(self.inputs.ground_threshold.is_finite()
993 || self.inputs.ground_threshold == f64::NEG_INFINITY)
994 {
995 return Err(BallisticsError::from(
996 "ground_threshold must be finite or negative infinity",
997 ));
998 }
999
1000 match &self.wind_sock {
1001 Some(wind_sock) => wind_sock
1002 .validate_segments()
1003 .map_err(BallisticsError::from)?,
1004 None => {
1005 require_finite("wind.speed", self.wind.speed)?;
1006 require_finite("wind.direction", self.wind.direction)?;
1007 require_finite("wind.vertical_speed", self.wind.vertical_speed)?;
1008 }
1009 }
1010
1011 require_finite("atmosphere.temperature", self.atmosphere.temperature)?;
1012 require_finite("atmosphere.pressure", self.atmosphere.pressure)?;
1013 require_finite("atmosphere.humidity", self.atmosphere.humidity)?;
1014 require_finite("atmosphere.altitude", self.atmosphere.altitude)?;
1015
1016 require_positive("max_range", self.max_range)?;
1017 if !self.inputs.use_rk4 || !self.inputs.use_adaptive_rk45 {
1020 require_positive("time_step", self.time_step)?;
1021 }
1022
1023 if self.inputs.enable_trajectory_sampling {
1024 require_finite("sight_height", self.inputs.sight_height)?;
1025 require_positive("sample_interval", self.inputs.sample_interval)?;
1026 projected_sample_count(self.max_range, self.inputs.sample_interval)?;
1027 }
1028
1029 if self.inputs.enable_coriolis {
1030 require_finite("shot_azimuth", self.inputs.shot_azimuth)?;
1031 if let Some(latitude) = self.inputs.latitude {
1032 require_finite("latitude", latitude)?;
1033 }
1034 }
1035
1036 Ok(())
1037 }
1038
1039 fn validate_result_sanity(&self, result: &TrajectoryResult) -> Result<(), BallisticsError> {
1046 let require_finite = |name: &str, value: f64| {
1047 if value.is_finite() {
1048 Ok(())
1049 } else {
1050 Err(BallisticsError::from(format!(
1051 "trajectory result contains non-finite {name}"
1052 )))
1053 }
1054 };
1055 let require_non_negative = |name: &str, value: f64| {
1056 if value >= 0.0 {
1057 Ok(())
1058 } else {
1059 Err(BallisticsError::from(format!(
1060 "trajectory result contains non-physical negative {name} ({value})"
1061 )))
1062 }
1063 };
1064 let require_indexed_finite = |collection: &str, index: usize, field: &str, value: f64| {
1065 if value.is_finite() {
1066 Ok(())
1067 } else {
1068 Err(BallisticsError::from(format!(
1069 "trajectory result contains non-finite {collection}[{index}].{field}"
1070 )))
1071 }
1072 };
1073 let require_indexed_non_negative =
1074 |collection: &str, index: usize, field: &str, value: f64| {
1075 if value >= 0.0 {
1076 Ok(())
1077 } else {
1078 Err(BallisticsError::from(format!(
1079 "trajectory result contains non-physical negative {collection}[{index}].{field} ({value})"
1080 )))
1081 }
1082 };
1083
1084 require_finite("max_range", result.max_range)?;
1085 require_finite("max_height", result.max_height)?;
1086 require_finite("time_of_flight", result.time_of_flight)?;
1087 require_finite("impact_velocity", result.impact_velocity)?;
1088 require_finite("impact_energy", result.impact_energy)?;
1089 require_finite("projectile_mass_kg", result.projectile_mass_kg)?;
1090 require_finite(
1091 "line_of_sight_height_m",
1092 result.line_of_sight_height_m,
1093 )?;
1094 require_finite(
1095 "station_speed_of_sound_mps",
1096 result.station_speed_of_sound_mps,
1097 )?;
1098
1099 require_non_negative("max_range", result.max_range)?;
1103 require_non_negative("time_of_flight", result.time_of_flight)?;
1104 require_non_negative("impact_velocity", result.impact_velocity)?;
1105 require_non_negative("impact_energy", result.impact_energy)?;
1106 require_non_negative("projectile_mass_kg", result.projectile_mass_kg)?;
1107 require_non_negative(
1108 "station_speed_of_sound_mps",
1109 result.station_speed_of_sound_mps,
1110 )?;
1111
1112 for (index, point) in result.points.iter().enumerate() {
1113 require_indexed_finite("points", index, "time", point.time)?;
1114 require_indexed_finite("points", index, "position.x", point.position.x)?;
1115 require_indexed_finite("points", index, "position.y", point.position.y)?;
1116 require_indexed_finite("points", index, "position.z", point.position.z)?;
1117 require_indexed_finite(
1118 "points",
1119 index,
1120 "velocity_magnitude",
1121 point.velocity_magnitude,
1122 )?;
1123 require_indexed_finite("points", index, "kinetic_energy", point.kinetic_energy)?;
1124 require_indexed_non_negative("points", index, "time", point.time)?;
1125 require_indexed_non_negative(
1126 "points",
1127 index,
1128 "velocity_magnitude",
1129 point.velocity_magnitude,
1130 )?;
1131 require_indexed_non_negative("points", index, "kinetic_energy", point.kinetic_energy)?;
1132 }
1133
1134 if let Some(samples) = &result.sampled_points {
1135 for (index, sample) in samples.iter().enumerate() {
1136 require_indexed_finite("sampled_points", index, "distance_m", sample.distance_m)?;
1137 require_indexed_finite("sampled_points", index, "drop_m", sample.drop_m)?;
1138 require_indexed_finite(
1139 "sampled_points",
1140 index,
1141 "wind_drift_m",
1142 sample.wind_drift_m,
1143 )?;
1144 require_indexed_finite(
1145 "sampled_points",
1146 index,
1147 "velocity_mps",
1148 sample.velocity_mps,
1149 )?;
1150 require_indexed_finite("sampled_points", index, "energy_j", sample.energy_j)?;
1151 require_indexed_finite("sampled_points", index, "time_s", sample.time_s)?;
1152 }
1153 }
1154
1155 for (name, value) in [
1156 ("min_pitch_damping", result.min_pitch_damping),
1157 ("transonic_mach", result.transonic_mach),
1158 ("max_yaw_angle", result.max_yaw_angle),
1159 ("max_precession_angle", result.max_precession_angle),
1160 ] {
1161 if let Some(value) = value {
1162 require_finite(name, value)?;
1163 }
1164 }
1165
1166 if let Some(state) = result.angular_state {
1167 for (name, value) in [
1168 ("angular_state.pitch_angle", state.pitch_angle),
1169 ("angular_state.yaw_angle", state.yaw_angle),
1170 ("angular_state.pitch_rate", state.pitch_rate),
1171 ("angular_state.yaw_rate", state.yaw_rate),
1172 ("angular_state.precession_angle", state.precession_angle),
1173 ("angular_state.nutation_phase", state.nutation_phase),
1174 ] {
1175 require_finite(name, value)?;
1176 }
1177 }
1178
1179 if let Some(jump) = result.aerodynamic_jump {
1180 for (name, value) in [
1181 ("aerodynamic_jump.vertical_jump_moa", jump.vertical_jump_moa),
1182 (
1183 "aerodynamic_jump.horizontal_jump_moa",
1184 jump.horizontal_jump_moa,
1185 ),
1186 ("aerodynamic_jump.jump_angle_rad", jump.jump_angle_rad),
1187 (
1188 "aerodynamic_jump.magnus_component_moa",
1189 jump.magnus_component_moa,
1190 ),
1191 ("aerodynamic_jump.yaw_component_moa", jump.yaw_component_moa),
1192 (
1193 "aerodynamic_jump.stabilization_factor",
1194 jump.stabilization_factor,
1195 ),
1196 ] {
1197 require_finite(name, value)?;
1198 }
1199 }
1200
1201 Ok(())
1202 }
1203
1204 fn validate_integration_state(
1217 &self,
1218 position: &Vector3<f64>,
1219 velocity: &Vector3<f64>,
1220 time: f64,
1221 ) -> Result<(), BallisticsError> {
1222 if !(position.iter().all(|value| value.is_finite())
1223 && velocity.iter().all(|value| value.is_finite())
1224 && time.is_finite())
1225 {
1226 return Err(BallisticsError::from(
1227 "trajectory integration produced a non-finite state (often from physically \
1228 extreme inputs — e.g. an absurd bore/muzzle height placing the launch far \
1229 from sea level, or a degenerate atmosphere; check those inputs, or set \
1230 --altitude explicitly)",
1231 ));
1232 }
1233
1234 let speed = velocity.magnitude();
1235 let budget = self.speed_budget(time);
1236 if speed > budget {
1237 return Err(BallisticsError::from(format!(
1238 "trajectory integration diverged: speed {speed:.3e} m/s at t={time:.6}s exceeds \
1239 the physical budget of {budget:.3e} m/s"
1240 )));
1241 }
1242 Ok(())
1243 }
1244
1245 fn speed_budget(&self, time: f64) -> f64 {
1250 let scalar_wind = self.wind.speed.abs() + self.wind.vertical_speed.abs();
1251 let wind_bound = match &self.wind_sock {
1252 Some(sock) => scalar_wind.max(sock.max_speed_mps()),
1253 None => scalar_wind,
1254 };
1255 2.0 * (self.inputs.muzzle_velocity + wind_bound + 10.0)
1256 + crate::constants::G_ACCEL_MPS2 * time
1257 }
1258
1259 fn push_trajectory_point(
1261 &self,
1262 points: &mut Vec<TrajectoryPoint>,
1263 point: TrajectoryPoint,
1264 ) -> Result<(), BallisticsError> {
1265 if points.len() >= self.max_trajectory_points {
1266 return Err(BallisticsError::from(format!(
1267 "trajectory point limit of {} exceeded",
1268 self.max_trajectory_points
1269 )));
1270 }
1271 points.push(point);
1272 Ok(())
1273 }
1274
1275 pub fn set_wind_segments(&mut self, segments: Vec<crate::wind::WindSegment>) {
1282 self.wind_sock = if segments.is_empty() {
1283 None
1284 } else {
1285 Some(crate::wind::WindSock::new(segments))
1286 };
1287 }
1288
1289 pub fn set_atmo_segments(&mut self, segments: Vec<crate::atmosphere::AtmoSegment>) {
1298 self.atmo_sock = if segments.is_empty() {
1299 None
1300 } else {
1301 Some(crate::atmosphere::AtmoSock::new(segments))
1302 };
1303 }
1304
1305 fn launch_angles_from(
1314 &self,
1315 aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
1316 ) -> (f64, f64) {
1317 let (mut elev, mut azim) = (self.inputs.muzzle_angle, self.inputs.azimuth_angle);
1318 if self.inputs.cant_angle != 0.0 {
1325 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
1326 let (e0, a0) = (elev, azim);
1327 elev = e0 * cos_c - a0 * sin_c;
1328 azim = a0 * cos_c + e0 * sin_c;
1329 }
1330 match aj {
1331 Some(c) => {
1332 const MOA_PER_RAD: f64 = 3437.7467707849;
1334 (
1335 elev + c.vertical_jump_moa / MOA_PER_RAD,
1336 azim + c.horizontal_jump_moa / MOA_PER_RAD,
1337 )
1338 }
1339 None => (elev, azim),
1340 }
1341 }
1342
1343 fn aerodynamic_jump_components(
1351 &self,
1352 ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
1353 if !self.inputs.enable_aerodynamic_jump {
1354 return None;
1355 }
1356 let diameter_m = self.inputs.bullet_diameter;
1360 if !(self.inputs.twist_rate.is_finite()
1361 && self.inputs.twist_rate != 0.0
1362 && diameter_m.is_finite()
1363 && diameter_m > 0.0
1364 && self.inputs.bullet_length.is_finite()
1365 && self.inputs.bullet_length > 0.0
1366 && self.inputs.muzzle_velocity.is_finite())
1367 {
1368 return None;
1369 }
1370
1371 let (_, _, temp_c, pressure_hpa) = self.resolved_atmosphere();
1373 let sg = crate::stability::compute_stability_coefficient(
1374 &self.inputs,
1375 (self.atmosphere.altitude, temp_c, pressure_hpa, 0.0),
1376 );
1377 if !(sg.is_finite() && sg > 0.0) {
1378 return None;
1379 }
1380 let length_calibers = self.inputs.bullet_length / diameter_m;
1381
1382 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
1388 let crosswind_from_right_mps = if let Some(sock) = &self.wind_sock {
1389 -sock.vector_for_range_stateless(0.0)[2]
1390 } else {
1391 self.wind.speed * self.wind.direction.sin()
1392 };
1393 let crosswind_from_right_mph = crosswind_from_right_mps * MS_TO_MPH;
1394
1395 let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
1396 sg,
1397 length_calibers,
1398 crosswind_from_right_mph,
1399 self.inputs.is_twist_right,
1400 );
1401 if !vertical_jump_moa.is_finite() {
1402 return None;
1403 }
1404
1405 const MOA_PER_RAD: f64 = 3437.7467707849;
1406 Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
1407 vertical_jump_moa,
1408 horizontal_jump_moa: 0.0,
1410 jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
1411 magnus_component_moa: 0.0,
1412 yaw_component_moa: 0.0,
1413 stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
1414 })
1415 }
1416
1417 fn resolved_atmosphere(&self) -> (f64, f64, f64, f64) {
1418 let (temp_c, pressure_hpa) = match self.station_atmosphere_resolution {
1419 StationAtmosphereResolution::LegacyDefaultSentinels => {
1420 crate::atmosphere::resolve_station_conditions(
1421 self.atmosphere.temperature,
1422 self.atmosphere.pressure,
1423 self.atmosphere.altitude,
1424 )
1425 }
1426 StationAtmosphereResolution::Authoritative => {
1427 (self.atmosphere.temperature, self.atmosphere.pressure)
1428 }
1429 };
1430 let (density, speed_of_sound) = crate::atmosphere::calculate_atmosphere(
1431 self.atmosphere.altitude,
1432 Some(temp_c),
1433 Some(pressure_hpa),
1434 self.atmosphere.humidity,
1435 );
1436 (density, speed_of_sound, temp_c, pressure_hpa)
1437 }
1438
1439 fn precession_nutation_params(
1440 &self,
1441 velocity_mps: f64,
1442 air_density_kg_m3: f64,
1443 speed_of_sound_mps: f64,
1444 ) -> PrecessionNutationParams {
1445 let (spin_inertia, transverse_inertia) = self.precession_nutation_inertias;
1446 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
1447 let velocity_fps = velocity_mps * 3.28084;
1448 let twist_rate_ft = self.inputs.twist_rate / 12.0;
1449 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
1450 } else {
1451 0.0
1452 };
1453
1454 PrecessionNutationParams {
1455 mass_kg: self.inputs.bullet_mass,
1456 caliber_m: self.inputs.bullet_diameter,
1457 length_m: self.inputs.bullet_length,
1458 spin_rate_rad_s,
1459 spin_inertia,
1460 transverse_inertia,
1461 velocity_mps,
1462 air_density_kg_m3,
1463 mach: velocity_mps / speed_of_sound_mps,
1464 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
1465 nutation_damping_factor: 0.05,
1466 }
1467 }
1468
1469 fn append_terminal_endpoint(
1476 &self,
1477 points: &mut Vec<TrajectoryPoint>,
1478 post_position: Vector3<f64>,
1479 post_velocity: Vector3<f64>,
1480 post_time: f64,
1481 max_height: &mut f64,
1482 ) -> Result<TrajectoryTermination, BallisticsError> {
1483 let previous = points
1484 .last()
1485 .cloned()
1486 .ok_or_else(|| BallisticsError::from("No trajectory points generated"))?;
1487
1488 let mut crossings = Vec::with_capacity(3);
1489 if previous.position.x < self.max_range && post_position.x >= self.max_range {
1490 let span = post_position.x - previous.position.x;
1491 if span.is_finite() && span > 0.0 {
1492 crossings.push((
1493 (self.max_range - previous.position.x) / span,
1494 TrajectoryTermination::MaxRange,
1495 ));
1496 }
1497 }
1498 if self.inputs.ground_threshold.is_finite()
1499 && previous.position.y > self.inputs.ground_threshold
1500 && post_position.y <= self.inputs.ground_threshold
1501 {
1502 let span = post_position.y - previous.position.y;
1503 if span.is_finite() && span < 0.0 {
1504 crossings.push((
1505 (self.inputs.ground_threshold - previous.position.y) / span,
1506 TrajectoryTermination::GroundThreshold,
1507 ));
1508 }
1509 }
1510 if previous.time < TRAJECTORY_TIME_LIMIT_S && post_time >= TRAJECTORY_TIME_LIMIT_S {
1511 let span = post_time - previous.time;
1512 if span.is_finite() && span > 0.0 {
1513 crossings.push((
1514 (TRAJECTORY_TIME_LIMIT_S - previous.time) / span,
1515 TrajectoryTermination::TimeLimit,
1516 ));
1517 }
1518 }
1519
1520 let (fraction, termination) = crossings
1521 .into_iter()
1522 .filter(|(fraction, _)| fraction.is_finite() && (0.0..=1.0).contains(fraction))
1523 .min_by(|left, right| {
1524 let priority = |termination: TrajectoryTermination| match termination {
1525 TrajectoryTermination::GroundThreshold => 0,
1526 TrajectoryTermination::MaxRange => 1,
1527 TrajectoryTermination::TimeLimit => 2,
1528 TrajectoryTermination::VelocityFloor => 3,
1529 };
1530 left.0
1531 .total_cmp(&right.0)
1532 .then_with(|| priority(left.1).cmp(&priority(right.1)))
1533 })
1534 .ok_or_else(|| {
1535 BallisticsError::from(
1536 "trajectory integration stopped without crossing a supported boundary",
1537 )
1538 })?;
1539
1540 let mut position = previous.position + (post_position - previous.position) * fraction;
1541 match termination {
1542 TrajectoryTermination::MaxRange => position.x = self.max_range,
1543 TrajectoryTermination::GroundThreshold => {
1544 position.y = self.inputs.ground_threshold;
1545 }
1546 TrajectoryTermination::TimeLimit | TrajectoryTermination::VelocityFloor => {}
1547 }
1548 let velocity_magnitude = previous.velocity_magnitude
1549 + (post_velocity.magnitude() - previous.velocity_magnitude) * fraction;
1550 let mut time = previous.time + (post_time - previous.time) * fraction;
1551 if termination == TrajectoryTermination::TimeLimit {
1552 time = TRAJECTORY_TIME_LIMIT_S;
1553 }
1554 let kinetic_energy =
1555 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1556
1557 if position.y > *max_height {
1558 *max_height = position.y;
1559 }
1560 let terminal_point = TrajectoryPoint {
1561 time,
1562 position,
1563 velocity_magnitude,
1564 kinetic_energy,
1565 };
1566 if terminal_point.position.x < previous.position.x {
1567 return Err(BallisticsError::from(
1568 "trajectory terminal state reversed downrange before the crossed boundary",
1569 ));
1570 }
1571 if terminal_point.position.x == previous.position.x {
1572 let last = points.last_mut().ok_or_else(|| {
1577 BallisticsError::from("trajectory points disappeared during terminal finalization")
1578 })?;
1579 *last = terminal_point;
1580 } else {
1581 self.push_trajectory_point(points, terminal_point)?;
1582 }
1583 Ok(termination)
1584 }
1585
1586 fn gravity_acceleration(&self) -> Vector3<f64> {
1587 let theta = self.inputs.shooting_angle;
1588 Vector3::new(
1589 -crate::constants::G_ACCEL_MPS2 * theta.sin(),
1590 -crate::constants::G_ACCEL_MPS2 * theta.cos(),
1591 0.0,
1592 )
1593 }
1594
1595 fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
1596 let model = match self.inputs.wind_shear_model.as_str() {
1611 "logarithmic" => WindShearModel::Logarithmic,
1612 "power_law" | "powerlaw" | "exponential" => WindShearModel::PowerLaw,
1613 "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
1614 "custom_layers" | "custom" => WindShearModel::CustomLayers,
1615 _ => WindShearModel::PowerLaw,
1616 };
1617 let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
1618
1619 crate::wind::wind_vector(self.wind.speed, self.wind.direction, 0.0) * speed_ratio
1626 + Vector3::new(0.0, self.wind.vertical_speed, 0.0)
1627 }
1628
1629 pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
1630 self.validate_for_solve()?;
1631 let mut result = if self.inputs.use_rk4 {
1632 if self.inputs.use_adaptive_rk45 {
1633 self.solve_rk45()?
1634 } else {
1635 self.solve_rk4()?
1636 }
1637 } else {
1638 self.solve_euler()?
1639 };
1640 self.apply_spin_drift(&mut result);
1641 self.validate_result_sanity(&result)?;
1642 Ok(result)
1643 }
1644
1645 fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
1651 if !self.inputs.use_enhanced_spin_drift {
1652 return;
1653 }
1654 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 {
1658 return;
1659 }
1660
1661 let sg = self.effective_spin_drift_sg();
1668
1669 for p in result.points.iter_mut() {
1670 if p.time <= 0.0 {
1671 continue;
1672 }
1673 p.position.z +=
1675 crate::spin_drift::litz_drift_meters(sg, p.time, self.inputs.is_twist_right);
1676 }
1677
1678 if let Some(samples) = result.sampled_points.as_mut() {
1682 for s in samples.iter_mut() {
1683 if s.time_s <= 0.0 {
1684 continue;
1685 }
1686 s.wind_drift_m +=
1687 crate::spin_drift::litz_drift_meters(sg, s.time_s, self.inputs.is_twist_right);
1688 }
1689 }
1690 }
1691
1692 fn effective_spin_drift_sg(&self) -> f64 {
1697 let (_, _, temp_c, press_hpa) = self.resolved_atmosphere();
1698 crate::spin_drift::effective_sg_from_inputs(&self.inputs, temp_c, press_hpa)
1699 }
1700
1701 fn initial_position(&self) -> Vector3<f64> {
1707 if self.inputs.cant_angle == 0.0 {
1708 return Vector3::new(0.0, self.inputs.muzzle_height, 0.0);
1709 }
1710 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
1711 let sh = self.inputs.sight_height;
1712 Vector3::new(
1713 0.0,
1714 self.inputs.muzzle_height + sh * (1.0 - cos_c),
1715 -sh * sin_c,
1716 )
1717 }
1718
1719 fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
1720 let mut time = 0.0;
1722 let mut position = self.initial_position();
1726 let aj_components = self.aerodynamic_jump_components();
1732 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1733 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1734 let mut velocity = Vector3::new(
1735 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1739
1740 let mut points = Vec::new();
1741 let mut max_height = position.y;
1742 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
1748 let mut mach_transitions = MachTransitionTracker::default();
1749
1750 let mut angular_state = if self.inputs.enable_precession_nutation {
1752 Some(AngularState {
1753 pitch_angle: 0.001, yaw_angle: 0.001,
1755 pitch_rate: 0.0,
1756 yaw_rate: 0.0,
1757 precession_angle: 0.0,
1758 nutation_phase: 0.0,
1759 })
1760 } else {
1761 None
1762 };
1763 let mut max_yaw_angle = 0.0;
1764 let mut max_precession_angle = 0.0;
1765
1766 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
1768 self.resolved_atmosphere();
1769 let base_ratio = air_density / 1.225;
1774
1775 let wind_vector =
1781 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
1782
1783 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1786 self.inputs.bullet_model.as_deref().unwrap_or("default"),
1787 );
1788
1789 while position.x < self.max_range
1791 && position.y > self.inputs.ground_threshold
1792 && time < TRAJECTORY_TIME_LIMIT_S
1793 {
1794 let velocity_magnitude = velocity.magnitude();
1796 let kinetic_energy =
1797 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1798
1799 self.push_trajectory_point(
1800 &mut points,
1801 TrajectoryPoint {
1802 time,
1803 position,
1804 velocity_magnitude,
1805 kinetic_energy,
1806 },
1807 )?;
1808
1809 {
1812 let mach_here = if speed_of_sound > 0.0 {
1813 velocity_magnitude / speed_of_sound
1814 } else {
1815 0.0
1816 };
1817 mach_transitions.record_downward_crossings(
1818 mach_here,
1819 position.x,
1820 &mut transonic_distances,
1821 );
1822 }
1823
1824 if position.y > max_height {
1826 max_height = position.y;
1827 }
1828
1829 if self.inputs.enable_pitch_damping {
1831 let mach = velocity_magnitude / speed_of_sound;
1832
1833 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1835 transonic_mach = Some(mach);
1836 }
1837
1838 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1840
1841 if pitch_damping < min_pitch_damping {
1843 min_pitch_damping = pitch_damping;
1844 }
1845 }
1846
1847 if self.inputs.enable_precession_nutation {
1849 if let Some(ref mut state) = angular_state {
1850 let velocity_magnitude = velocity.magnitude();
1851 let params = self.precession_nutation_params(
1852 velocity_magnitude,
1853 air_density,
1854 speed_of_sound,
1855 );
1856
1857 *state = calculate_combined_angular_motion(
1859 ¶ms,
1860 state,
1861 time,
1862 self.time_step,
1863 0.001, );
1865
1866 if state.yaw_angle.abs() > max_yaw_angle {
1868 max_yaw_angle = state.yaw_angle.abs();
1869 }
1870 if state.precession_angle.abs() > max_precession_angle {
1871 max_precession_angle = state.precession_angle.abs();
1872 }
1873 }
1874 }
1875
1876 let acceleration = self.calculate_acceleration(
1883 &position,
1884 &velocity,
1885 &wind_vector,
1886 (resolved_temp_c, resolved_press_hpa, base_ratio),
1887 );
1888
1889 velocity += acceleration * self.time_step;
1891 position += velocity * self.time_step;
1892 time += self.time_step;
1893 self.validate_integration_state(&position, &velocity, time)?;
1894 }
1895
1896 let termination =
1897 self.append_terminal_endpoint(&mut points, position, velocity, time, &mut max_height)?;
1898
1899 let last_point = points.last().ok_or("No trajectory points generated")?;
1901
1902 let sampled_points = if self.inputs.enable_trajectory_sampling {
1904 let trajectory_data = TrajectoryData {
1905 times: points.iter().map(|p| p.time).collect(),
1906 positions: points.iter().map(|p| p.position).collect(),
1907 velocities: points
1908 .iter()
1909 .map(|p| {
1910 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1912 })
1913 .collect(),
1914 transonic_distances, };
1916
1917 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1922 let outputs = TrajectoryOutputs {
1923 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
1925 time_of_flight_s: last_point.time,
1926 max_ord_dist_horiz_m: max_height,
1927 sight_height_m: sight_position_m,
1928 };
1929
1930 let samples = sample_trajectory(
1932 &trajectory_data,
1933 &outputs,
1934 self.inputs.sample_interval,
1935 self.inputs.bullet_mass,
1936 )?;
1937 Some(samples)
1938 } else {
1939 None
1940 };
1941
1942 Ok(TrajectoryResult {
1943 max_range: last_point.position.x, max_height,
1945 time_of_flight: last_point.time,
1946 impact_velocity: last_point.velocity_magnitude,
1947 impact_energy: last_point.kinetic_energy,
1948 projectile_mass_kg: self.inputs.bullet_mass,
1949 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
1950 station_speed_of_sound_mps: speed_of_sound,
1951 termination,
1952 points,
1953 sampled_points,
1954 min_pitch_damping: if self.inputs.enable_pitch_damping {
1955 Some(min_pitch_damping)
1956 } else {
1957 None
1958 },
1959 transonic_mach,
1960 angular_state,
1961 max_yaw_angle: if self.inputs.enable_precession_nutation {
1962 Some(max_yaw_angle)
1963 } else {
1964 None
1965 },
1966 max_precession_angle: if self.inputs.enable_precession_nutation {
1967 Some(max_precession_angle)
1968 } else {
1969 None
1970 },
1971 aerodynamic_jump: aj_components,
1972 })
1973 }
1974
1975 fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
1976 let mut time = 0.0;
1978 let mut position = self.initial_position();
1983
1984 let aj_components = self.aerodynamic_jump_components();
1990 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1991 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1992 let mut velocity = Vector3::new(
1993 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1997
1998 let mut points = Vec::new();
1999 let mut max_height = position.y;
2000 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
2006 let mut mach_transitions = MachTransitionTracker::default();
2007
2008 let mut angular_state = if self.inputs.enable_precession_nutation {
2010 Some(AngularState {
2011 pitch_angle: 0.001, yaw_angle: 0.001,
2013 pitch_rate: 0.0,
2014 yaw_rate: 0.0,
2015 precession_angle: 0.0,
2016 nutation_phase: 0.0,
2017 })
2018 } else {
2019 None
2020 };
2021 let mut max_yaw_angle = 0.0;
2022 let mut max_precession_angle = 0.0;
2023
2024 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2026 self.resolved_atmosphere();
2027 let base_ratio = air_density / 1.225;
2032
2033 let wind_vector =
2039 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
2040
2041 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2044 self.inputs.bullet_model.as_deref().unwrap_or("default"),
2045 );
2046
2047 while position.x < self.max_range
2049 && position.y > self.inputs.ground_threshold
2050 && time < TRAJECTORY_TIME_LIMIT_S
2051 {
2052 let velocity_magnitude = velocity.magnitude();
2054 let kinetic_energy =
2055 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
2056
2057 self.push_trajectory_point(
2058 &mut points,
2059 TrajectoryPoint {
2060 time,
2061 position,
2062 velocity_magnitude,
2063 kinetic_energy,
2064 },
2065 )?;
2066
2067 {
2070 let mach_here = if speed_of_sound > 0.0 {
2071 velocity_magnitude / speed_of_sound
2072 } else {
2073 0.0
2074 };
2075 mach_transitions.record_downward_crossings(
2076 mach_here,
2077 position.x,
2078 &mut transonic_distances,
2079 );
2080 }
2081
2082 if position.y > max_height {
2083 max_height = position.y;
2084 }
2085
2086 if self.inputs.enable_pitch_damping {
2088 let mach = velocity_magnitude / speed_of_sound;
2089
2090 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2092 transonic_mach = Some(mach);
2093 }
2094
2095 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2097
2098 if pitch_damping < min_pitch_damping {
2100 min_pitch_damping = pitch_damping;
2101 }
2102 }
2103
2104 if self.inputs.enable_precession_nutation {
2106 if let Some(ref mut state) = angular_state {
2107 let velocity_magnitude = velocity.magnitude();
2108 let params = self.precession_nutation_params(
2109 velocity_magnitude,
2110 air_density,
2111 speed_of_sound,
2112 );
2113
2114 *state = calculate_combined_angular_motion(
2116 ¶ms,
2117 state,
2118 time,
2119 self.time_step,
2120 0.001, );
2122
2123 if state.yaw_angle.abs() > max_yaw_angle {
2125 max_yaw_angle = state.yaw_angle.abs();
2126 }
2127 if state.precession_angle.abs() > max_precession_angle {
2128 max_precession_angle = state.precession_angle.abs();
2129 }
2130 }
2131 }
2132
2133 let dt = self.time_step;
2135
2136 let acc1 = self.calculate_acceleration(
2138 &position,
2139 &velocity,
2140 &wind_vector,
2141 (resolved_temp_c, resolved_press_hpa, base_ratio),
2142 );
2143
2144 let pos2 = position + velocity * (dt * 0.5);
2146 let vel2 = velocity + acc1 * (dt * 0.5);
2147 let acc2 = self.calculate_acceleration(
2148 &pos2,
2149 &vel2,
2150 &wind_vector,
2151 (resolved_temp_c, resolved_press_hpa, base_ratio),
2152 );
2153
2154 let pos3 = position + vel2 * (dt * 0.5);
2156 let vel3 = velocity + acc2 * (dt * 0.5);
2157 let acc3 = self.calculate_acceleration(
2158 &pos3,
2159 &vel3,
2160 &wind_vector,
2161 (resolved_temp_c, resolved_press_hpa, base_ratio),
2162 );
2163
2164 let pos4 = position + vel3 * dt;
2166 let vel4 = velocity + acc3 * dt;
2167 let acc4 = self.calculate_acceleration(
2168 &pos4,
2169 &vel4,
2170 &wind_vector,
2171 (resolved_temp_c, resolved_press_hpa, base_ratio),
2172 );
2173
2174 position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
2176 velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
2177 time += dt;
2178 self.validate_integration_state(&position, &velocity, time)?;
2179 }
2180
2181 let termination =
2182 self.append_terminal_endpoint(&mut points, position, velocity, time, &mut max_height)?;
2183
2184 let last_point = points.last().ok_or("No trajectory points generated")?;
2186
2187 let sampled_points = if self.inputs.enable_trajectory_sampling {
2189 let trajectory_data = TrajectoryData {
2190 times: points.iter().map(|p| p.time).collect(),
2191 positions: points.iter().map(|p| p.position).collect(),
2192 velocities: points
2193 .iter()
2194 .map(|p| {
2195 Vector3::new(0.0, 0.0, p.velocity_magnitude)
2197 })
2198 .collect(),
2199 transonic_distances, };
2201
2202 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
2207 let outputs = TrajectoryOutputs {
2208 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
2210 time_of_flight_s: last_point.time,
2211 max_ord_dist_horiz_m: max_height,
2212 sight_height_m: sight_position_m,
2213 };
2214
2215 let samples = sample_trajectory(
2217 &trajectory_data,
2218 &outputs,
2219 self.inputs.sample_interval,
2220 self.inputs.bullet_mass,
2221 )?;
2222 Some(samples)
2223 } else {
2224 None
2225 };
2226
2227 Ok(TrajectoryResult {
2228 max_range: last_point.position.x, max_height,
2230 time_of_flight: last_point.time,
2231 impact_velocity: last_point.velocity_magnitude,
2232 impact_energy: last_point.kinetic_energy,
2233 projectile_mass_kg: self.inputs.bullet_mass,
2234 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
2235 station_speed_of_sound_mps: speed_of_sound,
2236 termination,
2237 points,
2238 sampled_points,
2239 min_pitch_damping: if self.inputs.enable_pitch_damping {
2240 Some(min_pitch_damping)
2241 } else {
2242 None
2243 },
2244 transonic_mach,
2245 angular_state,
2246 max_yaw_angle: if self.inputs.enable_precession_nutation {
2247 Some(max_yaw_angle)
2248 } else {
2249 None
2250 },
2251 max_precession_angle: if self.inputs.enable_precession_nutation {
2252 Some(max_precession_angle)
2253 } else {
2254 None
2255 },
2256 aerodynamic_jump: aj_components,
2257 })
2258 }
2259
2260 fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
2261 let mut time = 0.0;
2263 let mut position = self.initial_position();
2267
2268 let aj_components = self.aerodynamic_jump_components();
2274 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
2275 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
2276 let mut velocity = Vector3::new(
2277 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
2281
2282 let mut points = Vec::new();
2283 let mut max_height = position.y;
2284 let mut dt = 0.001; let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2289 self.resolved_atmosphere();
2290 let base_ratio = air_density / 1.225;
2295 let wind_vector =
2300 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
2301
2302 let mut transonic_distances: Vec<f64> = Vec::new();
2304 let mut mach_transitions = MachTransitionTracker::default();
2305
2306 let mut min_pitch_damping = f64::INFINITY;
2311 let mut transonic_mach: Option<f64> = None;
2312 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2313 self.inputs.bullet_model.as_deref().unwrap_or("default"),
2314 );
2315 let mut angular_state = if self.inputs.enable_precession_nutation {
2316 Some(AngularState {
2317 pitch_angle: 0.001,
2318 yaw_angle: 0.001,
2319 pitch_rate: 0.0,
2320 yaw_rate: 0.0,
2321 precession_angle: 0.0,
2322 nutation_phase: 0.0,
2323 })
2324 } else {
2325 None
2326 };
2327 let mut max_yaw_angle = 0.0;
2328 let mut max_precession_angle = 0.0;
2329
2330 while position.x < self.max_range
2331 && position.y > self.inputs.ground_threshold
2332 && time < TRAJECTORY_TIME_LIMIT_S
2333 {
2334 let velocity_magnitude = velocity.magnitude();
2336 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
2337
2338 self.push_trajectory_point(
2339 &mut points,
2340 TrajectoryPoint {
2341 time,
2342 position,
2343 velocity_magnitude,
2344 kinetic_energy,
2345 },
2346 )?;
2347
2348 {
2351 let mach_here = if speed_of_sound > 0.0 {
2352 velocity_magnitude / speed_of_sound
2353 } else {
2354 0.0
2355 };
2356 mach_transitions.record_downward_crossings(
2357 mach_here,
2358 position.x,
2359 &mut transonic_distances,
2360 );
2361 }
2362
2363 if position.y > max_height {
2364 max_height = position.y;
2365 }
2366
2367 if self.inputs.enable_pitch_damping {
2370 let mach = velocity_magnitude / speed_of_sound;
2371 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2372 transonic_mach = Some(mach);
2373 }
2374 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2375 if pitch_damping < min_pitch_damping {
2376 min_pitch_damping = pitch_damping;
2377 }
2378 }
2379
2380 let accepted_step = self.adaptive_rk45_step(
2383 &position,
2384 &velocity,
2385 dt,
2386 &wind_vector,
2387 (resolved_temp_c, resolved_press_hpa, base_ratio),
2388 );
2389 debug_assert!(
2390 accepted_step.error <= RK45_TOLERANCE || accepted_step.used_dt <= RK45_MIN_DT
2391 );
2392
2393 if self.inputs.enable_precession_nutation {
2397 if let Some(ref mut state) = angular_state {
2398 let params = self.precession_nutation_params(
2399 velocity_magnitude,
2400 air_density,
2401 speed_of_sound,
2402 );
2403
2404 *state = calculate_combined_angular_motion(
2405 ¶ms,
2406 state,
2407 time,
2408 accepted_step.used_dt,
2409 0.001,
2410 );
2411
2412 if state.yaw_angle.abs() > max_yaw_angle {
2413 max_yaw_angle = state.yaw_angle.abs();
2414 }
2415 if state.precession_angle.abs() > max_precession_angle {
2416 max_precession_angle = state.precession_angle.abs();
2417 }
2418 }
2419 }
2420
2421 position = accepted_step.position;
2422 velocity = accepted_step.velocity;
2423 time += accepted_step.used_dt;
2424 self.validate_integration_state(&position, &velocity, time)?;
2425
2426 dt = accepted_step.next_dt;
2428 }
2429
2430 if points.is_empty() {
2432 return Err(BallisticsError::from("No trajectory points calculated"));
2433 }
2434
2435 let termination =
2437 self.append_terminal_endpoint(&mut points, position, velocity, time, &mut max_height)?;
2438
2439 let last_point = points.last().unwrap();
2440
2441 let sampled_points = if self.inputs.enable_trajectory_sampling {
2443 let trajectory_data = TrajectoryData {
2445 times: points.iter().map(|p| p.time).collect(),
2446 positions: points.iter().map(|p| p.position).collect(),
2447 velocities: points
2448 .iter()
2449 .map(|p| {
2450 Vector3::new(0.0, 0.0, p.velocity_magnitude)
2452 })
2453 .collect(),
2454 transonic_distances, };
2456
2457 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
2462 let outputs = TrajectoryOutputs {
2463 target_distance_horiz_m: last_point.position.x,
2464 target_vertical_height_m: sight_position_m,
2465 time_of_flight_s: last_point.time,
2466 max_ord_dist_horiz_m: max_height,
2467 sight_height_m: sight_position_m,
2468 };
2469
2470 let samples = sample_trajectory(
2471 &trajectory_data,
2472 &outputs,
2473 self.inputs.sample_interval,
2474 self.inputs.bullet_mass,
2475 )?;
2476 Some(samples)
2477 } else {
2478 None
2479 };
2480
2481 Ok(TrajectoryResult {
2482 max_range: last_point.position.x, max_height,
2484 time_of_flight: last_point.time,
2485 impact_velocity: last_point.velocity_magnitude,
2486 impact_energy: last_point.kinetic_energy,
2487 projectile_mass_kg: self.inputs.bullet_mass,
2488 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
2489 station_speed_of_sound_mps: speed_of_sound,
2490 termination,
2491 points,
2492 sampled_points,
2493 min_pitch_damping: if self.inputs.enable_pitch_damping {
2494 Some(min_pitch_damping)
2495 } else {
2496 None
2497 },
2498 transonic_mach,
2499 angular_state,
2500 max_yaw_angle: if self.inputs.enable_precession_nutation {
2501 Some(max_yaw_angle)
2502 } else {
2503 None
2504 },
2505 max_precession_angle: if self.inputs.enable_precession_nutation {
2506 Some(max_precession_angle)
2507 } else {
2508 None
2509 },
2510 aerodynamic_jump: aj_components,
2511 })
2512 }
2513
2514 fn adaptive_rk45_step(
2515 &self,
2516 position: &Vector3<f64>,
2517 velocity: &Vector3<f64>,
2518 initial_dt: f64,
2519 wind_vector: &Vector3<f64>,
2520 resolved_atmo: (f64, f64, f64),
2521 ) -> Rk45AcceptedStep {
2522 let mut trial_dt = initial_dt;
2523
2524 loop {
2525 let trial = self.rk45_step(
2526 position,
2527 velocity,
2528 trial_dt,
2529 wind_vector,
2530 RK45_TOLERANCE,
2531 resolved_atmo,
2532 );
2533 let next_dt = if trial.suggested_dt.is_finite() {
2538 (RK45_SAFETY_FACTOR * trial.suggested_dt).clamp(RK45_MIN_DT, RK45_MAX_DT)
2539 } else {
2540 RK45_MIN_DT
2541 };
2542
2543 if trial.error <= RK45_TOLERANCE || trial_dt <= RK45_MIN_DT {
2544 return Rk45AcceptedStep {
2545 position: trial.position,
2546 velocity: trial.velocity,
2547 used_dt: trial_dt,
2548 next_dt,
2549 error: trial.error,
2550 };
2551 }
2552
2553 trial_dt = next_dt;
2554 }
2555 }
2556
2557 fn rk45_step(
2558 &self,
2559 position: &Vector3<f64>,
2560 velocity: &Vector3<f64>,
2561 dt: f64,
2562 wind_vector: &Vector3<f64>,
2563 tolerance: f64,
2564 resolved_atmo: (f64, f64, f64), ) -> Rk45Trial {
2566 const A21: f64 = 1.0 / 5.0;
2568 const A31: f64 = 3.0 / 40.0;
2569 const A32: f64 = 9.0 / 40.0;
2570 const A41: f64 = 44.0 / 45.0;
2571 const A42: f64 = -56.0 / 15.0;
2572 const A43: f64 = 32.0 / 9.0;
2573 const A51: f64 = 19372.0 / 6561.0;
2574 const A52: f64 = -25360.0 / 2187.0;
2575 const A53: f64 = 64448.0 / 6561.0;
2576 const A54: f64 = -212.0 / 729.0;
2577 const A61: f64 = 9017.0 / 3168.0;
2578 const A62: f64 = -355.0 / 33.0;
2579 const A63: f64 = 46732.0 / 5247.0;
2580 const A64: f64 = 49.0 / 176.0;
2581 const A65: f64 = -5103.0 / 18656.0;
2582 const A71: f64 = 35.0 / 384.0;
2583 const A73: f64 = 500.0 / 1113.0;
2584 const A74: f64 = 125.0 / 192.0;
2585 const A75: f64 = -2187.0 / 6784.0;
2586 const A76: f64 = 11.0 / 84.0;
2587
2588 const B1: f64 = 35.0 / 384.0;
2590 const B3: f64 = 500.0 / 1113.0;
2591 const B4: f64 = 125.0 / 192.0;
2592 const B5: f64 = -2187.0 / 6784.0;
2593 const B6: f64 = 11.0 / 84.0;
2594
2595 const B1_ERR: f64 = 5179.0 / 57600.0;
2597 const B3_ERR: f64 = 7571.0 / 16695.0;
2598 const B4_ERR: f64 = 393.0 / 640.0;
2599 const B5_ERR: f64 = -92097.0 / 339200.0;
2600 const B6_ERR: f64 = 187.0 / 2100.0;
2601 const B7_ERR: f64 = 1.0 / 40.0;
2602
2603 let k1_v = self.calculate_acceleration(position, velocity, wind_vector, resolved_atmo);
2605 let k1_p = *velocity;
2606
2607 let p2 = position + dt * A21 * k1_p;
2608 let v2 = velocity + dt * A21 * k1_v;
2609 let k2_v = self.calculate_acceleration(&p2, &v2, wind_vector, resolved_atmo);
2610 let k2_p = v2;
2611
2612 let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
2613 let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
2614 let k3_v = self.calculate_acceleration(&p3, &v3, wind_vector, resolved_atmo);
2615 let k3_p = v3;
2616
2617 let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
2618 let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
2619 let k4_v = self.calculate_acceleration(&p4, &v4, wind_vector, resolved_atmo);
2620 let k4_p = v4;
2621
2622 let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
2623 let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
2624 let k5_v = self.calculate_acceleration(&p5, &v5, wind_vector, resolved_atmo);
2625 let k5_p = v5;
2626
2627 let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
2628 let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
2629 let k6_v = self.calculate_acceleration(&p6, &v6, wind_vector, resolved_atmo);
2630 let k6_p = v6;
2631
2632 let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
2633 let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
2634 let k7_v = self.calculate_acceleration(&p7, &v7, wind_vector, resolved_atmo);
2635 let k7_p = v7;
2636
2637 let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
2639 let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
2640
2641 let pos_err = position
2643 + dt * (B1_ERR * k1_p
2644 + B3_ERR * k3_p
2645 + B4_ERR * k4_p
2646 + B5_ERR * k5_p
2647 + B6_ERR * k6_p
2648 + B7_ERR * k7_p);
2649 let vel_err = velocity
2650 + dt * (B1_ERR * k1_v
2651 + B3_ERR * k3_v
2652 + B4_ERR * k4_v
2653 + B5_ERR * k5_v
2654 + B6_ERR * k6_v
2655 + B7_ERR * k7_v);
2656
2657 let error = cli_rk45_error_norm(position, velocity, &new_pos, &new_vel, &pos_err, &vel_err);
2659
2660 let dt_new = if error < tolerance {
2662 dt * (tolerance / error).powf(0.2).min(2.0)
2663 } else {
2664 dt * (tolerance / error).powf(0.25).max(0.1)
2665 };
2666
2667 Rk45Trial {
2668 position: new_pos,
2669 velocity: new_vel,
2670 suggested_dt: dt_new,
2671 error,
2672 }
2673 }
2674
2675 fn apply_cluster_bc_correction(&self, base_bc: f64, velocity_fps: f64) -> f64 {
2676 if let Some(ref cluster_bc) = self.cluster_bc {
2677 cluster_bc.apply_correction_for_drag_model(
2678 base_bc,
2679 self.inputs.caliber_inches,
2680 self.inputs.weight_grains,
2681 velocity_fps,
2682 self.inputs.bc_type,
2683 )
2684 } else {
2685 base_bc
2686 }
2687 }
2688
2689 fn calculate_acceleration(
2690 &self,
2691 position: &Vector3<f64>,
2692 velocity: &Vector3<f64>,
2693 wind_vector: &Vector3<f64>,
2694 resolved_atmo: (f64, f64, f64), ) -> Vector3<f64> {
2696 let actual_wind = if let Some(ref sock) = self.wind_sock {
2702 sock.vector_for_range_stateless(position.x)
2703 } else if self.inputs.enable_wind_shear {
2704 self.get_wind_at_altitude(position.y)
2705 } else {
2706 *wind_vector
2707 };
2708 let actual_wind =
2709 crate::derivatives::level_vector_to_shot_frame(actual_wind, self.inputs.shooting_angle);
2710
2711 let relative_velocity = velocity - actual_wind;
2712 let velocity_magnitude = relative_velocity.magnitude();
2713
2714 if velocity_magnitude < 0.001 {
2715 return self.gravity_acceleration();
2716 }
2717
2718 let (base_temp_c, base_press_hpa, station_ratio) = resolved_atmo;
2729
2730 let (drag_base_temp_c, drag_base_press_hpa, drag_base_ratio, drag_humidity_percent) =
2738 if let Some(ref sock) = self.atmo_sock {
2739 let (zone_temp_c, zone_press_hpa, zone_humidity) = sock.atmo_for_range(position.x);
2740 let zone_base_ratio = crate::atmosphere::calculate_air_density_cimp(
2741 zone_temp_c,
2742 zone_press_hpa,
2743 zone_humidity,
2744 ) / 1.225;
2745 (zone_temp_c, zone_press_hpa, zone_base_ratio, zone_humidity)
2746 } else {
2747 (
2748 base_temp_c,
2749 base_press_hpa,
2750 station_ratio,
2751 self.atmosphere.humidity,
2752 )
2753 };
2754 let local_alt = crate::atmosphere::shot_frame_altitude(
2755 self.atmosphere.altitude,
2756 position.x,
2757 position.y,
2758 self.inputs.shooting_angle,
2759 );
2760 let (air_density, speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
2761 local_alt,
2762 self.atmosphere.altitude,
2763 drag_base_temp_c,
2764 drag_base_press_hpa,
2765 drag_base_ratio,
2766 drag_humidity_percent,
2767 );
2768
2769 let cd = self.calculate_drag_coefficient(velocity_magnitude, speed_of_sound);
2771
2772 let velocity_fps = velocity_magnitude * 3.28084;
2774
2775 let (base_bc, bc_from_segments) = if let Some(segments) = self
2780 .inputs
2781 .bc_segments_data
2782 .as_ref()
2783 .filter(|segments| self.inputs.use_bc_segments && !segments.is_empty())
2784 {
2785 (
2787 crate::bc_estimation::velocity_segment_bc(
2788 velocity_fps,
2789 segments,
2790 self.inputs.bc_value,
2791 ),
2792 true,
2793 )
2794 } else if let Some(segments) = self
2795 .inputs
2796 .bc_segments
2797 .as_ref()
2798 .filter(|segments| !segments.is_empty())
2799 {
2800 (
2801 crate::derivatives::interpolated_bc(
2802 velocity_magnitude / speed_of_sound,
2803 segments,
2804 Some(&self.inputs),
2805 ),
2806 true,
2807 )
2808 } else {
2809 (self.inputs.bc_value, false)
2810 };
2811
2812 let effective_bc = if bc_from_segments {
2817 base_bc
2818 } else {
2819 self.apply_cluster_bc_correction(base_bc, velocity_fps)
2820 };
2821 let effective_bc = effective_bc.max(1e-6);
2824
2825 let retard_denom = if self.inputs.custom_drag_table.is_some() {
2830 self.inputs.custom_drag_denominator(effective_bc)
2831 } else {
2832 effective_bc
2833 };
2834
2835 let cd_to_retard = crate::constants::CD_TO_RETARD;
2840 let standard_factor = cd * cd_to_retard;
2841 let density_scale = air_density / 1.225; let a_drag_ft_s2 =
2845 (velocity_fps * velocity_fps) * standard_factor * density_scale / retard_denom;
2846 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
2850
2851 let mut accel = drag_acceleration + self.gravity_acceleration();
2854
2855 if self.inputs.enable_coriolis {
2858 if let Some(lat_deg) = self.inputs.latitude {
2859 let omega_earth = 7.2921159e-5_f64; let lat = lat_deg.to_radians();
2861 let az = self.inputs.shot_azimuth; let omega = Vector3::new(
2868 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), );
2872 let omega = crate::derivatives::level_vector_to_shot_frame(
2873 omega,
2874 self.inputs.shooting_angle,
2875 );
2876 accel += -2.0 * omega.cross(velocity);
2881 }
2882 }
2883
2884 if self.inputs.enable_magnus
2891 && !self.inputs.use_enhanced_spin_drift
2892 && self.inputs.bullet_diameter > 0.0
2893 && self.inputs.twist_rate > 0.0
2894 {
2895 let diameter_m = self.inputs.bullet_diameter;
2896 let (spin_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
2897 self.inputs.muzzle_velocity,
2898 velocity_magnitude,
2899 self.inputs.twist_rate,
2900 diameter_m,
2901 );
2902 let mach = velocity_magnitude / speed_of_sound;
2904
2905 let d_in = self.inputs.bullet_diameter / 0.0254;
2907 let m_gr = self.inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
2908 let l_in = if self.inputs.bullet_length > 0.0 {
2909 self.inputs.bullet_length / 0.0254
2910 } else {
2911 let est_m = crate::stability::estimate_bullet_length_m(
2913 self.inputs.bullet_diameter,
2914 self.inputs.bullet_mass,
2915 );
2916 if est_m > 0.0 {
2917 est_m / 0.0254
2918 } else {
2919 4.5 * d_in
2920 }
2921 };
2922 let sg = crate::spin_drift::calculate_dynamic_stability(
2926 m_gr,
2927 velocity_magnitude,
2928 spin_rad_s,
2929 d_in,
2930 l_in,
2931 air_density,
2932 );
2933
2934 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
2936 sg,
2937 velocity_magnitude,
2938 spin_rad_s,
2939 0.0, 0.0, air_density,
2942 d_in,
2943 l_in,
2944 m_gr,
2945 mach,
2946 "match",
2947 false,
2948 );
2949
2950 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
2952 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
2953 let magnus_force = 0.5
2954 * air_density
2955 * velocity_magnitude.powi(2)
2956 * area
2957 * c_np
2958 * spin_param
2959 * yaw_rad.sin();
2960
2961 if magnus_force.abs() > 1e-12 {
2965 if let Some(dir) = crate::derivatives::yaw_of_repose_magnus_direction(
2966 relative_velocity,
2967 self.gravity_acceleration(),
2968 self.inputs.is_twist_right,
2969 ) {
2970 accel += (magnus_force / self.inputs.bullet_mass) * dir;
2971 }
2972 }
2973 }
2974
2975 accel
2976 }
2977
2978 fn calculate_drag_coefficient(&self, velocity: f64, speed_of_sound: f64) -> f64 {
2979 let mach = velocity / speed_of_sound;
2980
2981 if let Some(ref table) = self.inputs.custom_drag_table {
2985 return table.interpolate(mach);
2986 }
2987
2988 crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type)
2991 }
2992}
2993
2994#[derive(Debug, Clone)]
2996pub struct MonteCarloParams {
2997 pub num_simulations: usize,
2998 pub velocity_std_dev: f64,
2999 pub angle_std_dev: f64,
3000 pub bc_std_dev: f64,
3001 pub wind_speed_std_dev: f64,
3002 pub target_distance: Option<f64>,
3003 pub base_wind_speed: f64,
3004 pub base_wind_direction: f64,
3005 pub azimuth_std_dev: f64, }
3007
3008impl Default for MonteCarloParams {
3009 fn default() -> Self {
3010 Self {
3011 num_simulations: 1000,
3012 velocity_std_dev: 1.0,
3013 angle_std_dev: 0.001,
3014 bc_std_dev: 0.01,
3015 wind_speed_std_dev: 1.0,
3016 target_distance: None,
3017 base_wind_speed: 0.0,
3018 base_wind_direction: 0.0,
3019 azimuth_std_dev: 0.001, }
3021 }
3022}
3023
3024#[derive(Debug, Clone)]
3026pub struct MonteCarloResults {
3027 pub ranges: Vec<f64>,
3028 pub impact_velocities: Vec<f64>,
3029 pub impact_positions: Vec<Vector3<f64>>,
3035}
3036
3037pub const DEFAULT_HIT_RADIUS_M: f64 = 0.3;
3040
3041pub const TARGET_NOT_REACHED_SENTINEL_M: f64 = -1.0e9;
3047
3048impl MonteCarloResults {
3049 pub fn position_reached_target(position: &Vector3<f64>) -> bool {
3051 position.iter().all(|component| component.is_finite())
3052 && position.y != TARGET_NOT_REACHED_SENTINEL_M
3053 }
3054
3055 pub fn target_arrival_count(&self) -> usize {
3057 self.impact_positions
3058 .iter()
3059 .filter(|position| Self::position_reached_target(position))
3060 .count()
3061 }
3062
3063 pub fn target_shortfall_fraction(&self) -> f64 {
3066 if self.impact_positions.is_empty() {
3067 return 0.0;
3068 }
3069 (self.impact_positions.len() - self.target_arrival_count()) as f64
3070 / self.impact_positions.len() as f64
3071 }
3072
3073 pub fn target_plane_cep(&self) -> Option<f64> {
3079 let mut radial_misses: Vec<f64> = self
3080 .impact_positions
3081 .iter()
3082 .filter(|position| Self::position_reached_target(position))
3083 .map(Vector3::norm)
3084 .filter(|miss| miss.is_finite())
3085 .collect();
3086 radial_misses.sort_by(f64::total_cmp);
3087 if radial_misses.is_empty() {
3088 None
3089 } else {
3090 Some(radial_misses[radial_misses.len() / 2])
3091 }
3092 }
3093
3094 pub fn hit_probability(&self, hit_radius_m: f64) -> f64 {
3103 if self.impact_positions.is_empty() {
3104 return 0.0;
3105 }
3106 let hits = self
3107 .impact_positions
3108 .iter()
3109 .filter(|position| {
3110 Self::position_reached_target(position) && position.norm() < hit_radius_m
3111 })
3112 .count();
3113 hits as f64 / self.impact_positions.len() as f64
3114 }
3115
3116 pub fn rect_hit_probability(&self, width_m: f64, height_m: f64) -> f64 {
3128 let dimensions_invalid = width_m.is_nan()
3129 || width_m <= 0.0
3130 || height_m.is_nan()
3131 || height_m <= 0.0;
3132 if self.impact_positions.is_empty() || dimensions_invalid {
3133 return 0.0;
3134 }
3135 let half_width = width_m / 2.0;
3136 let half_height = height_m / 2.0;
3137 let hits = self
3138 .impact_positions
3139 .iter()
3140 .filter(|position| {
3141 Self::position_reached_target(position)
3142 && position.z.abs() <= half_width
3143 && position.y.abs() <= half_height
3144 })
3145 .count();
3146 hits as f64 / self.impact_positions.len() as f64
3147 }
3148}
3149
3150fn wind_from_signed_speed_sample(
3151 signed_speed: f64,
3152 sampled_direction: f64,
3153 vertical_speed: f64,
3154) -> WindConditions {
3155 if signed_speed < 0.0 {
3160 WindConditions {
3161 speed: -signed_speed,
3162 direction: sampled_direction + std::f64::consts::PI,
3163 vertical_speed,
3164 }
3165 } else {
3166 WindConditions {
3167 speed: signed_speed,
3168 direction: sampled_direction,
3169 vertical_speed,
3170 }
3171 }
3172}
3173
3174struct MonteCarloWindSampler {
3175 speed: rand_distr::Normal<f64>,
3176 direction: rand_distr::Normal<f64>,
3177 vertical_speed: f64,
3179}
3180
3181impl MonteCarloWindSampler {
3182 fn new(
3183 base_wind: &WindConditions,
3184 wind_speed_std_dev: f64,
3185 wind_direction_std_dev: f64,
3186 ) -> Result<Self, BallisticsError> {
3187 use rand_distr::Normal;
3188
3189 if !wind_direction_std_dev.is_finite() || wind_direction_std_dev < 0.0 {
3190 return Err("Wind direction standard deviation must be finite and non-negative".into());
3191 }
3192
3193 let speed = Normal::new(base_wind.speed, wind_speed_std_dev)
3194 .map_err(|e| format!("Invalid wind speed distribution: {e}"))?;
3195 let direction = Normal::new(base_wind.direction, wind_direction_std_dev)
3196 .map_err(|e| format!("Invalid wind direction distribution: {e}"))?;
3197 Ok(Self { speed, direction, vertical_speed: base_wind.vertical_speed })
3198 }
3199
3200 fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> WindConditions {
3201 use rand_distr::Distribution;
3202
3203 wind_from_signed_speed_sample(
3204 self.speed.sample(rng),
3205 self.direction.sample(rng),
3206 self.vertical_speed,
3207 )
3208 }
3209}
3210
3211pub fn run_monte_carlo(
3213 base_inputs: BallisticInputs,
3214 params: MonteCarloParams,
3215) -> Result<MonteCarloResults, BallisticsError> {
3216 run_monte_carlo_with_direction_std_dev(base_inputs, params, 0.0)
3217}
3218
3219pub fn run_monte_carlo_with_direction_std_dev(
3224 base_inputs: BallisticInputs,
3225 params: MonteCarloParams,
3226 wind_direction_std_dev: f64,
3227) -> Result<MonteCarloResults, BallisticsError> {
3228 let base_wind = WindConditions {
3229 speed: params.base_wind_speed,
3230 direction: params.base_wind_direction,
3231 vertical_speed: 0.0,
3232 };
3233 run_monte_carlo_with_wind_and_direction_std_dev(
3234 base_inputs,
3235 base_wind,
3236 params,
3237 wind_direction_std_dev,
3238 )
3239}
3240
3241pub fn run_monte_carlo_with_wind(
3243 base_inputs: BallisticInputs,
3244 base_wind: WindConditions,
3245 params: MonteCarloParams,
3246) -> Result<MonteCarloResults, BallisticsError> {
3247 run_monte_carlo_with_wind_and_direction_std_dev(base_inputs, base_wind, params, 0.0)
3248}
3249
3250pub fn run_monte_carlo_with_wind_and_direction_std_dev(
3255 base_inputs: BallisticInputs,
3256 base_wind: WindConditions,
3257 params: MonteCarloParams,
3258 wind_direction_std_dev: f64,
3259) -> Result<MonteCarloResults, BallisticsError> {
3260 let mut rng = rand::rng();
3261 run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
3262 base_inputs,
3263 base_wind,
3264 params,
3265 wind_direction_std_dev,
3266 &mut rng,
3267 )
3268}
3269
3270pub fn run_monte_carlo_with_wind_and_direction_std_dev_seeded(
3277 base_inputs: BallisticInputs,
3278 base_wind: WindConditions,
3279 params: MonteCarloParams,
3280 wind_direction_std_dev: f64,
3281 seed: u64,
3282) -> Result<MonteCarloResults, BallisticsError> {
3283 use rand::{rngs::StdRng, SeedableRng};
3284 let mut rng = StdRng::seed_from_u64(seed);
3285 run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
3286 base_inputs,
3287 base_wind,
3288 params,
3289 wind_direction_std_dev,
3290 &mut rng,
3291 )
3292}
3293
3294fn run_monte_carlo_with_wind_and_direction_std_dev_using_rng<R: rand::Rng + ?Sized>(
3295 base_inputs: BallisticInputs,
3296 base_wind: WindConditions,
3297 params: MonteCarloParams,
3298 wind_direction_std_dev: f64,
3299 rng: &mut R,
3300) -> Result<MonteCarloResults, BallisticsError> {
3301 use rand_distr::{Distribution, Normal};
3302
3303 let mut ranges = Vec::new();
3304 let mut impact_velocities = Vec::new();
3305 let mut impact_positions = Vec::new();
3306
3307 let atmosphere = AtmosphericConditions {
3308 temperature: base_inputs.temperature,
3309 pressure: base_inputs.pressure,
3310 humidity: base_inputs.humidity_percent(),
3311 altitude: base_inputs.altitude,
3312 };
3313 let target_hint = params
3314 .target_distance
3315 .unwrap_or(base_inputs.target_distance);
3316 let solver_max_range = target_hint.max(1000.0) * 2.0;
3317
3318 let mut baseline_solver =
3320 TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), atmosphere.clone());
3321 baseline_solver.set_max_range(solver_max_range);
3322 let baseline_result = baseline_solver.solve()?;
3323
3324 let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
3326
3327 let baseline_at_target = baseline_result
3329 .position_at_range(target_distance)
3330 .ok_or("Could not interpolate baseline at target distance")?;
3331
3332 let velocity_delta_dist = Normal::new(0.0, params.velocity_std_dev)
3337 .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
3338 let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
3339 .map_err(|e| format!("Invalid angle distribution: {}", e))?;
3340 let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
3341 .map_err(|e| format!("Invalid BC distribution: {}", e))?;
3342 let wind_sampler = MonteCarloWindSampler::new(
3345 &base_wind,
3346 params.wind_speed_std_dev,
3347 wind_direction_std_dev,
3348 )?;
3349 let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
3350 .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
3351
3352 for _ in 0..params.num_simulations {
3353 let mut inputs = base_inputs.clone();
3355 let muzzle_velocity_delta = velocity_delta_dist.sample(&mut *rng);
3356 inputs.muzzle_angle = angle_dist.sample(&mut *rng);
3357 inputs.bc_value = bc_dist.sample(&mut *rng).max(0.01);
3358 inputs.azimuth_angle = azimuth_dist.sample(&mut *rng); let wind = wind_sampler.sample(&mut *rng);
3362
3363 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere.clone());
3365 solver.inputs.muzzle_velocity =
3366 (solver.inputs.muzzle_velocity + muzzle_velocity_delta).max(0.0);
3367 solver.set_max_range(solver_max_range);
3368 match solver.solve() {
3369 Ok(result) => {
3370 let deviation = if result.max_range < target_distance {
3376 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)
3379 } else {
3380 let pos_at_target = match result.position_at_range(target_distance) {
3381 Some(p) => p,
3382 None => continue, };
3384 Vector3::new(
3389 0.0,
3390 pos_at_target.y - baseline_at_target.y,
3391 pos_at_target.z - baseline_at_target.z,
3392 )
3393 };
3394
3395 ranges.push(result.max_range);
3396 impact_velocities.push(result.impact_velocity);
3397 impact_positions.push(deviation);
3398 }
3399 Err(_) => {
3400 continue;
3402 }
3403 }
3404 }
3405
3406 if ranges.is_empty() {
3407 return Err("No successful simulations".into());
3408 }
3409
3410 Ok(MonteCarloResults {
3411 ranges,
3412 impact_velocities,
3413 impact_positions,
3414 })
3415}
3416
3417pub fn calculate_zero_angle(
3419 inputs: BallisticInputs,
3420 target_distance: f64,
3421 target_height: f64,
3422) -> Result<f64, BallisticsError> {
3423 calculate_zero_angle_with_conditions(
3424 inputs,
3425 target_distance,
3426 target_height,
3427 WindConditions::default(),
3428 AtmosphericConditions::default(),
3429 )
3430}
3431
3432pub fn calculate_zero_angle_with_conditions(
3433 inputs: BallisticInputs,
3434 target_distance: f64,
3435 target_height: f64,
3436 wind: WindConditions,
3437 atmosphere: AtmosphericConditions,
3438) -> Result<f64, BallisticsError> {
3439 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
3440 solver.calculate_and_set_zero_angle(target_distance, target_height)
3441}
3442
3443#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3445pub enum BcFitMode {
3446 Drop,
3448 Velocity,
3451}
3452
3453#[derive(Debug, Clone, Copy)]
3455pub struct BcEstimate {
3456 pub bc: f64,
3458 pub rms_error: f64,
3460 pub drag_model: DragModel,
3462 pub mode: BcFitMode,
3464 pub at_bound: bool,
3468}
3469
3470fn fit_value_at(
3478 points: &[TrajectoryPoint],
3479 target_dist: f64,
3480 mode: BcFitMode,
3481 drop_offset: f64,
3482) -> Option<f64> {
3483 let val = |p: &TrajectoryPoint| match mode {
3484 BcFitMode::Drop => drop_offset - p.position.y,
3485 BcFitMode::Velocity => p.velocity_magnitude,
3486 };
3487 for i in 0..points.len() {
3488 if points[i].position.x >= target_dist {
3489 if i == 0 {
3490 return Some(val(&points[0]));
3491 }
3492 let p1 = &points[i - 1];
3493 let p2 = &points[i];
3494 let dx = p2.position.x - p1.position.x;
3495 if dx.abs() < 1e-9 {
3496 return Some(val(p2));
3497 }
3498 let t = (target_dist - p1.position.x) / dx;
3499 return Some(val(p1) + t * (val(p2) - val(p1)));
3500 }
3501 }
3502 None
3503}
3504
3505fn fit_residual_sse(
3506 trajectory: &[TrajectoryPoint],
3507 observations: &[(f64, f64)],
3508 mode: BcFitMode,
3509 drop_offset: f64,
3510) -> Option<f64> {
3511 if observations.is_empty() {
3512 return None;
3513 }
3514 let mut total = 0.0;
3515 for (target_dist, target_val) in observations {
3516 let value = fit_value_at(trajectory, *target_dist, mode, drop_offset)?;
3519 let error = value - target_val;
3520 total += error * error;
3521 }
3522 Some(total)
3523}
3524
3525#[allow(clippy::too_many_arguments)] pub fn estimate_bc_fit(
3543 velocity: f64,
3544 mass: f64,
3545 diameter: f64,
3546 points: &[(f64, f64)],
3547 drag_model: DragModel,
3548 mode: BcFitMode,
3549 atmosphere: AtmosphericConditions,
3550 zero_range: Option<f64>,
3551 sight_height: f64,
3552) -> Result<BcEstimate, BallisticsError> {
3553 if points.is_empty() {
3554 return Err(BallisticsError::from(
3555 "No data points provided for BC estimation.".to_string(),
3556 ));
3557 }
3558 let max_dist = points.iter().map(|(d, _)| *d).fold(0.0_f64, f64::max);
3559 let drop_offset = if zero_range.is_some() { sight_height } else { 0.0 };
3562
3563 let sse = |bc_value: f64| -> Option<f64> {
3565 let mut inputs = BallisticInputs {
3566 muzzle_velocity: velocity,
3567 bc_value,
3568 bc_type: drag_model,
3569 bullet_mass: mass,
3570 bullet_diameter: diameter,
3571 sight_height,
3572 ..Default::default()
3573 };
3574 if let Some(zr) = zero_range {
3577 let za = calculate_zero_angle_with_conditions(
3583 inputs.clone(),
3584 zr,
3585 sight_height,
3586 WindConditions::default(),
3587 atmosphere.clone(),
3588 )
3589 .ok()?;
3590 inputs.muzzle_angle = za;
3591 }
3592 let mut solver =
3593 TrajectorySolver::new(inputs, WindConditions::default(), atmosphere.clone());
3594 solver.set_max_range(max_dist * 1.5);
3595 let result = solver.solve().ok()?;
3596 fit_residual_sse(&result.points, points, mode, drop_offset)
3597 };
3598
3599 let (bc_min, bc_max) = match drag_model {
3603 DragModel::G7 => (0.05, 0.70),
3604 _ => (0.10, 1.20),
3605 };
3606
3607 let mut best_bc = f64::NAN;
3609 let mut best_sse = f64::MAX;
3610 let mut bc = bc_min;
3611 while bc <= bc_max + 1e-9 {
3612 if let Some(s) = sse(bc) {
3613 if s < best_sse {
3614 best_sse = s;
3615 best_bc = bc;
3616 }
3617 }
3618 bc += 0.01;
3619 }
3620 if !best_bc.is_finite() {
3621 return Err(BallisticsError::from(
3622 "Unable to estimate BC from provided data. Check that the values and units are correct."
3623 .to_string(),
3624 ));
3625 }
3626
3627 let lo = (best_bc - 0.01).max(bc_min);
3629 let hi = (best_bc + 0.01).min(bc_max);
3630 let mut bc = lo;
3631 while bc <= hi + 1e-9 {
3632 if let Some(s) = sse(bc) {
3633 if s < best_sse {
3634 best_sse = s;
3635 best_bc = bc;
3636 }
3637 }
3638 bc += 0.001;
3639 }
3640
3641 let at_bound = best_bc <= bc_min + 0.011 || best_bc >= bc_max - 0.011;
3644 let rms_error = (best_sse / points.len() as f64).sqrt();
3647 Ok(BcEstimate {
3648 bc: best_bc,
3649 rms_error,
3650 drag_model,
3651 mode,
3652 at_bound,
3653 })
3654}
3655
3656pub fn estimate_bc_from_trajectory(
3659 velocity: f64,
3660 mass: f64,
3661 diameter: f64,
3662 points: &[(f64, f64)], ) -> Result<f64, BallisticsError> {
3664 estimate_bc_fit(
3665 velocity,
3666 mass,
3667 diameter,
3668 points,
3669 DragModel::G1,
3670 BcFitMode::Drop,
3671 AtmosphericConditions::default(),
3672 None,
3673 0.05,
3674 )
3675 .map(|e| e.bc)
3676}
3677
3678use rand;
3680use rand_distr;
3681
3682#[cfg(test)]
3683mod mba737_powder_resolution_tests {
3684 use super::*;
3685
3686 #[test]
3687 fn linear_model_cold_powder_subtracts() {
3688 let v = resolve_powder_adjusted_velocity(823.0, 11.1, true, 0.5486, 21.1, None, None);
3690 assert!((v - (823.0 + 0.5486 * (11.1 - 21.1))).abs() < 1e-12);
3691 assert!(v < 823.0);
3692 }
3693
3694 #[test]
3695 fn linear_model_hot_powder_adds() {
3696 let v = resolve_powder_adjusted_velocity(823.0, 31.1, true, 0.5486, 21.1, None, None);
3697 assert!((v - (823.0 + 0.5486 * 10.0)).abs() < 1e-12);
3698 }
3699
3700 #[test]
3701 fn disabled_flag_is_passthrough() {
3702 let v = resolve_powder_adjusted_velocity(823.0, 40.0, false, 0.5486, 21.1, None, None);
3703 assert_eq!(v, 823.0);
3704 }
3705
3706 #[test]
3707 fn curve_overrides_linear_and_interpolates_at_powder_temp() {
3708 let curve = [(4.4, 798.6), (21.1, 823.0), (37.8, 841.2)];
3709 let v = resolve_powder_adjusted_velocity(823.0, 30.0, true, 99.0, 21.1, Some(&curve), Some(4.4));
3711 assert!((v - 798.6).abs() < 1e-9);
3712 }
3713
3714 #[test]
3715 fn curve_falls_back_to_ambient_and_clamps() {
3716 let curve = [(4.4, 798.6), (37.8, 841.2)];
3717 let v = resolve_powder_adjusted_velocity(823.0, -40.0, true, 1.0, 21.1, Some(&curve), None);
3719 assert!((v - 798.6).abs() < 1e-9);
3720 let v_hot = resolve_powder_adjusted_velocity(823.0, 60.0, true, 1.0, 21.1, Some(&curve), None);
3721 assert!((v_hot - 841.2).abs() < 1e-9);
3722 }
3723
3724 #[test]
3725 fn empty_curve_suppresses_linear_fallback() {
3726 let v = resolve_powder_adjusted_velocity(823.0, 40.0, true, 0.5486, 21.1, Some(&[]), None);
3728 assert_eq!(v, 823.0);
3729 }
3730
3731 #[test]
3732 fn sweep_huge_range_errors_instead_of_overflowing() {
3733 assert!(parse_powder_sweep("0:1e20:1").is_err());
3736 assert!(parse_powder_sweep("0:1e308:1e-3").is_err());
3737 }
3738
3739 #[test]
3740 fn sweep_fractional_step_keeps_end_row() {
3741 let rows = parse_powder_sweep("0:0.3:0.1").unwrap();
3743 assert_eq!(rows.len(), 4);
3744 assert!((rows[3] - 0.3).abs() < 1e-9);
3745 }
3746
3747 #[test]
3748 fn solver_and_helper_agree_on_linear_model() {
3749 let inputs = BallisticInputs {
3751 use_powder_sensitivity: true,
3752 powder_temp_sensitivity: 0.5486,
3753 powder_temp: 21.1,
3754 temperature: 4.4,
3755 ..Default::default()
3756 };
3757 let expected = resolve_powder_adjusted_velocity(
3758 inputs.muzzle_velocity,
3759 inputs.temperature,
3760 true,
3761 0.5486,
3762 21.1,
3763 None,
3764 None,
3765 );
3766 let solver = TrajectorySolver::new(
3767 inputs,
3768 WindConditions::default(),
3769 AtmosphericConditions::default(),
3770 );
3771 assert!((solver.inputs.muzzle_velocity - expected).abs() < 1e-12);
3772 }
3773}
3774
3775#[cfg(test)]
3776mod mba1302_solver_seam_tests {
3777 use super::*;
3778 use crate::wind::WindSegment;
3779
3780 #[test]
3781 fn authoritative_station_atmosphere_preserves_explicit_standard_values_at_altitude() {
3782 let atmosphere = AtmosphericConditions {
3783 temperature: 15.0,
3784 pressure: 1013.25,
3785 humidity: 50.0,
3786 altitude: 2_000.0,
3787 };
3788 let legacy = TrajectorySolver::new(
3789 BallisticInputs::default(),
3790 WindConditions::default(),
3791 atmosphere.clone(),
3792 );
3793 let authoritative = TrajectorySolver::new_with_resolved_station_atmosphere(
3794 BallisticInputs::default(),
3795 WindConditions::default(),
3796 atmosphere,
3797 );
3798
3799 let (legacy_density, _, legacy_temp_c, legacy_pressure_hpa) = legacy.resolved_atmosphere();
3800 let (authoritative_density, _, authoritative_temp_c, authoritative_pressure_hpa) =
3801 authoritative.resolved_atmosphere();
3802 let (icao_temp_k, icao_pressure_pa) =
3803 crate::atmosphere::calculate_icao_standard_atmosphere(2_000.0);
3804 let (expected_authoritative_density, _) =
3805 crate::atmosphere::calculate_atmosphere(2_000.0, Some(15.0), Some(1013.25), 50.0);
3806
3807 assert!((legacy_temp_c - (icao_temp_k - 273.15)).abs() < 1e-12);
3808 assert!((legacy_pressure_hpa - icao_pressure_pa / 100.0).abs() < 1e-12);
3809 assert_eq!(authoritative_temp_c.to_bits(), 15.0_f64.to_bits());
3810 assert_eq!(authoritative_pressure_hpa.to_bits(), 1013.25_f64.to_bits());
3811 assert_eq!(
3812 authoritative_density.to_bits(),
3813 expected_authoritative_density.to_bits()
3814 );
3815 assert!(
3816 (authoritative_density - legacy_density).abs() > 0.1,
3817 "explicit standard values at altitude must differ from ICAO-at-altitude: explicit={authoritative_density}, ICAO={legacy_density}"
3818 );
3819 }
3820
3821 fn configured_euler_zero(vertical_wind_mps: f64, time_step_s: f64) -> TrajectorySolver {
3822 let inputs = BallisticInputs {
3823 muzzle_velocity: 800.0,
3824 bc_value: 0.5,
3825 bc_type: DragModel::G7,
3826 bullet_mass: 0.0109,
3827 bullet_diameter: 0.00782,
3828 bullet_length: 0.0309,
3829 sight_height: 0.05,
3830 ground_threshold: -100.0,
3831 use_rk4: false,
3832 use_adaptive_rk45: false,
3833 ..BallisticInputs::default()
3834 };
3835 let mut solver = TrajectorySolver::new_with_resolved_station_atmosphere(
3836 inputs,
3837 WindConditions::default(),
3838 AtmosphericConditions::default(),
3839 );
3840 solver.set_max_range(300.0);
3841 solver.set_time_step(time_step_s);
3842 if vertical_wind_mps != 0.0 {
3843 solver.set_wind_segments(vec![WindSegment {
3844 speed_kmh: 0.0,
3845 angle_deg: 0.0,
3846 until_m: 400.0,
3847 vertical_mps: vertical_wind_mps,
3848 }]);
3849 }
3850 solver
3851 }
3852
3853 #[test]
3854 fn configured_zero_keeps_segments_method_and_time_step_then_sets_base_angle() {
3855 const TARGET_DISTANCE_M: f64 = 150.0;
3856 const TARGET_HEIGHT_M: f64 = 0.05;
3857
3858 let mut segmented = configured_euler_zero(-10.0, 0.02);
3861 let coarse_height = segmented
3862 .zero_trial_height_at(0.0, TARGET_DISTANCE_M)
3863 .expect("coarse configured trial")
3864 .expect("coarse trial reaches target");
3865 let mut fine = segmented.clone();
3866 fine.set_time_step(0.001);
3867 let fine_height = fine
3868 .zero_trial_height_at(0.0, TARGET_DISTANCE_M)
3869 .expect("fine configured trial")
3870 .expect("fine trial reaches target");
3871 assert!(
3872 (coarse_height - fine_height).abs() > 1e-5,
3873 "configured Euler step must affect zero trials: coarse={coarse_height}, fine={fine_height}"
3874 );
3875
3876 let segmented_angle = segmented
3877 .calculate_and_set_zero_angle(TARGET_DISTANCE_M, TARGET_HEIGHT_M)
3878 .expect("segmented zero");
3879 assert_eq!(
3880 segmented.inputs.muzzle_angle.to_bits(),
3881 segmented_angle.to_bits(),
3882 "successful zero must install its angle on the configured solver"
3883 );
3884 assert_eq!(segmented.time_step.to_bits(), 0.02_f64.to_bits());
3885 assert_eq!(segmented.max_range.to_bits(), 300.0_f64.to_bits());
3886 assert!(segmented.wind_sock.is_some());
3887 assert_eq!(
3888 segmented.station_atmosphere_resolution,
3889 StationAtmosphereResolution::Authoritative
3890 );
3891 let zero_height = segmented
3892 .zero_trial_height_at(segmented_angle, TARGET_DISTANCE_M)
3893 .expect("verify segmented zero")
3894 .expect("zeroed trial reaches target");
3895 assert!(
3896 (zero_height - TARGET_HEIGHT_M).abs() < 0.0001,
3897 "configured zero missed target: height={zero_height}"
3898 );
3899
3900 let mut calm = configured_euler_zero(0.0, 0.02);
3901 let calm_angle = calm
3902 .calculate_and_set_zero_angle(TARGET_DISTANCE_M, TARGET_HEIGHT_M)
3903 .expect("calm zero");
3904 assert!(
3905 (segmented_angle - calm_angle).abs() > 1e-5,
3906 "segmented vertical wind must participate in zero trials: segmented={segmented_angle}, calm={calm_angle}"
3907 );
3908 }
3909}
3910
3911#[cfg(test)]
3912mod result_sanity_tests {
3913 use super::*;
3914
3915 fn default_solver() -> TrajectorySolver {
3916 TrajectorySolver::new(
3917 BallisticInputs::default(),
3918 WindConditions::default(),
3919 AtmosphericConditions::default(),
3920 )
3921 }
3922
3923 fn minimal_result() -> TrajectoryResult {
3924 TrajectoryResult {
3925 max_range: 100.0,
3926 max_height: 1.0,
3927 time_of_flight: 0.5,
3928 impact_velocity: 700.0,
3929 impact_energy: 2450.0,
3930 projectile_mass_kg: 0.01,
3931 line_of_sight_height_m: 1.5,
3932 station_speed_of_sound_mps: 340.0,
3933 termination: TrajectoryTermination::MaxRange,
3934 points: vec![],
3935 sampled_points: None,
3936 min_pitch_damping: None,
3937 transonic_mach: None,
3938 angular_state: None,
3939 max_yaw_angle: None,
3940 max_precession_angle: None,
3941 aerodynamic_jump: None,
3942 }
3943 }
3944
3945 #[test]
3946 fn mba1293_negative_scalars_fail_the_result_postcondition() {
3947 let solver = default_solver();
3948 solver
3949 .validate_result_sanity(&minimal_result())
3950 .expect("a sane result must pass");
3951
3952 for (name, mutate) in [
3953 ("max_range", (|r| r.max_range = -50.588) as fn(&mut TrajectoryResult)),
3954 ("time_of_flight", |r| r.time_of_flight = -1.0),
3955 ("impact_velocity", |r| r.impact_velocity = -700.0),
3956 ("impact_energy", |r| r.impact_energy = -1.0),
3957 ] {
3958 let mut result = minimal_result();
3959 mutate(&mut result);
3960 let error = solver
3961 .validate_result_sanity(&result)
3962 .expect_err("negative scalar must fail");
3963 assert!(
3964 error.to_string().contains(name),
3965 "error for {name} did not name the field: {error}"
3966 );
3967 }
3968 }
3969
3970 #[test]
3971 fn mba1293_speed_budget_bounds_legitimate_states_and_rejects_divergence() {
3972 let solver = default_solver();
3973 let mv = solver.inputs.muzzle_velocity;
3974
3975 let position = Vector3::new(10.0, 0.0, 0.0);
3977 solver
3978 .validate_integration_state(&position, &Vector3::new(mv, 0.0, 0.0), 0.01)
3979 .expect("muzzle-speed state must pass");
3980
3981 let error = solver
3983 .validate_integration_state(&position, &Vector3::new(-13.0 * mv, 0.0, 0.0), 0.01)
3984 .expect_err("13x muzzle speed must fail the budget");
3985 assert!(error.to_string().contains("diverged"), "{error}");
3986
3987 let after_fall = mv + crate::constants::G_ACCEL_MPS2 * 60.0;
3989 solver
3990 .validate_integration_state(&position, &Vector3::new(0.0, -after_fall, 0.0), 60.0)
3991 .expect("gravity-accelerated speed within g*t must pass");
3992 }
3993}
3994
3995#[cfg(test)]
3996mod trajectory_point_budget_tests {
3997 use super::*;
3998 use crate::MAX_TRAJECTORY_SAMPLES;
3999
4000 fn solver_with_budget(
4001 use_rk4: bool,
4002 use_adaptive_rk45: bool,
4003 point_budget: usize,
4004 max_range: f64,
4005 ) -> TrajectorySolver {
4006 let inputs = BallisticInputs {
4007 use_rk4,
4008 use_adaptive_rk45,
4009 ground_threshold: f64::NEG_INFINITY,
4010 ..BallisticInputs::default()
4011 };
4012 let mut solver = TrajectorySolver::new(
4013 inputs,
4014 WindConditions::default(),
4015 AtmosphericConditions::default(),
4016 );
4017 solver.max_trajectory_points = point_budget;
4018 solver.set_max_range(max_range);
4019 solver.set_time_step(0.001);
4020 solver
4021 }
4022
4023 #[test]
4024 fn mba1283_every_solver_errors_instead_of_exceeding_point_budget() {
4025 for (mode, use_rk4, use_adaptive_rk45) in [
4026 ("Euler", false, false),
4027 ("RK4", true, false),
4028 ("RK45", true, true),
4029 ] {
4030 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 3, 10.0)
4031 .solve()
4032 .expect_err("a solve requiring more than three points must fail");
4033 assert!(
4034 error.to_string().contains("point limit of 3"),
4035 "unexpected {mode} point-budget error: {error}"
4036 );
4037 }
4038 }
4039
4040 #[test]
4041 fn mba1283_interpolated_endpoint_counts_toward_point_budget() {
4042 for (mode, use_rk4, use_adaptive_rk45) in [
4043 ("Euler", false, false),
4044 ("RK4", true, false),
4045 ("RK45", true, true),
4046 ] {
4047 let result = solver_with_budget(use_rk4, use_adaptive_rk45, 2, 0.1)
4048 .solve()
4049 .expect("the initial point plus exact endpoint fit a two-point budget");
4050 assert_eq!(result.points.len(), 2, "unexpected {mode} point count");
4051
4052 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 1, 0.1)
4053 .solve()
4054 .expect_err("the exact endpoint must not exceed a one-point budget");
4055 assert!(
4056 error.to_string().contains("point limit of 1"),
4057 "unexpected {mode} endpoint-budget error: {error}"
4058 );
4059 }
4060 }
4061
4062 #[test]
4063 fn mba1299_every_solver_preflights_the_sample_budget() {
4064 for (mode, use_rk4, use_adaptive_rk45) in [
4065 ("Euler", false, false),
4066 ("RK4", true, false),
4067 ("RK45", true, true),
4068 ] {
4069 let inputs = BallisticInputs {
4070 use_rk4,
4071 use_adaptive_rk45,
4072 enable_trajectory_sampling: true,
4073 sample_interval: 1.0,
4074 ground_threshold: f64::NEG_INFINITY,
4075 ..BallisticInputs::default()
4076 };
4077 let mut solver = TrajectorySolver::new(
4078 inputs,
4079 WindConditions::default(),
4080 AtmosphericConditions::default(),
4081 );
4082 solver.set_max_range(MAX_TRAJECTORY_SAMPLES as f64);
4083 solver.max_trajectory_points = 0;
4086
4087 let error = solver
4088 .solve()
4089 .expect_err("an over-limit sample grid must fail before integration");
4090 assert!(
4091 error
4092 .to_string()
4093 .contains("trajectory sample limit of 250000 exceeded"),
4094 "unexpected {mode} sample-budget error: {error}"
4095 );
4096 }
4097 }
4098
4099 #[test]
4100 fn mba1299_normal_sampling_does_not_change_solver_results() {
4101 for (mode, use_rk4, use_adaptive_rk45) in [
4102 ("Euler", false, false),
4103 ("RK4", true, false),
4104 ("RK45", true, true),
4105 ] {
4106 let solve = |enable_trajectory_sampling| {
4107 let inputs = BallisticInputs {
4108 use_rk4,
4109 use_adaptive_rk45,
4110 enable_trajectory_sampling,
4111 sample_interval: 0.5,
4112 ground_threshold: f64::NEG_INFINITY,
4113 ..BallisticInputs::default()
4114 };
4115 let mut solver = TrajectorySolver::new(
4116 inputs,
4117 WindConditions::default(),
4118 AtmosphericConditions::default(),
4119 );
4120 solver.set_max_range(2.0);
4121 solver.solve().expect("normal short-range solve")
4122 };
4123
4124 let baseline = solve(false);
4125 let sampled = solve(true);
4126 for (field, left, right) in [
4127 ("max_range", baseline.max_range, sampled.max_range),
4128 ("max_height", baseline.max_height, sampled.max_height),
4129 (
4130 "time_of_flight",
4131 baseline.time_of_flight,
4132 sampled.time_of_flight,
4133 ),
4134 (
4135 "impact_velocity",
4136 baseline.impact_velocity,
4137 sampled.impact_velocity,
4138 ),
4139 (
4140 "impact_energy",
4141 baseline.impact_energy,
4142 sampled.impact_energy,
4143 ),
4144 ] {
4145 assert_eq!(
4146 left.to_bits(),
4147 right.to_bits(),
4148 "{mode} sampling changed {field}"
4149 );
4150 }
4151 assert_eq!(baseline.points.len(), sampled.points.len());
4152 for (index, (left, right)) in baseline
4153 .points
4154 .iter()
4155 .zip(&sampled.points)
4156 .enumerate()
4157 {
4158 assert_eq!(left.time.to_bits(), right.time.to_bits(), "{mode} point {index}");
4159 assert_eq!(
4160 left.position.map(f64::to_bits),
4161 right.position.map(f64::to_bits),
4162 "{mode} point {index} position"
4163 );
4164 assert_eq!(
4165 left.velocity_magnitude.to_bits(),
4166 right.velocity_magnitude.to_bits(),
4167 "{mode} point {index} velocity"
4168 );
4169 assert_eq!(
4170 left.kinetic_energy.to_bits(),
4171 right.kinetic_energy.to_bits(),
4172 "{mode} point {index} energy"
4173 );
4174 }
4175 assert!(baseline.sampled_points.is_none());
4176 let samples = sampled
4177 .sampled_points
4178 .expect("sampling-enabled solve should return observations");
4179 assert_eq!(
4180 samples
4181 .iter()
4182 .map(|sample| sample.distance_m)
4183 .collect::<Vec<_>>(),
4184 vec![0.0, 0.5, 1.0, 1.5, 2.0],
4185 "{mode} normal sampling grid changed"
4186 );
4187 }
4188 }
4189}
4190
4191#[cfg(test)]
4192mod monte_carlo_result_tests {
4193 use super::*;
4194
4195 fn make_results(impact_positions: Vec<Vector3<f64>>) -> MonteCarloResults {
4196 let count = impact_positions.len();
4197 MonteCarloResults {
4198 ranges: vec![500.0; count],
4199 impact_velocities: vec![300.0; count],
4200 impact_positions,
4201 }
4202 }
4203
4204 #[test]
4205 fn target_plane_cep_excludes_shortfall_markers() {
4206 let mut positions: Vec<Vector3<f64>> = (1..=5)
4207 .map(|radius| Vector3::new(0.0, radius as f64, 0.0))
4208 .collect();
4209 positions.extend(
4210 (0..5).map(|_| Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)),
4211 );
4212 let results = make_results(positions);
4213
4214 assert_eq!(results.target_arrival_count(), 5);
4215 assert_eq!(results.target_shortfall_fraction(), 0.5);
4216 assert_eq!(results.target_plane_cep(), Some(3.0));
4217
4218 let one_shortfall = make_results(vec![
4219 Vector3::new(0.0, 1.0, 0.0),
4220 Vector3::new(0.0, 2.0, 0.0),
4221 Vector3::new(0.0, 3.0, 0.0),
4222 Vector3::new(0.0, 4.0, 0.0),
4223 Vector3::new(0.0, 5.0, 0.0),
4224 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4225 ]);
4226 assert_eq!(one_shortfall.target_plane_cep(), Some(3.0));
4227 }
4228
4229 #[test]
4230 fn all_shortfalls_have_no_cep_but_still_count_as_misses() {
4231 let all_shortfalls = make_results(vec![
4232 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4233 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4234 ]);
4235 assert_eq!(all_shortfalls.target_arrival_count(), 0);
4236 assert_eq!(all_shortfalls.target_shortfall_fraction(), 1.0);
4237 assert_eq!(all_shortfalls.target_plane_cep(), None);
4238 assert_eq!(all_shortfalls.hit_probability(0.3), 0.0);
4239
4240 let one_hit_one_shortfall = make_results(vec![
4241 Vector3::new(0.0, 0.1, 0.0),
4242 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4243 ]);
4244 assert_eq!(one_hit_one_shortfall.hit_probability(0.3), 0.5);
4245 }
4246
4247 #[test]
4249 fn rect_hit_probability_checks_independent_axis_halves() {
4250 let results = make_results(vec![
4251 Vector3::new(0.0, 0.1, 0.1),
4253 Vector3::new(0.0, 0.0, 0.2),
4255 Vector3::new(0.0, 0.0, 0.201),
4257 Vector3::new(0.0, 0.301, 0.0),
4259 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4261 ]);
4262 assert!((results.rect_hit_probability(0.4, 0.6) - 0.4).abs() < 1e-12);
4264 }
4265
4266 #[test]
4267 fn rect_hit_probability_matches_circular_hit_probability_for_a_centered_hit() {
4268 let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
4269 assert_eq!(results.rect_hit_probability(0.5, 0.5), 1.0);
4270 assert_eq!(results.hit_probability(0.3), 1.0);
4271 }
4272
4273 #[test]
4274 fn rect_hit_probability_is_zero_for_empty_or_nonpositive_dimensions() {
4275 let empty = make_results(vec![]);
4276 assert_eq!(empty.rect_hit_probability(1.0, 1.0), 0.0);
4277
4278 let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
4279 assert_eq!(results.rect_hit_probability(0.0, 1.0), 0.0);
4280 assert_eq!(results.rect_hit_probability(1.0, 0.0), 0.0);
4281 assert_eq!(results.rect_hit_probability(-1.0, 1.0), 0.0);
4282 }
4283}
4284
4285#[cfg(test)]
4286mod monte_carlo_seeded_tests {
4287 use super::*;
4288
4289 #[test]
4290 fn seeded_runs_are_deterministic_and_match_the_using_rng_path() {
4291 let inputs = BallisticInputs {
4292 muzzle_velocity: 800.0,
4293 ..BallisticInputs::default()
4294 };
4295 let params = MonteCarloParams {
4296 num_simulations: 64,
4297 target_distance: Some(200.0),
4298 ..MonteCarloParams::default()
4299 };
4300
4301 let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4302 inputs.clone(),
4303 WindConditions::default(),
4304 params.clone(),
4305 0.01,
4306 42,
4307 )
4308 .expect("seeded run a");
4309 let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4310 inputs,
4311 WindConditions::default(),
4312 params,
4313 0.01,
4314 42,
4315 )
4316 .expect("seeded run b");
4317
4318 assert_eq!(a.ranges.len(), b.ranges.len());
4319 for (ra, rb) in a.ranges.iter().zip(b.ranges.iter()) {
4320 assert_eq!(ra.to_bits(), rb.to_bits());
4321 }
4322 for (pa, pb) in a.impact_positions.iter().zip(b.impact_positions.iter()) {
4323 assert_eq!(pa.x.to_bits(), pb.x.to_bits());
4324 assert_eq!(pa.y.to_bits(), pb.y.to_bits());
4325 assert_eq!(pa.z.to_bits(), pb.z.to_bits());
4326 }
4327 }
4328
4329 #[test]
4330 fn different_seeds_generally_produce_different_draws() {
4331 let inputs = BallisticInputs {
4332 muzzle_velocity: 800.0,
4333 ..BallisticInputs::default()
4334 };
4335 let params = MonteCarloParams {
4336 num_simulations: 32,
4337 velocity_std_dev: 5.0,
4338 target_distance: Some(200.0),
4339 ..MonteCarloParams::default()
4340 };
4341
4342 let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4343 inputs.clone(),
4344 WindConditions::default(),
4345 params.clone(),
4346 0.0,
4347 1,
4348 )
4349 .expect("seeded run a");
4350 let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4351 inputs,
4352 WindConditions::default(),
4353 params,
4354 0.0,
4355 2,
4356 )
4357 .expect("seeded run b");
4358
4359 assert_ne!(a.impact_velocities, b.impact_velocities);
4360 }
4361}
4362
4363#[cfg(test)]
4364mod monte_carlo_powder_curve_tests {
4365 use super::*;
4366 use rand::{rngs::StdRng, SeedableRng};
4367
4368 #[test]
4369 fn powder_curve_preserves_sampled_muzzle_velocity_dispersion() {
4370 let inputs = BallisticInputs {
4371 muzzle_velocity: 700.0,
4372 powder_temp_curve: Some(vec![(15.0, 800.0)]),
4373 powder_curve_temp_c: Some(15.0),
4374 ..BallisticInputs::default()
4375 };
4376 let params = MonteCarloParams {
4377 num_simulations: 16,
4378 velocity_std_dev: 20.0,
4379 angle_std_dev: 1e-12,
4380 bc_std_dev: 1e-12,
4381 wind_speed_std_dev: 1e-12,
4382 target_distance: Some(100.0),
4383 azimuth_std_dev: 1e-12,
4384 ..MonteCarloParams::default()
4385 };
4386
4387 let mut rng = StdRng::seed_from_u64(0x5EED_1176);
4388 let results = run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
4389 inputs,
4390 WindConditions::default(),
4391 params,
4392 0.0,
4393 &mut rng,
4394 )
4395 .expect("Monte Carlo solve");
4396 let min_velocity = results
4397 .impact_velocities
4398 .iter()
4399 .copied()
4400 .fold(f64::INFINITY, f64::min);
4401 let max_velocity = results
4402 .impact_velocities
4403 .iter()
4404 .copied()
4405 .fold(f64::NEG_INFINITY, f64::max);
4406
4407 assert!(
4408 max_velocity - min_velocity > 1.0,
4409 "20 m/s muzzle spread collapsed after curve resolution: impact-velocity span={} m/s",
4410 max_velocity - min_velocity
4411 );
4412 }
4413}
4414
4415#[cfg(test)]
4416mod monte_carlo_wind_sampling_tests {
4417 use super::*;
4418 use rand::{rngs::StdRng, SeedableRng};
4419
4420 #[test]
4421 fn wind_speed_sigma_does_not_change_seeded_direction_draws() {
4422 let base_wind = WindConditions {
4423 speed: 100.0,
4424 direction: 0.37,
4425 vertical_speed: 0.0,
4426 };
4427 let narrow_speed = MonteCarloWindSampler::new(&base_wind, 0.5, 0.2).unwrap();
4428 let wide_speed = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
4429 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
4430 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
4431 let mut speed_changed = false;
4432
4433 for _ in 0..32 {
4434 let narrow = narrow_speed.sample(&mut narrow_rng);
4435 let wide = wide_speed.sample(&mut wide_rng);
4436 assert!(narrow.speed > 0.0 && wide.speed > 0.0);
4437 assert_eq!(narrow.direction.to_bits(), wide.direction.to_bits());
4438 speed_changed |= narrow.speed.to_bits() != wide.speed.to_bits();
4439 }
4440 assert!(
4441 speed_changed,
4442 "different speed sigmas must still vary speed draws"
4443 );
4444 }
4445
4446 #[test]
4447 fn zero_direction_sigma_has_no_angular_jitter() {
4448 let base_wind = WindConditions {
4449 speed: 100.0,
4450 direction: 0.37,
4451 vertical_speed: 0.0,
4452 };
4453 let sampler = MonteCarloWindSampler::new(&base_wind, 4.0, 0.0).unwrap();
4454 let mut rng = StdRng::seed_from_u64(0x5EED_1223);
4455 let mut speed_changed = false;
4456
4457 for _ in 0..32 {
4458 let wind = sampler.sample(&mut rng);
4459 speed_changed |= wind.speed.to_bits() != base_wind.speed.to_bits();
4460 assert_eq!(wind.direction.to_bits(), base_wind.direction.to_bits());
4461 }
4462 assert!(speed_changed, "speed uncertainty should remain active");
4463 }
4464
4465 #[test]
4466 fn direction_sigma_controls_seeded_angular_spread_in_radians() {
4467 let base_wind = WindConditions {
4468 speed: 100.0,
4469 direction: 0.37,
4470 vertical_speed: 0.0,
4471 };
4472 let narrow = MonteCarloWindSampler::new(&base_wind, 4.0, 0.1).unwrap();
4473 let wide = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
4474 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
4475 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
4476 let mut nonzero_direction_draw = false;
4477
4478 for _ in 0..32 {
4479 let narrow_wind = narrow.sample(&mut narrow_rng);
4480 let wide_wind = wide.sample(&mut wide_rng);
4481 assert_eq!(narrow_wind.speed.to_bits(), wide_wind.speed.to_bits());
4482
4483 let narrow_delta = narrow_wind.direction - base_wind.direction;
4484 let wide_delta = wide_wind.direction - base_wind.direction;
4485 assert!((wide_delta - 2.0 * narrow_delta).abs() < 1e-12);
4486 nonzero_direction_draw |= narrow_delta.abs() > 1e-6;
4487 }
4488 assert!(
4489 nonzero_direction_draw,
4490 "positive radians sigma must vary direction"
4491 );
4492 }
4493
4494 #[test]
4495 fn direction_sigma_rejects_negative_or_nonfinite_values() {
4496 let base_wind = WindConditions::default();
4497 for sigma in [-0.1, f64::NAN, f64::INFINITY] {
4498 assert!(MonteCarloWindSampler::new(&base_wind, 1.0, sigma).is_err());
4499 }
4500 }
4501
4502 #[test]
4503 fn base_vertical_wind_rides_into_every_mc_sample() {
4504 use rand::SeedableRng;
4508 let base_wind = WindConditions { vertical_speed: 4.2, ..Default::default() };
4509 let sampler = MonteCarloWindSampler::new(&base_wind, 1.0, 0.2).unwrap();
4510 let mut rng = rand::rngs::StdRng::seed_from_u64(7);
4511 for _ in 0..32 {
4512 let w = sampler.sample(&mut rng);
4513 assert_eq!(w.vertical_speed, 4.2);
4514 }
4515 }
4516
4517 #[test]
4518 fn negative_speed_sample_reverses_wind_direction() {
4519 let direction = 0.25;
4520 let signed_speed = -2.5;
4521 let wind = wind_from_signed_speed_sample(signed_speed, direction, 0.0);
4522 let positive_wind = wind_from_signed_speed_sample(2.5, direction, 0.0);
4523
4524 assert_eq!(wind.speed, 2.5);
4525 assert!(
4526 (wind.direction - (direction + std::f64::consts::PI)).abs() < f64::EPSILON,
4527 "negative speed must reverse direction by pi: got {}",
4528 wind.direction
4529 );
4530 assert_eq!(positive_wind.speed, 2.5);
4531 assert_eq!(positive_wind.direction, direction);
4532
4533 let normalized_x = -wind.speed * wind.direction.cos();
4534 let normalized_z = -wind.speed * wind.direction.sin();
4535 let signed_x = -signed_speed * direction.cos();
4536 let signed_z = -signed_speed * direction.sin();
4537 assert!((normalized_x - signed_x).abs() < 1e-12);
4538 assert!((normalized_z - signed_z).abs() < 1e-12);
4539 }
4540}
4541
4542#[cfg(test)]
4543mod bc_fit_objective_tests {
4544 use super::*;
4545
4546 fn velocity_point(range_m: f64, velocity_mps: f64) -> TrajectoryPoint {
4547 TrajectoryPoint {
4548 time: 0.0,
4549 position: Vector3::new(range_m, 0.0, 0.0),
4550 velocity_magnitude: velocity_mps,
4551 kinetic_energy: 0.0,
4552 }
4553 }
4554
4555 #[test]
4556 fn candidate_that_misses_an_observation_has_no_score() {
4557 let trajectory = vec![velocity_point(0.0, 800.0), velocity_point(100.0, 700.0)];
4558 let observations = vec![(50.0, 750.0), (150.0, 600.0)];
4559
4560 assert!(
4561 fit_residual_sse(&trajectory, &observations, BcFitMode::Velocity, 0.0).is_none(),
4562 "a candidate that reaches only one of two observations must not compete on partial SSE"
4563 );
4564
4565 let complete_observations = vec![(50.0, 740.0), (100.0, 680.0)];
4566 assert_eq!(
4567 fit_residual_sse(
4568 &trajectory,
4569 &complete_observations,
4570 BcFitMode::Velocity,
4571 0.0,
4572 ),
4573 Some(500.0)
4574 );
4575 }
4576}
4577
4578#[cfg(test)]
4579mod cluster_bc_reference_space_tests {
4580 use super::*;
4581
4582 fn acceleration_at_1100_fps(inputs: BallisticInputs) -> Vector3<f64> {
4583 let solver = TrajectorySolver::new(
4584 inputs,
4585 WindConditions::default(),
4586 AtmosphericConditions::default(),
4587 );
4588 let position = Vector3::zeros();
4589 let velocity = Vector3::new(1100.0 / 3.28084, 0.0, 0.0);
4590 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4591 solver.calculate_acceleration(
4592 &position,
4593 &velocity,
4594 &Vector3::zeros(),
4595 (temp_c, pressure_hpa, density / 1.225),
4596 )
4597 }
4598
4599 #[test]
4600 fn solver_passes_g7_reference_model_to_cluster_classifier() {
4601 let inputs = BallisticInputs {
4602 bc_value: 0.190,
4603 bc_type: DragModel::G7,
4604 bullet_mass: 77.0 * crate::constants::GRAINS_TO_KG,
4605 bullet_diameter: 0.224 * 0.0254,
4606 use_cluster_bc: true,
4607 ..BallisticInputs::default()
4608 };
4609
4610 let solver = TrajectorySolver::new(
4611 inputs,
4612 WindConditions::default(),
4613 AtmosphericConditions::default(),
4614 );
4615 let corrected = solver.apply_cluster_bc_correction(0.190, 2800.0);
4616
4617 assert!(
4618 (corrected / 0.190 - 1.004).abs() < 1e-12,
4619 "solver selected the wrong G7 cluster multiplier: {}",
4620 corrected / 0.190
4621 );
4622 }
4623
4624 #[test]
4625 fn velocity_bc_segments_are_not_cluster_corrected_twice() {
4626 let segmented_clustered = BallisticInputs {
4627 bc_value: 0.5,
4628 bc_type: DragModel::G7,
4629 use_bc_segments: true,
4630 bc_segments_data: Some(vec![
4631 crate::BCSegmentData {
4632 velocity_min: 0.0,
4633 velocity_max: 1_600.0,
4634 bc_value: 0.4,
4635 },
4636 crate::BCSegmentData {
4637 velocity_min: 1_600.0,
4638 velocity_max: 5_000.0,
4639 bc_value: 0.45,
4640 },
4641 ]),
4642 use_cluster_bc: true,
4643 ..BallisticInputs::default()
4644 };
4645 let mut segmented_only = segmented_clustered.clone();
4646 segmented_only.use_cluster_bc = false;
4647 let mut constant_clustered = segmented_clustered.clone();
4648 constant_clustered.bc_value = 0.4;
4649 constant_clustered.bc_segments_data = None;
4650
4651 let stacked = acceleration_at_1100_fps(segmented_clustered);
4652 let segment_only = acceleration_at_1100_fps(segmented_only);
4653 let cluster_only = acceleration_at_1100_fps(constant_clustered);
4654
4655 assert!(
4656 (stacked.x - segment_only.x).abs() < 1e-12,
4657 "segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
4658 stacked.x,
4659 segment_only.x
4660 );
4661 assert!(
4662 (cluster_only.x - segment_only.x).abs() > 1e-6,
4663 "cluster correction must remain active for a constant BC"
4664 );
4665 }
4666
4667 #[test]
4668 fn mach_bc_segments_are_not_cluster_corrected_twice() {
4669 let mach_segmented_clustered = BallisticInputs {
4670 bc_value: 0.5,
4671 bc_type: DragModel::G7,
4672 use_bc_segments: false,
4673 bc_segments: Some(vec![(0.5, 0.3), (1.5, 0.5)]),
4674 use_cluster_bc: true,
4675 ..BallisticInputs::default()
4676 };
4677 let mut mach_segmented_only = mach_segmented_clustered.clone();
4678 mach_segmented_only.use_cluster_bc = false;
4679
4680 let stacked = acceleration_at_1100_fps(mach_segmented_clustered);
4681 let segment_only = acceleration_at_1100_fps(mach_segmented_only);
4682
4683 assert!(
4684 (stacked.x - segment_only.x).abs() < 1e-12,
4685 "Mach segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
4686 stacked.x,
4687 segment_only.x
4688 );
4689 }
4690}
4691
4692#[cfg(test)]
4693mod velocity_bc_flag_tests {
4694 use super::*;
4695
4696 fn acceleration_at_600_mps(inputs: BallisticInputs) -> Vector3<f64> {
4697 let solver = TrajectorySolver::new(
4698 inputs,
4699 WindConditions::default(),
4700 AtmosphericConditions::default(),
4701 );
4702 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4703 solver.calculate_acceleration(
4704 &Vector3::zeros(),
4705 &Vector3::new(600.0, 0.0, 0.0),
4706 &Vector3::zeros(),
4707 (temp_c, pressure_hpa, density / 1.225),
4708 )
4709 }
4710
4711 #[test]
4712 fn velocity_bc_data_requires_opt_in_in_trajectory_solver() {
4713 let scalar_inputs = BallisticInputs {
4714 bc_value: 0.5,
4715 bc_type: DragModel::G7,
4716 ..BallisticInputs::default()
4717 };
4718 let mut disabled_inputs = scalar_inputs.clone();
4719 disabled_inputs.bc_segments_data = Some(vec![crate::BCSegmentData {
4720 velocity_min: 0.0,
4721 velocity_max: 4_000.0,
4722 bc_value: 0.46,
4723 }]);
4724 disabled_inputs.use_bc_segments = false;
4725 let mut enabled_inputs = disabled_inputs.clone();
4726 enabled_inputs.use_bc_segments = true;
4727 let mut mach_only_inputs = scalar_inputs.clone();
4728 mach_only_inputs.bc_segments = Some(vec![(0.0, 0.4), (3.0, 0.4)]);
4729 let mut disabled_with_both = mach_only_inputs.clone();
4730 disabled_with_both.bc_segments_data = disabled_inputs.bc_segments_data.clone();
4731
4732 let scalar = acceleration_at_600_mps(scalar_inputs);
4733 let disabled = acceleration_at_600_mps(disabled_inputs);
4734 let enabled = acceleration_at_600_mps(enabled_inputs);
4735 let mach_only = acceleration_at_600_mps(mach_only_inputs);
4736 let disabled_with_both = acceleration_at_600_mps(disabled_with_both);
4737
4738 assert_eq!(
4739 disabled.x.to_bits(),
4740 scalar.x.to_bits(),
4741 "a populated velocity table must not change drag while use_bc_segments is false"
4742 );
4743 assert!(
4744 enabled.x < disabled.x - 1.0,
4745 "enabling the lower BC table must increase drag: disabled ax={} enabled ax={}",
4746 disabled.x,
4747 enabled.x
4748 );
4749 assert_eq!(
4750 disabled_with_both.x.to_bits(),
4751 mach_only.x.to_bits(),
4752 "disabling velocity data must fall through to an explicit Mach table"
4753 );
4754 }
4755}
4756
4757#[cfg(test)]
4758mod mach_bc_segment_tests {
4759 use super::*;
4760
4761 #[test]
4762 fn trajectory_solver_interpolates_explicit_mach_bc_segments() {
4763 let segmented_inputs = BallisticInputs {
4764 bc_value: 0.8,
4765 use_bc_segments: false,
4766 bc_segments: Some(vec![(1.0, 0.2), (2.0, 0.4)]),
4767 bc_segments_data: None,
4768 ..BallisticInputs::default()
4769 };
4770
4771 let mut expected_inputs = segmented_inputs.clone();
4772 expected_inputs.bc_value = 0.3;
4773 expected_inputs.bc_segments = None;
4774
4775 let atmosphere = AtmosphericConditions::default();
4776 let segmented_solver = TrajectorySolver::new(
4777 segmented_inputs,
4778 WindConditions::default(),
4779 atmosphere.clone(),
4780 );
4781 let expected_solver = TrajectorySolver::new(
4782 expected_inputs,
4783 WindConditions::default(),
4784 atmosphere,
4785 );
4786 let position = Vector3::zeros();
4787 let (density, _, temp_c, pressure_hpa) = segmented_solver.resolved_atmosphere();
4788 let (_, local_speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
4789 segmented_solver.atmosphere.altitude,
4790 segmented_solver.atmosphere.altitude,
4791 temp_c,
4792 pressure_hpa,
4793 density / 1.225,
4794 segmented_solver.atmosphere.humidity,
4795 );
4796 let velocity = Vector3::new(1.5 * local_speed_of_sound, 0.0, 0.0);
4797 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
4798
4799 let segmented_acceleration = segmented_solver.calculate_acceleration(
4800 &position,
4801 &velocity,
4802 &Vector3::zeros(),
4803 resolved_atmo,
4804 );
4805 let expected_acceleration = expected_solver.calculate_acceleration(
4806 &position,
4807 &velocity,
4808 &Vector3::zeros(),
4809 resolved_atmo,
4810 );
4811
4812 assert!(
4813 (segmented_acceleration.x - expected_acceleration.x).abs() < 1e-12,
4814 "Mach 1.5 must interpolate BC 0.3: segmented ax={} expected ax={}",
4815 segmented_acceleration.x,
4816 expected_acceleration.x
4817 );
4818 }
4819}
4820
4821#[cfg(test)]
4822mod custom_drag_table_validation_tests {
4823 use super::*;
4824
4825 #[test]
4826 fn solve_accepts_zero_bc_when_custom_table_present() {
4827 let inputs = BallisticInputs {
4828 bc_value: 0.0, bullet_mass: 0.0106,
4830 bullet_diameter: 0.00782,
4831 muzzle_velocity: 850.0,
4832 custom_drag_table: Some(crate::drag::DragTable::new(
4833 vec![0.5, 1.0, 2.0, 3.0],
4834 vec![0.23, 0.40, 0.30, 0.26],
4835 )),
4836 ..BallisticInputs::default()
4837 };
4838 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
4839 assert!(solver.solve().is_ok());
4841 }
4842
4843 #[test]
4844 fn solve_still_requires_bc_without_table() {
4845 let inputs = BallisticInputs {
4846 bc_value: 0.0,
4847 bullet_mass: 0.0106,
4848 bullet_diameter: 0.00782,
4849 muzzle_velocity: 850.0,
4850 ..BallisticInputs::default()
4851 };
4852 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
4853 assert!(solver.solve().is_err());
4854 }
4855}
4856
4857#[cfg(test)]
4858mod humid_local_mach_tests {
4859 use super::*;
4860
4861 fn solver_with_station_humidity(humidity_percent: f64) -> TrajectorySolver {
4862 let inputs = BallisticInputs {
4863 custom_drag_table: Some(crate::drag::DragTable::new(vec![0.5, 1.5], vec![0.1, 1.1])),
4864 ..BallisticInputs::default()
4865 };
4866 TrajectorySolver::new(
4867 inputs,
4868 WindConditions::default(),
4869 AtmosphericConditions {
4870 temperature: 30.0,
4871 pressure: 1013.25,
4872 humidity: humidity_percent,
4873 altitude: 0.0,
4874 },
4875 )
4876 }
4877
4878 fn acceleration(solver: &TrajectorySolver, base_ratio: f64) -> Vector3<f64> {
4879 solver.calculate_acceleration(
4880 &Vector3::zeros(),
4881 &Vector3::new(350.0, 0.0, 0.0),
4882 &Vector3::zeros(),
4883 (30.0, 1013.25, base_ratio),
4884 )
4885 }
4886
4887 #[test]
4888 fn local_mach_uses_station_humidity_when_density_is_held_constant() {
4889 let dry = acceleration(&solver_with_station_humidity(0.0), 1.0);
4890 let humid = acceleration(&solver_with_station_humidity(100.0), 1.0);
4891
4892 assert!(
4893 humid.x > dry.x,
4894 "humid sound speed should lower Mach and drag on the rising test curve: dry ax={} humid ax={}",
4895 dry.x,
4896 humid.x
4897 );
4898 }
4899
4900 #[test]
4901 fn active_atmosphere_zone_uses_zone_humidity_instead_of_station_humidity() {
4902 let zone_humidity = 80.0;
4903 let zone_ratio =
4904 crate::atmosphere::calculate_air_density_cimp(30.0, 1013.25, zone_humidity) / 1.225;
4905 let station_solver = solver_with_station_humidity(zone_humidity);
4906 let mut zoned_solver = solver_with_station_humidity(0.0);
4907 zoned_solver.set_atmo_segments(vec![(30.0, 1013.25, zone_humidity, 1_000.0)]);
4908
4909 let station = acceleration(&station_solver, zone_ratio);
4910 let zoned = acceleration(&zoned_solver, zone_ratio);
4911
4912 assert!(
4913 (zoned - station).norm() < 1e-12,
4914 "active zone T/P/RH should override the station atmosphere: station={station:?} zoned={zoned:?}"
4915 );
4916 }
4917}
4918
4919#[cfg(test)]
4920mod inclined_atmosphere_frame_tests {
4921 use super::*;
4922
4923 fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
4924 let (sin_angle, cos_angle) = angle.sin_cos();
4925 Vector3::new(
4926 level.x * cos_angle + level.y * sin_angle,
4927 -level.x * sin_angle + level.y * cos_angle,
4928 level.z,
4929 )
4930 }
4931
4932 #[test]
4933 fn inclined_positions_at_same_world_altitude_have_same_solver_acceleration() {
4934 let angle = std::f64::consts::FRAC_PI_6;
4935 let inputs = BallisticInputs {
4936 shooting_angle: angle,
4937 ..BallisticInputs::default()
4938 };
4939 let atmosphere = AtmosphericConditions {
4940 altitude: 100.0,
4941 ..AtmosphericConditions::default()
4942 };
4943 let solver = TrajectorySolver::new(inputs, WindConditions::default(), atmosphere);
4944 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4945 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
4946 let velocity = Vector3::new(600.0, 0.0, 0.0);
4947 let along_slant = Vector3::new(1_000.0, 0.0, 0.0);
4948 let across_slant = Vector3::new(0.0, 500.0 / angle.cos(), 0.0);
4949
4950 let a = solver.calculate_acceleration(
4951 &along_slant,
4952 &velocity,
4953 &Vector3::zeros(),
4954 resolved_atmo,
4955 );
4956 let b = solver.calculate_acceleration(
4957 &across_slant,
4958 &velocity,
4959 &Vector3::zeros(),
4960 resolved_atmo,
4961 );
4962
4963 assert!(
4964 (a - b).norm() < 1e-10,
4965 "solver acceleration differs at equal world altitude: {a:?} vs {b:?}"
4966 );
4967 }
4968
4969 #[test]
4970 fn inclined_headwind_is_rotated_into_solver_frame() {
4971 let angle = std::f64::consts::FRAC_PI_6;
4972 let inputs = BallisticInputs {
4973 shooting_angle: angle,
4974 ..BallisticInputs::default()
4975 };
4976 let solver = TrajectorySolver::new(
4977 inputs,
4978 WindConditions::default(),
4979 AtmosphericConditions::default(),
4980 );
4981 let level_headwind = Vector3::new(-100.0, 0.0, 0.0);
4982 let velocity = expected_shot_frame_vector(level_headwind, angle);
4983 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4984 let actual = solver.calculate_acceleration(
4985 &Vector3::zeros(),
4986 &velocity,
4987 &level_headwind,
4988 (temp_c, pressure_hpa, density / 1.225),
4989 );
4990
4991 assert!(
4992 (actual - solver.gravity_acceleration()).norm() < 1e-12,
4993 "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
4994 );
4995 }
4996
4997 #[test]
4998 fn inclined_coriolis_is_rotated_into_solver_frame() {
4999 let angle = std::f64::consts::FRAC_PI_6;
5000 let latitude_deg = 45.0_f64;
5001 let shot_azimuth = 0.4_f64;
5002 let velocity = Vector3::new(600.0, 20.0, 5.0);
5003 let base_inputs = BallisticInputs {
5004 shooting_angle: angle,
5005 latitude: Some(latitude_deg),
5006 shot_azimuth,
5007 ..BallisticInputs::default()
5008 };
5009 let acceleration = |enable_coriolis| {
5010 let mut inputs = base_inputs.clone();
5011 inputs.enable_coriolis = enable_coriolis;
5012 let solver = TrajectorySolver::new(
5013 inputs,
5014 WindConditions::default(),
5015 AtmosphericConditions::default(),
5016 );
5017 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5018 solver.calculate_acceleration(
5019 &Vector3::zeros(),
5020 &velocity,
5021 &Vector3::zeros(),
5022 (temp_c, pressure_hpa, density / 1.225),
5023 )
5024 };
5025
5026 let omega_earth = 7.2921159e-5_f64;
5027 let latitude = latitude_deg.to_radians();
5028 let level_omega = Vector3::new(
5029 omega_earth * latitude.cos() * shot_azimuth.cos(),
5030 omega_earth * latitude.sin(),
5031 -omega_earth * latitude.cos() * shot_azimuth.sin(),
5032 );
5033 let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
5034 let actual = acceleration(true) - acceleration(false);
5035
5036 assert!(
5037 (actual - expected).norm() < 1e-12,
5038 "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
5039 );
5040 }
5041}
5042
5043#[cfg(test)]
5044mod terminal_range_interpolation_tests {
5045 use super::*;
5046
5047 #[test]
5048 fn terminal_finalizer_selects_the_earliest_crossed_boundary() {
5049 let inputs = BallisticInputs {
5050 ground_threshold: 0.0,
5051 ..BallisticInputs::default()
5052 };
5053 let mut solver = TrajectorySolver::new(
5054 inputs,
5055 WindConditions::default(),
5056 AtmosphericConditions::default(),
5057 );
5058 solver.set_max_range(120.0);
5059
5060 let previous_speed = 700.0;
5061 let mut points = vec![TrajectoryPoint {
5062 time: 99.0,
5063 position: Vector3::new(90.0, 1.0, -1.0),
5064 velocity_magnitude: previous_speed,
5065 kinetic_energy: 0.5 * solver.inputs.bullet_mass * previous_speed.powi(2),
5066 }];
5067 let mut max_height = 1.0;
5068 let termination = solver
5069 .append_terminal_endpoint(
5070 &mut points,
5071 Vector3::new(130.0, -3.0, 3.0),
5072 Vector3::new(600.0, 0.0, 0.0),
5073 101.0,
5074 &mut max_height,
5075 )
5076 .expect("the final step brackets supported boundaries");
5077
5078 assert_eq!(termination, TrajectoryTermination::GroundThreshold);
5079 assert_eq!(points.len(), 2);
5080 let terminal = points.last().expect("terminal point");
5081 assert_eq!(terminal.time, 99.5);
5082 assert_eq!(terminal.position, Vector3::new(100.0, 0.0, 0.0));
5083 assert_eq!(terminal.velocity_magnitude, 675.0);
5084 assert_eq!(
5085 terminal.kinetic_energy,
5086 0.5 * solver.inputs.bullet_mass * 675.0_f64.powi(2)
5087 );
5088
5089 solver.set_max_range(100.0);
5091 let mut tied_points = vec![points[0].clone()];
5092 assert_eq!(
5093 solver
5094 .append_terminal_endpoint(
5095 &mut tied_points,
5096 Vector3::new(130.0, -3.0, 3.0),
5097 Vector3::new(600.0, 0.0, 0.0),
5098 101.0,
5099 &mut max_height,
5100 )
5101 .expect("tied boundaries remain a valid terminal"),
5102 TrajectoryTermination::GroundThreshold
5103 );
5104 }
5105
5106 #[test]
5107 fn sub_ulp_terminal_crossing_replaces_instead_of_duplicating_range() {
5108 let ground_threshold = f64::from_bits(1.0_f64.to_bits() - 1);
5109 let inputs = BallisticInputs {
5110 ground_threshold,
5111 ..BallisticInputs::default()
5112 };
5113 let mut solver = TrajectorySolver::new(
5114 inputs,
5115 WindConditions::default(),
5116 AtmosphericConditions::default(),
5117 );
5118 solver.set_max_range(1_000.0);
5119
5120 let speed = 700.0;
5121 let mut points = vec![TrajectoryPoint {
5122 time: 0.0,
5123 position: Vector3::new(100.0, 1.0, 0.0),
5124 velocity_magnitude: speed,
5125 kinetic_energy: 0.5 * solver.inputs.bullet_mass * speed.powi(2),
5126 }];
5127 let mut max_height = 1.0;
5128 let termination = solver
5129 .append_terminal_endpoint(
5130 &mut points,
5131 Vector3::new(101.0, 0.0, 0.0),
5132 Vector3::new(699.0, 0.0, 0.0),
5133 1.0,
5134 &mut max_height,
5135 )
5136 .expect("sub-ULP ground crossing remains representable as one terminal state");
5137
5138 assert_eq!(termination, TrajectoryTermination::GroundThreshold);
5139 assert_eq!(points.len(), 1);
5140 assert_eq!(points[0].position.x, 100.0);
5141 assert_eq!(points[0].position.y.to_bits(), ground_threshold.to_bits());
5142 assert!(points[0].time > 0.0);
5143 }
5144
5145 #[test]
5146 fn every_solver_appends_an_exact_max_range_endpoint() {
5147 let target_range = 0.1;
5148 let modes = [
5149 ("Euler", false, false),
5150 ("RK4", true, false),
5151 ("RK45", true, true),
5152 ];
5153
5154 for (name, use_rk4, use_adaptive_rk45) in modes {
5155 let inputs = BallisticInputs {
5156 use_rk4,
5157 use_adaptive_rk45,
5158 ground_threshold: f64::NEG_INFINITY,
5159 enable_trajectory_sampling: true,
5160 sample_interval: target_range,
5161 ..BallisticInputs::default()
5162 };
5163 let mut solver = TrajectorySolver::new(
5164 inputs,
5165 WindConditions::default(),
5166 AtmosphericConditions::default(),
5167 );
5168 solver.set_max_range(target_range);
5169
5170 let result = solver.solve().expect("short-range solve should succeed");
5171 let terminal = result.points.last().expect("terminal point is missing");
5172 let muzzle = result.points.first().expect("muzzle point is missing");
5173
5174 assert_eq!(result.termination, TrajectoryTermination::MaxRange);
5175 assert_eq!(
5176 terminal.position.x.to_bits(),
5177 target_range.to_bits(),
5178 "{name} did not terminate exactly at max_range"
5179 );
5180 assert_eq!(result.max_range.to_bits(), target_range.to_bits());
5181 assert!(
5182 result.time_of_flight > 0.0 && result.time_of_flight < solver.time_step,
5183 "{name} terminal time was not interpolated within the crossing step: {}",
5184 result.time_of_flight
5185 );
5186 assert_eq!(result.time_of_flight.to_bits(), terminal.time.to_bits());
5187 assert_eq!(
5188 result.impact_velocity.to_bits(),
5189 terminal.velocity_magnitude.to_bits()
5190 );
5191 assert_eq!(
5192 result.impact_energy.to_bits(),
5193 terminal.kinetic_energy.to_bits()
5194 );
5195 let expected_energy = 0.5 * solver.inputs.bullet_mass * result.impact_velocity.powi(2);
5196 assert!((result.impact_energy - expected_energy).abs() < 1e-12);
5197 assert!(terminal.velocity_magnitude < muzzle.velocity_magnitude);
5198 assert!(terminal.kinetic_energy < muzzle.kinetic_energy);
5199
5200 let terminal_sample = result
5201 .sampled_points
5202 .as_ref()
5203 .and_then(|samples| samples.last())
5204 .expect("terminal trajectory sample is missing");
5205 assert_eq!(
5206 terminal_sample.distance_m.to_bits(),
5207 target_range.to_bits(),
5208 "{name} sampling did not include max_range"
5209 );
5210 assert_eq!(
5211 terminal_sample.time_s.to_bits(),
5212 result.time_of_flight.to_bits()
5213 );
5214 assert_eq!(
5215 terminal_sample.velocity_mps.to_bits(),
5216 result.impact_velocity.to_bits()
5217 );
5218 assert!((terminal_sample.energy_j - result.impact_energy).abs() < 1e-12);
5219 }
5220 }
5221}
5222
5223#[cfg(test)]
5224mod precession_inertia_wiring_tests {
5225 use super::*;
5226
5227 #[test]
5228 fn solver_uses_projectile_specific_moments_of_inertia() {
5229 let mass_kg = 55.0 * crate::constants::GRAINS_TO_KG;
5230 let caliber_m = 0.224 * 0.0254;
5231 let length_m = 0.75 * 0.0254;
5232 let inputs = BallisticInputs {
5233 bullet_mass: mass_kg,
5234 bullet_diameter: caliber_m,
5235 bullet_length: length_m,
5236 muzzle_velocity: 800.0,
5237 twist_rate: 7.0,
5238 enable_precession_nutation: true,
5239 use_rk4: false,
5240 use_adaptive_rk45: false,
5241 ..BallisticInputs::default()
5242 };
5243 let mut solver = TrajectorySolver::new(
5244 inputs,
5245 WindConditions::default(),
5246 AtmosphericConditions::default(),
5247 );
5248 solver.set_max_range(0.1);
5249
5250 let (air_density, speed_of_sound, _, _) = solver.resolved_atmosphere();
5251 let velocity_mps = solver.inputs.muzzle_velocity;
5252 let velocity_fps = velocity_mps * 3.28084;
5253 let twist_rate_ft = solver.inputs.twist_rate / 12.0;
5254 let spin_rate_rad_s = (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI;
5255 let initial_state = AngularState {
5256 pitch_angle: 0.001,
5257 yaw_angle: 0.001,
5258 pitch_rate: 0.0,
5259 yaw_rate: 0.0,
5260 precession_angle: 0.0,
5261 nutation_phase: 0.0,
5262 };
5263 let params = PrecessionNutationParams {
5264 mass_kg,
5265 caliber_m,
5266 length_m,
5267 spin_rate_rad_s,
5268 spin_inertia: crate::spin_decay::calculate_moment_of_inertia(
5269 mass_kg, caliber_m, length_m, "ogive",
5270 ),
5271 transverse_inertia: crate::pitch_damping::calculate_transverse_moment_of_inertia(
5272 mass_kg, caliber_m, length_m, "ogive",
5273 ),
5274 velocity_mps,
5275 air_density_kg_m3: air_density,
5276 mach: velocity_mps / speed_of_sound,
5277 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
5278 nutation_damping_factor: 0.05,
5279 };
5280 let expected = calculate_combined_angular_motion(
5281 ¶ms,
5282 &initial_state,
5283 0.0,
5284 solver.time_step,
5285 0.001,
5286 );
5287 let actual = solver
5288 .solve()
5289 .expect("one-step solve should succeed")
5290 .angular_state
5291 .expect("precession/nutation was enabled");
5292
5293 assert!(
5294 (actual.precession_angle - expected.precession_angle).abs() < 1e-15,
5295 "precession phase used the wrong inertia: actual={}, expected={}",
5296 actual.precession_angle,
5297 expected.precession_angle
5298 );
5299 assert!(
5300 (actual.nutation_phase - expected.nutation_phase).abs() < 1e-15,
5301 "nutation phase used the wrong inertia: actual={}, expected={}",
5302 actual.nutation_phase,
5303 expected.nutation_phase
5304 );
5305 }
5306}
5307
5308#[cfg(test)]
5309mod form_factor_drag_tests {
5310 use super::*;
5311
5312 fn acceleration_with_form_factor_flag(enabled: bool) -> Vector3<f64> {
5313 let inputs = BallisticInputs {
5314 bc_value: 0.462,
5315 bc_type: DragModel::G1,
5316 bullet_model: Some("168gr SMK Match".to_string()),
5317 use_form_factor: enabled,
5318 ..BallisticInputs::default()
5319 };
5320 let solver = TrajectorySolver::new(
5321 inputs,
5322 WindConditions::default(),
5323 AtmosphericConditions::default(),
5324 );
5325 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5326 solver.calculate_acceleration(
5327 &Vector3::zeros(),
5328 &Vector3::new(600.0, 0.0, 0.0),
5329 &Vector3::zeros(),
5330 (temp_c, pressure_hpa, density / 1.225),
5331 )
5332 }
5333
5334 #[test]
5335 fn measured_bc_drag_does_not_apply_name_based_form_factor_again() {
5336 let baseline = acceleration_with_form_factor_flag(false);
5337 let flagged = acceleration_with_form_factor_flag(true);
5338
5339 assert!(
5340 (flagged - baseline).norm() < 1e-12,
5341 "published BC already encodes form factor: baseline={baseline:?} flagged={flagged:?}"
5342 );
5343 }
5344}
5345
5346#[cfg(test)]
5347mod rk45_adaptivity_tests {
5348 use super::*;
5349
5350 #[test]
5351 fn cli_rk45_error_norm_scales_components_independently() {
5352 let position = Vector3::new(1.0e9, 0.0, 0.0);
5353 let velocity = Vector3::new(800.0, 0.0, 0.0);
5354 let fifth_position = position;
5355 let fifth_velocity = velocity;
5356 let fourth_position = position;
5357 let fourth_velocity = Vector3::new(800.0, 1.0e-3, 0.0);
5358
5359 let error = cli_rk45_error_norm(
5360 &position,
5361 &velocity,
5362 &fifth_position,
5363 &fifth_velocity,
5364 &fourth_position,
5365 &fourth_velocity,
5366 );
5367 let expected = 1.0e-3 / 6.0_f64.sqrt();
5368
5369 assert!(
5370 (error - expected).abs() <= 1e-15,
5371 "large downrange position masked a velocity-component error: {error}"
5372 );
5373 }
5374
5375 fn discontinuous_wind_solver() -> TrajectorySolver {
5376 let inputs = BallisticInputs::default();
5377 let mut solver = TrajectorySolver::new(
5378 inputs,
5379 WindConditions::default(),
5380 AtmosphericConditions::default(),
5381 );
5382 solver.set_wind_segments(vec![
5383 crate::wind::WindSegment::new(0.0, 90.0, 4.0),
5384 crate::wind::WindSegment::new(1_000.0, 90.0, 10_000.0),
5385 ]);
5386 solver
5387 }
5388
5389 #[test]
5390 fn rk45_retries_discontinuous_trial_before_advancing() {
5391 let solver = discontinuous_wind_solver();
5392 let position = Vector3::new(0.0, solver.inputs.muzzle_height, 0.0);
5393 let velocity = Vector3::new(solver.inputs.muzzle_velocity, 0.0, 0.0);
5394 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5395 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
5396 let dt = 0.01;
5397
5398 let rejected_trial = solver.rk45_step(
5399 &position,
5400 &velocity,
5401 dt,
5402 &Vector3::zeros(),
5403 RK45_TOLERANCE,
5404 resolved_atmo,
5405 );
5406 assert!(
5407 rejected_trial.error > RK45_TOLERANCE,
5408 "discontinuous full step must exceed tolerance, got {}",
5409 rejected_trial.error
5410 );
5411
5412 let accepted = solver.adaptive_rk45_step(
5413 &position,
5414 &velocity,
5415 dt,
5416 &Vector3::zeros(),
5417 resolved_atmo,
5418 );
5419 assert!(accepted.used_dt < dt, "oversized trial was not retried");
5420 assert!(
5421 accepted.error <= RK45_TOLERANCE || accepted.used_dt <= RK45_MIN_DT,
5422 "accepted error {} exceeds tolerance at dt {}",
5423 accepted.error,
5424 accepted.used_dt
5425 );
5426
5427 let accepted_trial = solver.rk45_step(
5428 &position,
5429 &velocity,
5430 accepted.used_dt,
5431 &Vector3::zeros(),
5432 RK45_TOLERANCE,
5433 resolved_atmo,
5434 );
5435 assert_eq!(accepted.position, accepted_trial.position);
5436 assert_eq!(accepted.velocity, accepted_trial.velocity);
5437 assert!((RK45_MIN_DT..=RK45_MAX_DT).contains(&accepted.next_dt));
5438 }
5439}
5440
5441#[cfg(test)]
5442mod ground_termination_tests {
5443 use super::*;
5444 use crate::trajectory_observation::TrajectoryObservationFlag;
5445
5446 #[test]
5447 fn every_solver_reports_one_exact_early_ground_endpoint() {
5448 for (name, use_rk4, use_adaptive_rk45) in [
5449 ("Euler", false, false),
5450 ("RK4", true, false),
5451 ("RK45", true, true),
5452 ] {
5453 let inputs = BallisticInputs {
5454 muzzle_height: 1.0,
5455 muzzle_angle: -0.2,
5456 ground_threshold: 0.0,
5457 use_rk4,
5458 use_adaptive_rk45,
5459 ..BallisticInputs::default()
5460 };
5461 let mut solver = TrajectorySolver::new(
5462 inputs,
5463 WindConditions::default(),
5464 AtmosphericConditions::default(),
5465 );
5466 solver.set_max_range(1_000.0);
5467
5468 let result = solver.solve().expect("early-ground solve should succeed");
5469 let terminal = result.points.last().expect("terminal point is missing");
5470
5471 assert_eq!(result.termination, TrajectoryTermination::GroundThreshold);
5472 assert_eq!(terminal.position.y.to_bits(), 0.0_f64.to_bits());
5473 assert!(
5474 terminal.position.x < 1_000.0,
5475 "{name} incorrectly reached max range"
5476 );
5477 assert_eq!(result.max_range.to_bits(), terminal.position.x.to_bits());
5478 assert_eq!(
5479 result
5480 .points
5481 .iter()
5482 .filter(|point| point.position.y == 0.0)
5483 .count(),
5484 1,
5485 "{name} did not retain exactly one ground endpoint"
5486 );
5487
5488 let observations = result
5489 .sample_observations(1.0, 100)
5490 .expect("checked early-ground sampling should succeed");
5491 assert!(observations[..observations.len() - 1]
5492 .iter()
5493 .all(|observation| observation.distance_m < terminal.position.x));
5494 let terminal_observation = observations.last().expect("terminal observation");
5495 assert_eq!(
5496 terminal_observation.distance_m.to_bits(),
5497 terminal.position.x.to_bits()
5498 );
5499 assert!(terminal_observation
5500 .flags
5501 .contains(&TrajectoryObservationFlag::Terminal));
5502 assert!(terminal_observation
5503 .flags
5504 .contains(&TrajectoryObservationFlag::GroundThreshold));
5505 assert_eq!(
5506 observations
5507 .iter()
5508 .filter(|observation| observation
5509 .flags
5510 .contains(&TrajectoryObservationFlag::Terminal))
5511 .count(),
5512 1,
5513 "{name} repeated the terminal observation"
5514 );
5515 }
5516 }
5517
5518 #[test]
5523 fn rk4_and_rk45_descend_to_ground_threshold() {
5524 for adaptive in [false, true] {
5525 let inputs = BallisticInputs {
5526 muzzle_angle: 0.1, use_rk4: true,
5528 use_adaptive_rk45: adaptive,
5529 ..BallisticInputs::default()
5530 };
5531 assert_eq!(
5532 inputs.ground_threshold, -100.0,
5533 "default ground_threshold is -100 m"
5534 );
5535
5536 let mut solver = TrajectorySolver::new(
5537 inputs,
5538 WindConditions::default(),
5539 AtmosphericConditions::default(),
5540 );
5541 solver.set_max_range(1.0e7);
5543
5544 let result = solver.solve().expect("solve should succeed");
5545 let final_y = result
5546 .points
5547 .last()
5548 .expect("trajectory has points")
5549 .position
5550 .y;
5551 assert!(
5552 final_y < -1.0,
5553 "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
5554 past launch level toward the ground_threshold floor, not stop at y = 0"
5555 );
5556 }
5557 }
5558}
5559
5560#[cfg(test)]
5561mod magnus_stability_tests {
5562 use super::*;
5563
5564 #[test]
5565 fn yaw_of_repose_magnus_force_is_vertical_and_twist_signed() {
5566 let acceleration = |enable_magnus, is_twist_right| {
5567 let inputs = BallisticInputs {
5568 muzzle_velocity: 822.96,
5569 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
5570 bullet_diameter: 0.308 * 0.0254,
5571 bullet_length: 1.215 * 0.0254,
5572 twist_rate: 10.0,
5573 is_twist_right,
5574 enable_magnus,
5575 ..BallisticInputs::default()
5576 };
5577 let solver = TrajectorySolver::new(
5578 inputs,
5579 WindConditions::default(),
5580 AtmosphericConditions::default(),
5581 );
5582 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5583 solver.calculate_acceleration(
5584 &Vector3::zeros(),
5585 &Vector3::new(822.96, 0.0, 0.0),
5586 &Vector3::zeros(),
5587 (temp_c, pressure_hpa, density / 1.225),
5588 )
5589 };
5590
5591 let baseline = acceleration(false, true);
5592 let right_twist = acceleration(true, true) - baseline;
5593 let left_twist = acceleration(true, false) - baseline;
5594
5595 assert!(
5596 right_twist.y < 0.0,
5597 "right-hand Magnus must point down, got {right_twist:?}"
5598 );
5599 assert!(
5600 left_twist.y > 0.0,
5601 "left-hand Magnus must point up, got {left_twist:?}"
5602 );
5603 assert!((right_twist.y + left_twist.y).abs() < 1e-12);
5604 assert!(right_twist.x.abs() < 1e-12 && right_twist.z.abs() < 1e-12);
5605 assert!(left_twist.x.abs() < 1e-12 && left_twist.z.abs() < 1e-12);
5606 }
5607
5608 #[test]
5609 fn magnus_uses_velocity_corrected_muzzle_stability_gate() {
5610 let muzzle_velocity = 1_400.0 / 3.28084;
5611 let inputs = BallisticInputs {
5612 muzzle_velocity,
5613 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
5614 bullet_diameter: 0.308 * 0.0254,
5615 bullet_length: 1.215 * 0.0254,
5616 twist_rate: 15.0,
5617 enable_magnus: true,
5618 ..BallisticInputs::default()
5619 };
5620 let solver = TrajectorySolver::new(
5621 inputs.clone(),
5622 WindConditions::default(),
5623 AtmosphericConditions::default(),
5624 );
5625
5626 let bare_sg = crate::spin_drift::miller_stability(0.308, 168.0, 15.0, 1.215);
5627 let canonical_sg = solver.effective_spin_drift_sg();
5628 assert!(bare_sg > 1.0, "test requires bare Sg above the Magnus gate");
5629 assert!(
5630 canonical_sg < 1.0,
5631 "velocity-corrected Sg must be below the gate, got {canonical_sg}"
5632 );
5633
5634 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5635 let acceleration = solver.calculate_acceleration(
5636 &Vector3::zeros(),
5637 &Vector3::new(muzzle_velocity, 0.0, 0.0),
5638 &Vector3::zeros(),
5639 (temp_c, pressure_hpa, density / 1.225),
5640 );
5641 let mut baseline_inputs = inputs;
5642 baseline_inputs.enable_magnus = false;
5643 let baseline_solver = TrajectorySolver::new(
5644 baseline_inputs,
5645 WindConditions::default(),
5646 AtmosphericConditions::default(),
5647 );
5648 let baseline = baseline_solver.calculate_acceleration(
5649 &Vector3::zeros(),
5650 &Vector3::new(muzzle_velocity, 0.0, 0.0),
5651 &Vector3::zeros(),
5652 (temp_c, pressure_hpa, density / 1.225),
5653 );
5654
5655 assert_eq!(
5656 acceleration, baseline,
5657 "canonical Sg below 1 must suppress every Magnus acceleration component"
5658 );
5659 }
5660
5661 #[test]
5662 fn magnus_force_grows_as_fixed_spin_projectile_slows() {
5663 let inputs = BallisticInputs {
5664 muzzle_velocity: 800.0,
5665 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
5666 bullet_diameter: 0.308 * 0.0254,
5667 bullet_length: 1.215 * 0.0254,
5668 twist_rate: 12.0,
5669 enable_magnus: true,
5670 ..BallisticInputs::default()
5671 };
5672
5673 let magnus_acceleration = |speed_mps| {
5674 let evaluate = |enable_magnus| {
5675 let mut run_inputs = inputs.clone();
5676 run_inputs.enable_magnus = enable_magnus;
5677 let solver = TrajectorySolver::new(
5678 run_inputs,
5679 WindConditions::default(),
5680 AtmosphericConditions::default(),
5681 );
5682 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5683 solver
5684 .calculate_acceleration(
5685 &Vector3::zeros(),
5686 &Vector3::new(speed_mps, 0.0, 0.0),
5687 &Vector3::zeros(),
5688 (temp_c, pressure_hpa, density / 1.225),
5689 )
5690 .y
5691 };
5692 (evaluate(true) - evaluate(false)).abs()
5693 };
5694
5695 let fast = magnus_acceleration(200.0);
5696 let slow = magnus_acceleration(100.0);
5697 let ratio = slow / fast;
5698 let expected_ratio = 2.0_f64.powf(5.0 / 3.0);
5699
5700 assert!(fast > 0.0 && slow > 0.0, "fast={fast}, slow={slow}");
5701 assert!(
5702 (ratio - expected_ratio).abs() < 1e-3,
5703 "fixed-spin Magnus acceleration must grow downrange; slow/fast={ratio}, \
5704 expected={expected_ratio}"
5705 );
5706 }
5707}
5708
5709#[cfg(test)]
5710mod coriolis_direction_tests {
5711 use super::*;
5712 use std::f64::consts::FRAC_PI_2;
5713
5714 #[test]
5715 fn supersonic_crossing_flags_a_positive_range_sample() {
5716 use crate::trajectory_sampling::TrajectoryFlag;
5720
5721 for (solver_name, use_rk4, use_adaptive_rk45) in [
5722 ("Euler", false, false),
5723 ("RK4", true, false),
5724 ("RK45", true, true),
5725 ] {
5726 let inputs = BallisticInputs {
5727 muzzle_velocity: 850.0,
5728 bc_value: 0.2,
5729 bc_type: DragModel::G7,
5730 muzzle_angle: 0.03,
5731 enable_trajectory_sampling: true,
5732 sample_interval: 50.0,
5733 use_rk4,
5734 use_adaptive_rk45,
5735 ..BallisticInputs::default()
5736 };
5737 let mut solver = TrajectorySolver::new(
5738 inputs,
5739 WindConditions::default(),
5740 AtmosphericConditions::default(),
5741 );
5742 solver.set_max_range(2000.0);
5743 let samples = solver
5744 .solve()
5745 .expect("supersonic solve should succeed")
5746 .sampled_points
5747 .expect("sampling was enabled");
5748 let flagged_distances: Vec<_> = samples
5749 .iter()
5750 .filter(|sample| sample.flags.contains(&TrajectoryFlag::MachTransition))
5751 .map(|sample| sample.distance_m)
5752 .collect();
5753
5754 assert!(
5755 !flagged_distances.is_empty()
5756 && flagged_distances.iter().all(|distance| *distance > 0.0),
5757 "{solver_name} must flag genuine crossings only at positive range: {flagged_distances:?}"
5758 );
5759 }
5760 }
5761
5762 #[test]
5763 fn subsonic_launch_does_not_flag_a_muzzle_transition() {
5764 use crate::trajectory_sampling::TrajectoryFlag;
5765
5766 for (solver_name, use_rk4, use_adaptive_rk45) in [
5767 ("Euler", false, false),
5768 ("RK4", true, false),
5769 ("RK45", true, true),
5770 ] {
5771 let inputs = BallisticInputs {
5772 muzzle_velocity: 250.0,
5773 muzzle_angle: 0.02,
5774 enable_trajectory_sampling: true,
5775 sample_interval: 25.0,
5776 use_rk4,
5777 use_adaptive_rk45,
5778 ..BallisticInputs::default()
5779 };
5780 let mut solver = TrajectorySolver::new(
5781 inputs,
5782 WindConditions::default(),
5783 AtmosphericConditions::default(),
5784 );
5785 solver.set_max_range(300.0);
5786 let samples = solver
5787 .solve()
5788 .expect("subsonic solve should succeed")
5789 .sampled_points
5790 .expect("sampling was enabled");
5791
5792 assert!(
5793 samples
5794 .iter()
5795 .all(|sample| !sample.flags.contains(&TrajectoryFlag::MachTransition)),
5796 "{solver_name} marked a Mach transition for a launch already below Mach 1"
5797 );
5798 }
5799 }
5800
5801 #[test]
5802 fn mach_transition_tracker_requires_a_downward_crossing() {
5803 fn record(mach_values: &[f64]) -> Vec<f64> {
5804 let mut tracker = MachTransitionTracker::default();
5805 let mut distances = Vec::new();
5806 for (index, mach) in mach_values.iter().copied().enumerate() {
5807 tracker.record_downward_crossings(mach, index as f64 * 10.0, &mut distances);
5808 }
5809 distances
5810 }
5811
5812 assert!(record(&[0.9, 0.8, 0.7]).is_empty());
5813 assert_eq!(record(&[1.1, 1.05, 0.99]), vec![20.0]);
5814 assert_eq!(record(&[1.2, 1.19, 1.0, 0.99]), vec![10.0, 30.0]);
5815 assert_eq!(record(&[0.9, 1.3, 1.1, 0.9, 1.3, 0.8]), vec![20.0, 30.0]);
5816 assert!(record(&[1.3, f64::NAN, 1.1]).is_empty());
5817 }
5818
5819 #[test]
5820 fn humidity_percent_converts_and_clamps() {
5821 let mut i = BallisticInputs {
5823 humidity: 0.5,
5824 ..BallisticInputs::default()
5825 };
5826 assert!((i.humidity_percent() - 50.0).abs() < 1e-9, "0.5 -> 50%");
5827 i.humidity = 0.0;
5828 assert_eq!(i.humidity_percent(), 0.0);
5829 i.humidity = 1.0;
5830 assert_eq!(i.humidity_percent(), 100.0);
5831 i.humidity = 1.5; assert_eq!(i.humidity_percent(), 100.0);
5833 }
5834
5835 fn vertical_at(shot_azimuth: f64, range_m: f64) -> f64 {
5838 let inputs = BallisticInputs {
5839 muzzle_velocity: 800.0,
5840 bc_value: 0.5,
5841 bc_type: DragModel::G7,
5842 muzzle_angle: 0.02, enable_coriolis: true,
5844 latitude: Some(45.0),
5845 shot_azimuth,
5846 ground_threshold: f64::NEG_INFINITY, ..BallisticInputs::default()
5848 };
5849 let mut solver = TrajectorySolver::new(
5850 inputs,
5851 WindConditions::default(),
5852 AtmosphericConditions::default(),
5853 );
5854 solver.set_max_range(range_m + 50.0);
5855 let r = solver.solve().expect("solve");
5856 let pts = &r.points;
5857 for i in 1..pts.len() {
5858 if pts[i].position.x >= range_m {
5859 let p1 = &pts[i - 1];
5860 let p2 = &pts[i];
5861 let t = (range_m - p1.position.x) / (p2.position.x - p1.position.x);
5862 return p1.position.y + t * (p2.position.y - p1.position.y);
5863 }
5864 }
5865 panic!("range {range_m} not reached");
5866 }
5867
5868 #[test]
5873 fn eotvos_east_higher_than_west() {
5874 let range = 600.0;
5875 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!(
5879 east > west,
5880 "east ({east:.5}) must be higher than west ({west:.5}) at {range} m (Eötvös)"
5881 );
5882 assert!(
5883 east > north && north > west,
5884 "north ({north:.5}) must lie between east ({east:.5}) and west ({west:.5})"
5885 );
5886 assert!(
5887 (east - west) > 1e-3,
5888 "E-W vertical separation ({:.6} m) should be physically meaningful, not FP noise",
5889 east - west
5890 );
5891 }
5892}
5893
5894#[cfg(test)]
5895mod cant_tests {
5896 use super::*;
5897
5898 fn base_inputs() -> BallisticInputs {
5899 BallisticInputs {
5900 muzzle_velocity: 800.0,
5901 bc_value: 0.5,
5902 bc_type: DragModel::G7,
5903 bullet_mass: 0.0109,
5904 bullet_diameter: 0.00782,
5905 bullet_length: 0.0309,
5906 sight_height: 0.05,
5907 twist_rate: 10.0,
5908 use_rk4: true,
5909 ..BallisticInputs::default()
5910 }
5911 }
5912
5913 fn solve_with(inputs: BallisticInputs, max_range: f64) -> TrajectoryResult {
5914 let mut s = TrajectorySolver::new(
5915 inputs,
5916 WindConditions::default(),
5917 AtmosphericConditions::default(),
5918 );
5919 s.set_max_range(max_range);
5920 s.solve().expect("solve")
5921 }
5922
5923 fn yz_at(result: &TrajectoryResult, x: f64) -> (f64, f64) {
5925 let pts = &result.points;
5926 for i in 1..pts.len() {
5927 if pts[i].position.x >= x {
5928 let (p1, p2) = (&pts[i - 1], &pts[i]);
5929 let dx = p2.position.x - p1.position.x;
5930 let t = if dx.abs() < 1e-12 { 0.0 } else { (x - p1.position.x) / dx };
5931 return (
5932 p1.position.y + t * (p2.position.y - p1.position.y),
5933 p1.position.z + t * (p2.position.z - p1.position.z),
5934 );
5935 }
5936 }
5937 panic!("trajectory never reached {x} m");
5938 }
5939
5940 #[test]
5941 fn cant_sign_clockwise_up_offset_goes_right_and_low() {
5942 let mut level = base_inputs();
5944 level.muzzle_angle = 0.003; let mut canted = level.clone();
5946 canted.cant_angle = 10f64.to_radians();
5947
5948 let (y0, z0) = yz_at(&solve_with(level, 400.0), 300.0);
5949 let (y1, z1) = yz_at(&solve_with(canted, 400.0), 300.0);
5950 assert!(z1 > z0 + 0.01, "clockwise cant must move POI right: z0={z0} z1={z1}");
5951 assert!(y1 < y0 - 0.001, "clockwise cant must move POI low: y0={y0} y1={y1}");
5952 }
5953
5954 #[test]
5955 fn pure_cant_shows_bore_offset_near_range() {
5956 let mut i = base_inputs();
5959 i.muzzle_angle = 0.0;
5960 i.cant_angle = 10f64.to_radians();
5961 let sh = i.sight_height;
5962 let r = solve_with(i, 60.0);
5963 let first = &r.points[1]; let expected = -sh * 10f64.to_radians().sin();
5965 assert!(
5966 (first.position.z - expected).abs() < 0.005,
5967 "near-muzzle lateral {} should be ~bore offset {expected}",
5968 first.position.z
5969 );
5970 }
5971
5972 #[test]
5973 fn zero_angle_is_independent_of_cant() {
5974 let a = base_inputs();
5975 let mut b = base_inputs();
5976 b.cant_angle = 15f64.to_radians();
5977 let za = calculate_zero_angle(a.clone(), 100.0, 0.0).expect("zero a");
5978 let zb = calculate_zero_angle(b.clone(), 100.0, 0.0).expect("zero b");
5979 assert_eq!(za.to_bits(), zb.to_bits(), "zeroing must ignore cant: {za} vs {zb}");
5980 let _ = (a.cant_angle, b.cant_angle);
5982 }
5983
5984 #[test]
5985 fn nonfinite_cant_is_rejected() {
5986 let mut i = base_inputs();
5987 i.cant_angle = f64::NAN;
5988 let s = TrajectorySolver::new(i, WindConditions::default(), AtmosphericConditions::default());
5989 assert!(s.solve().is_err());
5990 }
5991
5992 #[test]
5993 fn incline_and_cant_compose_without_breaking() {
5994 let mut flat = base_inputs();
5996 flat.muzzle_angle = 0.003;
5997 flat.shooting_angle = 15f64.to_radians();
5998 let mut canted = flat.clone();
5999 canted.cant_angle = 10f64.to_radians();
6000 let (_, z_flat) = yz_at(&solve_with(flat, 400.0), 300.0);
6001 let (_, z_cant) = yz_at(&solve_with(canted, 400.0), 300.0);
6002 assert!(z_cant > z_flat, "cant must still deflect right on an incline");
6003 }
6004}
6005
6006#[cfg(test)]
6007mod vertical_wind_tests {
6008 use super::*;
6009
6010 fn base_inputs() -> BallisticInputs {
6011 BallisticInputs {
6012 muzzle_velocity: 800.0,
6013 bc_value: 0.5,
6014 bc_type: DragModel::G7,
6015 bullet_mass: 0.0109,
6016 bullet_diameter: 0.00782,
6017 bullet_length: 0.0309,
6018 sight_height: 0.05,
6019 twist_rate: 10.0,
6020 use_rk4: true,
6021 ..BallisticInputs::default()
6022 }
6023 }
6024
6025 fn y_at(result: &TrajectoryResult, x: f64) -> f64 {
6027 let pts = &result.points;
6028 for i in 1..pts.len() {
6029 if pts[i].position.x >= x {
6030 let (p1, p2) = (&pts[i - 1], &pts[i]);
6031 let dx = p2.position.x - p1.position.x;
6032 let t = if dx.abs() < 1e-12 { 0.0 } else { (x - p1.position.x) / dx };
6033 return p1.position.y + t * (p2.position.y - p1.position.y);
6034 }
6035 }
6036 panic!("trajectory never reached {x} m");
6037 }
6038
6039 fn solve_with(inputs: BallisticInputs, wind: WindConditions, max_range: f64) -> TrajectoryResult {
6040 let mut s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
6041 s.set_max_range(max_range);
6042 s.solve().expect("solve")
6043 }
6044
6045 #[test]
6046 fn updraft_raises_poi_downrange() {
6047 let calm_inputs = base_inputs();
6050 let calm_wind = WindConditions::default();
6051 let updraft = WindConditions {
6052 vertical_speed: 5.0,
6053 ..Default::default()
6054 };
6055
6056 let calm = solve_with(calm_inputs.clone(), calm_wind, 500.0);
6057 let updraft_result = solve_with(calm_inputs, updraft, 500.0);
6058
6059 let y_calm = y_at(&calm, 400.0);
6060 let y_updraft = y_at(&updraft_result, 400.0);
6061 assert!(
6062 y_updraft > y_calm,
6063 "5 m/s updraft must raise POI at 400m: calm={y_calm}, updraft={y_updraft}"
6064 );
6065 }
6066
6067 #[test]
6068 fn zero_vertical_is_default_and_finite_required() {
6069 assert_eq!(WindConditions::default().vertical_speed, 0.0);
6070
6071 let inputs = base_inputs();
6072 let wind = WindConditions {
6073 vertical_speed: f64::NAN,
6074 ..Default::default()
6075 };
6076 let s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
6077 assert!(
6078 s.solve().is_err(),
6079 "NaN wind.vertical_speed must be rejected by validate_for_solve"
6080 );
6081 }
6082}