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 pub cd_scale: f64,
189
190 pub bc_type_str: Option<String>,
192}
193
194impl BallisticInputs {
195 pub fn humidity_percent(&self) -> f64 {
200 (self.humidity * 100.0).clamp(0.0, 100.0)
201 }
202
203 pub fn sectional_density_lb_in2(&self) -> Option<f64> {
209 let weight_gr = if self.weight_grains > 0.0 {
210 self.weight_grains
211 } else {
212 self.bullet_mass / crate::constants::GRAINS_TO_KG };
214 let diameter_in = if self.caliber_inches > 0.0 {
215 self.caliber_inches
216 } else {
217 self.bullet_diameter / 0.0254 };
219 if weight_gr > 0.0 && diameter_in > 0.0 {
220 Some(weight_gr / 7000.0 / (diameter_in * diameter_in))
221 } else {
222 None
223 }
224 }
225
226 pub fn custom_drag_denominator(&self, fallback_bc: f64) -> f64 {
238 match self.sectional_density_lb_in2() {
239 Some(sd) => sd,
240 None => {
241 static WARN_ONCE: std::sync::Once = std::sync::Once::new();
242 WARN_ONCE.call_once(|| {
243 eprintln!(
244 "Warning: custom drag table active but bullet mass/diameter are \
245 unavailable; falling back to bc_value for the retardation denominator"
246 );
247 });
248 fallback_bc
249 }
250 }
251 }
252}
253
254impl Default for BallisticInputs {
255 fn default() -> Self {
256 let mass_kg = 0.01;
257 let diameter_m = 0.00762;
258 let bc = 0.5;
259 let muzzle_angle_rad = 0.0;
260 let bc_type = DragModel::G1;
261
262 Self {
263 bc_value: bc,
265 bc_type,
266 bullet_mass: mass_kg,
267 muzzle_velocity: 800.0,
268 bullet_diameter: diameter_m,
269 bullet_length: crate::stability::estimate_bullet_length_m(diameter_m, mass_kg),
273
274 muzzle_angle: muzzle_angle_rad,
276 target_distance: 100.0,
277 azimuth_angle: 0.0,
278 shot_azimuth: 0.0,
279 shooting_angle: 0.0,
280 cant_angle: 0.0,
281 sight_height: 0.05,
282 muzzle_height: 0.0, target_height: 0.0, ground_threshold: -100.0, altitude: 0.0,
288 temperature: 15.0,
289 pressure: 1013.25, humidity: 0.5, latitude: None,
292
293 wind_speed: 0.0,
295 wind_angle: 0.0,
296
297 twist_rate: 12.0, is_twist_right: true,
300 caliber_inches: diameter_m / 0.0254, weight_grains: mass_kg / crate::constants::GRAINS_TO_KG, manufacturer: None,
303 bullet_model: None,
304 bullet_id: None,
305 bullet_cluster: None,
306
307 use_rk4: true, use_adaptive_rk45: true, enable_advanced_effects: false,
313 enable_magnus: false,
314 enable_coriolis: false,
315 use_powder_sensitivity: false,
316 powder_temp_sensitivity: 0.0,
317 powder_temp: 15.0,
318 powder_temp_curve: None,
319 powder_curve_temp_c: None,
320 tipoff_yaw: 0.0,
321 tipoff_decay_distance: 50.0,
322 use_bc_segments: false,
323 bc_segments: None,
324 bc_segments_data: None,
325 use_enhanced_spin_drift: false,
326 use_form_factor: false,
327 enable_wind_shear: false,
328 wind_shear_model: "none".to_string(),
329 enable_trajectory_sampling: false,
330 sample_interval: 10.0, enable_pitch_damping: false,
332 enable_precession_nutation: false,
333 enable_aerodynamic_jump: false,
334 use_cluster_bc: false, custom_drag_table: None,
338 cd_scale: 1.0,
339
340 bc_type_str: None,
342 }
343 }
344}
345
346pub fn interpolate_powder_temp_curve(curve: &[(f64, f64)], temp_c: f64) -> f64 {
352 debug_assert!(!curve.is_empty());
353 if curve.is_empty() {
354 return 0.0;
355 }
356 let mut sorted;
359 let pts: &[(f64, f64)] = if curve.windows(2).all(|w| w[0].0 <= w[1].0) {
360 curve
361 } else {
362 sorted = curve.to_vec();
363 sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
364 &sorted
365 };
366 let n = pts.len();
367 if temp_c <= pts[0].0 {
368 return pts[0].1; }
370 if temp_c >= pts[n - 1].0 {
371 return pts[n - 1].1; }
373 for i in 1..n {
374 let (t0, v0) = pts[i - 1];
375 let (t1, v1) = pts[i];
376 if temp_c <= t1 {
377 let span = t1 - t0;
378 if span.abs() < f64::EPSILON {
379 return v1; }
381 let f = (temp_c - t0) / span;
382 return v0 + f * (v1 - v0);
383 }
384 }
385 pts[n - 1].1
386}
387
388pub fn parse_powder_sweep(s: &str) -> Result<Vec<f64>, String> {
394 const MAX_SWEEP_ROWS: usize = 500;
395 let parts: Vec<&str> = s.split(':').collect();
396 if parts.len() != 3 {
397 return Err(format!(
398 "Invalid --sweep '{}': expected START:END:STEP (e.g. \"20:110:10\")",
399 s
400 ));
401 }
402 let parse = |p: &str, name: &str| -> Result<f64, String> {
403 p.trim()
404 .parse::<f64>()
405 .map_err(|_| format!("Invalid --sweep {}: '{}' is not a number", name, p.trim()))
406 };
407 let start = parse(parts[0], "START")?;
408 let end = parse(parts[1], "END")?;
409 let step = parse(parts[2], "STEP")?;
410 if !step.is_finite() || step <= 0.0 {
411 return Err(format!("Invalid --sweep STEP {}: must be positive", step));
412 }
413 if !start.is_finite() || !end.is_finite() || end < start {
414 return Err(format!(
415 "Invalid --sweep range {}:{}: END must be >= START",
416 start, end
417 ));
418 }
419 let n_f = ((end - start) / step + 1e-9).floor();
425 if !n_f.is_finite() || n_f + 1.0 > MAX_SWEEP_ROWS as f64 {
426 return Err(format!(
427 "--sweep would produce more than {} rows; use a larger STEP",
428 MAX_SWEEP_ROWS
429 ));
430 }
431 let n = n_f as usize + 1;
432 Ok((0..n).map(|i| start + step * i as f64).collect())
434}
435
436pub fn resolve_powder_adjusted_velocity(
445 nominal_velocity_mps: f64,
446 ambient_temperature_c: f64,
447 use_powder_sensitivity: bool,
448 powder_temp_sensitivity_mps_per_c: f64,
449 powder_reference_temp_c: f64,
450 powder_temp_curve: Option<&[(f64, f64)]>,
451 powder_curve_temp_c: Option<f64>,
452) -> f64 {
453 if let Some(curve) = powder_temp_curve {
454 if !curve.is_empty() {
455 let lookup_c = powder_curve_temp_c.unwrap_or(ambient_temperature_c);
456 return interpolate_powder_temp_curve(curve, lookup_c);
457 }
458 return nominal_velocity_mps;
461 }
462 if use_powder_sensitivity {
463 let temp_delta_c = ambient_temperature_c - powder_reference_temp_c;
464 return nominal_velocity_mps + powder_temp_sensitivity_mps_per_c * temp_delta_c;
465 }
466 nominal_velocity_mps
467}
468
469#[derive(Debug, Clone)]
471pub struct WindConditions {
472 pub speed: f64, pub direction: f64,
476 pub vertical_speed: f64,
484}
485
486impl Default for WindConditions {
487 fn default() -> Self {
488 Self {
489 speed: 0.0,
490 direction: 0.0,
491 vertical_speed: 0.0,
492 }
493 }
494}
495
496#[derive(Debug, Clone)]
498pub struct AtmosphericConditions {
499 pub temperature: f64, pub pressure: f64, pub humidity: f64,
505 pub altitude: f64, }
507
508impl Default for AtmosphericConditions {
509 fn default() -> Self {
510 Self {
511 temperature: 15.0,
512 pressure: 1013.25,
513 humidity: 50.0,
514 altitude: 0.0,
515 }
516 }
517}
518
519#[derive(Debug, Clone)]
521pub struct TrajectoryPoint {
522 pub time: f64,
523 pub position: Vector3<f64>,
524 pub velocity_magnitude: f64,
525 pub kinetic_energy: f64,
526}
527
528#[derive(Debug, Clone)]
530pub struct TrajectoryResult {
531 pub max_range: f64,
532 pub max_height: f64,
533 pub time_of_flight: f64,
534 pub impact_velocity: f64,
535 pub impact_energy: f64,
536 pub projectile_mass_kg: f64,
538 pub line_of_sight_height_m: f64,
540 pub station_speed_of_sound_mps: f64,
542 pub termination: TrajectoryTermination,
544 pub points: Vec<TrajectoryPoint>,
545 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>,
554}
555
556const RK45_TOLERANCE: f64 = 1e-6;
557const RK45_SAFETY_FACTOR: f64 = 0.9;
558const RK45_MAX_DT: f64 = 0.01;
559const RK45_MIN_DT: f64 = 1e-6;
560const TRAJECTORY_TIME_LIMIT_S: f64 = 100.0;
561
562pub const MAX_TRAJECTORY_POINTS: usize = 250_000;
568
569fn cli_rk45_error_norm(
571 position: &Vector3<f64>,
572 velocity: &Vector3<f64>,
573 fifth_position: &Vector3<f64>,
574 fifth_velocity: &Vector3<f64>,
575 fourth_position: &Vector3<f64>,
576 fourth_velocity: &Vector3<f64>,
577) -> f64 {
578 let pack_state = |position: &Vector3<f64>, velocity: &Vector3<f64>| {
579 Vector6::new(
580 position.x, position.y, position.z, velocity.x, velocity.y, velocity.z,
581 )
582 };
583 let state = pack_state(position, velocity);
584 let fifth_order = pack_state(fifth_position, fifth_velocity);
585 let fourth_order = pack_state(fourth_position, fourth_velocity);
586
587 crate::trajectory_integration::rk45_error_norm(&state, &fifth_order, &fourth_order)
588}
589
590struct Rk45Trial {
591 position: Vector3<f64>,
592 velocity: Vector3<f64>,
593 suggested_dt: f64,
594 error: f64,
595}
596
597struct Rk45AcceptedStep {
598 position: Vector3<f64>,
599 velocity: Vector3<f64>,
600 used_dt: f64,
601 next_dt: f64,
602 error: f64,
603}
604
605#[derive(Default)]
606struct MachTransitionTracker {
607 previous_mach: Option<f64>,
608 crossed_transonic: bool,
609 crossed_subsonic: bool,
610}
611
612impl MachTransitionTracker {
613 fn record_downward_crossings(&mut self, mach: f64, downrange_m: f64, distances: &mut Vec<f64>) {
614 if !mach.is_finite() {
615 self.previous_mach = None;
616 return;
617 }
618
619 if let Some(previous_mach) = self.previous_mach {
620 if !self.crossed_transonic && previous_mach >= 1.2 && mach < 1.2 {
621 self.crossed_transonic = true;
622 distances.push(downrange_m);
623 }
624 if !self.crossed_subsonic && previous_mach >= 1.0 && mach < 1.0 {
625 self.crossed_subsonic = true;
626 distances.push(downrange_m);
627 }
628 }
629 self.previous_mach = Some(mach);
630 }
631}
632
633impl TrajectoryResult {
634 pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
638 if self.points.is_empty() {
639 return None;
640 }
641
642 for i in 0..self.points.len() - 1 {
644 let p1 = &self.points[i];
645 let p2 = &self.points[i + 1];
646
647 if p1.position.x <= target_range && p2.position.x >= target_range {
649 let dx = p2.position.x - p1.position.x;
651 if dx.abs() < 1e-10 {
652 return Some(p1.position);
653 }
654 let t = (target_range - p1.position.x) / dx;
655
656 return Some(Vector3::new(
658 target_range,
659 p1.position.y + t * (p2.position.y - p1.position.y),
660 p1.position.z + t * (p2.position.z - p1.position.z),
661 ));
662 }
663 }
664
665 self.points.last().map(|p| p.position)
667 }
668}
669
670#[derive(Debug, Clone, Copy, PartialEq, Eq)]
672enum StationAtmosphereResolution {
673 LegacyDefaultSentinels,
676 Authoritative,
679}
680
681#[derive(Clone)]
682pub struct TrajectorySolver {
683 inputs: BallisticInputs,
684 wind: WindConditions,
685 atmosphere: AtmosphericConditions,
686 station_atmosphere_resolution: StationAtmosphereResolution,
687 max_range: f64,
688 time_step: f64,
689 max_trajectory_points: usize,
690 cluster_bc: Option<ClusterBCDegradation>,
691 precession_nutation_inertias: (f64, f64),
693 wind_sock: Option<crate::wind::WindSock>,
698 atmo_sock: Option<crate::atmosphere::AtmoSock>,
705}
706
707impl TrajectorySolver {
708 pub fn new(
709 inputs: BallisticInputs,
710 wind: WindConditions,
711 atmosphere: AtmosphericConditions,
712 ) -> Self {
713 Self::new_with_station_atmosphere_resolution(
714 inputs,
715 wind,
716 atmosphere,
717 StationAtmosphereResolution::LegacyDefaultSentinels,
718 )
719 }
720
721 pub(crate) fn new_with_resolved_station_atmosphere(
725 inputs: BallisticInputs,
726 wind: WindConditions,
727 atmosphere: AtmosphericConditions,
728 ) -> Self {
729 Self::new_with_station_atmosphere_resolution(
730 inputs,
731 wind,
732 atmosphere,
733 StationAtmosphereResolution::Authoritative,
734 )
735 }
736
737 fn new_with_station_atmosphere_resolution(
738 mut inputs: BallisticInputs,
739 wind: WindConditions,
740 atmosphere: AtmosphericConditions,
741 station_atmosphere_resolution: StationAtmosphereResolution,
742 ) -> Self {
743 inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
745 inputs.weight_grains = inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
746
747 inputs.muzzle_velocity = resolve_powder_adjusted_velocity(
759 inputs.muzzle_velocity,
760 inputs.temperature,
761 inputs.use_powder_sensitivity,
762 inputs.powder_temp_sensitivity,
763 inputs.powder_temp,
764 inputs.powder_temp_curve.as_deref(),
765 inputs.powder_curve_temp_c,
766 );
767
768 let cluster_bc = if inputs.use_cluster_bc {
770 Some(ClusterBCDegradation::new())
771 } else {
772 None
773 };
774 let precession_nutation_inertias = projectile_moments_of_inertia(
775 inputs.bullet_mass,
776 inputs.bullet_diameter,
777 inputs.bullet_length,
778 );
779
780 Self {
781 inputs,
782 wind,
783 atmosphere,
784 station_atmosphere_resolution,
785 max_range: 1000.0,
786 time_step: 0.001,
787 max_trajectory_points: MAX_TRAJECTORY_POINTS,
788 cluster_bc,
789 precession_nutation_inertias,
790 wind_sock: None,
791 atmo_sock: None,
792 }
793 }
794
795 pub fn set_max_range(&mut self, range: f64) {
796 self.max_range = range;
797 }
798
799 pub fn set_time_step(&mut self, step: f64) {
800 self.time_step = step;
801 }
802
803 pub(crate) fn calculate_and_set_zero_angle(
807 &mut self,
808 target_distance_m: f64,
809 target_height_m: f64,
810 ) -> Result<f64, BallisticsError> {
811 let angle = self.find_zero_angle(target_distance_m, target_height_m)?;
812 self.inputs.muzzle_angle = angle;
813 Ok(angle)
814 }
815
816 fn find_zero_angle(
817 &self,
818 target_distance_m: f64,
819 target_height_m: f64,
820 ) -> Result<f64, BallisticsError> {
821 let mut low_angle = 0.0;
824 let mut high_angle = 0.2; let tolerance = 1e-7;
826 let max_iterations = 60;
827
828 let low_height = self.zero_trial_height_at(low_angle, target_distance_m)?;
830 let high_height = self.zero_trial_height_at(high_angle, target_distance_m)?;
831
832 match (low_height, high_height) {
833 (Some(low_height), Some(high_height)) => {
834 let low_error = low_height - target_height_m;
835 let high_error = high_height - target_height_m;
836
837 if low_error > 0.0 && high_error > 0.0 {
838 } else if low_error < 0.0 && high_error < 0.0 {
841 let mut expanded = false;
843 for multiplier in [2.0, 3.0, 4.0] {
844 let new_high = (high_angle * multiplier).min(0.785);
845 if let Ok(Some(height)) =
846 self.zero_trial_height_at(new_high, target_distance_m)
847 {
848 if height - target_height_m > 0.0 {
849 high_angle = new_high;
850 expanded = true;
851 break;
852 }
853 }
854 if new_high >= 0.785 {
855 break;
856 }
857 }
858 if !expanded {
859 return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
860 }
861 }
862 }
863 (None, Some(_)) => {
864 }
867 (Some(_), None) => {
868 return Err(
869 "Cannot find zero angle: high angle trajectory doesn't reach target distance"
870 .into(),
871 );
872 }
873 (None, None) => {
874 return Err(
875 "Cannot find zero angle: trajectory cannot reach target distance at any angle"
876 .into(),
877 );
878 }
879 }
880
881 for _ in 0..max_iterations {
882 let mid_angle = (low_angle + high_angle) / 2.0;
883 match self.zero_trial_height_at(mid_angle, target_distance_m)? {
884 Some(height) => {
885 let error = height - target_height_m;
886 if error.abs() < 0.0001 {
889 return Ok(mid_angle);
890 }
891
892 if (high_angle - low_angle).abs() < tolerance {
895 if error.abs() < 0.01 {
896 return Ok(mid_angle);
897 }
898 return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
899 }
900
901 if error > 0.0 {
902 high_angle = mid_angle;
903 } else {
904 low_angle = mid_angle;
905 }
906 }
907 None => {
908 low_angle = mid_angle;
909 if (high_angle - low_angle).abs() < tolerance {
910 return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
911 }
912 }
913 }
914 }
915
916 Err("Failed to find zero angle".into())
917 }
918
919 fn zero_trial_height_at(
922 &self,
923 angle_rad: f64,
924 target_distance_m: f64,
925 ) -> Result<Option<f64>, BallisticsError> {
926 let mut trial = self.clone();
927 trial.inputs.muzzle_angle = angle_rad;
928 trial.inputs.enable_aerodynamic_jump = false;
931 trial.inputs.cant_angle = 0.0;
934 trial.set_max_range(target_distance_m * 2.0);
935 let result = trial.solve()?;
936
937 for (index, point) in result.points.iter().enumerate() {
938 if point.position.x >= target_distance_m {
939 let shot_y_m = if index == 0 {
940 point.position.y
941 } else {
942 let previous = &result.points[index - 1];
943 let span = point.position.x - previous.position.x;
944 let fraction = (target_distance_m - previous.position.x) / span;
945 previous.position.y + fraction * (point.position.y - previous.position.y)
946 };
947 return Ok(Some(crate::atmosphere::shot_frame_altitude(
948 0.0,
949 target_distance_m,
950 shot_y_m,
951 trial.inputs.shooting_angle,
952 )));
953 }
954 }
955 Ok(None)
956 }
957
958 fn validate_for_solve(&self) -> Result<(), BallisticsError> {
964 let require_finite = |name: &str, value: f64| {
965 if value.is_finite() {
966 Ok(())
967 } else {
968 Err(BallisticsError::from(format!("{name} must be finite")))
969 }
970 };
971 let require_positive = |name: &str, value: f64| {
972 if value.is_finite() && value > 0.0 {
973 Ok(())
974 } else {
975 Err(BallisticsError::from(format!(
976 "{name} must be finite and greater than zero"
977 )))
978 }
979 };
980
981 if self.inputs.custom_drag_table.is_none() {
987 require_positive("bc_value", self.inputs.bc_value)?;
988 }
989 require_positive("bullet_mass", self.inputs.bullet_mass)?;
990 require_positive("bullet_diameter", self.inputs.bullet_diameter)?;
991 require_positive("muzzle_velocity", self.inputs.muzzle_velocity)?;
992 require_positive("cd_scale", self.inputs.cd_scale)?;
998
999 require_finite("muzzle_angle", self.inputs.muzzle_angle)?;
1000 require_finite("azimuth_angle", self.inputs.azimuth_angle)?;
1001 require_finite("shooting_angle", self.inputs.shooting_angle)?;
1002 require_finite("cant_angle", self.inputs.cant_angle)?;
1003 require_finite("muzzle_height", self.inputs.muzzle_height)?;
1004
1005 if !(self.inputs.ground_threshold.is_finite()
1008 || self.inputs.ground_threshold == f64::NEG_INFINITY)
1009 {
1010 return Err(BallisticsError::from(
1011 "ground_threshold must be finite or negative infinity",
1012 ));
1013 }
1014
1015 match &self.wind_sock {
1016 Some(wind_sock) => wind_sock
1017 .validate_segments()
1018 .map_err(BallisticsError::from)?,
1019 None => {
1020 require_finite("wind.speed", self.wind.speed)?;
1021 require_finite("wind.direction", self.wind.direction)?;
1022 require_finite("wind.vertical_speed", self.wind.vertical_speed)?;
1023 }
1024 }
1025
1026 require_finite("atmosphere.temperature", self.atmosphere.temperature)?;
1027 require_finite("atmosphere.pressure", self.atmosphere.pressure)?;
1028 require_finite("atmosphere.humidity", self.atmosphere.humidity)?;
1029 require_finite("atmosphere.altitude", self.atmosphere.altitude)?;
1030
1031 require_positive("max_range", self.max_range)?;
1032 if !self.inputs.use_rk4 || !self.inputs.use_adaptive_rk45 {
1035 require_positive("time_step", self.time_step)?;
1036 }
1037
1038 if self.inputs.enable_trajectory_sampling {
1039 require_finite("sight_height", self.inputs.sight_height)?;
1040 require_positive("sample_interval", self.inputs.sample_interval)?;
1041 projected_sample_count(self.max_range, self.inputs.sample_interval)?;
1042 }
1043
1044 if self.inputs.enable_coriolis {
1045 require_finite("shot_azimuth", self.inputs.shot_azimuth)?;
1046 if let Some(latitude) = self.inputs.latitude {
1047 require_finite("latitude", latitude)?;
1048 }
1049 }
1050
1051 Ok(())
1052 }
1053
1054 fn validate_result_sanity(&self, result: &TrajectoryResult) -> Result<(), BallisticsError> {
1061 let require_finite = |name: &str, value: f64| {
1062 if value.is_finite() {
1063 Ok(())
1064 } else {
1065 Err(BallisticsError::from(format!(
1066 "trajectory result contains non-finite {name}"
1067 )))
1068 }
1069 };
1070 let require_non_negative = |name: &str, value: f64| {
1071 if value >= 0.0 {
1072 Ok(())
1073 } else {
1074 Err(BallisticsError::from(format!(
1075 "trajectory result contains non-physical negative {name} ({value})"
1076 )))
1077 }
1078 };
1079 let require_indexed_finite = |collection: &str, index: usize, field: &str, value: f64| {
1080 if value.is_finite() {
1081 Ok(())
1082 } else {
1083 Err(BallisticsError::from(format!(
1084 "trajectory result contains non-finite {collection}[{index}].{field}"
1085 )))
1086 }
1087 };
1088 let require_indexed_non_negative =
1089 |collection: &str, index: usize, field: &str, value: f64| {
1090 if value >= 0.0 {
1091 Ok(())
1092 } else {
1093 Err(BallisticsError::from(format!(
1094 "trajectory result contains non-physical negative {collection}[{index}].{field} ({value})"
1095 )))
1096 }
1097 };
1098
1099 require_finite("max_range", result.max_range)?;
1100 require_finite("max_height", result.max_height)?;
1101 require_finite("time_of_flight", result.time_of_flight)?;
1102 require_finite("impact_velocity", result.impact_velocity)?;
1103 require_finite("impact_energy", result.impact_energy)?;
1104 require_finite("projectile_mass_kg", result.projectile_mass_kg)?;
1105 require_finite(
1106 "line_of_sight_height_m",
1107 result.line_of_sight_height_m,
1108 )?;
1109 require_finite(
1110 "station_speed_of_sound_mps",
1111 result.station_speed_of_sound_mps,
1112 )?;
1113
1114 require_non_negative("max_range", result.max_range)?;
1118 require_non_negative("time_of_flight", result.time_of_flight)?;
1119 require_non_negative("impact_velocity", result.impact_velocity)?;
1120 require_non_negative("impact_energy", result.impact_energy)?;
1121 require_non_negative("projectile_mass_kg", result.projectile_mass_kg)?;
1122 require_non_negative(
1123 "station_speed_of_sound_mps",
1124 result.station_speed_of_sound_mps,
1125 )?;
1126
1127 for (index, point) in result.points.iter().enumerate() {
1128 require_indexed_finite("points", index, "time", point.time)?;
1129 require_indexed_finite("points", index, "position.x", point.position.x)?;
1130 require_indexed_finite("points", index, "position.y", point.position.y)?;
1131 require_indexed_finite("points", index, "position.z", point.position.z)?;
1132 require_indexed_finite(
1133 "points",
1134 index,
1135 "velocity_magnitude",
1136 point.velocity_magnitude,
1137 )?;
1138 require_indexed_finite("points", index, "kinetic_energy", point.kinetic_energy)?;
1139 require_indexed_non_negative("points", index, "time", point.time)?;
1140 require_indexed_non_negative(
1141 "points",
1142 index,
1143 "velocity_magnitude",
1144 point.velocity_magnitude,
1145 )?;
1146 require_indexed_non_negative("points", index, "kinetic_energy", point.kinetic_energy)?;
1147 }
1148
1149 if let Some(samples) = &result.sampled_points {
1150 for (index, sample) in samples.iter().enumerate() {
1151 require_indexed_finite("sampled_points", index, "distance_m", sample.distance_m)?;
1152 require_indexed_finite("sampled_points", index, "drop_m", sample.drop_m)?;
1153 require_indexed_finite(
1154 "sampled_points",
1155 index,
1156 "wind_drift_m",
1157 sample.wind_drift_m,
1158 )?;
1159 require_indexed_finite(
1160 "sampled_points",
1161 index,
1162 "velocity_mps",
1163 sample.velocity_mps,
1164 )?;
1165 require_indexed_finite("sampled_points", index, "energy_j", sample.energy_j)?;
1166 require_indexed_finite("sampled_points", index, "time_s", sample.time_s)?;
1167 }
1168 }
1169
1170 for (name, value) in [
1171 ("min_pitch_damping", result.min_pitch_damping),
1172 ("transonic_mach", result.transonic_mach),
1173 ("max_yaw_angle", result.max_yaw_angle),
1174 ("max_precession_angle", result.max_precession_angle),
1175 ] {
1176 if let Some(value) = value {
1177 require_finite(name, value)?;
1178 }
1179 }
1180
1181 if let Some(state) = result.angular_state {
1182 for (name, value) in [
1183 ("angular_state.pitch_angle", state.pitch_angle),
1184 ("angular_state.yaw_angle", state.yaw_angle),
1185 ("angular_state.pitch_rate", state.pitch_rate),
1186 ("angular_state.yaw_rate", state.yaw_rate),
1187 ("angular_state.precession_angle", state.precession_angle),
1188 ("angular_state.nutation_phase", state.nutation_phase),
1189 ] {
1190 require_finite(name, value)?;
1191 }
1192 }
1193
1194 if let Some(jump) = result.aerodynamic_jump {
1195 for (name, value) in [
1196 ("aerodynamic_jump.vertical_jump_moa", jump.vertical_jump_moa),
1197 (
1198 "aerodynamic_jump.horizontal_jump_moa",
1199 jump.horizontal_jump_moa,
1200 ),
1201 ("aerodynamic_jump.jump_angle_rad", jump.jump_angle_rad),
1202 (
1203 "aerodynamic_jump.magnus_component_moa",
1204 jump.magnus_component_moa,
1205 ),
1206 ("aerodynamic_jump.yaw_component_moa", jump.yaw_component_moa),
1207 (
1208 "aerodynamic_jump.stabilization_factor",
1209 jump.stabilization_factor,
1210 ),
1211 ] {
1212 require_finite(name, value)?;
1213 }
1214 }
1215
1216 Ok(())
1217 }
1218
1219 fn validate_integration_state(
1232 &self,
1233 position: &Vector3<f64>,
1234 velocity: &Vector3<f64>,
1235 time: f64,
1236 ) -> Result<(), BallisticsError> {
1237 if !(position.iter().all(|value| value.is_finite())
1238 && velocity.iter().all(|value| value.is_finite())
1239 && time.is_finite())
1240 {
1241 return Err(BallisticsError::from(
1242 "trajectory integration produced a non-finite state (often from physically \
1243 extreme inputs — e.g. an absurd bore/muzzle height placing the launch far \
1244 from sea level, or a degenerate atmosphere; check those inputs, or set \
1245 --altitude explicitly)",
1246 ));
1247 }
1248
1249 let speed = velocity.magnitude();
1250 let budget = self.speed_budget(time);
1251 if speed > budget {
1252 return Err(BallisticsError::from(format!(
1253 "trajectory integration diverged: speed {speed:.3e} m/s at t={time:.6}s exceeds \
1254 the physical budget of {budget:.3e} m/s"
1255 )));
1256 }
1257 Ok(())
1258 }
1259
1260 fn speed_budget(&self, time: f64) -> f64 {
1265 let scalar_wind = self.wind.speed.abs() + self.wind.vertical_speed.abs();
1266 let wind_bound = match &self.wind_sock {
1267 Some(sock) => scalar_wind.max(sock.max_speed_mps()),
1268 None => scalar_wind,
1269 };
1270 2.0 * (self.inputs.muzzle_velocity + wind_bound + 10.0)
1271 + crate::constants::G_ACCEL_MPS2 * time
1272 }
1273
1274 fn push_trajectory_point(
1276 &self,
1277 points: &mut Vec<TrajectoryPoint>,
1278 point: TrajectoryPoint,
1279 ) -> Result<(), BallisticsError> {
1280 if points.len() >= self.max_trajectory_points {
1281 return Err(BallisticsError::from(format!(
1282 "trajectory point limit of {} exceeded",
1283 self.max_trajectory_points
1284 )));
1285 }
1286 points.push(point);
1287 Ok(())
1288 }
1289
1290 pub fn set_wind_segments(&mut self, segments: Vec<crate::wind::WindSegment>) {
1297 self.wind_sock = if segments.is_empty() {
1298 None
1299 } else {
1300 Some(crate::wind::WindSock::new(segments))
1301 };
1302 }
1303
1304 pub fn set_atmo_segments(&mut self, segments: Vec<crate::atmosphere::AtmoSegment>) {
1313 self.atmo_sock = if segments.is_empty() {
1314 None
1315 } else {
1316 Some(crate::atmosphere::AtmoSock::new(segments))
1317 };
1318 }
1319
1320 fn launch_angles_from(
1329 &self,
1330 aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
1331 ) -> (f64, f64) {
1332 let (mut elev, mut azim) = (self.inputs.muzzle_angle, self.inputs.azimuth_angle);
1333 if self.inputs.cant_angle != 0.0 {
1340 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
1341 let (e0, a0) = (elev, azim);
1342 elev = e0 * cos_c - a0 * sin_c;
1343 azim = a0 * cos_c + e0 * sin_c;
1344 }
1345 match aj {
1346 Some(c) => {
1347 const MOA_PER_RAD: f64 = 3437.7467707849;
1349 (
1350 elev + c.vertical_jump_moa / MOA_PER_RAD,
1351 azim + c.horizontal_jump_moa / MOA_PER_RAD,
1352 )
1353 }
1354 None => (elev, azim),
1355 }
1356 }
1357
1358 fn aerodynamic_jump_components(
1366 &self,
1367 ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
1368 if !self.inputs.enable_aerodynamic_jump {
1369 return None;
1370 }
1371 let diameter_m = self.inputs.bullet_diameter;
1375 if !(self.inputs.twist_rate.is_finite()
1376 && self.inputs.twist_rate != 0.0
1377 && diameter_m.is_finite()
1378 && diameter_m > 0.0
1379 && self.inputs.bullet_length.is_finite()
1380 && self.inputs.bullet_length > 0.0
1381 && self.inputs.muzzle_velocity.is_finite())
1382 {
1383 return None;
1384 }
1385
1386 let (_, _, temp_c, pressure_hpa) = self.resolved_atmosphere();
1388 let sg = crate::stability::compute_stability_coefficient(
1389 &self.inputs,
1390 (self.atmosphere.altitude, temp_c, pressure_hpa, 0.0),
1391 );
1392 if !(sg.is_finite() && sg > 0.0) {
1393 return None;
1394 }
1395 let length_calibers = self.inputs.bullet_length / diameter_m;
1396
1397 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
1403 let crosswind_from_right_mps = if let Some(sock) = &self.wind_sock {
1404 -sock.vector_for_range_stateless(0.0)[2]
1405 } else {
1406 self.wind.speed * self.wind.direction.sin()
1407 };
1408 let crosswind_from_right_mph = crosswind_from_right_mps * MS_TO_MPH;
1409
1410 let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
1411 sg,
1412 length_calibers,
1413 crosswind_from_right_mph,
1414 self.inputs.is_twist_right,
1415 );
1416 if !vertical_jump_moa.is_finite() {
1417 return None;
1418 }
1419
1420 const MOA_PER_RAD: f64 = 3437.7467707849;
1421 Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
1422 vertical_jump_moa,
1423 horizontal_jump_moa: 0.0,
1425 jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
1426 magnus_component_moa: 0.0,
1427 yaw_component_moa: 0.0,
1428 stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
1429 })
1430 }
1431
1432 fn resolved_atmosphere(&self) -> (f64, f64, f64, f64) {
1433 let (temp_c, pressure_hpa) = match self.station_atmosphere_resolution {
1434 StationAtmosphereResolution::LegacyDefaultSentinels => {
1435 crate::atmosphere::resolve_station_conditions(
1436 self.atmosphere.temperature,
1437 self.atmosphere.pressure,
1438 self.atmosphere.altitude,
1439 )
1440 }
1441 StationAtmosphereResolution::Authoritative => {
1442 (self.atmosphere.temperature, self.atmosphere.pressure)
1443 }
1444 };
1445 let (density, speed_of_sound) = crate::atmosphere::calculate_atmosphere(
1446 self.atmosphere.altitude,
1447 Some(temp_c),
1448 Some(pressure_hpa),
1449 self.atmosphere.humidity,
1450 );
1451 (density, speed_of_sound, temp_c, pressure_hpa)
1452 }
1453
1454 fn precession_nutation_params(
1455 &self,
1456 velocity_mps: f64,
1457 air_density_kg_m3: f64,
1458 speed_of_sound_mps: f64,
1459 ) -> PrecessionNutationParams {
1460 let (spin_inertia, transverse_inertia) = self.precession_nutation_inertias;
1461 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
1462 let velocity_fps = velocity_mps * 3.28084;
1463 let twist_rate_ft = self.inputs.twist_rate / 12.0;
1464 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
1465 } else {
1466 0.0
1467 };
1468
1469 PrecessionNutationParams {
1470 mass_kg: self.inputs.bullet_mass,
1471 caliber_m: self.inputs.bullet_diameter,
1472 length_m: self.inputs.bullet_length,
1473 spin_rate_rad_s,
1474 spin_inertia,
1475 transverse_inertia,
1476 velocity_mps,
1477 air_density_kg_m3,
1478 mach: velocity_mps / speed_of_sound_mps,
1479 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
1480 nutation_damping_factor: 0.05,
1481 }
1482 }
1483
1484 fn append_terminal_endpoint(
1491 &self,
1492 points: &mut Vec<TrajectoryPoint>,
1493 post_position: Vector3<f64>,
1494 post_velocity: Vector3<f64>,
1495 post_time: f64,
1496 max_height: &mut f64,
1497 ) -> Result<TrajectoryTermination, BallisticsError> {
1498 let previous = points
1499 .last()
1500 .cloned()
1501 .ok_or_else(|| BallisticsError::from("No trajectory points generated"))?;
1502
1503 let mut crossings = Vec::with_capacity(3);
1504 if previous.position.x < self.max_range && post_position.x >= self.max_range {
1505 let span = post_position.x - previous.position.x;
1506 if span.is_finite() && span > 0.0 {
1507 crossings.push((
1508 (self.max_range - previous.position.x) / span,
1509 TrajectoryTermination::MaxRange,
1510 ));
1511 }
1512 }
1513 if self.inputs.ground_threshold.is_finite()
1514 && previous.position.y > self.inputs.ground_threshold
1515 && post_position.y <= self.inputs.ground_threshold
1516 {
1517 let span = post_position.y - previous.position.y;
1518 if span.is_finite() && span < 0.0 {
1519 crossings.push((
1520 (self.inputs.ground_threshold - previous.position.y) / span,
1521 TrajectoryTermination::GroundThreshold,
1522 ));
1523 }
1524 }
1525 if previous.time < TRAJECTORY_TIME_LIMIT_S && post_time >= TRAJECTORY_TIME_LIMIT_S {
1526 let span = post_time - previous.time;
1527 if span.is_finite() && span > 0.0 {
1528 crossings.push((
1529 (TRAJECTORY_TIME_LIMIT_S - previous.time) / span,
1530 TrajectoryTermination::TimeLimit,
1531 ));
1532 }
1533 }
1534
1535 let (fraction, termination) = crossings
1536 .into_iter()
1537 .filter(|(fraction, _)| fraction.is_finite() && (0.0..=1.0).contains(fraction))
1538 .min_by(|left, right| {
1539 let priority = |termination: TrajectoryTermination| match termination {
1540 TrajectoryTermination::GroundThreshold => 0,
1541 TrajectoryTermination::MaxRange => 1,
1542 TrajectoryTermination::TimeLimit => 2,
1543 TrajectoryTermination::VelocityFloor => 3,
1544 };
1545 left.0
1546 .total_cmp(&right.0)
1547 .then_with(|| priority(left.1).cmp(&priority(right.1)))
1548 })
1549 .ok_or_else(|| {
1550 BallisticsError::from(
1551 "trajectory integration stopped without crossing a supported boundary",
1552 )
1553 })?;
1554
1555 let mut position = previous.position + (post_position - previous.position) * fraction;
1556 match termination {
1557 TrajectoryTermination::MaxRange => position.x = self.max_range,
1558 TrajectoryTermination::GroundThreshold => {
1559 position.y = self.inputs.ground_threshold;
1560 }
1561 TrajectoryTermination::TimeLimit | TrajectoryTermination::VelocityFloor => {}
1562 }
1563 let velocity_magnitude = previous.velocity_magnitude
1564 + (post_velocity.magnitude() - previous.velocity_magnitude) * fraction;
1565 let mut time = previous.time + (post_time - previous.time) * fraction;
1566 if termination == TrajectoryTermination::TimeLimit {
1567 time = TRAJECTORY_TIME_LIMIT_S;
1568 }
1569 let kinetic_energy =
1570 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1571
1572 if position.y > *max_height {
1573 *max_height = position.y;
1574 }
1575 let terminal_point = TrajectoryPoint {
1576 time,
1577 position,
1578 velocity_magnitude,
1579 kinetic_energy,
1580 };
1581 if terminal_point.position.x < previous.position.x {
1582 return Err(BallisticsError::from(
1583 "trajectory terminal state reversed downrange before the crossed boundary",
1584 ));
1585 }
1586 if terminal_point.position.x == previous.position.x {
1587 let last = points.last_mut().ok_or_else(|| {
1592 BallisticsError::from("trajectory points disappeared during terminal finalization")
1593 })?;
1594 *last = terminal_point;
1595 } else {
1596 self.push_trajectory_point(points, terminal_point)?;
1597 }
1598 Ok(termination)
1599 }
1600
1601 fn gravity_acceleration(&self) -> Vector3<f64> {
1602 let theta = self.inputs.shooting_angle;
1603 Vector3::new(
1604 -crate::constants::G_ACCEL_MPS2 * theta.sin(),
1605 -crate::constants::G_ACCEL_MPS2 * theta.cos(),
1606 0.0,
1607 )
1608 }
1609
1610 fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
1611 let model = match self.inputs.wind_shear_model.as_str() {
1626 "logarithmic" => WindShearModel::Logarithmic,
1627 "power_law" | "powerlaw" | "exponential" => WindShearModel::PowerLaw,
1628 "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
1629 "custom_layers" | "custom" => WindShearModel::CustomLayers,
1630 _ => WindShearModel::PowerLaw,
1631 };
1632 let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
1633
1634 crate::wind::wind_vector(self.wind.speed, self.wind.direction, 0.0) * speed_ratio
1641 + Vector3::new(0.0, self.wind.vertical_speed, 0.0)
1642 }
1643
1644 pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
1645 self.validate_for_solve()?;
1646 let mut result = if self.inputs.use_rk4 {
1647 if self.inputs.use_adaptive_rk45 {
1648 self.solve_rk45()?
1649 } else {
1650 self.solve_rk4()?
1651 }
1652 } else {
1653 self.solve_euler()?
1654 };
1655 self.apply_spin_drift(&mut result);
1656 self.validate_result_sanity(&result)?;
1657 Ok(result)
1658 }
1659
1660 fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
1666 if !self.inputs.use_enhanced_spin_drift {
1667 return;
1668 }
1669 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 {
1673 return;
1674 }
1675
1676 let sg = self.effective_spin_drift_sg();
1683
1684 for p in result.points.iter_mut() {
1685 if p.time <= 0.0 {
1686 continue;
1687 }
1688 p.position.z +=
1690 crate::spin_drift::litz_drift_meters(sg, p.time, self.inputs.is_twist_right);
1691 }
1692
1693 if let Some(samples) = result.sampled_points.as_mut() {
1697 for s in samples.iter_mut() {
1698 if s.time_s <= 0.0 {
1699 continue;
1700 }
1701 s.wind_drift_m +=
1702 crate::spin_drift::litz_drift_meters(sg, s.time_s, self.inputs.is_twist_right);
1703 }
1704 }
1705 }
1706
1707 fn effective_spin_drift_sg(&self) -> f64 {
1712 let (_, _, temp_c, press_hpa) = self.resolved_atmosphere();
1713 crate::spin_drift::effective_sg_from_inputs(&self.inputs, temp_c, press_hpa)
1714 }
1715
1716 fn initial_position(&self) -> Vector3<f64> {
1722 if self.inputs.cant_angle == 0.0 {
1723 return Vector3::new(0.0, self.inputs.muzzle_height, 0.0);
1724 }
1725 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
1726 let sh = self.inputs.sight_height;
1727 Vector3::new(
1728 0.0,
1729 self.inputs.muzzle_height + sh * (1.0 - cos_c),
1730 -sh * sin_c,
1731 )
1732 }
1733
1734 fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
1735 let mut time = 0.0;
1737 let mut position = self.initial_position();
1741 let aj_components = self.aerodynamic_jump_components();
1747 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1748 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1749 let mut velocity = Vector3::new(
1750 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1754
1755 let mut points = Vec::new();
1756 let mut max_height = position.y;
1757 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
1763 let mut mach_transitions = MachTransitionTracker::default();
1764
1765 let mut angular_state = if self.inputs.enable_precession_nutation {
1767 Some(AngularState {
1768 pitch_angle: 0.001, yaw_angle: 0.001,
1770 pitch_rate: 0.0,
1771 yaw_rate: 0.0,
1772 precession_angle: 0.0,
1773 nutation_phase: 0.0,
1774 })
1775 } else {
1776 None
1777 };
1778 let mut max_yaw_angle = 0.0;
1779 let mut max_precession_angle = 0.0;
1780
1781 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
1783 self.resolved_atmosphere();
1784 let base_ratio = air_density / 1.225;
1789
1790 let wind_vector =
1796 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
1797
1798 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1801 self.inputs.bullet_model.as_deref().unwrap_or("default"),
1802 );
1803
1804 while position.x < self.max_range
1806 && position.y > self.inputs.ground_threshold
1807 && time < TRAJECTORY_TIME_LIMIT_S
1808 {
1809 let velocity_magnitude = velocity.magnitude();
1811 let kinetic_energy =
1812 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1813
1814 self.push_trajectory_point(
1815 &mut points,
1816 TrajectoryPoint {
1817 time,
1818 position,
1819 velocity_magnitude,
1820 kinetic_energy,
1821 },
1822 )?;
1823
1824 {
1827 let mach_here = if speed_of_sound > 0.0 {
1828 velocity_magnitude / speed_of_sound
1829 } else {
1830 0.0
1831 };
1832 mach_transitions.record_downward_crossings(
1833 mach_here,
1834 position.x,
1835 &mut transonic_distances,
1836 );
1837 }
1838
1839 if position.y > max_height {
1841 max_height = position.y;
1842 }
1843
1844 if self.inputs.enable_pitch_damping {
1846 let mach = velocity_magnitude / speed_of_sound;
1847
1848 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1850 transonic_mach = Some(mach);
1851 }
1852
1853 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1855
1856 if pitch_damping < min_pitch_damping {
1858 min_pitch_damping = pitch_damping;
1859 }
1860 }
1861
1862 if self.inputs.enable_precession_nutation {
1864 if let Some(ref mut state) = angular_state {
1865 let velocity_magnitude = velocity.magnitude();
1866 let params = self.precession_nutation_params(
1867 velocity_magnitude,
1868 air_density,
1869 speed_of_sound,
1870 );
1871
1872 *state = calculate_combined_angular_motion(
1874 ¶ms,
1875 state,
1876 time,
1877 self.time_step,
1878 0.001, );
1880
1881 if state.yaw_angle.abs() > max_yaw_angle {
1883 max_yaw_angle = state.yaw_angle.abs();
1884 }
1885 if state.precession_angle.abs() > max_precession_angle {
1886 max_precession_angle = state.precession_angle.abs();
1887 }
1888 }
1889 }
1890
1891 let acceleration = self.calculate_acceleration(
1898 &position,
1899 &velocity,
1900 &wind_vector,
1901 (resolved_temp_c, resolved_press_hpa, base_ratio),
1902 );
1903
1904 velocity += acceleration * self.time_step;
1906 position += velocity * self.time_step;
1907 time += self.time_step;
1908 self.validate_integration_state(&position, &velocity, time)?;
1909 }
1910
1911 let termination =
1912 self.append_terminal_endpoint(&mut points, position, velocity, time, &mut max_height)?;
1913
1914 let last_point = points.last().ok_or("No trajectory points generated")?;
1916
1917 let sampled_points = if self.inputs.enable_trajectory_sampling {
1919 let trajectory_data = TrajectoryData {
1920 times: points.iter().map(|p| p.time).collect(),
1921 positions: points.iter().map(|p| p.position).collect(),
1922 velocities: points
1923 .iter()
1924 .map(|p| {
1925 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1927 })
1928 .collect(),
1929 transonic_distances, };
1931
1932 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1937 let outputs = TrajectoryOutputs {
1938 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
1940 time_of_flight_s: last_point.time,
1941 max_ord_dist_horiz_m: max_height,
1942 sight_height_m: sight_position_m,
1943 };
1944
1945 let samples = sample_trajectory(
1947 &trajectory_data,
1948 &outputs,
1949 self.inputs.sample_interval,
1950 self.inputs.bullet_mass,
1951 )?;
1952 Some(samples)
1953 } else {
1954 None
1955 };
1956
1957 Ok(TrajectoryResult {
1958 max_range: last_point.position.x, max_height,
1960 time_of_flight: last_point.time,
1961 impact_velocity: last_point.velocity_magnitude,
1962 impact_energy: last_point.kinetic_energy,
1963 projectile_mass_kg: self.inputs.bullet_mass,
1964 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
1965 station_speed_of_sound_mps: speed_of_sound,
1966 termination,
1967 points,
1968 sampled_points,
1969 min_pitch_damping: if self.inputs.enable_pitch_damping {
1970 Some(min_pitch_damping)
1971 } else {
1972 None
1973 },
1974 transonic_mach,
1975 angular_state,
1976 max_yaw_angle: if self.inputs.enable_precession_nutation {
1977 Some(max_yaw_angle)
1978 } else {
1979 None
1980 },
1981 max_precession_angle: if self.inputs.enable_precession_nutation {
1982 Some(max_precession_angle)
1983 } else {
1984 None
1985 },
1986 aerodynamic_jump: aj_components,
1987 })
1988 }
1989
1990 fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
1991 let mut time = 0.0;
1993 let mut position = self.initial_position();
1998
1999 let aj_components = self.aerodynamic_jump_components();
2005 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
2006 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
2007 let mut velocity = Vector3::new(
2008 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
2012
2013 let mut points = Vec::new();
2014 let mut max_height = position.y;
2015 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
2021 let mut mach_transitions = MachTransitionTracker::default();
2022
2023 let mut angular_state = if self.inputs.enable_precession_nutation {
2025 Some(AngularState {
2026 pitch_angle: 0.001, yaw_angle: 0.001,
2028 pitch_rate: 0.0,
2029 yaw_rate: 0.0,
2030 precession_angle: 0.0,
2031 nutation_phase: 0.0,
2032 })
2033 } else {
2034 None
2035 };
2036 let mut max_yaw_angle = 0.0;
2037 let mut max_precession_angle = 0.0;
2038
2039 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2041 self.resolved_atmosphere();
2042 let base_ratio = air_density / 1.225;
2047
2048 let wind_vector =
2054 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
2055
2056 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2059 self.inputs.bullet_model.as_deref().unwrap_or("default"),
2060 );
2061
2062 while position.x < self.max_range
2064 && position.y > self.inputs.ground_threshold
2065 && time < TRAJECTORY_TIME_LIMIT_S
2066 {
2067 let velocity_magnitude = velocity.magnitude();
2069 let kinetic_energy =
2070 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
2071
2072 self.push_trajectory_point(
2073 &mut points,
2074 TrajectoryPoint {
2075 time,
2076 position,
2077 velocity_magnitude,
2078 kinetic_energy,
2079 },
2080 )?;
2081
2082 {
2085 let mach_here = if speed_of_sound > 0.0 {
2086 velocity_magnitude / speed_of_sound
2087 } else {
2088 0.0
2089 };
2090 mach_transitions.record_downward_crossings(
2091 mach_here,
2092 position.x,
2093 &mut transonic_distances,
2094 );
2095 }
2096
2097 if position.y > max_height {
2098 max_height = position.y;
2099 }
2100
2101 if self.inputs.enable_pitch_damping {
2103 let mach = velocity_magnitude / speed_of_sound;
2104
2105 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2107 transonic_mach = Some(mach);
2108 }
2109
2110 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2112
2113 if pitch_damping < min_pitch_damping {
2115 min_pitch_damping = pitch_damping;
2116 }
2117 }
2118
2119 if self.inputs.enable_precession_nutation {
2121 if let Some(ref mut state) = angular_state {
2122 let velocity_magnitude = velocity.magnitude();
2123 let params = self.precession_nutation_params(
2124 velocity_magnitude,
2125 air_density,
2126 speed_of_sound,
2127 );
2128
2129 *state = calculate_combined_angular_motion(
2131 ¶ms,
2132 state,
2133 time,
2134 self.time_step,
2135 0.001, );
2137
2138 if state.yaw_angle.abs() > max_yaw_angle {
2140 max_yaw_angle = state.yaw_angle.abs();
2141 }
2142 if state.precession_angle.abs() > max_precession_angle {
2143 max_precession_angle = state.precession_angle.abs();
2144 }
2145 }
2146 }
2147
2148 let dt = self.time_step;
2150
2151 let acc1 = self.calculate_acceleration(
2153 &position,
2154 &velocity,
2155 &wind_vector,
2156 (resolved_temp_c, resolved_press_hpa, base_ratio),
2157 );
2158
2159 let pos2 = position + velocity * (dt * 0.5);
2161 let vel2 = velocity + acc1 * (dt * 0.5);
2162 let acc2 = self.calculate_acceleration(
2163 &pos2,
2164 &vel2,
2165 &wind_vector,
2166 (resolved_temp_c, resolved_press_hpa, base_ratio),
2167 );
2168
2169 let pos3 = position + vel2 * (dt * 0.5);
2171 let vel3 = velocity + acc2 * (dt * 0.5);
2172 let acc3 = self.calculate_acceleration(
2173 &pos3,
2174 &vel3,
2175 &wind_vector,
2176 (resolved_temp_c, resolved_press_hpa, base_ratio),
2177 );
2178
2179 let pos4 = position + vel3 * dt;
2181 let vel4 = velocity + acc3 * dt;
2182 let acc4 = self.calculate_acceleration(
2183 &pos4,
2184 &vel4,
2185 &wind_vector,
2186 (resolved_temp_c, resolved_press_hpa, base_ratio),
2187 );
2188
2189 position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
2191 velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
2192 time += dt;
2193 self.validate_integration_state(&position, &velocity, time)?;
2194 }
2195
2196 let termination =
2197 self.append_terminal_endpoint(&mut points, position, velocity, time, &mut max_height)?;
2198
2199 let last_point = points.last().ok_or("No trajectory points generated")?;
2201
2202 let sampled_points = if self.inputs.enable_trajectory_sampling {
2204 let trajectory_data = TrajectoryData {
2205 times: points.iter().map(|p| p.time).collect(),
2206 positions: points.iter().map(|p| p.position).collect(),
2207 velocities: points
2208 .iter()
2209 .map(|p| {
2210 Vector3::new(0.0, 0.0, p.velocity_magnitude)
2212 })
2213 .collect(),
2214 transonic_distances, };
2216
2217 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
2222 let outputs = TrajectoryOutputs {
2223 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
2225 time_of_flight_s: last_point.time,
2226 max_ord_dist_horiz_m: max_height,
2227 sight_height_m: sight_position_m,
2228 };
2229
2230 let samples = sample_trajectory(
2232 &trajectory_data,
2233 &outputs,
2234 self.inputs.sample_interval,
2235 self.inputs.bullet_mass,
2236 )?;
2237 Some(samples)
2238 } else {
2239 None
2240 };
2241
2242 Ok(TrajectoryResult {
2243 max_range: last_point.position.x, max_height,
2245 time_of_flight: last_point.time,
2246 impact_velocity: last_point.velocity_magnitude,
2247 impact_energy: last_point.kinetic_energy,
2248 projectile_mass_kg: self.inputs.bullet_mass,
2249 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
2250 station_speed_of_sound_mps: speed_of_sound,
2251 termination,
2252 points,
2253 sampled_points,
2254 min_pitch_damping: if self.inputs.enable_pitch_damping {
2255 Some(min_pitch_damping)
2256 } else {
2257 None
2258 },
2259 transonic_mach,
2260 angular_state,
2261 max_yaw_angle: if self.inputs.enable_precession_nutation {
2262 Some(max_yaw_angle)
2263 } else {
2264 None
2265 },
2266 max_precession_angle: if self.inputs.enable_precession_nutation {
2267 Some(max_precession_angle)
2268 } else {
2269 None
2270 },
2271 aerodynamic_jump: aj_components,
2272 })
2273 }
2274
2275 fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
2276 let mut time = 0.0;
2278 let mut position = self.initial_position();
2282
2283 let aj_components = self.aerodynamic_jump_components();
2289 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
2290 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
2291 let mut velocity = Vector3::new(
2292 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
2296
2297 let mut points = Vec::new();
2298 let mut max_height = position.y;
2299 let mut dt = 0.001; let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2304 self.resolved_atmosphere();
2305 let base_ratio = air_density / 1.225;
2310 let wind_vector =
2315 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
2316
2317 let mut transonic_distances: Vec<f64> = Vec::new();
2319 let mut mach_transitions = MachTransitionTracker::default();
2320
2321 let mut min_pitch_damping = f64::INFINITY;
2326 let mut transonic_mach: Option<f64> = None;
2327 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2328 self.inputs.bullet_model.as_deref().unwrap_or("default"),
2329 );
2330 let mut angular_state = if self.inputs.enable_precession_nutation {
2331 Some(AngularState {
2332 pitch_angle: 0.001,
2333 yaw_angle: 0.001,
2334 pitch_rate: 0.0,
2335 yaw_rate: 0.0,
2336 precession_angle: 0.0,
2337 nutation_phase: 0.0,
2338 })
2339 } else {
2340 None
2341 };
2342 let mut max_yaw_angle = 0.0;
2343 let mut max_precession_angle = 0.0;
2344
2345 while position.x < self.max_range
2346 && position.y > self.inputs.ground_threshold
2347 && time < TRAJECTORY_TIME_LIMIT_S
2348 {
2349 let velocity_magnitude = velocity.magnitude();
2351 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
2352
2353 self.push_trajectory_point(
2354 &mut points,
2355 TrajectoryPoint {
2356 time,
2357 position,
2358 velocity_magnitude,
2359 kinetic_energy,
2360 },
2361 )?;
2362
2363 {
2366 let mach_here = if speed_of_sound > 0.0 {
2367 velocity_magnitude / speed_of_sound
2368 } else {
2369 0.0
2370 };
2371 mach_transitions.record_downward_crossings(
2372 mach_here,
2373 position.x,
2374 &mut transonic_distances,
2375 );
2376 }
2377
2378 if position.y > max_height {
2379 max_height = position.y;
2380 }
2381
2382 if self.inputs.enable_pitch_damping {
2385 let mach = velocity_magnitude / speed_of_sound;
2386 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2387 transonic_mach = Some(mach);
2388 }
2389 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2390 if pitch_damping < min_pitch_damping {
2391 min_pitch_damping = pitch_damping;
2392 }
2393 }
2394
2395 let accepted_step = self.adaptive_rk45_step(
2398 &position,
2399 &velocity,
2400 dt,
2401 &wind_vector,
2402 (resolved_temp_c, resolved_press_hpa, base_ratio),
2403 );
2404 debug_assert!(
2405 accepted_step.error <= RK45_TOLERANCE || accepted_step.used_dt <= RK45_MIN_DT
2406 );
2407
2408 if self.inputs.enable_precession_nutation {
2412 if let Some(ref mut state) = angular_state {
2413 let params = self.precession_nutation_params(
2414 velocity_magnitude,
2415 air_density,
2416 speed_of_sound,
2417 );
2418
2419 *state = calculate_combined_angular_motion(
2420 ¶ms,
2421 state,
2422 time,
2423 accepted_step.used_dt,
2424 0.001,
2425 );
2426
2427 if state.yaw_angle.abs() > max_yaw_angle {
2428 max_yaw_angle = state.yaw_angle.abs();
2429 }
2430 if state.precession_angle.abs() > max_precession_angle {
2431 max_precession_angle = state.precession_angle.abs();
2432 }
2433 }
2434 }
2435
2436 position = accepted_step.position;
2437 velocity = accepted_step.velocity;
2438 time += accepted_step.used_dt;
2439 self.validate_integration_state(&position, &velocity, time)?;
2440
2441 dt = accepted_step.next_dt;
2443 }
2444
2445 if points.is_empty() {
2447 return Err(BallisticsError::from("No trajectory points calculated"));
2448 }
2449
2450 let termination =
2452 self.append_terminal_endpoint(&mut points, position, velocity, time, &mut max_height)?;
2453
2454 let last_point = points.last().unwrap();
2455
2456 let sampled_points = if self.inputs.enable_trajectory_sampling {
2458 let trajectory_data = TrajectoryData {
2460 times: points.iter().map(|p| p.time).collect(),
2461 positions: points.iter().map(|p| p.position).collect(),
2462 velocities: points
2463 .iter()
2464 .map(|p| {
2465 Vector3::new(0.0, 0.0, p.velocity_magnitude)
2467 })
2468 .collect(),
2469 transonic_distances, };
2471
2472 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
2477 let outputs = TrajectoryOutputs {
2478 target_distance_horiz_m: last_point.position.x,
2479 target_vertical_height_m: sight_position_m,
2480 time_of_flight_s: last_point.time,
2481 max_ord_dist_horiz_m: max_height,
2482 sight_height_m: sight_position_m,
2483 };
2484
2485 let samples = sample_trajectory(
2486 &trajectory_data,
2487 &outputs,
2488 self.inputs.sample_interval,
2489 self.inputs.bullet_mass,
2490 )?;
2491 Some(samples)
2492 } else {
2493 None
2494 };
2495
2496 Ok(TrajectoryResult {
2497 max_range: last_point.position.x, max_height,
2499 time_of_flight: last_point.time,
2500 impact_velocity: last_point.velocity_magnitude,
2501 impact_energy: last_point.kinetic_energy,
2502 projectile_mass_kg: self.inputs.bullet_mass,
2503 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
2504 station_speed_of_sound_mps: speed_of_sound,
2505 termination,
2506 points,
2507 sampled_points,
2508 min_pitch_damping: if self.inputs.enable_pitch_damping {
2509 Some(min_pitch_damping)
2510 } else {
2511 None
2512 },
2513 transonic_mach,
2514 angular_state,
2515 max_yaw_angle: if self.inputs.enable_precession_nutation {
2516 Some(max_yaw_angle)
2517 } else {
2518 None
2519 },
2520 max_precession_angle: if self.inputs.enable_precession_nutation {
2521 Some(max_precession_angle)
2522 } else {
2523 None
2524 },
2525 aerodynamic_jump: aj_components,
2526 })
2527 }
2528
2529 fn adaptive_rk45_step(
2530 &self,
2531 position: &Vector3<f64>,
2532 velocity: &Vector3<f64>,
2533 initial_dt: f64,
2534 wind_vector: &Vector3<f64>,
2535 resolved_atmo: (f64, f64, f64),
2536 ) -> Rk45AcceptedStep {
2537 let mut trial_dt = initial_dt;
2538
2539 loop {
2540 let trial = self.rk45_step(
2541 position,
2542 velocity,
2543 trial_dt,
2544 wind_vector,
2545 RK45_TOLERANCE,
2546 resolved_atmo,
2547 );
2548 let next_dt = if trial.suggested_dt.is_finite() {
2553 (RK45_SAFETY_FACTOR * trial.suggested_dt).clamp(RK45_MIN_DT, RK45_MAX_DT)
2554 } else {
2555 RK45_MIN_DT
2556 };
2557
2558 if trial.error <= RK45_TOLERANCE || trial_dt <= RK45_MIN_DT {
2559 return Rk45AcceptedStep {
2560 position: trial.position,
2561 velocity: trial.velocity,
2562 used_dt: trial_dt,
2563 next_dt,
2564 error: trial.error,
2565 };
2566 }
2567
2568 trial_dt = next_dt;
2569 }
2570 }
2571
2572 fn rk45_step(
2573 &self,
2574 position: &Vector3<f64>,
2575 velocity: &Vector3<f64>,
2576 dt: f64,
2577 wind_vector: &Vector3<f64>,
2578 tolerance: f64,
2579 resolved_atmo: (f64, f64, f64), ) -> Rk45Trial {
2581 const A21: f64 = 1.0 / 5.0;
2583 const A31: f64 = 3.0 / 40.0;
2584 const A32: f64 = 9.0 / 40.0;
2585 const A41: f64 = 44.0 / 45.0;
2586 const A42: f64 = -56.0 / 15.0;
2587 const A43: f64 = 32.0 / 9.0;
2588 const A51: f64 = 19372.0 / 6561.0;
2589 const A52: f64 = -25360.0 / 2187.0;
2590 const A53: f64 = 64448.0 / 6561.0;
2591 const A54: f64 = -212.0 / 729.0;
2592 const A61: f64 = 9017.0 / 3168.0;
2593 const A62: f64 = -355.0 / 33.0;
2594 const A63: f64 = 46732.0 / 5247.0;
2595 const A64: f64 = 49.0 / 176.0;
2596 const A65: f64 = -5103.0 / 18656.0;
2597 const A71: f64 = 35.0 / 384.0;
2598 const A73: f64 = 500.0 / 1113.0;
2599 const A74: f64 = 125.0 / 192.0;
2600 const A75: f64 = -2187.0 / 6784.0;
2601 const A76: f64 = 11.0 / 84.0;
2602
2603 const B1: f64 = 35.0 / 384.0;
2605 const B3: f64 = 500.0 / 1113.0;
2606 const B4: f64 = 125.0 / 192.0;
2607 const B5: f64 = -2187.0 / 6784.0;
2608 const B6: f64 = 11.0 / 84.0;
2609
2610 const B1_ERR: f64 = 5179.0 / 57600.0;
2612 const B3_ERR: f64 = 7571.0 / 16695.0;
2613 const B4_ERR: f64 = 393.0 / 640.0;
2614 const B5_ERR: f64 = -92097.0 / 339200.0;
2615 const B6_ERR: f64 = 187.0 / 2100.0;
2616 const B7_ERR: f64 = 1.0 / 40.0;
2617
2618 let k1_v = self.calculate_acceleration(position, velocity, wind_vector, resolved_atmo);
2620 let k1_p = *velocity;
2621
2622 let p2 = position + dt * A21 * k1_p;
2623 let v2 = velocity + dt * A21 * k1_v;
2624 let k2_v = self.calculate_acceleration(&p2, &v2, wind_vector, resolved_atmo);
2625 let k2_p = v2;
2626
2627 let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
2628 let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
2629 let k3_v = self.calculate_acceleration(&p3, &v3, wind_vector, resolved_atmo);
2630 let k3_p = v3;
2631
2632 let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
2633 let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
2634 let k4_v = self.calculate_acceleration(&p4, &v4, wind_vector, resolved_atmo);
2635 let k4_p = v4;
2636
2637 let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
2638 let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
2639 let k5_v = self.calculate_acceleration(&p5, &v5, wind_vector, resolved_atmo);
2640 let k5_p = v5;
2641
2642 let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
2643 let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
2644 let k6_v = self.calculate_acceleration(&p6, &v6, wind_vector, resolved_atmo);
2645 let k6_p = v6;
2646
2647 let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
2648 let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
2649 let k7_v = self.calculate_acceleration(&p7, &v7, wind_vector, resolved_atmo);
2650 let k7_p = v7;
2651
2652 let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
2654 let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
2655
2656 let pos_err = position
2658 + dt * (B1_ERR * k1_p
2659 + B3_ERR * k3_p
2660 + B4_ERR * k4_p
2661 + B5_ERR * k5_p
2662 + B6_ERR * k6_p
2663 + B7_ERR * k7_p);
2664 let vel_err = velocity
2665 + dt * (B1_ERR * k1_v
2666 + B3_ERR * k3_v
2667 + B4_ERR * k4_v
2668 + B5_ERR * k5_v
2669 + B6_ERR * k6_v
2670 + B7_ERR * k7_v);
2671
2672 let error = cli_rk45_error_norm(position, velocity, &new_pos, &new_vel, &pos_err, &vel_err);
2674
2675 let dt_new = if error < tolerance {
2677 dt * (tolerance / error).powf(0.2).min(2.0)
2678 } else {
2679 dt * (tolerance / error).powf(0.25).max(0.1)
2680 };
2681
2682 Rk45Trial {
2683 position: new_pos,
2684 velocity: new_vel,
2685 suggested_dt: dt_new,
2686 error,
2687 }
2688 }
2689
2690 fn apply_cluster_bc_correction(&self, base_bc: f64, velocity_fps: f64) -> f64 {
2691 if let Some(ref cluster_bc) = self.cluster_bc {
2692 cluster_bc.apply_correction_for_drag_model(
2693 base_bc,
2694 self.inputs.caliber_inches,
2695 self.inputs.weight_grains,
2696 velocity_fps,
2697 self.inputs.bc_type,
2698 )
2699 } else {
2700 base_bc
2701 }
2702 }
2703
2704 fn calculate_acceleration(
2705 &self,
2706 position: &Vector3<f64>,
2707 velocity: &Vector3<f64>,
2708 wind_vector: &Vector3<f64>,
2709 resolved_atmo: (f64, f64, f64), ) -> Vector3<f64> {
2711 let actual_wind = if let Some(ref sock) = self.wind_sock {
2717 sock.vector_for_range_stateless(position.x)
2718 } else if self.inputs.enable_wind_shear {
2719 self.get_wind_at_altitude(position.y)
2720 } else {
2721 *wind_vector
2722 };
2723 let actual_wind =
2724 crate::derivatives::level_vector_to_shot_frame(actual_wind, self.inputs.shooting_angle);
2725
2726 let relative_velocity = velocity - actual_wind;
2727 let velocity_magnitude = relative_velocity.magnitude();
2728
2729 if velocity_magnitude < 0.001 {
2730 return self.gravity_acceleration();
2731 }
2732
2733 let (base_temp_c, base_press_hpa, station_ratio) = resolved_atmo;
2744
2745 let (drag_base_temp_c, drag_base_press_hpa, drag_base_ratio, drag_humidity_percent) =
2753 if let Some(ref sock) = self.atmo_sock {
2754 let (zone_temp_c, zone_press_hpa, zone_humidity) = sock.atmo_for_range(position.x);
2755 let zone_base_ratio = crate::atmosphere::calculate_air_density_cimp(
2756 zone_temp_c,
2757 zone_press_hpa,
2758 zone_humidity,
2759 ) / 1.225;
2760 (zone_temp_c, zone_press_hpa, zone_base_ratio, zone_humidity)
2761 } else {
2762 (
2763 base_temp_c,
2764 base_press_hpa,
2765 station_ratio,
2766 self.atmosphere.humidity,
2767 )
2768 };
2769 let local_alt = crate::atmosphere::shot_frame_altitude(
2770 self.atmosphere.altitude,
2771 position.x,
2772 position.y,
2773 self.inputs.shooting_angle,
2774 );
2775 let (air_density, speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
2776 local_alt,
2777 self.atmosphere.altitude,
2778 drag_base_temp_c,
2779 drag_base_press_hpa,
2780 drag_base_ratio,
2781 drag_humidity_percent,
2782 );
2783
2784 let cd = self.calculate_drag_coefficient(velocity_magnitude, speed_of_sound);
2786
2787 let velocity_fps = velocity_magnitude * 3.28084;
2789
2790 let (base_bc, bc_from_segments) = if let Some(segments) = self
2795 .inputs
2796 .bc_segments_data
2797 .as_ref()
2798 .filter(|segments| self.inputs.use_bc_segments && !segments.is_empty())
2799 {
2800 (
2802 crate::bc_estimation::velocity_segment_bc(
2803 velocity_fps,
2804 segments,
2805 self.inputs.bc_value,
2806 ),
2807 true,
2808 )
2809 } else if let Some(segments) = self
2810 .inputs
2811 .bc_segments
2812 .as_ref()
2813 .filter(|segments| !segments.is_empty())
2814 {
2815 (
2816 crate::derivatives::interpolated_bc(
2817 velocity_magnitude / speed_of_sound,
2818 segments,
2819 Some(&self.inputs),
2820 ),
2821 true,
2822 )
2823 } else {
2824 (self.inputs.bc_value, false)
2825 };
2826
2827 let effective_bc = if bc_from_segments {
2832 base_bc
2833 } else {
2834 self.apply_cluster_bc_correction(base_bc, velocity_fps)
2835 };
2836 let effective_bc = effective_bc.max(1e-6);
2839
2840 let retard_denom = if self.inputs.custom_drag_table.is_some() {
2845 self.inputs.custom_drag_denominator(effective_bc)
2846 } else {
2847 effective_bc
2848 };
2849
2850 let cd_to_retard = crate::constants::CD_TO_RETARD;
2855 let standard_factor = cd * cd_to_retard;
2856 let density_scale = air_density / 1.225; let a_drag_ft_s2 =
2860 (velocity_fps * velocity_fps) * standard_factor * density_scale / retard_denom;
2861 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
2865
2866 let mut accel = drag_acceleration + self.gravity_acceleration();
2869
2870 if self.inputs.enable_coriolis {
2873 if let Some(lat_deg) = self.inputs.latitude {
2874 let omega_earth = 7.2921159e-5_f64; let lat = lat_deg.to_radians();
2876 let az = self.inputs.shot_azimuth; let omega = Vector3::new(
2883 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), );
2887 let omega = crate::derivatives::level_vector_to_shot_frame(
2888 omega,
2889 self.inputs.shooting_angle,
2890 );
2891 accel += -2.0 * omega.cross(velocity);
2896 }
2897 }
2898
2899 if self.inputs.enable_magnus
2906 && !self.inputs.use_enhanced_spin_drift
2907 && self.inputs.bullet_diameter > 0.0
2908 && self.inputs.twist_rate > 0.0
2909 {
2910 let diameter_m = self.inputs.bullet_diameter;
2911 let (spin_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
2912 self.inputs.muzzle_velocity,
2913 velocity_magnitude,
2914 self.inputs.twist_rate,
2915 diameter_m,
2916 );
2917 let mach = velocity_magnitude / speed_of_sound;
2919
2920 let d_in = self.inputs.bullet_diameter / 0.0254;
2922 let m_gr = self.inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
2923 let l_in = if self.inputs.bullet_length > 0.0 {
2924 self.inputs.bullet_length / 0.0254
2925 } else {
2926 let est_m = crate::stability::estimate_bullet_length_m(
2928 self.inputs.bullet_diameter,
2929 self.inputs.bullet_mass,
2930 );
2931 if est_m > 0.0 {
2932 est_m / 0.0254
2933 } else {
2934 4.5 * d_in
2935 }
2936 };
2937 let sg = crate::spin_drift::calculate_dynamic_stability(
2941 m_gr,
2942 velocity_magnitude,
2943 spin_rad_s,
2944 d_in,
2945 l_in,
2946 air_density,
2947 );
2948
2949 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
2951 sg,
2952 velocity_magnitude,
2953 spin_rad_s,
2954 0.0, 0.0, air_density,
2957 d_in,
2958 l_in,
2959 m_gr,
2960 mach,
2961 "match",
2962 false,
2963 );
2964
2965 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
2967 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
2968 let magnus_force = 0.5
2969 * air_density
2970 * velocity_magnitude.powi(2)
2971 * area
2972 * c_np
2973 * spin_param
2974 * yaw_rad.sin();
2975
2976 if magnus_force.abs() > 1e-12 {
2980 if let Some(dir) = crate::derivatives::yaw_of_repose_magnus_direction(
2981 relative_velocity,
2982 self.gravity_acceleration(),
2983 self.inputs.is_twist_right,
2984 ) {
2985 accel += (magnus_force / self.inputs.bullet_mass) * dir;
2986 }
2987 }
2988 }
2989
2990 accel
2991 }
2992
2993 fn calculate_drag_coefficient(&self, velocity: f64, speed_of_sound: f64) -> f64 {
2994 let mach = velocity / speed_of_sound;
2995
2996 if let Some(ref table) = self.inputs.custom_drag_table {
3000 return table.interpolate(mach) * self.inputs.cd_scale;
3005 }
3006
3007 crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type)
3010 }
3011}
3012
3013#[derive(Debug, Clone)]
3015pub struct MonteCarloParams {
3016 pub num_simulations: usize,
3017 pub velocity_std_dev: f64,
3018 pub angle_std_dev: f64,
3019 pub bc_std_dev: f64,
3020 pub wind_speed_std_dev: f64,
3021 pub target_distance: Option<f64>,
3022 pub base_wind_speed: f64,
3023 pub base_wind_direction: f64,
3024 pub azimuth_std_dev: f64, }
3026
3027impl Default for MonteCarloParams {
3028 fn default() -> Self {
3029 Self {
3030 num_simulations: 1000,
3031 velocity_std_dev: 1.0,
3032 angle_std_dev: 0.001,
3033 bc_std_dev: 0.01,
3034 wind_speed_std_dev: 1.0,
3035 target_distance: None,
3036 base_wind_speed: 0.0,
3037 base_wind_direction: 0.0,
3038 azimuth_std_dev: 0.001, }
3040 }
3041}
3042
3043#[derive(Debug, Clone)]
3045pub struct MonteCarloResults {
3046 pub ranges: Vec<f64>,
3047 pub impact_velocities: Vec<f64>,
3048 pub impact_positions: Vec<Vector3<f64>>,
3054}
3055
3056pub const DEFAULT_HIT_RADIUS_M: f64 = 0.3;
3059
3060pub const TARGET_NOT_REACHED_SENTINEL_M: f64 = -1.0e9;
3066
3067impl MonteCarloResults {
3068 pub fn position_reached_target(position: &Vector3<f64>) -> bool {
3070 position.iter().all(|component| component.is_finite())
3071 && position.y != TARGET_NOT_REACHED_SENTINEL_M
3072 }
3073
3074 pub fn target_arrival_count(&self) -> usize {
3076 self.impact_positions
3077 .iter()
3078 .filter(|position| Self::position_reached_target(position))
3079 .count()
3080 }
3081
3082 pub fn target_shortfall_fraction(&self) -> f64 {
3085 if self.impact_positions.is_empty() {
3086 return 0.0;
3087 }
3088 (self.impact_positions.len() - self.target_arrival_count()) as f64
3089 / self.impact_positions.len() as f64
3090 }
3091
3092 pub fn target_plane_cep(&self) -> Option<f64> {
3098 let mut radial_misses: Vec<f64> = self
3099 .impact_positions
3100 .iter()
3101 .filter(|position| Self::position_reached_target(position))
3102 .map(Vector3::norm)
3103 .filter(|miss| miss.is_finite())
3104 .collect();
3105 radial_misses.sort_by(f64::total_cmp);
3106 if radial_misses.is_empty() {
3107 None
3108 } else {
3109 Some(radial_misses[radial_misses.len() / 2])
3110 }
3111 }
3112
3113 pub fn hit_probability(&self, hit_radius_m: f64) -> f64 {
3122 if self.impact_positions.is_empty() {
3123 return 0.0;
3124 }
3125 let hits = self
3126 .impact_positions
3127 .iter()
3128 .filter(|position| {
3129 Self::position_reached_target(position) && position.norm() < hit_radius_m
3130 })
3131 .count();
3132 hits as f64 / self.impact_positions.len() as f64
3133 }
3134
3135 pub fn rect_hit_probability(&self, width_m: f64, height_m: f64) -> f64 {
3147 let dimensions_invalid = width_m.is_nan()
3148 || width_m <= 0.0
3149 || height_m.is_nan()
3150 || height_m <= 0.0;
3151 if self.impact_positions.is_empty() || dimensions_invalid {
3152 return 0.0;
3153 }
3154 let half_width = width_m / 2.0;
3155 let half_height = height_m / 2.0;
3156 let hits = self
3157 .impact_positions
3158 .iter()
3159 .filter(|position| {
3160 Self::position_reached_target(position)
3161 && position.z.abs() <= half_width
3162 && position.y.abs() <= half_height
3163 })
3164 .count();
3165 hits as f64 / self.impact_positions.len() as f64
3166 }
3167}
3168
3169fn wind_from_signed_speed_sample(
3170 signed_speed: f64,
3171 sampled_direction: f64,
3172 vertical_speed: f64,
3173) -> WindConditions {
3174 if signed_speed < 0.0 {
3179 WindConditions {
3180 speed: -signed_speed,
3181 direction: sampled_direction + std::f64::consts::PI,
3182 vertical_speed,
3183 }
3184 } else {
3185 WindConditions {
3186 speed: signed_speed,
3187 direction: sampled_direction,
3188 vertical_speed,
3189 }
3190 }
3191}
3192
3193struct MonteCarloWindSampler {
3194 speed: rand_distr::Normal<f64>,
3195 direction: rand_distr::Normal<f64>,
3196 vertical_speed: f64,
3198}
3199
3200impl MonteCarloWindSampler {
3201 fn new(
3202 base_wind: &WindConditions,
3203 wind_speed_std_dev: f64,
3204 wind_direction_std_dev: f64,
3205 ) -> Result<Self, BallisticsError> {
3206 use rand_distr::Normal;
3207
3208 if !wind_direction_std_dev.is_finite() || wind_direction_std_dev < 0.0 {
3209 return Err("Wind direction standard deviation must be finite and non-negative".into());
3210 }
3211
3212 let speed = Normal::new(base_wind.speed, wind_speed_std_dev)
3213 .map_err(|e| format!("Invalid wind speed distribution: {e}"))?;
3214 let direction = Normal::new(base_wind.direction, wind_direction_std_dev)
3215 .map_err(|e| format!("Invalid wind direction distribution: {e}"))?;
3216 Ok(Self { speed, direction, vertical_speed: base_wind.vertical_speed })
3217 }
3218
3219 fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> WindConditions {
3220 use rand_distr::Distribution;
3221
3222 wind_from_signed_speed_sample(
3223 self.speed.sample(rng),
3224 self.direction.sample(rng),
3225 self.vertical_speed,
3226 )
3227 }
3228}
3229
3230pub fn run_monte_carlo(
3232 base_inputs: BallisticInputs,
3233 params: MonteCarloParams,
3234) -> Result<MonteCarloResults, BallisticsError> {
3235 run_monte_carlo_with_direction_std_dev(base_inputs, params, 0.0)
3236}
3237
3238pub fn run_monte_carlo_with_direction_std_dev(
3243 base_inputs: BallisticInputs,
3244 params: MonteCarloParams,
3245 wind_direction_std_dev: f64,
3246) -> Result<MonteCarloResults, BallisticsError> {
3247 let base_wind = WindConditions {
3248 speed: params.base_wind_speed,
3249 direction: params.base_wind_direction,
3250 vertical_speed: 0.0,
3251 };
3252 run_monte_carlo_with_wind_and_direction_std_dev(
3253 base_inputs,
3254 base_wind,
3255 params,
3256 wind_direction_std_dev,
3257 )
3258}
3259
3260pub fn run_monte_carlo_with_wind(
3262 base_inputs: BallisticInputs,
3263 base_wind: WindConditions,
3264 params: MonteCarloParams,
3265) -> Result<MonteCarloResults, BallisticsError> {
3266 run_monte_carlo_with_wind_and_direction_std_dev(base_inputs, base_wind, params, 0.0)
3267}
3268
3269pub fn run_monte_carlo_with_wind_and_direction_std_dev(
3274 base_inputs: BallisticInputs,
3275 base_wind: WindConditions,
3276 params: MonteCarloParams,
3277 wind_direction_std_dev: f64,
3278) -> Result<MonteCarloResults, BallisticsError> {
3279 let mut rng = rand::rng();
3280 run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
3281 base_inputs,
3282 base_wind,
3283 params,
3284 wind_direction_std_dev,
3285 &mut rng,
3286 )
3287}
3288
3289pub fn run_monte_carlo_with_wind_and_direction_std_dev_seeded(
3296 base_inputs: BallisticInputs,
3297 base_wind: WindConditions,
3298 params: MonteCarloParams,
3299 wind_direction_std_dev: f64,
3300 seed: u64,
3301) -> Result<MonteCarloResults, BallisticsError> {
3302 use rand::{rngs::StdRng, SeedableRng};
3303 let mut rng = StdRng::seed_from_u64(seed);
3304 run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
3305 base_inputs,
3306 base_wind,
3307 params,
3308 wind_direction_std_dev,
3309 &mut rng,
3310 )
3311}
3312
3313fn run_monte_carlo_with_wind_and_direction_std_dev_using_rng<R: rand::Rng + ?Sized>(
3314 base_inputs: BallisticInputs,
3315 base_wind: WindConditions,
3316 params: MonteCarloParams,
3317 wind_direction_std_dev: f64,
3318 rng: &mut R,
3319) -> Result<MonteCarloResults, BallisticsError> {
3320 use rand_distr::{Distribution, Normal};
3321
3322 let mut ranges = Vec::new();
3323 let mut impact_velocities = Vec::new();
3324 let mut impact_positions = Vec::new();
3325
3326 let atmosphere = AtmosphericConditions {
3327 temperature: base_inputs.temperature,
3328 pressure: base_inputs.pressure,
3329 humidity: base_inputs.humidity_percent(),
3330 altitude: base_inputs.altitude,
3331 };
3332 let target_hint = params
3333 .target_distance
3334 .unwrap_or(base_inputs.target_distance);
3335 let solver_max_range = target_hint.max(1000.0) * 2.0;
3336
3337 let mut baseline_solver =
3339 TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), atmosphere.clone());
3340 baseline_solver.set_max_range(solver_max_range);
3341 let baseline_result = baseline_solver.solve()?;
3342
3343 let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
3345
3346 let baseline_at_target = baseline_result
3348 .position_at_range(target_distance)
3349 .ok_or("Could not interpolate baseline at target distance")?;
3350
3351 let velocity_delta_dist = Normal::new(0.0, params.velocity_std_dev)
3356 .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
3357 let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
3358 .map_err(|e| format!("Invalid angle distribution: {}", e))?;
3359 let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
3360 .map_err(|e| format!("Invalid BC distribution: {}", e))?;
3361 let wind_sampler = MonteCarloWindSampler::new(
3364 &base_wind,
3365 params.wind_speed_std_dev,
3366 wind_direction_std_dev,
3367 )?;
3368 let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
3369 .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
3370
3371 for _ in 0..params.num_simulations {
3372 let mut inputs = base_inputs.clone();
3374 let muzzle_velocity_delta = velocity_delta_dist.sample(&mut *rng);
3375 inputs.muzzle_angle = angle_dist.sample(&mut *rng);
3376 inputs.bc_value = bc_dist.sample(&mut *rng).max(0.01);
3377 inputs.azimuth_angle = azimuth_dist.sample(&mut *rng); let wind = wind_sampler.sample(&mut *rng);
3381
3382 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere.clone());
3384 solver.inputs.muzzle_velocity =
3385 (solver.inputs.muzzle_velocity + muzzle_velocity_delta).max(0.0);
3386 solver.set_max_range(solver_max_range);
3387 match solver.solve() {
3388 Ok(result) => {
3389 let deviation = if result.max_range < target_distance {
3395 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)
3398 } else {
3399 let pos_at_target = match result.position_at_range(target_distance) {
3400 Some(p) => p,
3401 None => continue, };
3403 Vector3::new(
3408 0.0,
3409 pos_at_target.y - baseline_at_target.y,
3410 pos_at_target.z - baseline_at_target.z,
3411 )
3412 };
3413
3414 ranges.push(result.max_range);
3415 impact_velocities.push(result.impact_velocity);
3416 impact_positions.push(deviation);
3417 }
3418 Err(_) => {
3419 continue;
3421 }
3422 }
3423 }
3424
3425 if ranges.is_empty() {
3426 return Err("No successful simulations".into());
3427 }
3428
3429 Ok(MonteCarloResults {
3430 ranges,
3431 impact_velocities,
3432 impact_positions,
3433 })
3434}
3435
3436pub fn calculate_zero_angle(
3438 inputs: BallisticInputs,
3439 target_distance: f64,
3440 target_height: f64,
3441) -> Result<f64, BallisticsError> {
3442 calculate_zero_angle_with_conditions(
3443 inputs,
3444 target_distance,
3445 target_height,
3446 WindConditions::default(),
3447 AtmosphericConditions::default(),
3448 )
3449}
3450
3451pub fn calculate_zero_angle_with_conditions(
3452 inputs: BallisticInputs,
3453 target_distance: f64,
3454 target_height: f64,
3455 wind: WindConditions,
3456 atmosphere: AtmosphericConditions,
3457) -> Result<f64, BallisticsError> {
3458 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
3459 solver.calculate_and_set_zero_angle(target_distance, target_height)
3460}
3461
3462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3464pub enum BcFitMode {
3465 Drop,
3467 Velocity,
3470}
3471
3472#[derive(Debug, Clone, Copy)]
3474pub struct BcEstimate {
3475 pub bc: f64,
3477 pub rms_error: f64,
3479 pub drag_model: DragModel,
3481 pub mode: BcFitMode,
3483 pub at_bound: bool,
3487}
3488
3489fn fit_value_at(
3497 points: &[TrajectoryPoint],
3498 target_dist: f64,
3499 mode: BcFitMode,
3500 drop_offset: f64,
3501) -> Option<f64> {
3502 let val = |p: &TrajectoryPoint| match mode {
3503 BcFitMode::Drop => drop_offset - p.position.y,
3504 BcFitMode::Velocity => p.velocity_magnitude,
3505 };
3506 for i in 0..points.len() {
3507 if points[i].position.x >= target_dist {
3508 if i == 0 {
3509 return Some(val(&points[0]));
3510 }
3511 let p1 = &points[i - 1];
3512 let p2 = &points[i];
3513 let dx = p2.position.x - p1.position.x;
3514 if dx.abs() < 1e-9 {
3515 return Some(val(p2));
3516 }
3517 let t = (target_dist - p1.position.x) / dx;
3518 return Some(val(p1) + t * (val(p2) - val(p1)));
3519 }
3520 }
3521 None
3522}
3523
3524fn fit_residual_sse(
3525 trajectory: &[TrajectoryPoint],
3526 observations: &[(f64, f64)],
3527 mode: BcFitMode,
3528 drop_offset: f64,
3529) -> Option<f64> {
3530 if observations.is_empty() {
3531 return None;
3532 }
3533 let mut total = 0.0;
3534 for (target_dist, target_val) in observations {
3535 let value = fit_value_at(trajectory, *target_dist, mode, drop_offset)?;
3538 let error = value - target_val;
3539 total += error * error;
3540 }
3541 Some(total)
3542}
3543
3544#[allow(clippy::too_many_arguments)] pub fn estimate_bc_fit(
3562 velocity: f64,
3563 mass: f64,
3564 diameter: f64,
3565 points: &[(f64, f64)],
3566 drag_model: DragModel,
3567 mode: BcFitMode,
3568 atmosphere: AtmosphericConditions,
3569 zero_range: Option<f64>,
3570 sight_height: f64,
3571) -> Result<BcEstimate, BallisticsError> {
3572 if points.is_empty() {
3573 return Err(BallisticsError::from(
3574 "No data points provided for BC estimation.".to_string(),
3575 ));
3576 }
3577 let max_dist = points.iter().map(|(d, _)| *d).fold(0.0_f64, f64::max);
3578 let drop_offset = if zero_range.is_some() { sight_height } else { 0.0 };
3581
3582 let sse = |bc_value: f64| -> Option<f64> {
3584 let mut inputs = BallisticInputs {
3585 muzzle_velocity: velocity,
3586 bc_value,
3587 bc_type: drag_model,
3588 bullet_mass: mass,
3589 bullet_diameter: diameter,
3590 sight_height,
3591 ..Default::default()
3592 };
3593 if let Some(zr) = zero_range {
3596 let za = calculate_zero_angle_with_conditions(
3602 inputs.clone(),
3603 zr,
3604 sight_height,
3605 WindConditions::default(),
3606 atmosphere.clone(),
3607 )
3608 .ok()?;
3609 inputs.muzzle_angle = za;
3610 }
3611 let mut solver =
3612 TrajectorySolver::new(inputs, WindConditions::default(), atmosphere.clone());
3613 solver.set_max_range(max_dist * 1.5);
3614 let result = solver.solve().ok()?;
3615 fit_residual_sse(&result.points, points, mode, drop_offset)
3616 };
3617
3618 let (bc_min, bc_max) = match drag_model {
3622 DragModel::G7 => (0.05, 0.70),
3623 _ => (0.10, 1.20),
3624 };
3625
3626 let mut best_bc = f64::NAN;
3628 let mut best_sse = f64::MAX;
3629 let mut bc = bc_min;
3630 while bc <= bc_max + 1e-9 {
3631 if let Some(s) = sse(bc) {
3632 if s < best_sse {
3633 best_sse = s;
3634 best_bc = bc;
3635 }
3636 }
3637 bc += 0.01;
3638 }
3639 if !best_bc.is_finite() {
3640 return Err(BallisticsError::from(
3641 "Unable to estimate BC from provided data. Check that the values and units are correct."
3642 .to_string(),
3643 ));
3644 }
3645
3646 let lo = (best_bc - 0.01).max(bc_min);
3648 let hi = (best_bc + 0.01).min(bc_max);
3649 let mut bc = lo;
3650 while bc <= hi + 1e-9 {
3651 if let Some(s) = sse(bc) {
3652 if s < best_sse {
3653 best_sse = s;
3654 best_bc = bc;
3655 }
3656 }
3657 bc += 0.001;
3658 }
3659
3660 let at_bound = best_bc <= bc_min + 0.011 || best_bc >= bc_max - 0.011;
3663 let rms_error = (best_sse / points.len() as f64).sqrt();
3666 Ok(BcEstimate {
3667 bc: best_bc,
3668 rms_error,
3669 drag_model,
3670 mode,
3671 at_bound,
3672 })
3673}
3674
3675pub fn estimate_bc_from_trajectory(
3678 velocity: f64,
3679 mass: f64,
3680 diameter: f64,
3681 points: &[(f64, f64)], ) -> Result<f64, BallisticsError> {
3683 estimate_bc_fit(
3684 velocity,
3685 mass,
3686 diameter,
3687 points,
3688 DragModel::G1,
3689 BcFitMode::Drop,
3690 AtmosphericConditions::default(),
3691 None,
3692 0.05,
3693 )
3694 .map(|e| e.bc)
3695}
3696
3697use rand;
3699use rand_distr;
3700
3701#[cfg(test)]
3702mod mba737_powder_resolution_tests {
3703 use super::*;
3704
3705 #[test]
3706 fn linear_model_cold_powder_subtracts() {
3707 let v = resolve_powder_adjusted_velocity(823.0, 11.1, true, 0.5486, 21.1, None, None);
3709 assert!((v - (823.0 + 0.5486 * (11.1 - 21.1))).abs() < 1e-12);
3710 assert!(v < 823.0);
3711 }
3712
3713 #[test]
3714 fn linear_model_hot_powder_adds() {
3715 let v = resolve_powder_adjusted_velocity(823.0, 31.1, true, 0.5486, 21.1, None, None);
3716 assert!((v - (823.0 + 0.5486 * 10.0)).abs() < 1e-12);
3717 }
3718
3719 #[test]
3720 fn disabled_flag_is_passthrough() {
3721 let v = resolve_powder_adjusted_velocity(823.0, 40.0, false, 0.5486, 21.1, None, None);
3722 assert_eq!(v, 823.0);
3723 }
3724
3725 #[test]
3726 fn curve_overrides_linear_and_interpolates_at_powder_temp() {
3727 let curve = [(4.4, 798.6), (21.1, 823.0), (37.8, 841.2)];
3728 let v = resolve_powder_adjusted_velocity(823.0, 30.0, true, 99.0, 21.1, Some(&curve), Some(4.4));
3730 assert!((v - 798.6).abs() < 1e-9);
3731 }
3732
3733 #[test]
3734 fn curve_falls_back_to_ambient_and_clamps() {
3735 let curve = [(4.4, 798.6), (37.8, 841.2)];
3736 let v = resolve_powder_adjusted_velocity(823.0, -40.0, true, 1.0, 21.1, Some(&curve), None);
3738 assert!((v - 798.6).abs() < 1e-9);
3739 let v_hot = resolve_powder_adjusted_velocity(823.0, 60.0, true, 1.0, 21.1, Some(&curve), None);
3740 assert!((v_hot - 841.2).abs() < 1e-9);
3741 }
3742
3743 #[test]
3744 fn empty_curve_suppresses_linear_fallback() {
3745 let v = resolve_powder_adjusted_velocity(823.0, 40.0, true, 0.5486, 21.1, Some(&[]), None);
3747 assert_eq!(v, 823.0);
3748 }
3749
3750 #[test]
3751 fn sweep_huge_range_errors_instead_of_overflowing() {
3752 assert!(parse_powder_sweep("0:1e20:1").is_err());
3755 assert!(parse_powder_sweep("0:1e308:1e-3").is_err());
3756 }
3757
3758 #[test]
3759 fn sweep_fractional_step_keeps_end_row() {
3760 let rows = parse_powder_sweep("0:0.3:0.1").unwrap();
3762 assert_eq!(rows.len(), 4);
3763 assert!((rows[3] - 0.3).abs() < 1e-9);
3764 }
3765
3766 #[test]
3767 fn solver_and_helper_agree_on_linear_model() {
3768 let inputs = BallisticInputs {
3770 use_powder_sensitivity: true,
3771 powder_temp_sensitivity: 0.5486,
3772 powder_temp: 21.1,
3773 temperature: 4.4,
3774 ..Default::default()
3775 };
3776 let expected = resolve_powder_adjusted_velocity(
3777 inputs.muzzle_velocity,
3778 inputs.temperature,
3779 true,
3780 0.5486,
3781 21.1,
3782 None,
3783 None,
3784 );
3785 let solver = TrajectorySolver::new(
3786 inputs,
3787 WindConditions::default(),
3788 AtmosphericConditions::default(),
3789 );
3790 assert!((solver.inputs.muzzle_velocity - expected).abs() < 1e-12);
3791 }
3792}
3793
3794#[cfg(test)]
3795mod mba1302_solver_seam_tests {
3796 use super::*;
3797 use crate::wind::WindSegment;
3798
3799 #[test]
3800 fn authoritative_station_atmosphere_preserves_explicit_standard_values_at_altitude() {
3801 let atmosphere = AtmosphericConditions {
3802 temperature: 15.0,
3803 pressure: 1013.25,
3804 humidity: 50.0,
3805 altitude: 2_000.0,
3806 };
3807 let legacy = TrajectorySolver::new(
3808 BallisticInputs::default(),
3809 WindConditions::default(),
3810 atmosphere.clone(),
3811 );
3812 let authoritative = TrajectorySolver::new_with_resolved_station_atmosphere(
3813 BallisticInputs::default(),
3814 WindConditions::default(),
3815 atmosphere,
3816 );
3817
3818 let (legacy_density, _, legacy_temp_c, legacy_pressure_hpa) = legacy.resolved_atmosphere();
3819 let (authoritative_density, _, authoritative_temp_c, authoritative_pressure_hpa) =
3820 authoritative.resolved_atmosphere();
3821 let (icao_temp_k, icao_pressure_pa) =
3822 crate::atmosphere::calculate_icao_standard_atmosphere(2_000.0);
3823 let (expected_authoritative_density, _) =
3824 crate::atmosphere::calculate_atmosphere(2_000.0, Some(15.0), Some(1013.25), 50.0);
3825
3826 assert!((legacy_temp_c - (icao_temp_k - 273.15)).abs() < 1e-12);
3827 assert!((legacy_pressure_hpa - icao_pressure_pa / 100.0).abs() < 1e-12);
3828 assert_eq!(authoritative_temp_c.to_bits(), 15.0_f64.to_bits());
3829 assert_eq!(authoritative_pressure_hpa.to_bits(), 1013.25_f64.to_bits());
3830 assert_eq!(
3831 authoritative_density.to_bits(),
3832 expected_authoritative_density.to_bits()
3833 );
3834 assert!(
3835 (authoritative_density - legacy_density).abs() > 0.1,
3836 "explicit standard values at altitude must differ from ICAO-at-altitude: explicit={authoritative_density}, ICAO={legacy_density}"
3837 );
3838 }
3839
3840 fn configured_euler_zero(vertical_wind_mps: f64, time_step_s: f64) -> TrajectorySolver {
3841 let inputs = BallisticInputs {
3842 muzzle_velocity: 800.0,
3843 bc_value: 0.5,
3844 bc_type: DragModel::G7,
3845 bullet_mass: 0.0109,
3846 bullet_diameter: 0.00782,
3847 bullet_length: 0.0309,
3848 sight_height: 0.05,
3849 ground_threshold: -100.0,
3850 use_rk4: false,
3851 use_adaptive_rk45: false,
3852 ..BallisticInputs::default()
3853 };
3854 let mut solver = TrajectorySolver::new_with_resolved_station_atmosphere(
3855 inputs,
3856 WindConditions::default(),
3857 AtmosphericConditions::default(),
3858 );
3859 solver.set_max_range(300.0);
3860 solver.set_time_step(time_step_s);
3861 if vertical_wind_mps != 0.0 {
3862 solver.set_wind_segments(vec![WindSegment {
3863 speed_kmh: 0.0,
3864 angle_deg: 0.0,
3865 until_m: 400.0,
3866 vertical_mps: vertical_wind_mps,
3867 }]);
3868 }
3869 solver
3870 }
3871
3872 #[test]
3873 fn configured_zero_keeps_segments_method_and_time_step_then_sets_base_angle() {
3874 const TARGET_DISTANCE_M: f64 = 150.0;
3875 const TARGET_HEIGHT_M: f64 = 0.05;
3876
3877 let mut segmented = configured_euler_zero(-10.0, 0.02);
3880 let coarse_height = segmented
3881 .zero_trial_height_at(0.0, TARGET_DISTANCE_M)
3882 .expect("coarse configured trial")
3883 .expect("coarse trial reaches target");
3884 let mut fine = segmented.clone();
3885 fine.set_time_step(0.001);
3886 let fine_height = fine
3887 .zero_trial_height_at(0.0, TARGET_DISTANCE_M)
3888 .expect("fine configured trial")
3889 .expect("fine trial reaches target");
3890 assert!(
3891 (coarse_height - fine_height).abs() > 1e-5,
3892 "configured Euler step must affect zero trials: coarse={coarse_height}, fine={fine_height}"
3893 );
3894
3895 let segmented_angle = segmented
3896 .calculate_and_set_zero_angle(TARGET_DISTANCE_M, TARGET_HEIGHT_M)
3897 .expect("segmented zero");
3898 assert_eq!(
3899 segmented.inputs.muzzle_angle.to_bits(),
3900 segmented_angle.to_bits(),
3901 "successful zero must install its angle on the configured solver"
3902 );
3903 assert_eq!(segmented.time_step.to_bits(), 0.02_f64.to_bits());
3904 assert_eq!(segmented.max_range.to_bits(), 300.0_f64.to_bits());
3905 assert!(segmented.wind_sock.is_some());
3906 assert_eq!(
3907 segmented.station_atmosphere_resolution,
3908 StationAtmosphereResolution::Authoritative
3909 );
3910 let zero_height = segmented
3911 .zero_trial_height_at(segmented_angle, TARGET_DISTANCE_M)
3912 .expect("verify segmented zero")
3913 .expect("zeroed trial reaches target");
3914 assert!(
3915 (zero_height - TARGET_HEIGHT_M).abs() < 0.0001,
3916 "configured zero missed target: height={zero_height}"
3917 );
3918
3919 let mut calm = configured_euler_zero(0.0, 0.02);
3920 let calm_angle = calm
3921 .calculate_and_set_zero_angle(TARGET_DISTANCE_M, TARGET_HEIGHT_M)
3922 .expect("calm zero");
3923 assert!(
3924 (segmented_angle - calm_angle).abs() > 1e-5,
3925 "segmented vertical wind must participate in zero trials: segmented={segmented_angle}, calm={calm_angle}"
3926 );
3927 }
3928}
3929
3930#[cfg(test)]
3931mod result_sanity_tests {
3932 use super::*;
3933
3934 fn default_solver() -> TrajectorySolver {
3935 TrajectorySolver::new(
3936 BallisticInputs::default(),
3937 WindConditions::default(),
3938 AtmosphericConditions::default(),
3939 )
3940 }
3941
3942 fn minimal_result() -> TrajectoryResult {
3943 TrajectoryResult {
3944 max_range: 100.0,
3945 max_height: 1.0,
3946 time_of_flight: 0.5,
3947 impact_velocity: 700.0,
3948 impact_energy: 2450.0,
3949 projectile_mass_kg: 0.01,
3950 line_of_sight_height_m: 1.5,
3951 station_speed_of_sound_mps: 340.0,
3952 termination: TrajectoryTermination::MaxRange,
3953 points: vec![],
3954 sampled_points: None,
3955 min_pitch_damping: None,
3956 transonic_mach: None,
3957 angular_state: None,
3958 max_yaw_angle: None,
3959 max_precession_angle: None,
3960 aerodynamic_jump: None,
3961 }
3962 }
3963
3964 #[test]
3965 fn mba1293_negative_scalars_fail_the_result_postcondition() {
3966 let solver = default_solver();
3967 solver
3968 .validate_result_sanity(&minimal_result())
3969 .expect("a sane result must pass");
3970
3971 for (name, mutate) in [
3972 ("max_range", (|r| r.max_range = -50.588) as fn(&mut TrajectoryResult)),
3973 ("time_of_flight", |r| r.time_of_flight = -1.0),
3974 ("impact_velocity", |r| r.impact_velocity = -700.0),
3975 ("impact_energy", |r| r.impact_energy = -1.0),
3976 ] {
3977 let mut result = minimal_result();
3978 mutate(&mut result);
3979 let error = solver
3980 .validate_result_sanity(&result)
3981 .expect_err("negative scalar must fail");
3982 assert!(
3983 error.to_string().contains(name),
3984 "error for {name} did not name the field: {error}"
3985 );
3986 }
3987 }
3988
3989 #[test]
3990 fn mba1293_speed_budget_bounds_legitimate_states_and_rejects_divergence() {
3991 let solver = default_solver();
3992 let mv = solver.inputs.muzzle_velocity;
3993
3994 let position = Vector3::new(10.0, 0.0, 0.0);
3996 solver
3997 .validate_integration_state(&position, &Vector3::new(mv, 0.0, 0.0), 0.01)
3998 .expect("muzzle-speed state must pass");
3999
4000 let error = solver
4002 .validate_integration_state(&position, &Vector3::new(-13.0 * mv, 0.0, 0.0), 0.01)
4003 .expect_err("13x muzzle speed must fail the budget");
4004 assert!(error.to_string().contains("diverged"), "{error}");
4005
4006 let after_fall = mv + crate::constants::G_ACCEL_MPS2 * 60.0;
4008 solver
4009 .validate_integration_state(&position, &Vector3::new(0.0, -after_fall, 0.0), 60.0)
4010 .expect("gravity-accelerated speed within g*t must pass");
4011 }
4012}
4013
4014#[cfg(test)]
4015mod trajectory_point_budget_tests {
4016 use super::*;
4017 use crate::MAX_TRAJECTORY_SAMPLES;
4018
4019 fn solver_with_budget(
4020 use_rk4: bool,
4021 use_adaptive_rk45: bool,
4022 point_budget: usize,
4023 max_range: f64,
4024 ) -> TrajectorySolver {
4025 let inputs = BallisticInputs {
4026 use_rk4,
4027 use_adaptive_rk45,
4028 ground_threshold: f64::NEG_INFINITY,
4029 ..BallisticInputs::default()
4030 };
4031 let mut solver = TrajectorySolver::new(
4032 inputs,
4033 WindConditions::default(),
4034 AtmosphericConditions::default(),
4035 );
4036 solver.max_trajectory_points = point_budget;
4037 solver.set_max_range(max_range);
4038 solver.set_time_step(0.001);
4039 solver
4040 }
4041
4042 #[test]
4043 fn mba1283_every_solver_errors_instead_of_exceeding_point_budget() {
4044 for (mode, use_rk4, use_adaptive_rk45) in [
4045 ("Euler", false, false),
4046 ("RK4", true, false),
4047 ("RK45", true, true),
4048 ] {
4049 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 3, 10.0)
4050 .solve()
4051 .expect_err("a solve requiring more than three points must fail");
4052 assert!(
4053 error.to_string().contains("point limit of 3"),
4054 "unexpected {mode} point-budget error: {error}"
4055 );
4056 }
4057 }
4058
4059 #[test]
4060 fn mba1283_interpolated_endpoint_counts_toward_point_budget() {
4061 for (mode, use_rk4, use_adaptive_rk45) in [
4062 ("Euler", false, false),
4063 ("RK4", true, false),
4064 ("RK45", true, true),
4065 ] {
4066 let result = solver_with_budget(use_rk4, use_adaptive_rk45, 2, 0.1)
4067 .solve()
4068 .expect("the initial point plus exact endpoint fit a two-point budget");
4069 assert_eq!(result.points.len(), 2, "unexpected {mode} point count");
4070
4071 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 1, 0.1)
4072 .solve()
4073 .expect_err("the exact endpoint must not exceed a one-point budget");
4074 assert!(
4075 error.to_string().contains("point limit of 1"),
4076 "unexpected {mode} endpoint-budget error: {error}"
4077 );
4078 }
4079 }
4080
4081 #[test]
4082 fn mba1299_every_solver_preflights_the_sample_budget() {
4083 for (mode, use_rk4, use_adaptive_rk45) in [
4084 ("Euler", false, false),
4085 ("RK4", true, false),
4086 ("RK45", true, true),
4087 ] {
4088 let inputs = BallisticInputs {
4089 use_rk4,
4090 use_adaptive_rk45,
4091 enable_trajectory_sampling: true,
4092 sample_interval: 1.0,
4093 ground_threshold: f64::NEG_INFINITY,
4094 ..BallisticInputs::default()
4095 };
4096 let mut solver = TrajectorySolver::new(
4097 inputs,
4098 WindConditions::default(),
4099 AtmosphericConditions::default(),
4100 );
4101 solver.set_max_range(MAX_TRAJECTORY_SAMPLES as f64);
4102 solver.max_trajectory_points = 0;
4105
4106 let error = solver
4107 .solve()
4108 .expect_err("an over-limit sample grid must fail before integration");
4109 assert!(
4110 error
4111 .to_string()
4112 .contains("trajectory sample limit of 250000 exceeded"),
4113 "unexpected {mode} sample-budget error: {error}"
4114 );
4115 }
4116 }
4117
4118 #[test]
4119 fn mba1299_normal_sampling_does_not_change_solver_results() {
4120 for (mode, use_rk4, use_adaptive_rk45) in [
4121 ("Euler", false, false),
4122 ("RK4", true, false),
4123 ("RK45", true, true),
4124 ] {
4125 let solve = |enable_trajectory_sampling| {
4126 let inputs = BallisticInputs {
4127 use_rk4,
4128 use_adaptive_rk45,
4129 enable_trajectory_sampling,
4130 sample_interval: 0.5,
4131 ground_threshold: f64::NEG_INFINITY,
4132 ..BallisticInputs::default()
4133 };
4134 let mut solver = TrajectorySolver::new(
4135 inputs,
4136 WindConditions::default(),
4137 AtmosphericConditions::default(),
4138 );
4139 solver.set_max_range(2.0);
4140 solver.solve().expect("normal short-range solve")
4141 };
4142
4143 let baseline = solve(false);
4144 let sampled = solve(true);
4145 for (field, left, right) in [
4146 ("max_range", baseline.max_range, sampled.max_range),
4147 ("max_height", baseline.max_height, sampled.max_height),
4148 (
4149 "time_of_flight",
4150 baseline.time_of_flight,
4151 sampled.time_of_flight,
4152 ),
4153 (
4154 "impact_velocity",
4155 baseline.impact_velocity,
4156 sampled.impact_velocity,
4157 ),
4158 (
4159 "impact_energy",
4160 baseline.impact_energy,
4161 sampled.impact_energy,
4162 ),
4163 ] {
4164 assert_eq!(
4165 left.to_bits(),
4166 right.to_bits(),
4167 "{mode} sampling changed {field}"
4168 );
4169 }
4170 assert_eq!(baseline.points.len(), sampled.points.len());
4171 for (index, (left, right)) in baseline
4172 .points
4173 .iter()
4174 .zip(&sampled.points)
4175 .enumerate()
4176 {
4177 assert_eq!(left.time.to_bits(), right.time.to_bits(), "{mode} point {index}");
4178 assert_eq!(
4179 left.position.map(f64::to_bits),
4180 right.position.map(f64::to_bits),
4181 "{mode} point {index} position"
4182 );
4183 assert_eq!(
4184 left.velocity_magnitude.to_bits(),
4185 right.velocity_magnitude.to_bits(),
4186 "{mode} point {index} velocity"
4187 );
4188 assert_eq!(
4189 left.kinetic_energy.to_bits(),
4190 right.kinetic_energy.to_bits(),
4191 "{mode} point {index} energy"
4192 );
4193 }
4194 assert!(baseline.sampled_points.is_none());
4195 let samples = sampled
4196 .sampled_points
4197 .expect("sampling-enabled solve should return observations");
4198 assert_eq!(
4199 samples
4200 .iter()
4201 .map(|sample| sample.distance_m)
4202 .collect::<Vec<_>>(),
4203 vec![0.0, 0.5, 1.0, 1.5, 2.0],
4204 "{mode} normal sampling grid changed"
4205 );
4206 }
4207 }
4208}
4209
4210#[cfg(test)]
4211mod monte_carlo_result_tests {
4212 use super::*;
4213
4214 fn make_results(impact_positions: Vec<Vector3<f64>>) -> MonteCarloResults {
4215 let count = impact_positions.len();
4216 MonteCarloResults {
4217 ranges: vec![500.0; count],
4218 impact_velocities: vec![300.0; count],
4219 impact_positions,
4220 }
4221 }
4222
4223 #[test]
4224 fn target_plane_cep_excludes_shortfall_markers() {
4225 let mut positions: Vec<Vector3<f64>> = (1..=5)
4226 .map(|radius| Vector3::new(0.0, radius as f64, 0.0))
4227 .collect();
4228 positions.extend(
4229 (0..5).map(|_| Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)),
4230 );
4231 let results = make_results(positions);
4232
4233 assert_eq!(results.target_arrival_count(), 5);
4234 assert_eq!(results.target_shortfall_fraction(), 0.5);
4235 assert_eq!(results.target_plane_cep(), Some(3.0));
4236
4237 let one_shortfall = make_results(vec![
4238 Vector3::new(0.0, 1.0, 0.0),
4239 Vector3::new(0.0, 2.0, 0.0),
4240 Vector3::new(0.0, 3.0, 0.0),
4241 Vector3::new(0.0, 4.0, 0.0),
4242 Vector3::new(0.0, 5.0, 0.0),
4243 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4244 ]);
4245 assert_eq!(one_shortfall.target_plane_cep(), Some(3.0));
4246 }
4247
4248 #[test]
4249 fn all_shortfalls_have_no_cep_but_still_count_as_misses() {
4250 let all_shortfalls = make_results(vec![
4251 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4252 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4253 ]);
4254 assert_eq!(all_shortfalls.target_arrival_count(), 0);
4255 assert_eq!(all_shortfalls.target_shortfall_fraction(), 1.0);
4256 assert_eq!(all_shortfalls.target_plane_cep(), None);
4257 assert_eq!(all_shortfalls.hit_probability(0.3), 0.0);
4258
4259 let one_hit_one_shortfall = make_results(vec![
4260 Vector3::new(0.0, 0.1, 0.0),
4261 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4262 ]);
4263 assert_eq!(one_hit_one_shortfall.hit_probability(0.3), 0.5);
4264 }
4265
4266 #[test]
4268 fn rect_hit_probability_checks_independent_axis_halves() {
4269 let results = make_results(vec![
4270 Vector3::new(0.0, 0.1, 0.1),
4272 Vector3::new(0.0, 0.0, 0.2),
4274 Vector3::new(0.0, 0.0, 0.201),
4276 Vector3::new(0.0, 0.301, 0.0),
4278 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4280 ]);
4281 assert!((results.rect_hit_probability(0.4, 0.6) - 0.4).abs() < 1e-12);
4283 }
4284
4285 #[test]
4286 fn rect_hit_probability_matches_circular_hit_probability_for_a_centered_hit() {
4287 let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
4288 assert_eq!(results.rect_hit_probability(0.5, 0.5), 1.0);
4289 assert_eq!(results.hit_probability(0.3), 1.0);
4290 }
4291
4292 #[test]
4293 fn rect_hit_probability_is_zero_for_empty_or_nonpositive_dimensions() {
4294 let empty = make_results(vec![]);
4295 assert_eq!(empty.rect_hit_probability(1.0, 1.0), 0.0);
4296
4297 let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
4298 assert_eq!(results.rect_hit_probability(0.0, 1.0), 0.0);
4299 assert_eq!(results.rect_hit_probability(1.0, 0.0), 0.0);
4300 assert_eq!(results.rect_hit_probability(-1.0, 1.0), 0.0);
4301 }
4302}
4303
4304#[cfg(test)]
4305mod monte_carlo_seeded_tests {
4306 use super::*;
4307
4308 #[test]
4309 fn seeded_runs_are_deterministic_and_match_the_using_rng_path() {
4310 let inputs = BallisticInputs {
4311 muzzle_velocity: 800.0,
4312 ..BallisticInputs::default()
4313 };
4314 let params = MonteCarloParams {
4315 num_simulations: 64,
4316 target_distance: Some(200.0),
4317 ..MonteCarloParams::default()
4318 };
4319
4320 let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4321 inputs.clone(),
4322 WindConditions::default(),
4323 params.clone(),
4324 0.01,
4325 42,
4326 )
4327 .expect("seeded run a");
4328 let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4329 inputs,
4330 WindConditions::default(),
4331 params,
4332 0.01,
4333 42,
4334 )
4335 .expect("seeded run b");
4336
4337 assert_eq!(a.ranges.len(), b.ranges.len());
4338 for (ra, rb) in a.ranges.iter().zip(b.ranges.iter()) {
4339 assert_eq!(ra.to_bits(), rb.to_bits());
4340 }
4341 for (pa, pb) in a.impact_positions.iter().zip(b.impact_positions.iter()) {
4342 assert_eq!(pa.x.to_bits(), pb.x.to_bits());
4343 assert_eq!(pa.y.to_bits(), pb.y.to_bits());
4344 assert_eq!(pa.z.to_bits(), pb.z.to_bits());
4345 }
4346 }
4347
4348 #[test]
4349 fn different_seeds_generally_produce_different_draws() {
4350 let inputs = BallisticInputs {
4351 muzzle_velocity: 800.0,
4352 ..BallisticInputs::default()
4353 };
4354 let params = MonteCarloParams {
4355 num_simulations: 32,
4356 velocity_std_dev: 5.0,
4357 target_distance: Some(200.0),
4358 ..MonteCarloParams::default()
4359 };
4360
4361 let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4362 inputs.clone(),
4363 WindConditions::default(),
4364 params.clone(),
4365 0.0,
4366 1,
4367 )
4368 .expect("seeded run a");
4369 let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4370 inputs,
4371 WindConditions::default(),
4372 params,
4373 0.0,
4374 2,
4375 )
4376 .expect("seeded run b");
4377
4378 assert_ne!(a.impact_velocities, b.impact_velocities);
4379 }
4380}
4381
4382#[cfg(test)]
4383mod monte_carlo_powder_curve_tests {
4384 use super::*;
4385 use rand::{rngs::StdRng, SeedableRng};
4386
4387 #[test]
4388 fn powder_curve_preserves_sampled_muzzle_velocity_dispersion() {
4389 let inputs = BallisticInputs {
4390 muzzle_velocity: 700.0,
4391 powder_temp_curve: Some(vec![(15.0, 800.0)]),
4392 powder_curve_temp_c: Some(15.0),
4393 ..BallisticInputs::default()
4394 };
4395 let params = MonteCarloParams {
4396 num_simulations: 16,
4397 velocity_std_dev: 20.0,
4398 angle_std_dev: 1e-12,
4399 bc_std_dev: 1e-12,
4400 wind_speed_std_dev: 1e-12,
4401 target_distance: Some(100.0),
4402 azimuth_std_dev: 1e-12,
4403 ..MonteCarloParams::default()
4404 };
4405
4406 let mut rng = StdRng::seed_from_u64(0x5EED_1176);
4407 let results = run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
4408 inputs,
4409 WindConditions::default(),
4410 params,
4411 0.0,
4412 &mut rng,
4413 )
4414 .expect("Monte Carlo solve");
4415 let min_velocity = results
4416 .impact_velocities
4417 .iter()
4418 .copied()
4419 .fold(f64::INFINITY, f64::min);
4420 let max_velocity = results
4421 .impact_velocities
4422 .iter()
4423 .copied()
4424 .fold(f64::NEG_INFINITY, f64::max);
4425
4426 assert!(
4427 max_velocity - min_velocity > 1.0,
4428 "20 m/s muzzle spread collapsed after curve resolution: impact-velocity span={} m/s",
4429 max_velocity - min_velocity
4430 );
4431 }
4432}
4433
4434#[cfg(test)]
4435mod monte_carlo_wind_sampling_tests {
4436 use super::*;
4437 use rand::{rngs::StdRng, SeedableRng};
4438
4439 #[test]
4440 fn wind_speed_sigma_does_not_change_seeded_direction_draws() {
4441 let base_wind = WindConditions {
4442 speed: 100.0,
4443 direction: 0.37,
4444 vertical_speed: 0.0,
4445 };
4446 let narrow_speed = MonteCarloWindSampler::new(&base_wind, 0.5, 0.2).unwrap();
4447 let wide_speed = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
4448 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
4449 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
4450 let mut speed_changed = false;
4451
4452 for _ in 0..32 {
4453 let narrow = narrow_speed.sample(&mut narrow_rng);
4454 let wide = wide_speed.sample(&mut wide_rng);
4455 assert!(narrow.speed > 0.0 && wide.speed > 0.0);
4456 assert_eq!(narrow.direction.to_bits(), wide.direction.to_bits());
4457 speed_changed |= narrow.speed.to_bits() != wide.speed.to_bits();
4458 }
4459 assert!(
4460 speed_changed,
4461 "different speed sigmas must still vary speed draws"
4462 );
4463 }
4464
4465 #[test]
4466 fn zero_direction_sigma_has_no_angular_jitter() {
4467 let base_wind = WindConditions {
4468 speed: 100.0,
4469 direction: 0.37,
4470 vertical_speed: 0.0,
4471 };
4472 let sampler = MonteCarloWindSampler::new(&base_wind, 4.0, 0.0).unwrap();
4473 let mut rng = StdRng::seed_from_u64(0x5EED_1223);
4474 let mut speed_changed = false;
4475
4476 for _ in 0..32 {
4477 let wind = sampler.sample(&mut rng);
4478 speed_changed |= wind.speed.to_bits() != base_wind.speed.to_bits();
4479 assert_eq!(wind.direction.to_bits(), base_wind.direction.to_bits());
4480 }
4481 assert!(speed_changed, "speed uncertainty should remain active");
4482 }
4483
4484 #[test]
4485 fn direction_sigma_controls_seeded_angular_spread_in_radians() {
4486 let base_wind = WindConditions {
4487 speed: 100.0,
4488 direction: 0.37,
4489 vertical_speed: 0.0,
4490 };
4491 let narrow = MonteCarloWindSampler::new(&base_wind, 4.0, 0.1).unwrap();
4492 let wide = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
4493 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
4494 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
4495 let mut nonzero_direction_draw = false;
4496
4497 for _ in 0..32 {
4498 let narrow_wind = narrow.sample(&mut narrow_rng);
4499 let wide_wind = wide.sample(&mut wide_rng);
4500 assert_eq!(narrow_wind.speed.to_bits(), wide_wind.speed.to_bits());
4501
4502 let narrow_delta = narrow_wind.direction - base_wind.direction;
4503 let wide_delta = wide_wind.direction - base_wind.direction;
4504 assert!((wide_delta - 2.0 * narrow_delta).abs() < 1e-12);
4505 nonzero_direction_draw |= narrow_delta.abs() > 1e-6;
4506 }
4507 assert!(
4508 nonzero_direction_draw,
4509 "positive radians sigma must vary direction"
4510 );
4511 }
4512
4513 #[test]
4514 fn direction_sigma_rejects_negative_or_nonfinite_values() {
4515 let base_wind = WindConditions::default();
4516 for sigma in [-0.1, f64::NAN, f64::INFINITY] {
4517 assert!(MonteCarloWindSampler::new(&base_wind, 1.0, sigma).is_err());
4518 }
4519 }
4520
4521 #[test]
4522 fn base_vertical_wind_rides_into_every_mc_sample() {
4523 use rand::SeedableRng;
4527 let base_wind = WindConditions { vertical_speed: 4.2, ..Default::default() };
4528 let sampler = MonteCarloWindSampler::new(&base_wind, 1.0, 0.2).unwrap();
4529 let mut rng = rand::rngs::StdRng::seed_from_u64(7);
4530 for _ in 0..32 {
4531 let w = sampler.sample(&mut rng);
4532 assert_eq!(w.vertical_speed, 4.2);
4533 }
4534 }
4535
4536 #[test]
4537 fn negative_speed_sample_reverses_wind_direction() {
4538 let direction = 0.25;
4539 let signed_speed = -2.5;
4540 let wind = wind_from_signed_speed_sample(signed_speed, direction, 0.0);
4541 let positive_wind = wind_from_signed_speed_sample(2.5, direction, 0.0);
4542
4543 assert_eq!(wind.speed, 2.5);
4544 assert!(
4545 (wind.direction - (direction + std::f64::consts::PI)).abs() < f64::EPSILON,
4546 "negative speed must reverse direction by pi: got {}",
4547 wind.direction
4548 );
4549 assert_eq!(positive_wind.speed, 2.5);
4550 assert_eq!(positive_wind.direction, direction);
4551
4552 let normalized_x = -wind.speed * wind.direction.cos();
4553 let normalized_z = -wind.speed * wind.direction.sin();
4554 let signed_x = -signed_speed * direction.cos();
4555 let signed_z = -signed_speed * direction.sin();
4556 assert!((normalized_x - signed_x).abs() < 1e-12);
4557 assert!((normalized_z - signed_z).abs() < 1e-12);
4558 }
4559}
4560
4561#[cfg(test)]
4562mod bc_fit_objective_tests {
4563 use super::*;
4564
4565 fn velocity_point(range_m: f64, velocity_mps: f64) -> TrajectoryPoint {
4566 TrajectoryPoint {
4567 time: 0.0,
4568 position: Vector3::new(range_m, 0.0, 0.0),
4569 velocity_magnitude: velocity_mps,
4570 kinetic_energy: 0.0,
4571 }
4572 }
4573
4574 #[test]
4575 fn candidate_that_misses_an_observation_has_no_score() {
4576 let trajectory = vec![velocity_point(0.0, 800.0), velocity_point(100.0, 700.0)];
4577 let observations = vec![(50.0, 750.0), (150.0, 600.0)];
4578
4579 assert!(
4580 fit_residual_sse(&trajectory, &observations, BcFitMode::Velocity, 0.0).is_none(),
4581 "a candidate that reaches only one of two observations must not compete on partial SSE"
4582 );
4583
4584 let complete_observations = vec![(50.0, 740.0), (100.0, 680.0)];
4585 assert_eq!(
4586 fit_residual_sse(
4587 &trajectory,
4588 &complete_observations,
4589 BcFitMode::Velocity,
4590 0.0,
4591 ),
4592 Some(500.0)
4593 );
4594 }
4595}
4596
4597#[cfg(test)]
4598mod cluster_bc_reference_space_tests {
4599 use super::*;
4600
4601 fn acceleration_at_1100_fps(inputs: BallisticInputs) -> Vector3<f64> {
4602 let solver = TrajectorySolver::new(
4603 inputs,
4604 WindConditions::default(),
4605 AtmosphericConditions::default(),
4606 );
4607 let position = Vector3::zeros();
4608 let velocity = Vector3::new(1100.0 / 3.28084, 0.0, 0.0);
4609 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4610 solver.calculate_acceleration(
4611 &position,
4612 &velocity,
4613 &Vector3::zeros(),
4614 (temp_c, pressure_hpa, density / 1.225),
4615 )
4616 }
4617
4618 #[test]
4619 fn solver_passes_g7_reference_model_to_cluster_classifier() {
4620 let inputs = BallisticInputs {
4621 bc_value: 0.190,
4622 bc_type: DragModel::G7,
4623 bullet_mass: 77.0 * crate::constants::GRAINS_TO_KG,
4624 bullet_diameter: 0.224 * 0.0254,
4625 use_cluster_bc: true,
4626 ..BallisticInputs::default()
4627 };
4628
4629 let solver = TrajectorySolver::new(
4630 inputs,
4631 WindConditions::default(),
4632 AtmosphericConditions::default(),
4633 );
4634 let corrected = solver.apply_cluster_bc_correction(0.190, 2800.0);
4635
4636 assert!(
4637 (corrected / 0.190 - 1.004).abs() < 1e-12,
4638 "solver selected the wrong G7 cluster multiplier: {}",
4639 corrected / 0.190
4640 );
4641 }
4642
4643 #[test]
4644 fn velocity_bc_segments_are_not_cluster_corrected_twice() {
4645 let segmented_clustered = BallisticInputs {
4646 bc_value: 0.5,
4647 bc_type: DragModel::G7,
4648 use_bc_segments: true,
4649 bc_segments_data: Some(vec![
4650 crate::BCSegmentData {
4651 velocity_min: 0.0,
4652 velocity_max: 1_600.0,
4653 bc_value: 0.4,
4654 },
4655 crate::BCSegmentData {
4656 velocity_min: 1_600.0,
4657 velocity_max: 5_000.0,
4658 bc_value: 0.45,
4659 },
4660 ]),
4661 use_cluster_bc: true,
4662 ..BallisticInputs::default()
4663 };
4664 let mut segmented_only = segmented_clustered.clone();
4665 segmented_only.use_cluster_bc = false;
4666 let mut constant_clustered = segmented_clustered.clone();
4667 constant_clustered.bc_value = 0.4;
4668 constant_clustered.bc_segments_data = None;
4669
4670 let stacked = acceleration_at_1100_fps(segmented_clustered);
4671 let segment_only = acceleration_at_1100_fps(segmented_only);
4672 let cluster_only = acceleration_at_1100_fps(constant_clustered);
4673
4674 assert!(
4675 (stacked.x - segment_only.x).abs() < 1e-12,
4676 "segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
4677 stacked.x,
4678 segment_only.x
4679 );
4680 assert!(
4681 (cluster_only.x - segment_only.x).abs() > 1e-6,
4682 "cluster correction must remain active for a constant BC"
4683 );
4684 }
4685
4686 #[test]
4687 fn mach_bc_segments_are_not_cluster_corrected_twice() {
4688 let mach_segmented_clustered = BallisticInputs {
4689 bc_value: 0.5,
4690 bc_type: DragModel::G7,
4691 use_bc_segments: false,
4692 bc_segments: Some(vec![(0.5, 0.3), (1.5, 0.5)]),
4693 use_cluster_bc: true,
4694 ..BallisticInputs::default()
4695 };
4696 let mut mach_segmented_only = mach_segmented_clustered.clone();
4697 mach_segmented_only.use_cluster_bc = false;
4698
4699 let stacked = acceleration_at_1100_fps(mach_segmented_clustered);
4700 let segment_only = acceleration_at_1100_fps(mach_segmented_only);
4701
4702 assert!(
4703 (stacked.x - segment_only.x).abs() < 1e-12,
4704 "Mach segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
4705 stacked.x,
4706 segment_only.x
4707 );
4708 }
4709}
4710
4711#[cfg(test)]
4712mod velocity_bc_flag_tests {
4713 use super::*;
4714
4715 fn acceleration_at_600_mps(inputs: BallisticInputs) -> Vector3<f64> {
4716 let solver = TrajectorySolver::new(
4717 inputs,
4718 WindConditions::default(),
4719 AtmosphericConditions::default(),
4720 );
4721 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4722 solver.calculate_acceleration(
4723 &Vector3::zeros(),
4724 &Vector3::new(600.0, 0.0, 0.0),
4725 &Vector3::zeros(),
4726 (temp_c, pressure_hpa, density / 1.225),
4727 )
4728 }
4729
4730 #[test]
4731 fn velocity_bc_data_requires_opt_in_in_trajectory_solver() {
4732 let scalar_inputs = BallisticInputs {
4733 bc_value: 0.5,
4734 bc_type: DragModel::G7,
4735 ..BallisticInputs::default()
4736 };
4737 let mut disabled_inputs = scalar_inputs.clone();
4738 disabled_inputs.bc_segments_data = Some(vec![crate::BCSegmentData {
4739 velocity_min: 0.0,
4740 velocity_max: 4_000.0,
4741 bc_value: 0.46,
4742 }]);
4743 disabled_inputs.use_bc_segments = false;
4744 let mut enabled_inputs = disabled_inputs.clone();
4745 enabled_inputs.use_bc_segments = true;
4746 let mut mach_only_inputs = scalar_inputs.clone();
4747 mach_only_inputs.bc_segments = Some(vec![(0.0, 0.4), (3.0, 0.4)]);
4748 let mut disabled_with_both = mach_only_inputs.clone();
4749 disabled_with_both.bc_segments_data = disabled_inputs.bc_segments_data.clone();
4750
4751 let scalar = acceleration_at_600_mps(scalar_inputs);
4752 let disabled = acceleration_at_600_mps(disabled_inputs);
4753 let enabled = acceleration_at_600_mps(enabled_inputs);
4754 let mach_only = acceleration_at_600_mps(mach_only_inputs);
4755 let disabled_with_both = acceleration_at_600_mps(disabled_with_both);
4756
4757 assert_eq!(
4758 disabled.x.to_bits(),
4759 scalar.x.to_bits(),
4760 "a populated velocity table must not change drag while use_bc_segments is false"
4761 );
4762 assert!(
4763 enabled.x < disabled.x - 1.0,
4764 "enabling the lower BC table must increase drag: disabled ax={} enabled ax={}",
4765 disabled.x,
4766 enabled.x
4767 );
4768 assert_eq!(
4769 disabled_with_both.x.to_bits(),
4770 mach_only.x.to_bits(),
4771 "disabling velocity data must fall through to an explicit Mach table"
4772 );
4773 }
4774}
4775
4776#[cfg(test)]
4777mod mach_bc_segment_tests {
4778 use super::*;
4779
4780 #[test]
4781 fn trajectory_solver_interpolates_explicit_mach_bc_segments() {
4782 let segmented_inputs = BallisticInputs {
4783 bc_value: 0.8,
4784 use_bc_segments: false,
4785 bc_segments: Some(vec![(1.0, 0.2), (2.0, 0.4)]),
4786 bc_segments_data: None,
4787 ..BallisticInputs::default()
4788 };
4789
4790 let mut expected_inputs = segmented_inputs.clone();
4791 expected_inputs.bc_value = 0.3;
4792 expected_inputs.bc_segments = None;
4793
4794 let atmosphere = AtmosphericConditions::default();
4795 let segmented_solver = TrajectorySolver::new(
4796 segmented_inputs,
4797 WindConditions::default(),
4798 atmosphere.clone(),
4799 );
4800 let expected_solver = TrajectorySolver::new(
4801 expected_inputs,
4802 WindConditions::default(),
4803 atmosphere,
4804 );
4805 let position = Vector3::zeros();
4806 let (density, _, temp_c, pressure_hpa) = segmented_solver.resolved_atmosphere();
4807 let (_, local_speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
4808 segmented_solver.atmosphere.altitude,
4809 segmented_solver.atmosphere.altitude,
4810 temp_c,
4811 pressure_hpa,
4812 density / 1.225,
4813 segmented_solver.atmosphere.humidity,
4814 );
4815 let velocity = Vector3::new(1.5 * local_speed_of_sound, 0.0, 0.0);
4816 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
4817
4818 let segmented_acceleration = segmented_solver.calculate_acceleration(
4819 &position,
4820 &velocity,
4821 &Vector3::zeros(),
4822 resolved_atmo,
4823 );
4824 let expected_acceleration = expected_solver.calculate_acceleration(
4825 &position,
4826 &velocity,
4827 &Vector3::zeros(),
4828 resolved_atmo,
4829 );
4830
4831 assert!(
4832 (segmented_acceleration.x - expected_acceleration.x).abs() < 1e-12,
4833 "Mach 1.5 must interpolate BC 0.3: segmented ax={} expected ax={}",
4834 segmented_acceleration.x,
4835 expected_acceleration.x
4836 );
4837 }
4838}
4839
4840#[cfg(test)]
4841mod custom_drag_table_validation_tests {
4842 use super::*;
4843
4844 #[test]
4845 fn solve_accepts_zero_bc_when_custom_table_present() {
4846 let inputs = BallisticInputs {
4847 bc_value: 0.0, bullet_mass: 0.0106,
4849 bullet_diameter: 0.00782,
4850 muzzle_velocity: 850.0,
4851 custom_drag_table: Some(crate::drag::DragTable::new(
4852 vec![0.5, 1.0, 2.0, 3.0],
4853 vec![0.23, 0.40, 0.30, 0.26],
4854 )),
4855 ..BallisticInputs::default()
4856 };
4857 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
4858 assert!(solver.solve().is_ok());
4860 }
4861
4862 #[test]
4863 fn solve_still_requires_bc_without_table() {
4864 let inputs = BallisticInputs {
4865 bc_value: 0.0,
4866 bullet_mass: 0.0106,
4867 bullet_diameter: 0.00782,
4868 muzzle_velocity: 850.0,
4869 ..BallisticInputs::default()
4870 };
4871 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
4872 assert!(solver.solve().is_err());
4873 }
4874}
4875
4876#[cfg(test)]
4878mod cd_scale_tests {
4879 use super::*;
4880
4881 fn deck() -> crate::drag::DragTable {
4882 crate::drag::DragTable::new(vec![0.5, 1.0, 2.0, 3.0], vec![0.23, 0.40, 0.30, 0.26])
4883 }
4884
4885 fn deck_inputs(cd_scale: f64) -> BallisticInputs {
4886 BallisticInputs {
4887 bullet_mass: 0.0106,
4888 bullet_diameter: 0.00782,
4889 muzzle_velocity: 850.0,
4890 custom_drag_table: Some(deck()),
4891 cd_scale,
4892 ..BallisticInputs::default()
4893 }
4894 }
4895
4896 #[test]
4897 fn default_cd_scale_is_one() {
4898 assert_eq!(BallisticInputs::default().cd_scale, 1.0);
4899 }
4900
4901 #[test]
4904 fn cd_scale_absent_is_byte_identical_to_explicit_one() {
4905 let omitted = BallisticInputs {
4906 bullet_mass: 0.0106,
4907 bullet_diameter: 0.00782,
4908 muzzle_velocity: 850.0,
4909 custom_drag_table: Some(deck()),
4910 ..BallisticInputs::default()
4911 };
4912 let explicit = BallisticInputs {
4913 cd_scale: 1.0,
4914 ..omitted.clone()
4915 };
4916
4917 let solver_omitted =
4918 TrajectorySolver::new(omitted, WindConditions::default(), AtmosphericConditions::default());
4919 let solver_explicit =
4920 TrajectorySolver::new(explicit, WindConditions::default(), AtmosphericConditions::default());
4921
4922 let cd_omitted = solver_omitted.calculate_drag_coefficient(700.0, 340.0);
4923 let cd_explicit = solver_explicit.calculate_drag_coefficient(700.0, 340.0);
4924 assert_eq!(
4925 cd_omitted.to_bits(),
4926 cd_explicit.to_bits(),
4927 "default cd_scale must be bit-identical to an explicit 1.0"
4928 );
4929
4930 let result = solver_omitted.solve();
4933 assert!(result.is_ok(), "existing custom-deck solves must pass unchanged");
4934 }
4935
4936 #[test]
4938 fn cd_scale_multiplies_the_interpolated_cd_exactly() {
4939 let velocity = 700.0;
4940 let speed_of_sound = 340.0;
4941 let mach = velocity / speed_of_sound;
4942 let expected_unscaled = deck().interpolate(mach);
4943
4944 for &scale in &[0.90, 1.0, 1.10, 1.5] {
4945 let solver = TrajectorySolver::new(
4946 deck_inputs(scale),
4947 WindConditions::default(),
4948 AtmosphericConditions::default(),
4949 );
4950 let cd = solver.calculate_drag_coefficient(velocity, speed_of_sound);
4951 assert!(
4952 (cd - expected_unscaled * scale).abs() < 1e-12,
4953 "scale={scale}: cd={cd} expected={}",
4954 expected_unscaled * scale
4955 );
4956 }
4957 }
4958
4959 #[test]
4963 fn cd_scale_direction_on_cli_api_solver() {
4964 let solve = |scale: f64| {
4965 TrajectorySolver::new(
4966 deck_inputs(scale),
4967 WindConditions::default(),
4968 AtmosphericConditions::default(),
4969 )
4970 .solve()
4971 .expect("custom-deck solve should succeed")
4972 };
4973
4974 let baseline = solve(1.0);
4975 let scaled_up = solve(1.10);
4976 let scaled_down = solve(0.90);
4977
4978 assert!(
4979 scaled_up.impact_velocity < baseline.impact_velocity,
4980 "cd_scale=1.10 must increase drag -> lower impact velocity: base={} up={}",
4981 baseline.impact_velocity,
4982 scaled_up.impact_velocity
4983 );
4984 assert!(
4985 scaled_down.impact_velocity > baseline.impact_velocity,
4986 "cd_scale=0.90 must decrease drag -> higher impact velocity: base={} down={}",
4987 baseline.impact_velocity,
4988 scaled_down.impact_velocity
4989 );
4990 }
4991
4992 #[test]
4994 fn validate_for_solve_rejects_invalid_cd_scale() {
4995 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
4996 let solver = TrajectorySolver::new(
4997 deck_inputs(bad),
4998 WindConditions::default(),
4999 AtmosphericConditions::default(),
5000 );
5001 assert!(
5002 solver.solve().is_err(),
5003 "cd_scale={bad} must be rejected by validate_for_solve"
5004 );
5005 }
5006 }
5007
5008 #[test]
5015 fn validate_for_solve_rejects_invalid_cd_scale_without_a_custom_drag_table() {
5016 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
5017 let inputs = BallisticInputs {
5018 bc_value: 0.5,
5019 bc_type: crate::DragModel::G1,
5020 bullet_mass: 0.0106,
5021 bullet_diameter: 0.00782,
5022 muzzle_velocity: 850.0,
5023 cd_scale: bad,
5024 ..BallisticInputs::default()
5025 };
5026 assert!(inputs.custom_drag_table.is_none(), "precondition: no custom deck");
5027 let solver = TrajectorySolver::new(
5028 inputs,
5029 WindConditions::default(),
5030 AtmosphericConditions::default(),
5031 );
5032 assert!(
5033 solver.solve().is_err(),
5034 "cd_scale={bad} must be rejected by validate_for_solve even without a custom \
5035 drag table"
5036 );
5037 }
5038 }
5039
5040 #[test]
5044 fn cd_scale_is_inert_without_a_custom_drag_table() {
5045 let make = |cd_scale: f64| BallisticInputs {
5046 bc_value: 0.5,
5047 bc_type: crate::DragModel::G1,
5048 bullet_mass: 0.0106,
5049 bullet_diameter: 0.00782,
5050 muzzle_velocity: 850.0,
5051 cd_scale,
5052 ..BallisticInputs::default()
5053 };
5054 let solver_neutral = TrajectorySolver::new(
5055 make(1.0),
5056 WindConditions::default(),
5057 AtmosphericConditions::default(),
5058 );
5059 let solver_far = TrajectorySolver::new(
5060 make(1.5),
5061 WindConditions::default(),
5062 AtmosphericConditions::default(),
5063 );
5064 let cd_neutral = solver_neutral.calculate_drag_coefficient(700.0, 340.0);
5065 let cd_far = solver_far.calculate_drag_coefficient(700.0, 340.0);
5066 assert_eq!(
5067 cd_neutral.to_bits(),
5068 cd_far.to_bits(),
5069 "cd_scale must not affect the G-model/BC drag path"
5070 );
5071 }
5072
5073 #[test]
5076 fn cd_scale_shifts_all_three_solver_paths_in_the_same_direction() {
5077 let cli_solve = |scale: f64| {
5079 TrajectorySolver::new(
5080 deck_inputs(scale),
5081 WindConditions::default(),
5082 AtmosphericConditions::default(),
5083 )
5084 .solve()
5085 .expect("cli_api custom-deck solve should succeed")
5086 };
5087 let cli_baseline = cli_solve(1.0);
5088 let cli_scaled = cli_solve(1.10);
5089 assert!(
5090 cli_scaled.impact_velocity < cli_baseline.impact_velocity,
5091 "cli_api: cd_scale=1.10 must lower impact velocity"
5092 );
5093
5094 let derivatives_accel_x = |scale: f64| {
5096 let inputs = deck_inputs(scale);
5097 crate::derivatives::compute_derivatives(
5098 nalgebra::Vector3::zeros(),
5099 nalgebra::Vector3::new(700.0, 0.0, 0.0),
5100 &inputs,
5101 nalgebra::Vector3::zeros(),
5102 (1.225, 340.0, 0.0, 0.0),
5103 inputs.bc_value,
5104 None,
5105 0.0,
5106 None,
5107 )[3]
5108 };
5109 let deriv_baseline = derivatives_accel_x(1.0);
5110 let deriv_scaled = derivatives_accel_x(1.10);
5111 assert!(
5112 deriv_scaled < deriv_baseline,
5113 "derivatives: cd_scale=1.10 must make x-acceleration more negative (more drag): \
5114 base={deriv_baseline} scaled={deriv_scaled}"
5115 );
5116
5117 let fast_final_speed = |scale: f64| {
5119 let inputs = deck_inputs(scale);
5120 let wind_sock = crate::wind::WindSock::new(vec![]);
5121 let params = crate::fast_trajectory::FastIntegrationParams {
5122 horiz: 500.0,
5123 vert: 0.0,
5124 initial_state: [0.0, 0.0, 0.0, 850.0, 0.0, 0.0],
5125 t_span: (0.0, 5.0),
5126 atmo_params: (0.0, 15.0, 1013.25, 1.0),
5127 atmo_sock: None,
5128 };
5129 let solution = crate::fast_trajectory::fast_integrate(&inputs, &wind_sock, params);
5130 assert!(solution.success, "fast_integrate must succeed for scale={scale}");
5131 let last = solution.t.len() - 1;
5132 let (vx, vy, vz) = (
5133 solution.y[3][last],
5134 solution.y[4][last],
5135 solution.y[5][last],
5136 );
5137 (vx * vx + vy * vy + vz * vz).sqrt()
5138 };
5139 let fast_baseline = fast_final_speed(1.0);
5140 let fast_scaled = fast_final_speed(1.10);
5141 assert!(
5142 fast_scaled < fast_baseline,
5143 "fast_trajectory: cd_scale=1.10 must lower final speed: base={fast_baseline} scaled={fast_scaled}"
5144 );
5145 }
5146}
5147
5148#[cfg(test)]
5149mod humid_local_mach_tests {
5150 use super::*;
5151
5152 fn solver_with_station_humidity(humidity_percent: f64) -> TrajectorySolver {
5153 let inputs = BallisticInputs {
5154 custom_drag_table: Some(crate::drag::DragTable::new(vec![0.5, 1.5], vec![0.1, 1.1])),
5155 ..BallisticInputs::default()
5156 };
5157 TrajectorySolver::new(
5158 inputs,
5159 WindConditions::default(),
5160 AtmosphericConditions {
5161 temperature: 30.0,
5162 pressure: 1013.25,
5163 humidity: humidity_percent,
5164 altitude: 0.0,
5165 },
5166 )
5167 }
5168
5169 fn acceleration(solver: &TrajectorySolver, base_ratio: f64) -> Vector3<f64> {
5170 solver.calculate_acceleration(
5171 &Vector3::zeros(),
5172 &Vector3::new(350.0, 0.0, 0.0),
5173 &Vector3::zeros(),
5174 (30.0, 1013.25, base_ratio),
5175 )
5176 }
5177
5178 #[test]
5179 fn local_mach_uses_station_humidity_when_density_is_held_constant() {
5180 let dry = acceleration(&solver_with_station_humidity(0.0), 1.0);
5181 let humid = acceleration(&solver_with_station_humidity(100.0), 1.0);
5182
5183 assert!(
5184 humid.x > dry.x,
5185 "humid sound speed should lower Mach and drag on the rising test curve: dry ax={} humid ax={}",
5186 dry.x,
5187 humid.x
5188 );
5189 }
5190
5191 #[test]
5192 fn active_atmosphere_zone_uses_zone_humidity_instead_of_station_humidity() {
5193 let zone_humidity = 80.0;
5194 let zone_ratio =
5195 crate::atmosphere::calculate_air_density_cimp(30.0, 1013.25, zone_humidity) / 1.225;
5196 let station_solver = solver_with_station_humidity(zone_humidity);
5197 let mut zoned_solver = solver_with_station_humidity(0.0);
5198 zoned_solver.set_atmo_segments(vec![(30.0, 1013.25, zone_humidity, 1_000.0)]);
5199
5200 let station = acceleration(&station_solver, zone_ratio);
5201 let zoned = acceleration(&zoned_solver, zone_ratio);
5202
5203 assert!(
5204 (zoned - station).norm() < 1e-12,
5205 "active zone T/P/RH should override the station atmosphere: station={station:?} zoned={zoned:?}"
5206 );
5207 }
5208}
5209
5210#[cfg(test)]
5211mod inclined_atmosphere_frame_tests {
5212 use super::*;
5213
5214 fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
5215 let (sin_angle, cos_angle) = angle.sin_cos();
5216 Vector3::new(
5217 level.x * cos_angle + level.y * sin_angle,
5218 -level.x * sin_angle + level.y * cos_angle,
5219 level.z,
5220 )
5221 }
5222
5223 #[test]
5224 fn inclined_positions_at_same_world_altitude_have_same_solver_acceleration() {
5225 let angle = std::f64::consts::FRAC_PI_6;
5226 let inputs = BallisticInputs {
5227 shooting_angle: angle,
5228 ..BallisticInputs::default()
5229 };
5230 let atmosphere = AtmosphericConditions {
5231 altitude: 100.0,
5232 ..AtmosphericConditions::default()
5233 };
5234 let solver = TrajectorySolver::new(inputs, WindConditions::default(), atmosphere);
5235 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5236 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
5237 let velocity = Vector3::new(600.0, 0.0, 0.0);
5238 let along_slant = Vector3::new(1_000.0, 0.0, 0.0);
5239 let across_slant = Vector3::new(0.0, 500.0 / angle.cos(), 0.0);
5240
5241 let a = solver.calculate_acceleration(
5242 &along_slant,
5243 &velocity,
5244 &Vector3::zeros(),
5245 resolved_atmo,
5246 );
5247 let b = solver.calculate_acceleration(
5248 &across_slant,
5249 &velocity,
5250 &Vector3::zeros(),
5251 resolved_atmo,
5252 );
5253
5254 assert!(
5255 (a - b).norm() < 1e-10,
5256 "solver acceleration differs at equal world altitude: {a:?} vs {b:?}"
5257 );
5258 }
5259
5260 #[test]
5261 fn inclined_headwind_is_rotated_into_solver_frame() {
5262 let angle = std::f64::consts::FRAC_PI_6;
5263 let inputs = BallisticInputs {
5264 shooting_angle: angle,
5265 ..BallisticInputs::default()
5266 };
5267 let solver = TrajectorySolver::new(
5268 inputs,
5269 WindConditions::default(),
5270 AtmosphericConditions::default(),
5271 );
5272 let level_headwind = Vector3::new(-100.0, 0.0, 0.0);
5273 let velocity = expected_shot_frame_vector(level_headwind, angle);
5274 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5275 let actual = solver.calculate_acceleration(
5276 &Vector3::zeros(),
5277 &velocity,
5278 &level_headwind,
5279 (temp_c, pressure_hpa, density / 1.225),
5280 );
5281
5282 assert!(
5283 (actual - solver.gravity_acceleration()).norm() < 1e-12,
5284 "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
5285 );
5286 }
5287
5288 #[test]
5289 fn inclined_coriolis_is_rotated_into_solver_frame() {
5290 let angle = std::f64::consts::FRAC_PI_6;
5291 let latitude_deg = 45.0_f64;
5292 let shot_azimuth = 0.4_f64;
5293 let velocity = Vector3::new(600.0, 20.0, 5.0);
5294 let base_inputs = BallisticInputs {
5295 shooting_angle: angle,
5296 latitude: Some(latitude_deg),
5297 shot_azimuth,
5298 ..BallisticInputs::default()
5299 };
5300 let acceleration = |enable_coriolis| {
5301 let mut inputs = base_inputs.clone();
5302 inputs.enable_coriolis = enable_coriolis;
5303 let solver = TrajectorySolver::new(
5304 inputs,
5305 WindConditions::default(),
5306 AtmosphericConditions::default(),
5307 );
5308 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5309 solver.calculate_acceleration(
5310 &Vector3::zeros(),
5311 &velocity,
5312 &Vector3::zeros(),
5313 (temp_c, pressure_hpa, density / 1.225),
5314 )
5315 };
5316
5317 let omega_earth = 7.2921159e-5_f64;
5318 let latitude = latitude_deg.to_radians();
5319 let level_omega = Vector3::new(
5320 omega_earth * latitude.cos() * shot_azimuth.cos(),
5321 omega_earth * latitude.sin(),
5322 -omega_earth * latitude.cos() * shot_azimuth.sin(),
5323 );
5324 let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
5325 let actual = acceleration(true) - acceleration(false);
5326
5327 assert!(
5328 (actual - expected).norm() < 1e-12,
5329 "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
5330 );
5331 }
5332}
5333
5334#[cfg(test)]
5335mod terminal_range_interpolation_tests {
5336 use super::*;
5337
5338 #[test]
5339 fn terminal_finalizer_selects_the_earliest_crossed_boundary() {
5340 let inputs = BallisticInputs {
5341 ground_threshold: 0.0,
5342 ..BallisticInputs::default()
5343 };
5344 let mut solver = TrajectorySolver::new(
5345 inputs,
5346 WindConditions::default(),
5347 AtmosphericConditions::default(),
5348 );
5349 solver.set_max_range(120.0);
5350
5351 let previous_speed = 700.0;
5352 let mut points = vec![TrajectoryPoint {
5353 time: 99.0,
5354 position: Vector3::new(90.0, 1.0, -1.0),
5355 velocity_magnitude: previous_speed,
5356 kinetic_energy: 0.5 * solver.inputs.bullet_mass * previous_speed.powi(2),
5357 }];
5358 let mut max_height = 1.0;
5359 let termination = solver
5360 .append_terminal_endpoint(
5361 &mut points,
5362 Vector3::new(130.0, -3.0, 3.0),
5363 Vector3::new(600.0, 0.0, 0.0),
5364 101.0,
5365 &mut max_height,
5366 )
5367 .expect("the final step brackets supported boundaries");
5368
5369 assert_eq!(termination, TrajectoryTermination::GroundThreshold);
5370 assert_eq!(points.len(), 2);
5371 let terminal = points.last().expect("terminal point");
5372 assert_eq!(terminal.time, 99.5);
5373 assert_eq!(terminal.position, Vector3::new(100.0, 0.0, 0.0));
5374 assert_eq!(terminal.velocity_magnitude, 675.0);
5375 assert_eq!(
5376 terminal.kinetic_energy,
5377 0.5 * solver.inputs.bullet_mass * 675.0_f64.powi(2)
5378 );
5379
5380 solver.set_max_range(100.0);
5382 let mut tied_points = vec![points[0].clone()];
5383 assert_eq!(
5384 solver
5385 .append_terminal_endpoint(
5386 &mut tied_points,
5387 Vector3::new(130.0, -3.0, 3.0),
5388 Vector3::new(600.0, 0.0, 0.0),
5389 101.0,
5390 &mut max_height,
5391 )
5392 .expect("tied boundaries remain a valid terminal"),
5393 TrajectoryTermination::GroundThreshold
5394 );
5395 }
5396
5397 #[test]
5398 fn sub_ulp_terminal_crossing_replaces_instead_of_duplicating_range() {
5399 let ground_threshold = f64::from_bits(1.0_f64.to_bits() - 1);
5400 let inputs = BallisticInputs {
5401 ground_threshold,
5402 ..BallisticInputs::default()
5403 };
5404 let mut solver = TrajectorySolver::new(
5405 inputs,
5406 WindConditions::default(),
5407 AtmosphericConditions::default(),
5408 );
5409 solver.set_max_range(1_000.0);
5410
5411 let speed = 700.0;
5412 let mut points = vec![TrajectoryPoint {
5413 time: 0.0,
5414 position: Vector3::new(100.0, 1.0, 0.0),
5415 velocity_magnitude: speed,
5416 kinetic_energy: 0.5 * solver.inputs.bullet_mass * speed.powi(2),
5417 }];
5418 let mut max_height = 1.0;
5419 let termination = solver
5420 .append_terminal_endpoint(
5421 &mut points,
5422 Vector3::new(101.0, 0.0, 0.0),
5423 Vector3::new(699.0, 0.0, 0.0),
5424 1.0,
5425 &mut max_height,
5426 )
5427 .expect("sub-ULP ground crossing remains representable as one terminal state");
5428
5429 assert_eq!(termination, TrajectoryTermination::GroundThreshold);
5430 assert_eq!(points.len(), 1);
5431 assert_eq!(points[0].position.x, 100.0);
5432 assert_eq!(points[0].position.y.to_bits(), ground_threshold.to_bits());
5433 assert!(points[0].time > 0.0);
5434 }
5435
5436 #[test]
5437 fn every_solver_appends_an_exact_max_range_endpoint() {
5438 let target_range = 0.1;
5439 let modes = [
5440 ("Euler", false, false),
5441 ("RK4", true, false),
5442 ("RK45", true, true),
5443 ];
5444
5445 for (name, use_rk4, use_adaptive_rk45) in modes {
5446 let inputs = BallisticInputs {
5447 use_rk4,
5448 use_adaptive_rk45,
5449 ground_threshold: f64::NEG_INFINITY,
5450 enable_trajectory_sampling: true,
5451 sample_interval: target_range,
5452 ..BallisticInputs::default()
5453 };
5454 let mut solver = TrajectorySolver::new(
5455 inputs,
5456 WindConditions::default(),
5457 AtmosphericConditions::default(),
5458 );
5459 solver.set_max_range(target_range);
5460
5461 let result = solver.solve().expect("short-range solve should succeed");
5462 let terminal = result.points.last().expect("terminal point is missing");
5463 let muzzle = result.points.first().expect("muzzle point is missing");
5464
5465 assert_eq!(result.termination, TrajectoryTermination::MaxRange);
5466 assert_eq!(
5467 terminal.position.x.to_bits(),
5468 target_range.to_bits(),
5469 "{name} did not terminate exactly at max_range"
5470 );
5471 assert_eq!(result.max_range.to_bits(), target_range.to_bits());
5472 assert!(
5473 result.time_of_flight > 0.0 && result.time_of_flight < solver.time_step,
5474 "{name} terminal time was not interpolated within the crossing step: {}",
5475 result.time_of_flight
5476 );
5477 assert_eq!(result.time_of_flight.to_bits(), terminal.time.to_bits());
5478 assert_eq!(
5479 result.impact_velocity.to_bits(),
5480 terminal.velocity_magnitude.to_bits()
5481 );
5482 assert_eq!(
5483 result.impact_energy.to_bits(),
5484 terminal.kinetic_energy.to_bits()
5485 );
5486 let expected_energy = 0.5 * solver.inputs.bullet_mass * result.impact_velocity.powi(2);
5487 assert!((result.impact_energy - expected_energy).abs() < 1e-12);
5488 assert!(terminal.velocity_magnitude < muzzle.velocity_magnitude);
5489 assert!(terminal.kinetic_energy < muzzle.kinetic_energy);
5490
5491 let terminal_sample = result
5492 .sampled_points
5493 .as_ref()
5494 .and_then(|samples| samples.last())
5495 .expect("terminal trajectory sample is missing");
5496 assert_eq!(
5497 terminal_sample.distance_m.to_bits(),
5498 target_range.to_bits(),
5499 "{name} sampling did not include max_range"
5500 );
5501 assert_eq!(
5502 terminal_sample.time_s.to_bits(),
5503 result.time_of_flight.to_bits()
5504 );
5505 assert_eq!(
5506 terminal_sample.velocity_mps.to_bits(),
5507 result.impact_velocity.to_bits()
5508 );
5509 assert!((terminal_sample.energy_j - result.impact_energy).abs() < 1e-12);
5510 }
5511 }
5512}
5513
5514#[cfg(test)]
5515mod precession_inertia_wiring_tests {
5516 use super::*;
5517
5518 #[test]
5519 fn solver_uses_projectile_specific_moments_of_inertia() {
5520 let mass_kg = 55.0 * crate::constants::GRAINS_TO_KG;
5521 let caliber_m = 0.224 * 0.0254;
5522 let length_m = 0.75 * 0.0254;
5523 let inputs = BallisticInputs {
5524 bullet_mass: mass_kg,
5525 bullet_diameter: caliber_m,
5526 bullet_length: length_m,
5527 muzzle_velocity: 800.0,
5528 twist_rate: 7.0,
5529 enable_precession_nutation: true,
5530 use_rk4: false,
5531 use_adaptive_rk45: false,
5532 ..BallisticInputs::default()
5533 };
5534 let mut solver = TrajectorySolver::new(
5535 inputs,
5536 WindConditions::default(),
5537 AtmosphericConditions::default(),
5538 );
5539 solver.set_max_range(0.1);
5540
5541 let (air_density, speed_of_sound, _, _) = solver.resolved_atmosphere();
5542 let velocity_mps = solver.inputs.muzzle_velocity;
5543 let velocity_fps = velocity_mps * 3.28084;
5544 let twist_rate_ft = solver.inputs.twist_rate / 12.0;
5545 let spin_rate_rad_s = (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI;
5546 let initial_state = AngularState {
5547 pitch_angle: 0.001,
5548 yaw_angle: 0.001,
5549 pitch_rate: 0.0,
5550 yaw_rate: 0.0,
5551 precession_angle: 0.0,
5552 nutation_phase: 0.0,
5553 };
5554 let params = PrecessionNutationParams {
5555 mass_kg,
5556 caliber_m,
5557 length_m,
5558 spin_rate_rad_s,
5559 spin_inertia: crate::spin_decay::calculate_moment_of_inertia(
5560 mass_kg, caliber_m, length_m, "ogive",
5561 ),
5562 transverse_inertia: crate::pitch_damping::calculate_transverse_moment_of_inertia(
5563 mass_kg, caliber_m, length_m, "ogive",
5564 ),
5565 velocity_mps,
5566 air_density_kg_m3: air_density,
5567 mach: velocity_mps / speed_of_sound,
5568 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
5569 nutation_damping_factor: 0.05,
5570 };
5571 let expected = calculate_combined_angular_motion(
5572 ¶ms,
5573 &initial_state,
5574 0.0,
5575 solver.time_step,
5576 0.001,
5577 );
5578 let actual = solver
5579 .solve()
5580 .expect("one-step solve should succeed")
5581 .angular_state
5582 .expect("precession/nutation was enabled");
5583
5584 assert!(
5585 (actual.precession_angle - expected.precession_angle).abs() < 1e-15,
5586 "precession phase used the wrong inertia: actual={}, expected={}",
5587 actual.precession_angle,
5588 expected.precession_angle
5589 );
5590 assert!(
5591 (actual.nutation_phase - expected.nutation_phase).abs() < 1e-15,
5592 "nutation phase used the wrong inertia: actual={}, expected={}",
5593 actual.nutation_phase,
5594 expected.nutation_phase
5595 );
5596 }
5597}
5598
5599#[cfg(test)]
5600mod form_factor_drag_tests {
5601 use super::*;
5602
5603 fn acceleration_with_form_factor_flag(enabled: bool) -> Vector3<f64> {
5604 let inputs = BallisticInputs {
5605 bc_value: 0.462,
5606 bc_type: DragModel::G1,
5607 bullet_model: Some("168gr SMK Match".to_string()),
5608 use_form_factor: enabled,
5609 ..BallisticInputs::default()
5610 };
5611 let solver = TrajectorySolver::new(
5612 inputs,
5613 WindConditions::default(),
5614 AtmosphericConditions::default(),
5615 );
5616 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5617 solver.calculate_acceleration(
5618 &Vector3::zeros(),
5619 &Vector3::new(600.0, 0.0, 0.0),
5620 &Vector3::zeros(),
5621 (temp_c, pressure_hpa, density / 1.225),
5622 )
5623 }
5624
5625 #[test]
5626 fn measured_bc_drag_does_not_apply_name_based_form_factor_again() {
5627 let baseline = acceleration_with_form_factor_flag(false);
5628 let flagged = acceleration_with_form_factor_flag(true);
5629
5630 assert!(
5631 (flagged - baseline).norm() < 1e-12,
5632 "published BC already encodes form factor: baseline={baseline:?} flagged={flagged:?}"
5633 );
5634 }
5635}
5636
5637#[cfg(test)]
5638mod rk45_adaptivity_tests {
5639 use super::*;
5640
5641 #[test]
5642 fn cli_rk45_error_norm_scales_components_independently() {
5643 let position = Vector3::new(1.0e9, 0.0, 0.0);
5644 let velocity = Vector3::new(800.0, 0.0, 0.0);
5645 let fifth_position = position;
5646 let fifth_velocity = velocity;
5647 let fourth_position = position;
5648 let fourth_velocity = Vector3::new(800.0, 1.0e-3, 0.0);
5649
5650 let error = cli_rk45_error_norm(
5651 &position,
5652 &velocity,
5653 &fifth_position,
5654 &fifth_velocity,
5655 &fourth_position,
5656 &fourth_velocity,
5657 );
5658 let expected = 1.0e-3 / 6.0_f64.sqrt();
5659
5660 assert!(
5661 (error - expected).abs() <= 1e-15,
5662 "large downrange position masked a velocity-component error: {error}"
5663 );
5664 }
5665
5666 fn discontinuous_wind_solver() -> TrajectorySolver {
5667 let inputs = BallisticInputs::default();
5668 let mut solver = TrajectorySolver::new(
5669 inputs,
5670 WindConditions::default(),
5671 AtmosphericConditions::default(),
5672 );
5673 solver.set_wind_segments(vec![
5674 crate::wind::WindSegment::new(0.0, 90.0, 4.0),
5675 crate::wind::WindSegment::new(1_000.0, 90.0, 10_000.0),
5676 ]);
5677 solver
5678 }
5679
5680 #[test]
5681 fn rk45_retries_discontinuous_trial_before_advancing() {
5682 let solver = discontinuous_wind_solver();
5683 let position = Vector3::new(0.0, solver.inputs.muzzle_height, 0.0);
5684 let velocity = Vector3::new(solver.inputs.muzzle_velocity, 0.0, 0.0);
5685 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5686 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
5687 let dt = 0.01;
5688
5689 let rejected_trial = solver.rk45_step(
5690 &position,
5691 &velocity,
5692 dt,
5693 &Vector3::zeros(),
5694 RK45_TOLERANCE,
5695 resolved_atmo,
5696 );
5697 assert!(
5698 rejected_trial.error > RK45_TOLERANCE,
5699 "discontinuous full step must exceed tolerance, got {}",
5700 rejected_trial.error
5701 );
5702
5703 let accepted = solver.adaptive_rk45_step(
5704 &position,
5705 &velocity,
5706 dt,
5707 &Vector3::zeros(),
5708 resolved_atmo,
5709 );
5710 assert!(accepted.used_dt < dt, "oversized trial was not retried");
5711 assert!(
5712 accepted.error <= RK45_TOLERANCE || accepted.used_dt <= RK45_MIN_DT,
5713 "accepted error {} exceeds tolerance at dt {}",
5714 accepted.error,
5715 accepted.used_dt
5716 );
5717
5718 let accepted_trial = solver.rk45_step(
5719 &position,
5720 &velocity,
5721 accepted.used_dt,
5722 &Vector3::zeros(),
5723 RK45_TOLERANCE,
5724 resolved_atmo,
5725 );
5726 assert_eq!(accepted.position, accepted_trial.position);
5727 assert_eq!(accepted.velocity, accepted_trial.velocity);
5728 assert!((RK45_MIN_DT..=RK45_MAX_DT).contains(&accepted.next_dt));
5729 }
5730}
5731
5732#[cfg(test)]
5733mod ground_termination_tests {
5734 use super::*;
5735 use crate::trajectory_observation::TrajectoryObservationFlag;
5736
5737 #[test]
5738 fn every_solver_reports_one_exact_early_ground_endpoint() {
5739 for (name, use_rk4, use_adaptive_rk45) in [
5740 ("Euler", false, false),
5741 ("RK4", true, false),
5742 ("RK45", true, true),
5743 ] {
5744 let inputs = BallisticInputs {
5745 muzzle_height: 1.0,
5746 muzzle_angle: -0.2,
5747 ground_threshold: 0.0,
5748 use_rk4,
5749 use_adaptive_rk45,
5750 ..BallisticInputs::default()
5751 };
5752 let mut solver = TrajectorySolver::new(
5753 inputs,
5754 WindConditions::default(),
5755 AtmosphericConditions::default(),
5756 );
5757 solver.set_max_range(1_000.0);
5758
5759 let result = solver.solve().expect("early-ground solve should succeed");
5760 let terminal = result.points.last().expect("terminal point is missing");
5761
5762 assert_eq!(result.termination, TrajectoryTermination::GroundThreshold);
5763 assert_eq!(terminal.position.y.to_bits(), 0.0_f64.to_bits());
5764 assert!(
5765 terminal.position.x < 1_000.0,
5766 "{name} incorrectly reached max range"
5767 );
5768 assert_eq!(result.max_range.to_bits(), terminal.position.x.to_bits());
5769 assert_eq!(
5770 result
5771 .points
5772 .iter()
5773 .filter(|point| point.position.y == 0.0)
5774 .count(),
5775 1,
5776 "{name} did not retain exactly one ground endpoint"
5777 );
5778
5779 let observations = result
5780 .sample_observations(1.0, 100)
5781 .expect("checked early-ground sampling should succeed");
5782 assert!(observations[..observations.len() - 1]
5783 .iter()
5784 .all(|observation| observation.distance_m < terminal.position.x));
5785 let terminal_observation = observations.last().expect("terminal observation");
5786 assert_eq!(
5787 terminal_observation.distance_m.to_bits(),
5788 terminal.position.x.to_bits()
5789 );
5790 assert!(terminal_observation
5791 .flags
5792 .contains(&TrajectoryObservationFlag::Terminal));
5793 assert!(terminal_observation
5794 .flags
5795 .contains(&TrajectoryObservationFlag::GroundThreshold));
5796 assert_eq!(
5797 observations
5798 .iter()
5799 .filter(|observation| observation
5800 .flags
5801 .contains(&TrajectoryObservationFlag::Terminal))
5802 .count(),
5803 1,
5804 "{name} repeated the terminal observation"
5805 );
5806 }
5807 }
5808
5809 #[test]
5814 fn rk4_and_rk45_descend_to_ground_threshold() {
5815 for adaptive in [false, true] {
5816 let inputs = BallisticInputs {
5817 muzzle_angle: 0.1, use_rk4: true,
5819 use_adaptive_rk45: adaptive,
5820 ..BallisticInputs::default()
5821 };
5822 assert_eq!(
5823 inputs.ground_threshold, -100.0,
5824 "default ground_threshold is -100 m"
5825 );
5826
5827 let mut solver = TrajectorySolver::new(
5828 inputs,
5829 WindConditions::default(),
5830 AtmosphericConditions::default(),
5831 );
5832 solver.set_max_range(1.0e7);
5834
5835 let result = solver.solve().expect("solve should succeed");
5836 let final_y = result
5837 .points
5838 .last()
5839 .expect("trajectory has points")
5840 .position
5841 .y;
5842 assert!(
5843 final_y < -1.0,
5844 "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
5845 past launch level toward the ground_threshold floor, not stop at y = 0"
5846 );
5847 }
5848 }
5849}
5850
5851#[cfg(test)]
5852mod magnus_stability_tests {
5853 use super::*;
5854
5855 #[test]
5856 fn yaw_of_repose_magnus_force_is_vertical_and_twist_signed() {
5857 let acceleration = |enable_magnus, is_twist_right| {
5858 let inputs = BallisticInputs {
5859 muzzle_velocity: 822.96,
5860 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
5861 bullet_diameter: 0.308 * 0.0254,
5862 bullet_length: 1.215 * 0.0254,
5863 twist_rate: 10.0,
5864 is_twist_right,
5865 enable_magnus,
5866 ..BallisticInputs::default()
5867 };
5868 let solver = TrajectorySolver::new(
5869 inputs,
5870 WindConditions::default(),
5871 AtmosphericConditions::default(),
5872 );
5873 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5874 solver.calculate_acceleration(
5875 &Vector3::zeros(),
5876 &Vector3::new(822.96, 0.0, 0.0),
5877 &Vector3::zeros(),
5878 (temp_c, pressure_hpa, density / 1.225),
5879 )
5880 };
5881
5882 let baseline = acceleration(false, true);
5883 let right_twist = acceleration(true, true) - baseline;
5884 let left_twist = acceleration(true, false) - baseline;
5885
5886 assert!(
5887 right_twist.y < 0.0,
5888 "right-hand Magnus must point down, got {right_twist:?}"
5889 );
5890 assert!(
5891 left_twist.y > 0.0,
5892 "left-hand Magnus must point up, got {left_twist:?}"
5893 );
5894 assert!((right_twist.y + left_twist.y).abs() < 1e-12);
5895 assert!(right_twist.x.abs() < 1e-12 && right_twist.z.abs() < 1e-12);
5896 assert!(left_twist.x.abs() < 1e-12 && left_twist.z.abs() < 1e-12);
5897 }
5898
5899 #[test]
5900 fn magnus_uses_velocity_corrected_muzzle_stability_gate() {
5901 let muzzle_velocity = 1_400.0 / 3.28084;
5902 let inputs = BallisticInputs {
5903 muzzle_velocity,
5904 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
5905 bullet_diameter: 0.308 * 0.0254,
5906 bullet_length: 1.215 * 0.0254,
5907 twist_rate: 15.0,
5908 enable_magnus: true,
5909 ..BallisticInputs::default()
5910 };
5911 let solver = TrajectorySolver::new(
5912 inputs.clone(),
5913 WindConditions::default(),
5914 AtmosphericConditions::default(),
5915 );
5916
5917 let bare_sg = crate::spin_drift::miller_stability(0.308, 168.0, 15.0, 1.215);
5918 let canonical_sg = solver.effective_spin_drift_sg();
5919 assert!(bare_sg > 1.0, "test requires bare Sg above the Magnus gate");
5920 assert!(
5921 canonical_sg < 1.0,
5922 "velocity-corrected Sg must be below the gate, got {canonical_sg}"
5923 );
5924
5925 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5926 let acceleration = solver.calculate_acceleration(
5927 &Vector3::zeros(),
5928 &Vector3::new(muzzle_velocity, 0.0, 0.0),
5929 &Vector3::zeros(),
5930 (temp_c, pressure_hpa, density / 1.225),
5931 );
5932 let mut baseline_inputs = inputs;
5933 baseline_inputs.enable_magnus = false;
5934 let baseline_solver = TrajectorySolver::new(
5935 baseline_inputs,
5936 WindConditions::default(),
5937 AtmosphericConditions::default(),
5938 );
5939 let baseline = baseline_solver.calculate_acceleration(
5940 &Vector3::zeros(),
5941 &Vector3::new(muzzle_velocity, 0.0, 0.0),
5942 &Vector3::zeros(),
5943 (temp_c, pressure_hpa, density / 1.225),
5944 );
5945
5946 assert_eq!(
5947 acceleration, baseline,
5948 "canonical Sg below 1 must suppress every Magnus acceleration component"
5949 );
5950 }
5951
5952 #[test]
5953 fn magnus_force_grows_as_fixed_spin_projectile_slows() {
5954 let inputs = BallisticInputs {
5955 muzzle_velocity: 800.0,
5956 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
5957 bullet_diameter: 0.308 * 0.0254,
5958 bullet_length: 1.215 * 0.0254,
5959 twist_rate: 12.0,
5960 enable_magnus: true,
5961 ..BallisticInputs::default()
5962 };
5963
5964 let magnus_acceleration = |speed_mps| {
5965 let evaluate = |enable_magnus| {
5966 let mut run_inputs = inputs.clone();
5967 run_inputs.enable_magnus = enable_magnus;
5968 let solver = TrajectorySolver::new(
5969 run_inputs,
5970 WindConditions::default(),
5971 AtmosphericConditions::default(),
5972 );
5973 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5974 solver
5975 .calculate_acceleration(
5976 &Vector3::zeros(),
5977 &Vector3::new(speed_mps, 0.0, 0.0),
5978 &Vector3::zeros(),
5979 (temp_c, pressure_hpa, density / 1.225),
5980 )
5981 .y
5982 };
5983 (evaluate(true) - evaluate(false)).abs()
5984 };
5985
5986 let fast = magnus_acceleration(200.0);
5987 let slow = magnus_acceleration(100.0);
5988 let ratio = slow / fast;
5989 let expected_ratio = 2.0_f64.powf(5.0 / 3.0);
5990
5991 assert!(fast > 0.0 && slow > 0.0, "fast={fast}, slow={slow}");
5992 assert!(
5993 (ratio - expected_ratio).abs() < 1e-3,
5994 "fixed-spin Magnus acceleration must grow downrange; slow/fast={ratio}, \
5995 expected={expected_ratio}"
5996 );
5997 }
5998}
5999
6000#[cfg(test)]
6001mod coriolis_direction_tests {
6002 use super::*;
6003 use std::f64::consts::FRAC_PI_2;
6004
6005 #[test]
6006 fn supersonic_crossing_flags_a_positive_range_sample() {
6007 use crate::trajectory_sampling::TrajectoryFlag;
6011
6012 for (solver_name, use_rk4, use_adaptive_rk45) in [
6013 ("Euler", false, false),
6014 ("RK4", true, false),
6015 ("RK45", true, true),
6016 ] {
6017 let inputs = BallisticInputs {
6018 muzzle_velocity: 850.0,
6019 bc_value: 0.2,
6020 bc_type: DragModel::G7,
6021 muzzle_angle: 0.03,
6022 enable_trajectory_sampling: true,
6023 sample_interval: 50.0,
6024 use_rk4,
6025 use_adaptive_rk45,
6026 ..BallisticInputs::default()
6027 };
6028 let mut solver = TrajectorySolver::new(
6029 inputs,
6030 WindConditions::default(),
6031 AtmosphericConditions::default(),
6032 );
6033 solver.set_max_range(2000.0);
6034 let samples = solver
6035 .solve()
6036 .expect("supersonic solve should succeed")
6037 .sampled_points
6038 .expect("sampling was enabled");
6039 let flagged_distances: Vec<_> = samples
6040 .iter()
6041 .filter(|sample| sample.flags.contains(&TrajectoryFlag::MachTransition))
6042 .map(|sample| sample.distance_m)
6043 .collect();
6044
6045 assert!(
6046 !flagged_distances.is_empty()
6047 && flagged_distances.iter().all(|distance| *distance > 0.0),
6048 "{solver_name} must flag genuine crossings only at positive range: {flagged_distances:?}"
6049 );
6050 }
6051 }
6052
6053 #[test]
6054 fn subsonic_launch_does_not_flag_a_muzzle_transition() {
6055 use crate::trajectory_sampling::TrajectoryFlag;
6056
6057 for (solver_name, use_rk4, use_adaptive_rk45) in [
6058 ("Euler", false, false),
6059 ("RK4", true, false),
6060 ("RK45", true, true),
6061 ] {
6062 let inputs = BallisticInputs {
6063 muzzle_velocity: 250.0,
6064 muzzle_angle: 0.02,
6065 enable_trajectory_sampling: true,
6066 sample_interval: 25.0,
6067 use_rk4,
6068 use_adaptive_rk45,
6069 ..BallisticInputs::default()
6070 };
6071 let mut solver = TrajectorySolver::new(
6072 inputs,
6073 WindConditions::default(),
6074 AtmosphericConditions::default(),
6075 );
6076 solver.set_max_range(300.0);
6077 let samples = solver
6078 .solve()
6079 .expect("subsonic solve should succeed")
6080 .sampled_points
6081 .expect("sampling was enabled");
6082
6083 assert!(
6084 samples
6085 .iter()
6086 .all(|sample| !sample.flags.contains(&TrajectoryFlag::MachTransition)),
6087 "{solver_name} marked a Mach transition for a launch already below Mach 1"
6088 );
6089 }
6090 }
6091
6092 #[test]
6093 fn mach_transition_tracker_requires_a_downward_crossing() {
6094 fn record(mach_values: &[f64]) -> Vec<f64> {
6095 let mut tracker = MachTransitionTracker::default();
6096 let mut distances = Vec::new();
6097 for (index, mach) in mach_values.iter().copied().enumerate() {
6098 tracker.record_downward_crossings(mach, index as f64 * 10.0, &mut distances);
6099 }
6100 distances
6101 }
6102
6103 assert!(record(&[0.9, 0.8, 0.7]).is_empty());
6104 assert_eq!(record(&[1.1, 1.05, 0.99]), vec![20.0]);
6105 assert_eq!(record(&[1.2, 1.19, 1.0, 0.99]), vec![10.0, 30.0]);
6106 assert_eq!(record(&[0.9, 1.3, 1.1, 0.9, 1.3, 0.8]), vec![20.0, 30.0]);
6107 assert!(record(&[1.3, f64::NAN, 1.1]).is_empty());
6108 }
6109
6110 #[test]
6111 fn humidity_percent_converts_and_clamps() {
6112 let mut i = BallisticInputs {
6114 humidity: 0.5,
6115 ..BallisticInputs::default()
6116 };
6117 assert!((i.humidity_percent() - 50.0).abs() < 1e-9, "0.5 -> 50%");
6118 i.humidity = 0.0;
6119 assert_eq!(i.humidity_percent(), 0.0);
6120 i.humidity = 1.0;
6121 assert_eq!(i.humidity_percent(), 100.0);
6122 i.humidity = 1.5; assert_eq!(i.humidity_percent(), 100.0);
6124 }
6125
6126 fn vertical_at(shot_azimuth: f64, range_m: f64) -> f64 {
6129 let inputs = BallisticInputs {
6130 muzzle_velocity: 800.0,
6131 bc_value: 0.5,
6132 bc_type: DragModel::G7,
6133 muzzle_angle: 0.02, enable_coriolis: true,
6135 latitude: Some(45.0),
6136 shot_azimuth,
6137 ground_threshold: f64::NEG_INFINITY, ..BallisticInputs::default()
6139 };
6140 let mut solver = TrajectorySolver::new(
6141 inputs,
6142 WindConditions::default(),
6143 AtmosphericConditions::default(),
6144 );
6145 solver.set_max_range(range_m + 50.0);
6146 let r = solver.solve().expect("solve");
6147 let pts = &r.points;
6148 for i in 1..pts.len() {
6149 if pts[i].position.x >= range_m {
6150 let p1 = &pts[i - 1];
6151 let p2 = &pts[i];
6152 let t = (range_m - p1.position.x) / (p2.position.x - p1.position.x);
6153 return p1.position.y + t * (p2.position.y - p1.position.y);
6154 }
6155 }
6156 panic!("range {range_m} not reached");
6157 }
6158
6159 #[test]
6164 fn eotvos_east_higher_than_west() {
6165 let range = 600.0;
6166 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!(
6170 east > west,
6171 "east ({east:.5}) must be higher than west ({west:.5}) at {range} m (Eötvös)"
6172 );
6173 assert!(
6174 east > north && north > west,
6175 "north ({north:.5}) must lie between east ({east:.5}) and west ({west:.5})"
6176 );
6177 assert!(
6178 (east - west) > 1e-3,
6179 "E-W vertical separation ({:.6} m) should be physically meaningful, not FP noise",
6180 east - west
6181 );
6182 }
6183}
6184
6185#[cfg(test)]
6186mod cant_tests {
6187 use super::*;
6188
6189 fn base_inputs() -> BallisticInputs {
6190 BallisticInputs {
6191 muzzle_velocity: 800.0,
6192 bc_value: 0.5,
6193 bc_type: DragModel::G7,
6194 bullet_mass: 0.0109,
6195 bullet_diameter: 0.00782,
6196 bullet_length: 0.0309,
6197 sight_height: 0.05,
6198 twist_rate: 10.0,
6199 use_rk4: true,
6200 ..BallisticInputs::default()
6201 }
6202 }
6203
6204 fn solve_with(inputs: BallisticInputs, max_range: f64) -> TrajectoryResult {
6205 let mut s = TrajectorySolver::new(
6206 inputs,
6207 WindConditions::default(),
6208 AtmosphericConditions::default(),
6209 );
6210 s.set_max_range(max_range);
6211 s.solve().expect("solve")
6212 }
6213
6214 fn yz_at(result: &TrajectoryResult, x: f64) -> (f64, f64) {
6216 let pts = &result.points;
6217 for i in 1..pts.len() {
6218 if pts[i].position.x >= x {
6219 let (p1, p2) = (&pts[i - 1], &pts[i]);
6220 let dx = p2.position.x - p1.position.x;
6221 let t = if dx.abs() < 1e-12 { 0.0 } else { (x - p1.position.x) / dx };
6222 return (
6223 p1.position.y + t * (p2.position.y - p1.position.y),
6224 p1.position.z + t * (p2.position.z - p1.position.z),
6225 );
6226 }
6227 }
6228 panic!("trajectory never reached {x} m");
6229 }
6230
6231 #[test]
6232 fn cant_sign_clockwise_up_offset_goes_right_and_low() {
6233 let mut level = base_inputs();
6235 level.muzzle_angle = 0.003; let mut canted = level.clone();
6237 canted.cant_angle = 10f64.to_radians();
6238
6239 let (y0, z0) = yz_at(&solve_with(level, 400.0), 300.0);
6240 let (y1, z1) = yz_at(&solve_with(canted, 400.0), 300.0);
6241 assert!(z1 > z0 + 0.01, "clockwise cant must move POI right: z0={z0} z1={z1}");
6242 assert!(y1 < y0 - 0.001, "clockwise cant must move POI low: y0={y0} y1={y1}");
6243 }
6244
6245 #[test]
6246 fn pure_cant_shows_bore_offset_near_range() {
6247 let mut i = base_inputs();
6250 i.muzzle_angle = 0.0;
6251 i.cant_angle = 10f64.to_radians();
6252 let sh = i.sight_height;
6253 let r = solve_with(i, 60.0);
6254 let first = &r.points[1]; let expected = -sh * 10f64.to_radians().sin();
6256 assert!(
6257 (first.position.z - expected).abs() < 0.005,
6258 "near-muzzle lateral {} should be ~bore offset {expected}",
6259 first.position.z
6260 );
6261 }
6262
6263 #[test]
6264 fn zero_angle_is_independent_of_cant() {
6265 let a = base_inputs();
6266 let mut b = base_inputs();
6267 b.cant_angle = 15f64.to_radians();
6268 let za = calculate_zero_angle(a.clone(), 100.0, 0.0).expect("zero a");
6269 let zb = calculate_zero_angle(b.clone(), 100.0, 0.0).expect("zero b");
6270 assert_eq!(za.to_bits(), zb.to_bits(), "zeroing must ignore cant: {za} vs {zb}");
6271 let _ = (a.cant_angle, b.cant_angle);
6273 }
6274
6275 #[test]
6276 fn nonfinite_cant_is_rejected() {
6277 let mut i = base_inputs();
6278 i.cant_angle = f64::NAN;
6279 let s = TrajectorySolver::new(i, WindConditions::default(), AtmosphericConditions::default());
6280 assert!(s.solve().is_err());
6281 }
6282
6283 #[test]
6284 fn incline_and_cant_compose_without_breaking() {
6285 let mut flat = base_inputs();
6287 flat.muzzle_angle = 0.003;
6288 flat.shooting_angle = 15f64.to_radians();
6289 let mut canted = flat.clone();
6290 canted.cant_angle = 10f64.to_radians();
6291 let (_, z_flat) = yz_at(&solve_with(flat, 400.0), 300.0);
6292 let (_, z_cant) = yz_at(&solve_with(canted, 400.0), 300.0);
6293 assert!(z_cant > z_flat, "cant must still deflect right on an incline");
6294 }
6295}
6296
6297#[cfg(test)]
6298mod vertical_wind_tests {
6299 use super::*;
6300
6301 fn base_inputs() -> BallisticInputs {
6302 BallisticInputs {
6303 muzzle_velocity: 800.0,
6304 bc_value: 0.5,
6305 bc_type: DragModel::G7,
6306 bullet_mass: 0.0109,
6307 bullet_diameter: 0.00782,
6308 bullet_length: 0.0309,
6309 sight_height: 0.05,
6310 twist_rate: 10.0,
6311 use_rk4: true,
6312 ..BallisticInputs::default()
6313 }
6314 }
6315
6316 fn y_at(result: &TrajectoryResult, x: f64) -> f64 {
6318 let pts = &result.points;
6319 for i in 1..pts.len() {
6320 if pts[i].position.x >= x {
6321 let (p1, p2) = (&pts[i - 1], &pts[i]);
6322 let dx = p2.position.x - p1.position.x;
6323 let t = if dx.abs() < 1e-12 { 0.0 } else { (x - p1.position.x) / dx };
6324 return p1.position.y + t * (p2.position.y - p1.position.y);
6325 }
6326 }
6327 panic!("trajectory never reached {x} m");
6328 }
6329
6330 fn solve_with(inputs: BallisticInputs, wind: WindConditions, max_range: f64) -> TrajectoryResult {
6331 let mut s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
6332 s.set_max_range(max_range);
6333 s.solve().expect("solve")
6334 }
6335
6336 #[test]
6337 fn updraft_raises_poi_downrange() {
6338 let calm_inputs = base_inputs();
6341 let calm_wind = WindConditions::default();
6342 let updraft = WindConditions {
6343 vertical_speed: 5.0,
6344 ..Default::default()
6345 };
6346
6347 let calm = solve_with(calm_inputs.clone(), calm_wind, 500.0);
6348 let updraft_result = solve_with(calm_inputs, updraft, 500.0);
6349
6350 let y_calm = y_at(&calm, 400.0);
6351 let y_updraft = y_at(&updraft_result, 400.0);
6352 assert!(
6353 y_updraft > y_calm,
6354 "5 m/s updraft must raise POI at 400m: calm={y_calm}, updraft={y_updraft}"
6355 );
6356 }
6357
6358 #[test]
6359 fn zero_vertical_is_default_and_finite_required() {
6360 assert_eq!(WindConditions::default().vertical_speed, 0.0);
6361
6362 let inputs = base_inputs();
6363 let wind = WindConditions {
6364 vertical_speed: f64::NAN,
6365 ..Default::default()
6366 };
6367 let s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
6368 assert!(
6369 s.solve().is_err(),
6370 "NaN wind.vertical_speed must be rejected by validate_for_solve"
6371 );
6372 }
6373}