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 pub mach_1_2_distance_m: Option<f64>,
559 pub mach_1_0_distance_m: Option<f64>,
563 pub mach_0_9_distance_m: Option<f64>,
571}
572
573const RK45_TOLERANCE: f64 = 1e-6;
574const RK45_SAFETY_FACTOR: f64 = 0.9;
575const RK45_MAX_DT: f64 = 0.01;
576const RK45_MIN_DT: f64 = 1e-6;
577const TRAJECTORY_TIME_LIMIT_S: f64 = 100.0;
578
579pub const MAX_TRAJECTORY_POINTS: usize = 250_000;
585
586fn cli_rk45_error_norm(
588 position: &Vector3<f64>,
589 velocity: &Vector3<f64>,
590 fifth_position: &Vector3<f64>,
591 fifth_velocity: &Vector3<f64>,
592 fourth_position: &Vector3<f64>,
593 fourth_velocity: &Vector3<f64>,
594) -> f64 {
595 let pack_state = |position: &Vector3<f64>, velocity: &Vector3<f64>| {
596 Vector6::new(
597 position.x, position.y, position.z, velocity.x, velocity.y, velocity.z,
598 )
599 };
600 let state = pack_state(position, velocity);
601 let fifth_order = pack_state(fifth_position, fifth_velocity);
602 let fourth_order = pack_state(fourth_position, fourth_velocity);
603
604 crate::trajectory_integration::rk45_error_norm(&state, &fifth_order, &fourth_order)
605}
606
607struct Rk45Trial {
608 position: Vector3<f64>,
609 velocity: Vector3<f64>,
610 suggested_dt: f64,
611 error: f64,
612}
613
614struct Rk45AcceptedStep {
615 position: Vector3<f64>,
616 velocity: Vector3<f64>,
617 used_dt: f64,
618 next_dt: f64,
619 error: f64,
620}
621
622#[derive(Default)]
636struct MachTransitionTracker {
637 previous_mach: Option<f64>,
638 crossed_transonic: bool,
639 crossed_subsonic: bool,
640 crossed_narrow: bool,
641 mach_1_2_distance_m: Option<f64>,
644 mach_1_0_distance_m: Option<f64>,
647 mach_0_9_distance_m: Option<f64>,
650}
651
652impl MachTransitionTracker {
653 fn record_downward_crossings(&mut self, mach: f64, downrange_m: f64, distances: &mut Vec<f64>) {
654 if !mach.is_finite() {
655 self.previous_mach = None;
656 return;
657 }
658
659 if let Some(previous_mach) = self.previous_mach {
660 if !self.crossed_transonic && previous_mach >= 1.2 && mach < 1.2 {
661 self.crossed_transonic = true;
662 distances.push(downrange_m);
663 self.mach_1_2_distance_m = Some(downrange_m);
664 }
665 if !self.crossed_subsonic && previous_mach >= 1.0 && mach < 1.0 {
666 self.crossed_subsonic = true;
667 distances.push(downrange_m);
668 self.mach_1_0_distance_m = Some(downrange_m);
669 }
670 if !self.crossed_narrow && previous_mach >= 0.9 && mach < 0.9 {
671 self.crossed_narrow = true;
672 self.mach_0_9_distance_m = Some(downrange_m);
674 }
675 }
676 self.previous_mach = Some(mach);
677 }
678}
679
680impl TrajectoryResult {
681 pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
685 if self.points.is_empty() {
686 return None;
687 }
688
689 for i in 0..self.points.len() - 1 {
691 let p1 = &self.points[i];
692 let p2 = &self.points[i + 1];
693
694 if p1.position.x <= target_range && p2.position.x >= target_range {
696 let dx = p2.position.x - p1.position.x;
698 if dx.abs() < 1e-10 {
699 return Some(p1.position);
700 }
701 let t = (target_range - p1.position.x) / dx;
702
703 return Some(Vector3::new(
705 target_range,
706 p1.position.y + t * (p2.position.y - p1.position.y),
707 p1.position.z + t * (p2.position.z - p1.position.z),
708 ));
709 }
710 }
711
712 self.points.last().map(|p| p.position)
714 }
715}
716
717#[derive(Debug, Clone, Copy, PartialEq, Eq)]
719enum StationAtmosphereResolution {
720 LegacyDefaultSentinels,
723 Authoritative,
726}
727
728#[derive(Clone)]
729pub struct TrajectorySolver {
730 inputs: BallisticInputs,
731 wind: WindConditions,
732 atmosphere: AtmosphericConditions,
733 station_atmosphere_resolution: StationAtmosphereResolution,
734 max_range: f64,
735 time_step: f64,
736 max_trajectory_points: usize,
737 cluster_bc: Option<ClusterBCDegradation>,
738 precession_nutation_inertias: (f64, f64),
740 wind_sock: Option<crate::wind::WindSock>,
745 atmo_sock: Option<crate::atmosphere::AtmoSock>,
752}
753
754#[derive(Debug, Clone, Copy, PartialEq, Eq)]
766pub(crate) enum ZeroTargetFrame {
767 SightLine,
768 WorldVertical,
769}
770
771impl TrajectorySolver {
772 pub fn new(
773 inputs: BallisticInputs,
774 wind: WindConditions,
775 atmosphere: AtmosphericConditions,
776 ) -> Self {
777 Self::new_with_station_atmosphere_resolution(
778 inputs,
779 wind,
780 atmosphere,
781 StationAtmosphereResolution::LegacyDefaultSentinels,
782 )
783 }
784
785 pub(crate) fn new_with_resolved_station_atmosphere(
789 inputs: BallisticInputs,
790 wind: WindConditions,
791 atmosphere: AtmosphericConditions,
792 ) -> Self {
793 Self::new_with_station_atmosphere_resolution(
794 inputs,
795 wind,
796 atmosphere,
797 StationAtmosphereResolution::Authoritative,
798 )
799 }
800
801 fn new_with_station_atmosphere_resolution(
802 mut inputs: BallisticInputs,
803 wind: WindConditions,
804 atmosphere: AtmosphericConditions,
805 station_atmosphere_resolution: StationAtmosphereResolution,
806 ) -> Self {
807 inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
809 inputs.weight_grains = inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
810
811 inputs.muzzle_velocity = resolve_powder_adjusted_velocity(
823 inputs.muzzle_velocity,
824 inputs.temperature,
825 inputs.use_powder_sensitivity,
826 inputs.powder_temp_sensitivity,
827 inputs.powder_temp,
828 inputs.powder_temp_curve.as_deref(),
829 inputs.powder_curve_temp_c,
830 );
831
832 let cluster_bc = if inputs.use_cluster_bc {
834 Some(ClusterBCDegradation::new())
835 } else {
836 None
837 };
838 let precession_nutation_inertias = projectile_moments_of_inertia(
839 inputs.bullet_mass,
840 inputs.bullet_diameter,
841 inputs.bullet_length,
842 );
843
844 Self {
845 inputs,
846 wind,
847 atmosphere,
848 station_atmosphere_resolution,
849 max_range: 1000.0,
850 time_step: 0.001,
851 max_trajectory_points: MAX_TRAJECTORY_POINTS,
852 cluster_bc,
853 precession_nutation_inertias,
854 wind_sock: None,
855 atmo_sock: None,
856 }
857 }
858
859 pub fn set_max_range(&mut self, range: f64) {
860 self.max_range = range;
861 }
862
863 pub fn set_time_step(&mut self, step: f64) {
864 self.time_step = step;
865 }
866
867 pub(crate) fn calculate_and_set_zero_angle(
871 &mut self,
872 target_distance_m: f64,
873 target_height_m: f64,
874 frame: ZeroTargetFrame,
875 ) -> Result<f64, BallisticsError> {
876 let angle = self.find_zero_angle(target_distance_m, target_height_m, frame)?;
877 self.inputs.muzzle_angle = angle;
878 Ok(angle)
879 }
880
881 fn find_zero_angle(
882 &self,
883 target_distance_m: f64,
884 target_height_m: f64,
885 frame: ZeroTargetFrame,
886 ) -> Result<f64, BallisticsError> {
887 let mut low_angle = 0.0;
890 let mut high_angle = 0.2; let tolerance = 1e-7;
892 let max_iterations = 60;
893
894 let low_height = self.zero_trial_height_at(low_angle, target_distance_m, frame)?;
896 let high_height = self.zero_trial_height_at(high_angle, target_distance_m, frame)?;
897
898 match (low_height, high_height) {
899 (Some(low_height), Some(high_height)) => {
900 let low_error = low_height - target_height_m;
901 let high_error = high_height - target_height_m;
902
903 if low_error > 0.0 && high_error > 0.0 {
904 } else if low_error < 0.0 && high_error < 0.0 {
907 let mut expanded = false;
909 for multiplier in [2.0, 3.0, 4.0] {
910 let new_high = (high_angle * multiplier).min(0.785);
911 if let Ok(Some(height)) =
912 self.zero_trial_height_at(new_high, target_distance_m, frame)
913 {
914 if height - target_height_m > 0.0 {
915 high_angle = new_high;
916 expanded = true;
917 break;
918 }
919 }
920 if new_high >= 0.785 {
921 break;
922 }
923 }
924 if !expanded {
925 return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
926 }
927 }
928 }
929 (None, Some(_)) => {
930 }
933 (Some(_), None) => {
934 return Err(
935 "Cannot find zero angle: high angle trajectory doesn't reach target distance"
936 .into(),
937 );
938 }
939 (None, None) => {
940 return Err(
941 "Cannot find zero angle: trajectory cannot reach target distance at any angle"
942 .into(),
943 );
944 }
945 }
946
947 for _ in 0..max_iterations {
948 let mid_angle = (low_angle + high_angle) / 2.0;
949 match self.zero_trial_height_at(mid_angle, target_distance_m, frame)? {
950 Some(height) => {
951 let error = height - target_height_m;
952 if error.abs() < 0.0001 {
955 return Ok(mid_angle);
956 }
957
958 if (high_angle - low_angle).abs() < tolerance {
961 if error.abs() < 0.01 {
962 return Ok(mid_angle);
963 }
964 return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
965 }
966
967 if error > 0.0 {
968 high_angle = mid_angle;
969 } else {
970 low_angle = mid_angle;
971 }
972 }
973 None => {
974 low_angle = mid_angle;
975 if (high_angle - low_angle).abs() < tolerance {
976 return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
977 }
978 }
979 }
980 }
981
982 Err("Failed to find zero angle".into())
983 }
984
985 fn zero_trial_height_at(
988 &self,
989 angle_rad: f64,
990 target_distance_m: f64,
991 frame: ZeroTargetFrame,
992 ) -> Result<Option<f64>, BallisticsError> {
993 let mut trial = self.clone();
994 trial.inputs.muzzle_angle = angle_rad;
995 trial.inputs.enable_aerodynamic_jump = false;
998 trial.inputs.cant_angle = 0.0;
1001 if frame == ZeroTargetFrame::SightLine {
1008 trial.inputs.shooting_angle = 0.0;
1009 }
1010 trial.set_max_range(target_distance_m * 2.0);
1011 let result = trial.solve()?;
1012
1013 for (index, point) in result.points.iter().enumerate() {
1014 if point.position.x >= target_distance_m {
1015 let shot_y_m = if index == 0 {
1016 point.position.y
1017 } else {
1018 let previous = &result.points[index - 1];
1019 let span = point.position.x - previous.position.x;
1020 let fraction = (target_distance_m - previous.position.x) / span;
1021 previous.position.y + fraction * (point.position.y - previous.position.y)
1022 };
1023 return Ok(Some(crate::atmosphere::shot_frame_altitude(
1024 0.0,
1025 target_distance_m,
1026 shot_y_m,
1027 trial.inputs.shooting_angle,
1028 )));
1029 }
1030 }
1031 Ok(None)
1032 }
1033
1034 fn validate_for_solve(&self) -> Result<(), BallisticsError> {
1040 let require_finite = |name: &str, value: f64| {
1041 if value.is_finite() {
1042 Ok(())
1043 } else {
1044 Err(BallisticsError::from(format!("{name} must be finite")))
1045 }
1046 };
1047 let require_positive = |name: &str, value: f64| {
1048 if value.is_finite() && value > 0.0 {
1049 Ok(())
1050 } else {
1051 Err(BallisticsError::from(format!(
1052 "{name} must be finite and greater than zero"
1053 )))
1054 }
1055 };
1056
1057 if self.inputs.custom_drag_table.is_none() {
1063 require_positive("bc_value", self.inputs.bc_value)?;
1064 }
1065 require_positive("bullet_mass", self.inputs.bullet_mass)?;
1066 require_positive("bullet_diameter", self.inputs.bullet_diameter)?;
1067 require_positive("muzzle_velocity", self.inputs.muzzle_velocity)?;
1068 require_positive("cd_scale", self.inputs.cd_scale)?;
1074
1075 require_finite("muzzle_angle", self.inputs.muzzle_angle)?;
1076 require_finite("azimuth_angle", self.inputs.azimuth_angle)?;
1077 require_finite("shooting_angle", self.inputs.shooting_angle)?;
1078 require_finite("cant_angle", self.inputs.cant_angle)?;
1079 require_finite("muzzle_height", self.inputs.muzzle_height)?;
1080
1081 if !(self.inputs.ground_threshold.is_finite()
1084 || self.inputs.ground_threshold == f64::NEG_INFINITY)
1085 {
1086 return Err(BallisticsError::from(
1087 "ground_threshold must be finite or negative infinity",
1088 ));
1089 }
1090
1091 match &self.wind_sock {
1092 Some(wind_sock) => wind_sock
1093 .validate_segments()
1094 .map_err(BallisticsError::from)?,
1095 None => {
1096 require_finite("wind.speed", self.wind.speed)?;
1097 require_finite("wind.direction", self.wind.direction)?;
1098 require_finite("wind.vertical_speed", self.wind.vertical_speed)?;
1099 }
1100 }
1101
1102 require_finite("atmosphere.temperature", self.atmosphere.temperature)?;
1103 require_finite("atmosphere.pressure", self.atmosphere.pressure)?;
1104 require_finite("atmosphere.humidity", self.atmosphere.humidity)?;
1105 require_finite("atmosphere.altitude", self.atmosphere.altitude)?;
1106
1107 require_positive("max_range", self.max_range)?;
1108 if !self.inputs.use_rk4 || !self.inputs.use_adaptive_rk45 {
1111 require_positive("time_step", self.time_step)?;
1112 }
1113
1114 if self.inputs.enable_trajectory_sampling {
1115 require_finite("sight_height", self.inputs.sight_height)?;
1116 require_positive("sample_interval", self.inputs.sample_interval)?;
1117 projected_sample_count(self.max_range, self.inputs.sample_interval)?;
1118 }
1119
1120 if self.inputs.enable_coriolis {
1121 require_finite("shot_azimuth", self.inputs.shot_azimuth)?;
1122 if let Some(latitude) = self.inputs.latitude {
1123 require_finite("latitude", latitude)?;
1124 }
1125 }
1126
1127 Ok(())
1128 }
1129
1130 fn validate_result_sanity(&self, result: &TrajectoryResult) -> Result<(), BallisticsError> {
1137 let require_finite = |name: &str, value: f64| {
1138 if value.is_finite() {
1139 Ok(())
1140 } else {
1141 Err(BallisticsError::from(format!(
1142 "trajectory result contains non-finite {name}"
1143 )))
1144 }
1145 };
1146 let require_non_negative = |name: &str, value: f64| {
1147 if value >= 0.0 {
1148 Ok(())
1149 } else {
1150 Err(BallisticsError::from(format!(
1151 "trajectory result contains non-physical negative {name} ({value})"
1152 )))
1153 }
1154 };
1155 let require_indexed_finite = |collection: &str, index: usize, field: &str, value: f64| {
1156 if value.is_finite() {
1157 Ok(())
1158 } else {
1159 Err(BallisticsError::from(format!(
1160 "trajectory result contains non-finite {collection}[{index}].{field}"
1161 )))
1162 }
1163 };
1164 let require_indexed_non_negative =
1165 |collection: &str, index: usize, field: &str, value: f64| {
1166 if value >= 0.0 {
1167 Ok(())
1168 } else {
1169 Err(BallisticsError::from(format!(
1170 "trajectory result contains non-physical negative {collection}[{index}].{field} ({value})"
1171 )))
1172 }
1173 };
1174
1175 require_finite("max_range", result.max_range)?;
1176 require_finite("max_height", result.max_height)?;
1177 require_finite("time_of_flight", result.time_of_flight)?;
1178 require_finite("impact_velocity", result.impact_velocity)?;
1179 require_finite("impact_energy", result.impact_energy)?;
1180 require_finite("projectile_mass_kg", result.projectile_mass_kg)?;
1181 require_finite(
1182 "line_of_sight_height_m",
1183 result.line_of_sight_height_m,
1184 )?;
1185 require_finite(
1186 "station_speed_of_sound_mps",
1187 result.station_speed_of_sound_mps,
1188 )?;
1189
1190 require_non_negative("max_range", result.max_range)?;
1194 require_non_negative("time_of_flight", result.time_of_flight)?;
1195 require_non_negative("impact_velocity", result.impact_velocity)?;
1196 require_non_negative("impact_energy", result.impact_energy)?;
1197 require_non_negative("projectile_mass_kg", result.projectile_mass_kg)?;
1198 require_non_negative(
1199 "station_speed_of_sound_mps",
1200 result.station_speed_of_sound_mps,
1201 )?;
1202
1203 for (index, point) in result.points.iter().enumerate() {
1204 require_indexed_finite("points", index, "time", point.time)?;
1205 require_indexed_finite("points", index, "position.x", point.position.x)?;
1206 require_indexed_finite("points", index, "position.y", point.position.y)?;
1207 require_indexed_finite("points", index, "position.z", point.position.z)?;
1208 require_indexed_finite(
1209 "points",
1210 index,
1211 "velocity_magnitude",
1212 point.velocity_magnitude,
1213 )?;
1214 require_indexed_finite("points", index, "kinetic_energy", point.kinetic_energy)?;
1215 require_indexed_non_negative("points", index, "time", point.time)?;
1216 require_indexed_non_negative(
1217 "points",
1218 index,
1219 "velocity_magnitude",
1220 point.velocity_magnitude,
1221 )?;
1222 require_indexed_non_negative("points", index, "kinetic_energy", point.kinetic_energy)?;
1223 }
1224
1225 if let Some(samples) = &result.sampled_points {
1226 for (index, sample) in samples.iter().enumerate() {
1227 require_indexed_finite("sampled_points", index, "distance_m", sample.distance_m)?;
1228 require_indexed_finite("sampled_points", index, "drop_m", sample.drop_m)?;
1229 require_indexed_finite(
1230 "sampled_points",
1231 index,
1232 "wind_drift_m",
1233 sample.wind_drift_m,
1234 )?;
1235 require_indexed_finite(
1236 "sampled_points",
1237 index,
1238 "velocity_mps",
1239 sample.velocity_mps,
1240 )?;
1241 require_indexed_finite("sampled_points", index, "energy_j", sample.energy_j)?;
1242 require_indexed_finite("sampled_points", index, "time_s", sample.time_s)?;
1243 }
1244 }
1245
1246 for (name, value) in [
1247 ("min_pitch_damping", result.min_pitch_damping),
1248 ("transonic_mach", result.transonic_mach),
1249 ("max_yaw_angle", result.max_yaw_angle),
1250 ("max_precession_angle", result.max_precession_angle),
1251 ] {
1252 if let Some(value) = value {
1253 require_finite(name, value)?;
1254 }
1255 }
1256
1257 if let Some(state) = result.angular_state {
1258 for (name, value) in [
1259 ("angular_state.pitch_angle", state.pitch_angle),
1260 ("angular_state.yaw_angle", state.yaw_angle),
1261 ("angular_state.pitch_rate", state.pitch_rate),
1262 ("angular_state.yaw_rate", state.yaw_rate),
1263 ("angular_state.precession_angle", state.precession_angle),
1264 ("angular_state.nutation_phase", state.nutation_phase),
1265 ] {
1266 require_finite(name, value)?;
1267 }
1268 }
1269
1270 if let Some(jump) = result.aerodynamic_jump {
1271 for (name, value) in [
1272 ("aerodynamic_jump.vertical_jump_moa", jump.vertical_jump_moa),
1273 (
1274 "aerodynamic_jump.horizontal_jump_moa",
1275 jump.horizontal_jump_moa,
1276 ),
1277 ("aerodynamic_jump.jump_angle_rad", jump.jump_angle_rad),
1278 (
1279 "aerodynamic_jump.magnus_component_moa",
1280 jump.magnus_component_moa,
1281 ),
1282 ("aerodynamic_jump.yaw_component_moa", jump.yaw_component_moa),
1283 (
1284 "aerodynamic_jump.stabilization_factor",
1285 jump.stabilization_factor,
1286 ),
1287 ] {
1288 require_finite(name, value)?;
1289 }
1290 }
1291
1292 Ok(())
1293 }
1294
1295 fn validate_integration_state(
1308 &self,
1309 position: &Vector3<f64>,
1310 velocity: &Vector3<f64>,
1311 time: f64,
1312 ) -> Result<(), BallisticsError> {
1313 if !(position.iter().all(|value| value.is_finite())
1314 && velocity.iter().all(|value| value.is_finite())
1315 && time.is_finite())
1316 {
1317 return Err(BallisticsError::from(
1318 "trajectory integration produced a non-finite state (often from physically \
1319 extreme inputs — e.g. an absurd bore/muzzle height placing the launch far \
1320 from sea level, or a degenerate atmosphere; check those inputs, or set \
1321 --altitude explicitly)",
1322 ));
1323 }
1324
1325 let speed = velocity.magnitude();
1326 let budget = self.speed_budget(time);
1327 if speed > budget {
1328 return Err(BallisticsError::from(format!(
1329 "trajectory integration diverged: speed {speed:.3e} m/s at t={time:.6}s exceeds \
1330 the physical budget of {budget:.3e} m/s"
1331 )));
1332 }
1333 Ok(())
1334 }
1335
1336 fn speed_budget(&self, time: f64) -> f64 {
1341 let scalar_wind = self.wind.speed.abs() + self.wind.vertical_speed.abs();
1342 let wind_bound = match &self.wind_sock {
1343 Some(sock) => scalar_wind.max(sock.max_speed_mps()),
1344 None => scalar_wind,
1345 };
1346 2.0 * (self.inputs.muzzle_velocity + wind_bound + 10.0)
1347 + crate::constants::G_ACCEL_MPS2 * time
1348 }
1349
1350 fn push_trajectory_point(
1352 &self,
1353 points: &mut Vec<TrajectoryPoint>,
1354 point: TrajectoryPoint,
1355 ) -> Result<(), BallisticsError> {
1356 if points.len() >= self.max_trajectory_points {
1357 return Err(BallisticsError::from(format!(
1358 "trajectory point limit of {} exceeded",
1359 self.max_trajectory_points
1360 )));
1361 }
1362 points.push(point);
1363 Ok(())
1364 }
1365
1366 pub fn set_wind_segments(&mut self, segments: Vec<crate::wind::WindSegment>) {
1373 self.wind_sock = if segments.is_empty() {
1374 None
1375 } else {
1376 Some(crate::wind::WindSock::new(segments))
1377 };
1378 }
1379
1380 pub fn set_atmo_segments(&mut self, segments: Vec<crate::atmosphere::AtmoSegment>) {
1389 self.atmo_sock = if segments.is_empty() {
1390 None
1391 } else {
1392 Some(crate::atmosphere::AtmoSock::new(segments))
1393 };
1394 }
1395
1396 fn launch_angles_from(
1405 &self,
1406 aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
1407 ) -> (f64, f64) {
1408 let (mut elev, mut azim) = (self.inputs.muzzle_angle, self.inputs.azimuth_angle);
1409 if self.inputs.cant_angle != 0.0 {
1416 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
1417 let (e0, a0) = (elev, azim);
1418 elev = e0 * cos_c - a0 * sin_c;
1419 azim = a0 * cos_c + e0 * sin_c;
1420 }
1421 match aj {
1422 Some(c) => {
1423 const MOA_PER_RAD: f64 = 3437.7467707849;
1425 (
1426 elev + c.vertical_jump_moa / MOA_PER_RAD,
1427 azim + c.horizontal_jump_moa / MOA_PER_RAD,
1428 )
1429 }
1430 None => (elev, azim),
1431 }
1432 }
1433
1434 fn aerodynamic_jump_components(
1442 &self,
1443 ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
1444 if !self.inputs.enable_aerodynamic_jump {
1445 return None;
1446 }
1447 let diameter_m = self.inputs.bullet_diameter;
1451 if !(self.inputs.twist_rate.is_finite()
1452 && self.inputs.twist_rate != 0.0
1453 && diameter_m.is_finite()
1454 && diameter_m > 0.0
1455 && self.inputs.bullet_length.is_finite()
1456 && self.inputs.bullet_length > 0.0
1457 && self.inputs.muzzle_velocity.is_finite())
1458 {
1459 return None;
1460 }
1461
1462 let (_, _, temp_c, pressure_hpa) = self.resolved_atmosphere();
1464 let sg = crate::stability::compute_stability_coefficient(
1465 &self.inputs,
1466 (self.atmosphere.altitude, temp_c, pressure_hpa, 0.0),
1467 );
1468 if !(sg.is_finite() && sg > 0.0) {
1469 return None;
1470 }
1471 let length_calibers = self.inputs.bullet_length / diameter_m;
1472
1473 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
1479 let crosswind_from_right_mps = if let Some(sock) = &self.wind_sock {
1480 -sock.vector_for_range_stateless(0.0)[2]
1481 } else {
1482 self.wind.speed * self.wind.direction.sin()
1483 };
1484 let crosswind_from_right_mph = crosswind_from_right_mps * MS_TO_MPH;
1485
1486 let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
1487 sg,
1488 length_calibers,
1489 crosswind_from_right_mph,
1490 self.inputs.is_twist_right,
1491 );
1492 if !vertical_jump_moa.is_finite() {
1493 return None;
1494 }
1495
1496 const MOA_PER_RAD: f64 = 3437.7467707849;
1497 Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
1498 vertical_jump_moa,
1499 horizontal_jump_moa: 0.0,
1501 jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
1502 magnus_component_moa: 0.0,
1503 yaw_component_moa: 0.0,
1504 stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
1505 })
1506 }
1507
1508 fn resolved_atmosphere(&self) -> (f64, f64, f64, f64) {
1509 let (temp_c, pressure_hpa) = match self.station_atmosphere_resolution {
1510 StationAtmosphereResolution::LegacyDefaultSentinels => {
1511 crate::atmosphere::resolve_station_conditions(
1512 self.atmosphere.temperature,
1513 self.atmosphere.pressure,
1514 self.atmosphere.altitude,
1515 )
1516 }
1517 StationAtmosphereResolution::Authoritative => {
1518 (self.atmosphere.temperature, self.atmosphere.pressure)
1519 }
1520 };
1521 let (density, speed_of_sound) = crate::atmosphere::calculate_atmosphere(
1522 self.atmosphere.altitude,
1523 Some(temp_c),
1524 Some(pressure_hpa),
1525 self.atmosphere.humidity,
1526 );
1527 (density, speed_of_sound, temp_c, pressure_hpa)
1528 }
1529
1530 fn precession_nutation_params(
1531 &self,
1532 velocity_mps: f64,
1533 air_density_kg_m3: f64,
1534 speed_of_sound_mps: f64,
1535 ) -> PrecessionNutationParams {
1536 let (spin_inertia, transverse_inertia) = self.precession_nutation_inertias;
1537 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
1538 let velocity_fps = velocity_mps * 3.28084;
1539 let twist_rate_ft = self.inputs.twist_rate / 12.0;
1540 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
1541 } else {
1542 0.0
1543 };
1544
1545 PrecessionNutationParams {
1546 mass_kg: self.inputs.bullet_mass,
1547 caliber_m: self.inputs.bullet_diameter,
1548 length_m: self.inputs.bullet_length,
1549 spin_rate_rad_s,
1550 spin_inertia,
1551 transverse_inertia,
1552 velocity_mps,
1553 air_density_kg_m3,
1554 mach: velocity_mps / speed_of_sound_mps,
1555 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
1556 nutation_damping_factor: 0.05,
1557 }
1558 }
1559
1560 fn append_terminal_endpoint(
1567 &self,
1568 points: &mut Vec<TrajectoryPoint>,
1569 post_position: Vector3<f64>,
1570 post_velocity: Vector3<f64>,
1571 post_time: f64,
1572 max_height: &mut f64,
1573 ) -> Result<TrajectoryTermination, BallisticsError> {
1574 let previous = points
1575 .last()
1576 .cloned()
1577 .ok_or_else(|| BallisticsError::from("No trajectory points generated"))?;
1578
1579 let mut crossings = Vec::with_capacity(3);
1580 if previous.position.x < self.max_range && post_position.x >= self.max_range {
1581 let span = post_position.x - previous.position.x;
1582 if span.is_finite() && span > 0.0 {
1583 crossings.push((
1584 (self.max_range - previous.position.x) / span,
1585 TrajectoryTermination::MaxRange,
1586 ));
1587 }
1588 }
1589 if self.inputs.ground_threshold.is_finite()
1590 && previous.position.y > self.inputs.ground_threshold
1591 && post_position.y <= self.inputs.ground_threshold
1592 {
1593 let span = post_position.y - previous.position.y;
1594 if span.is_finite() && span < 0.0 {
1595 crossings.push((
1596 (self.inputs.ground_threshold - previous.position.y) / span,
1597 TrajectoryTermination::GroundThreshold,
1598 ));
1599 }
1600 }
1601 if previous.time < TRAJECTORY_TIME_LIMIT_S && post_time >= TRAJECTORY_TIME_LIMIT_S {
1602 let span = post_time - previous.time;
1603 if span.is_finite() && span > 0.0 {
1604 crossings.push((
1605 (TRAJECTORY_TIME_LIMIT_S - previous.time) / span,
1606 TrajectoryTermination::TimeLimit,
1607 ));
1608 }
1609 }
1610
1611 let (fraction, termination) = crossings
1612 .into_iter()
1613 .filter(|(fraction, _)| fraction.is_finite() && (0.0..=1.0).contains(fraction))
1614 .min_by(|left, right| {
1615 let priority = |termination: TrajectoryTermination| match termination {
1616 TrajectoryTermination::GroundThreshold => 0,
1617 TrajectoryTermination::MaxRange => 1,
1618 TrajectoryTermination::TimeLimit => 2,
1619 TrajectoryTermination::VelocityFloor => 3,
1620 };
1621 left.0
1622 .total_cmp(&right.0)
1623 .then_with(|| priority(left.1).cmp(&priority(right.1)))
1624 })
1625 .ok_or_else(|| {
1626 BallisticsError::from(
1627 "trajectory integration stopped without crossing a supported boundary",
1628 )
1629 })?;
1630
1631 let mut position = previous.position + (post_position - previous.position) * fraction;
1632 match termination {
1633 TrajectoryTermination::MaxRange => position.x = self.max_range,
1634 TrajectoryTermination::GroundThreshold => {
1635 position.y = self.inputs.ground_threshold;
1636 }
1637 TrajectoryTermination::TimeLimit | TrajectoryTermination::VelocityFloor => {}
1638 }
1639 let velocity_magnitude = previous.velocity_magnitude
1640 + (post_velocity.magnitude() - previous.velocity_magnitude) * fraction;
1641 let mut time = previous.time + (post_time - previous.time) * fraction;
1642 if termination == TrajectoryTermination::TimeLimit {
1643 time = TRAJECTORY_TIME_LIMIT_S;
1644 }
1645 let kinetic_energy =
1646 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1647
1648 if position.y > *max_height {
1649 *max_height = position.y;
1650 }
1651 let terminal_point = TrajectoryPoint {
1652 time,
1653 position,
1654 velocity_magnitude,
1655 kinetic_energy,
1656 };
1657 if terminal_point.position.x < previous.position.x {
1658 return Err(BallisticsError::from(
1659 "trajectory terminal state reversed downrange before the crossed boundary",
1660 ));
1661 }
1662 if terminal_point.position.x == previous.position.x {
1663 let last = points.last_mut().ok_or_else(|| {
1668 BallisticsError::from("trajectory points disappeared during terminal finalization")
1669 })?;
1670 *last = terminal_point;
1671 } else {
1672 self.push_trajectory_point(points, terminal_point)?;
1673 }
1674 Ok(termination)
1675 }
1676
1677 fn gravity_acceleration(&self) -> Vector3<f64> {
1678 let theta = self.inputs.shooting_angle;
1679 Vector3::new(
1680 -crate::constants::G_ACCEL_MPS2 * theta.sin(),
1681 -crate::constants::G_ACCEL_MPS2 * theta.cos(),
1682 0.0,
1683 )
1684 }
1685
1686 fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
1687 let model = match self.inputs.wind_shear_model.as_str() {
1702 "logarithmic" => WindShearModel::Logarithmic,
1703 "power_law" | "powerlaw" | "exponential" => WindShearModel::PowerLaw,
1704 "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
1705 "custom_layers" | "custom" => WindShearModel::CustomLayers,
1706 _ => WindShearModel::PowerLaw,
1707 };
1708 let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
1709
1710 crate::wind::wind_vector(self.wind.speed, self.wind.direction, 0.0) * speed_ratio
1717 + Vector3::new(0.0, self.wind.vertical_speed, 0.0)
1718 }
1719
1720 pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
1721 self.validate_for_solve()?;
1722 let mut result = if self.inputs.use_rk4 {
1723 if self.inputs.use_adaptive_rk45 {
1724 self.solve_rk45()?
1725 } else {
1726 self.solve_rk4()?
1727 }
1728 } else {
1729 self.solve_euler()?
1730 };
1731 self.apply_spin_drift(&mut result);
1732 self.validate_result_sanity(&result)?;
1733 Ok(result)
1734 }
1735
1736 fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
1742 if !self.inputs.use_enhanced_spin_drift {
1743 return;
1744 }
1745 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 {
1749 return;
1750 }
1751
1752 let sg = self.effective_spin_drift_sg();
1759
1760 for p in result.points.iter_mut() {
1761 if p.time <= 0.0 {
1762 continue;
1763 }
1764 p.position.z +=
1766 crate::spin_drift::litz_drift_meters(sg, p.time, self.inputs.is_twist_right);
1767 }
1768
1769 if let Some(samples) = result.sampled_points.as_mut() {
1773 for s in samples.iter_mut() {
1774 if s.time_s <= 0.0 {
1775 continue;
1776 }
1777 s.wind_drift_m +=
1778 crate::spin_drift::litz_drift_meters(sg, s.time_s, self.inputs.is_twist_right);
1779 }
1780 }
1781 }
1782
1783 fn effective_spin_drift_sg(&self) -> f64 {
1788 let (_, _, temp_c, press_hpa) = self.resolved_atmosphere();
1789 crate::spin_drift::effective_sg_from_inputs(&self.inputs, temp_c, press_hpa)
1790 }
1791
1792 fn initial_position(&self) -> Vector3<f64> {
1798 if self.inputs.cant_angle == 0.0 {
1799 return Vector3::new(0.0, self.inputs.muzzle_height, 0.0);
1800 }
1801 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
1802 let sh = self.inputs.sight_height;
1803 Vector3::new(
1804 0.0,
1805 self.inputs.muzzle_height + sh * (1.0 - cos_c),
1806 -sh * sin_c,
1807 )
1808 }
1809
1810 fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
1811 let mut time = 0.0;
1813 let mut position = self.initial_position();
1817 let aj_components = self.aerodynamic_jump_components();
1823 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1824 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1825 let mut velocity = Vector3::new(
1826 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1830
1831 let mut points = Vec::new();
1832 let mut max_height = position.y;
1833 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
1839 let mut mach_transitions = MachTransitionTracker::default();
1840
1841 let mut angular_state = if self.inputs.enable_precession_nutation {
1843 Some(AngularState {
1844 pitch_angle: 0.001, yaw_angle: 0.001,
1846 pitch_rate: 0.0,
1847 yaw_rate: 0.0,
1848 precession_angle: 0.0,
1849 nutation_phase: 0.0,
1850 })
1851 } else {
1852 None
1853 };
1854 let mut max_yaw_angle = 0.0;
1855 let mut max_precession_angle = 0.0;
1856
1857 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
1859 self.resolved_atmosphere();
1860 let base_ratio = air_density / 1.225;
1865
1866 let wind_vector =
1872 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
1873
1874 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1877 self.inputs.bullet_model.as_deref().unwrap_or("default"),
1878 );
1879
1880 while position.x < self.max_range
1882 && position.y > self.inputs.ground_threshold
1883 && time < TRAJECTORY_TIME_LIMIT_S
1884 {
1885 let velocity_magnitude = velocity.magnitude();
1887 let kinetic_energy =
1888 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1889
1890 self.push_trajectory_point(
1891 &mut points,
1892 TrajectoryPoint {
1893 time,
1894 position,
1895 velocity_magnitude,
1896 kinetic_energy,
1897 },
1898 )?;
1899
1900 {
1903 let mach_here = if speed_of_sound > 0.0 {
1904 velocity_magnitude / speed_of_sound
1905 } else {
1906 0.0
1907 };
1908 mach_transitions.record_downward_crossings(
1909 mach_here,
1910 position.x,
1911 &mut transonic_distances,
1912 );
1913 }
1914
1915 if position.y > max_height {
1917 max_height = position.y;
1918 }
1919
1920 if self.inputs.enable_pitch_damping {
1922 let mach = velocity_magnitude / speed_of_sound;
1923
1924 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1926 transonic_mach = Some(mach);
1927 }
1928
1929 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1931
1932 if pitch_damping < min_pitch_damping {
1934 min_pitch_damping = pitch_damping;
1935 }
1936 }
1937
1938 if self.inputs.enable_precession_nutation {
1940 if let Some(ref mut state) = angular_state {
1941 let velocity_magnitude = velocity.magnitude();
1942 let params = self.precession_nutation_params(
1943 velocity_magnitude,
1944 air_density,
1945 speed_of_sound,
1946 );
1947
1948 *state = calculate_combined_angular_motion(
1950 ¶ms,
1951 state,
1952 time,
1953 self.time_step,
1954 0.001, );
1956
1957 if state.yaw_angle.abs() > max_yaw_angle {
1959 max_yaw_angle = state.yaw_angle.abs();
1960 }
1961 if state.precession_angle.abs() > max_precession_angle {
1962 max_precession_angle = state.precession_angle.abs();
1963 }
1964 }
1965 }
1966
1967 let acceleration = self.calculate_acceleration(
1974 &position,
1975 &velocity,
1976 &wind_vector,
1977 (resolved_temp_c, resolved_press_hpa, base_ratio),
1978 );
1979
1980 velocity += acceleration * self.time_step;
1982 position += velocity * self.time_step;
1983 time += self.time_step;
1984 self.validate_integration_state(&position, &velocity, time)?;
1985 }
1986
1987 let termination =
1988 self.append_terminal_endpoint(&mut points, position, velocity, time, &mut max_height)?;
1989
1990 let last_point = points.last().ok_or("No trajectory points generated")?;
1992
1993 let sampled_points = if self.inputs.enable_trajectory_sampling {
1995 let trajectory_data = TrajectoryData {
1996 times: points.iter().map(|p| p.time).collect(),
1997 positions: points.iter().map(|p| p.position).collect(),
1998 velocities: points
1999 .iter()
2000 .map(|p| {
2001 Vector3::new(0.0, 0.0, p.velocity_magnitude)
2003 })
2004 .collect(),
2005 transonic_distances, mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2007 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2008 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2009 };
2010
2011 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
2016 let outputs = TrajectoryOutputs {
2017 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
2019 time_of_flight_s: last_point.time,
2020 max_ord_dist_horiz_m: max_height,
2021 sight_height_m: sight_position_m,
2022 };
2023
2024 let samples = sample_trajectory(
2026 &trajectory_data,
2027 &outputs,
2028 self.inputs.sample_interval,
2029 self.inputs.bullet_mass,
2030 )?;
2031 Some(samples)
2032 } else {
2033 None
2034 };
2035
2036 Ok(TrajectoryResult {
2037 max_range: last_point.position.x, max_height,
2039 time_of_flight: last_point.time,
2040 impact_velocity: last_point.velocity_magnitude,
2041 impact_energy: last_point.kinetic_energy,
2042 projectile_mass_kg: self.inputs.bullet_mass,
2043 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
2044 station_speed_of_sound_mps: speed_of_sound,
2045 termination,
2046 points,
2047 sampled_points,
2048 min_pitch_damping: if self.inputs.enable_pitch_damping {
2049 Some(min_pitch_damping)
2050 } else {
2051 None
2052 },
2053 transonic_mach,
2054 angular_state,
2055 max_yaw_angle: if self.inputs.enable_precession_nutation {
2056 Some(max_yaw_angle)
2057 } else {
2058 None
2059 },
2060 max_precession_angle: if self.inputs.enable_precession_nutation {
2061 Some(max_precession_angle)
2062 } else {
2063 None
2064 },
2065 aerodynamic_jump: aj_components,
2066 mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2067 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2068 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2069 })
2070 }
2071
2072 fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
2073 let mut time = 0.0;
2075 let mut position = self.initial_position();
2080
2081 let aj_components = self.aerodynamic_jump_components();
2087 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
2088 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
2089 let mut velocity = Vector3::new(
2090 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
2094
2095 let mut points = Vec::new();
2096 let mut max_height = position.y;
2097 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
2103 let mut mach_transitions = MachTransitionTracker::default();
2104
2105 let mut angular_state = if self.inputs.enable_precession_nutation {
2107 Some(AngularState {
2108 pitch_angle: 0.001, yaw_angle: 0.001,
2110 pitch_rate: 0.0,
2111 yaw_rate: 0.0,
2112 precession_angle: 0.0,
2113 nutation_phase: 0.0,
2114 })
2115 } else {
2116 None
2117 };
2118 let mut max_yaw_angle = 0.0;
2119 let mut max_precession_angle = 0.0;
2120
2121 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2123 self.resolved_atmosphere();
2124 let base_ratio = air_density / 1.225;
2129
2130 let wind_vector =
2136 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
2137
2138 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2141 self.inputs.bullet_model.as_deref().unwrap_or("default"),
2142 );
2143
2144 while position.x < self.max_range
2146 && position.y > self.inputs.ground_threshold
2147 && time < TRAJECTORY_TIME_LIMIT_S
2148 {
2149 let velocity_magnitude = velocity.magnitude();
2151 let kinetic_energy =
2152 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
2153
2154 self.push_trajectory_point(
2155 &mut points,
2156 TrajectoryPoint {
2157 time,
2158 position,
2159 velocity_magnitude,
2160 kinetic_energy,
2161 },
2162 )?;
2163
2164 {
2167 let mach_here = if speed_of_sound > 0.0 {
2168 velocity_magnitude / speed_of_sound
2169 } else {
2170 0.0
2171 };
2172 mach_transitions.record_downward_crossings(
2173 mach_here,
2174 position.x,
2175 &mut transonic_distances,
2176 );
2177 }
2178
2179 if position.y > max_height {
2180 max_height = position.y;
2181 }
2182
2183 if self.inputs.enable_pitch_damping {
2185 let mach = velocity_magnitude / speed_of_sound;
2186
2187 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2189 transonic_mach = Some(mach);
2190 }
2191
2192 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2194
2195 if pitch_damping < min_pitch_damping {
2197 min_pitch_damping = pitch_damping;
2198 }
2199 }
2200
2201 if self.inputs.enable_precession_nutation {
2203 if let Some(ref mut state) = angular_state {
2204 let velocity_magnitude = velocity.magnitude();
2205 let params = self.precession_nutation_params(
2206 velocity_magnitude,
2207 air_density,
2208 speed_of_sound,
2209 );
2210
2211 *state = calculate_combined_angular_motion(
2213 ¶ms,
2214 state,
2215 time,
2216 self.time_step,
2217 0.001, );
2219
2220 if state.yaw_angle.abs() > max_yaw_angle {
2222 max_yaw_angle = state.yaw_angle.abs();
2223 }
2224 if state.precession_angle.abs() > max_precession_angle {
2225 max_precession_angle = state.precession_angle.abs();
2226 }
2227 }
2228 }
2229
2230 let dt = self.time_step;
2232
2233 let acc1 = self.calculate_acceleration(
2235 &position,
2236 &velocity,
2237 &wind_vector,
2238 (resolved_temp_c, resolved_press_hpa, base_ratio),
2239 );
2240
2241 let pos2 = position + velocity * (dt * 0.5);
2243 let vel2 = velocity + acc1 * (dt * 0.5);
2244 let acc2 = self.calculate_acceleration(
2245 &pos2,
2246 &vel2,
2247 &wind_vector,
2248 (resolved_temp_c, resolved_press_hpa, base_ratio),
2249 );
2250
2251 let pos3 = position + vel2 * (dt * 0.5);
2253 let vel3 = velocity + acc2 * (dt * 0.5);
2254 let acc3 = self.calculate_acceleration(
2255 &pos3,
2256 &vel3,
2257 &wind_vector,
2258 (resolved_temp_c, resolved_press_hpa, base_ratio),
2259 );
2260
2261 let pos4 = position + vel3 * dt;
2263 let vel4 = velocity + acc3 * dt;
2264 let acc4 = self.calculate_acceleration(
2265 &pos4,
2266 &vel4,
2267 &wind_vector,
2268 (resolved_temp_c, resolved_press_hpa, base_ratio),
2269 );
2270
2271 position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
2273 velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
2274 time += dt;
2275 self.validate_integration_state(&position, &velocity, time)?;
2276 }
2277
2278 let termination =
2279 self.append_terminal_endpoint(&mut points, position, velocity, time, &mut max_height)?;
2280
2281 let last_point = points.last().ok_or("No trajectory points generated")?;
2283
2284 let sampled_points = if self.inputs.enable_trajectory_sampling {
2286 let trajectory_data = TrajectoryData {
2287 times: points.iter().map(|p| p.time).collect(),
2288 positions: points.iter().map(|p| p.position).collect(),
2289 velocities: points
2290 .iter()
2291 .map(|p| {
2292 Vector3::new(0.0, 0.0, p.velocity_magnitude)
2294 })
2295 .collect(),
2296 transonic_distances, mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2298 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2299 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2300 };
2301
2302 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
2307 let outputs = TrajectoryOutputs {
2308 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
2310 time_of_flight_s: last_point.time,
2311 max_ord_dist_horiz_m: max_height,
2312 sight_height_m: sight_position_m,
2313 };
2314
2315 let samples = sample_trajectory(
2317 &trajectory_data,
2318 &outputs,
2319 self.inputs.sample_interval,
2320 self.inputs.bullet_mass,
2321 )?;
2322 Some(samples)
2323 } else {
2324 None
2325 };
2326
2327 Ok(TrajectoryResult {
2328 max_range: last_point.position.x, max_height,
2330 time_of_flight: last_point.time,
2331 impact_velocity: last_point.velocity_magnitude,
2332 impact_energy: last_point.kinetic_energy,
2333 projectile_mass_kg: self.inputs.bullet_mass,
2334 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
2335 station_speed_of_sound_mps: speed_of_sound,
2336 termination,
2337 points,
2338 sampled_points,
2339 min_pitch_damping: if self.inputs.enable_pitch_damping {
2340 Some(min_pitch_damping)
2341 } else {
2342 None
2343 },
2344 transonic_mach,
2345 angular_state,
2346 max_yaw_angle: if self.inputs.enable_precession_nutation {
2347 Some(max_yaw_angle)
2348 } else {
2349 None
2350 },
2351 max_precession_angle: if self.inputs.enable_precession_nutation {
2352 Some(max_precession_angle)
2353 } else {
2354 None
2355 },
2356 aerodynamic_jump: aj_components,
2357 mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2358 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2359 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2360 })
2361 }
2362
2363 fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
2364 let mut time = 0.0;
2366 let mut position = self.initial_position();
2370
2371 let aj_components = self.aerodynamic_jump_components();
2377 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
2378 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
2379 let mut velocity = Vector3::new(
2380 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
2384
2385 let mut points = Vec::new();
2386 let mut max_height = position.y;
2387 let mut dt = 0.001; let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2392 self.resolved_atmosphere();
2393 let base_ratio = air_density / 1.225;
2398 let wind_vector =
2403 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
2404
2405 let mut transonic_distances: Vec<f64> = Vec::new();
2407 let mut mach_transitions = MachTransitionTracker::default();
2408
2409 let mut min_pitch_damping = f64::INFINITY;
2414 let mut transonic_mach: Option<f64> = None;
2415 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2416 self.inputs.bullet_model.as_deref().unwrap_or("default"),
2417 );
2418 let mut angular_state = if self.inputs.enable_precession_nutation {
2419 Some(AngularState {
2420 pitch_angle: 0.001,
2421 yaw_angle: 0.001,
2422 pitch_rate: 0.0,
2423 yaw_rate: 0.0,
2424 precession_angle: 0.0,
2425 nutation_phase: 0.0,
2426 })
2427 } else {
2428 None
2429 };
2430 let mut max_yaw_angle = 0.0;
2431 let mut max_precession_angle = 0.0;
2432
2433 while position.x < self.max_range
2434 && position.y > self.inputs.ground_threshold
2435 && time < TRAJECTORY_TIME_LIMIT_S
2436 {
2437 let velocity_magnitude = velocity.magnitude();
2439 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
2440
2441 self.push_trajectory_point(
2442 &mut points,
2443 TrajectoryPoint {
2444 time,
2445 position,
2446 velocity_magnitude,
2447 kinetic_energy,
2448 },
2449 )?;
2450
2451 {
2454 let mach_here = if speed_of_sound > 0.0 {
2455 velocity_magnitude / speed_of_sound
2456 } else {
2457 0.0
2458 };
2459 mach_transitions.record_downward_crossings(
2460 mach_here,
2461 position.x,
2462 &mut transonic_distances,
2463 );
2464 }
2465
2466 if position.y > max_height {
2467 max_height = position.y;
2468 }
2469
2470 if self.inputs.enable_pitch_damping {
2473 let mach = velocity_magnitude / speed_of_sound;
2474 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2475 transonic_mach = Some(mach);
2476 }
2477 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2478 if pitch_damping < min_pitch_damping {
2479 min_pitch_damping = pitch_damping;
2480 }
2481 }
2482
2483 let accepted_step = self.adaptive_rk45_step(
2486 &position,
2487 &velocity,
2488 dt,
2489 &wind_vector,
2490 (resolved_temp_c, resolved_press_hpa, base_ratio),
2491 );
2492 debug_assert!(
2493 accepted_step.error <= RK45_TOLERANCE || accepted_step.used_dt <= RK45_MIN_DT
2494 );
2495
2496 if self.inputs.enable_precession_nutation {
2500 if let Some(ref mut state) = angular_state {
2501 let params = self.precession_nutation_params(
2502 velocity_magnitude,
2503 air_density,
2504 speed_of_sound,
2505 );
2506
2507 *state = calculate_combined_angular_motion(
2508 ¶ms,
2509 state,
2510 time,
2511 accepted_step.used_dt,
2512 0.001,
2513 );
2514
2515 if state.yaw_angle.abs() > max_yaw_angle {
2516 max_yaw_angle = state.yaw_angle.abs();
2517 }
2518 if state.precession_angle.abs() > max_precession_angle {
2519 max_precession_angle = state.precession_angle.abs();
2520 }
2521 }
2522 }
2523
2524 position = accepted_step.position;
2525 velocity = accepted_step.velocity;
2526 time += accepted_step.used_dt;
2527 self.validate_integration_state(&position, &velocity, time)?;
2528
2529 dt = accepted_step.next_dt;
2531 }
2532
2533 if points.is_empty() {
2535 return Err(BallisticsError::from("No trajectory points calculated"));
2536 }
2537
2538 let termination =
2540 self.append_terminal_endpoint(&mut points, position, velocity, time, &mut max_height)?;
2541
2542 let last_point = points.last().unwrap();
2543
2544 let sampled_points = if self.inputs.enable_trajectory_sampling {
2546 let trajectory_data = TrajectoryData {
2548 times: points.iter().map(|p| p.time).collect(),
2549 positions: points.iter().map(|p| p.position).collect(),
2550 velocities: points
2551 .iter()
2552 .map(|p| {
2553 Vector3::new(0.0, 0.0, p.velocity_magnitude)
2555 })
2556 .collect(),
2557 transonic_distances, mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2559 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2560 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2561 };
2562
2563 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
2568 let outputs = TrajectoryOutputs {
2569 target_distance_horiz_m: last_point.position.x,
2570 target_vertical_height_m: sight_position_m,
2571 time_of_flight_s: last_point.time,
2572 max_ord_dist_horiz_m: max_height,
2573 sight_height_m: sight_position_m,
2574 };
2575
2576 let samples = sample_trajectory(
2577 &trajectory_data,
2578 &outputs,
2579 self.inputs.sample_interval,
2580 self.inputs.bullet_mass,
2581 )?;
2582 Some(samples)
2583 } else {
2584 None
2585 };
2586
2587 Ok(TrajectoryResult {
2588 max_range: last_point.position.x, max_height,
2590 time_of_flight: last_point.time,
2591 impact_velocity: last_point.velocity_magnitude,
2592 impact_energy: last_point.kinetic_energy,
2593 projectile_mass_kg: self.inputs.bullet_mass,
2594 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
2595 station_speed_of_sound_mps: speed_of_sound,
2596 termination,
2597 points,
2598 sampled_points,
2599 min_pitch_damping: if self.inputs.enable_pitch_damping {
2600 Some(min_pitch_damping)
2601 } else {
2602 None
2603 },
2604 transonic_mach,
2605 angular_state,
2606 max_yaw_angle: if self.inputs.enable_precession_nutation {
2607 Some(max_yaw_angle)
2608 } else {
2609 None
2610 },
2611 max_precession_angle: if self.inputs.enable_precession_nutation {
2612 Some(max_precession_angle)
2613 } else {
2614 None
2615 },
2616 aerodynamic_jump: aj_components,
2617 mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2618 mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2619 mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2620 })
2621 }
2622
2623 fn adaptive_rk45_step(
2624 &self,
2625 position: &Vector3<f64>,
2626 velocity: &Vector3<f64>,
2627 initial_dt: f64,
2628 wind_vector: &Vector3<f64>,
2629 resolved_atmo: (f64, f64, f64),
2630 ) -> Rk45AcceptedStep {
2631 let mut trial_dt = initial_dt;
2632
2633 loop {
2634 let trial = self.rk45_step(
2635 position,
2636 velocity,
2637 trial_dt,
2638 wind_vector,
2639 RK45_TOLERANCE,
2640 resolved_atmo,
2641 );
2642 let next_dt = if trial.suggested_dt.is_finite() {
2647 (RK45_SAFETY_FACTOR * trial.suggested_dt).clamp(RK45_MIN_DT, RK45_MAX_DT)
2648 } else {
2649 RK45_MIN_DT
2650 };
2651
2652 if trial.error <= RK45_TOLERANCE || trial_dt <= RK45_MIN_DT {
2653 return Rk45AcceptedStep {
2654 position: trial.position,
2655 velocity: trial.velocity,
2656 used_dt: trial_dt,
2657 next_dt,
2658 error: trial.error,
2659 };
2660 }
2661
2662 trial_dt = next_dt;
2663 }
2664 }
2665
2666 fn rk45_step(
2667 &self,
2668 position: &Vector3<f64>,
2669 velocity: &Vector3<f64>,
2670 dt: f64,
2671 wind_vector: &Vector3<f64>,
2672 tolerance: f64,
2673 resolved_atmo: (f64, f64, f64), ) -> Rk45Trial {
2675 const A21: f64 = 1.0 / 5.0;
2677 const A31: f64 = 3.0 / 40.0;
2678 const A32: f64 = 9.0 / 40.0;
2679 const A41: f64 = 44.0 / 45.0;
2680 const A42: f64 = -56.0 / 15.0;
2681 const A43: f64 = 32.0 / 9.0;
2682 const A51: f64 = 19372.0 / 6561.0;
2683 const A52: f64 = -25360.0 / 2187.0;
2684 const A53: f64 = 64448.0 / 6561.0;
2685 const A54: f64 = -212.0 / 729.0;
2686 const A61: f64 = 9017.0 / 3168.0;
2687 const A62: f64 = -355.0 / 33.0;
2688 const A63: f64 = 46732.0 / 5247.0;
2689 const A64: f64 = 49.0 / 176.0;
2690 const A65: f64 = -5103.0 / 18656.0;
2691 const A71: f64 = 35.0 / 384.0;
2692 const A73: f64 = 500.0 / 1113.0;
2693 const A74: f64 = 125.0 / 192.0;
2694 const A75: f64 = -2187.0 / 6784.0;
2695 const A76: f64 = 11.0 / 84.0;
2696
2697 const B1: f64 = 35.0 / 384.0;
2699 const B3: f64 = 500.0 / 1113.0;
2700 const B4: f64 = 125.0 / 192.0;
2701 const B5: f64 = -2187.0 / 6784.0;
2702 const B6: f64 = 11.0 / 84.0;
2703
2704 const B1_ERR: f64 = 5179.0 / 57600.0;
2706 const B3_ERR: f64 = 7571.0 / 16695.0;
2707 const B4_ERR: f64 = 393.0 / 640.0;
2708 const B5_ERR: f64 = -92097.0 / 339200.0;
2709 const B6_ERR: f64 = 187.0 / 2100.0;
2710 const B7_ERR: f64 = 1.0 / 40.0;
2711
2712 let k1_v = self.calculate_acceleration(position, velocity, wind_vector, resolved_atmo);
2714 let k1_p = *velocity;
2715
2716 let p2 = position + dt * A21 * k1_p;
2717 let v2 = velocity + dt * A21 * k1_v;
2718 let k2_v = self.calculate_acceleration(&p2, &v2, wind_vector, resolved_atmo);
2719 let k2_p = v2;
2720
2721 let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
2722 let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
2723 let k3_v = self.calculate_acceleration(&p3, &v3, wind_vector, resolved_atmo);
2724 let k3_p = v3;
2725
2726 let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
2727 let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
2728 let k4_v = self.calculate_acceleration(&p4, &v4, wind_vector, resolved_atmo);
2729 let k4_p = v4;
2730
2731 let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
2732 let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
2733 let k5_v = self.calculate_acceleration(&p5, &v5, wind_vector, resolved_atmo);
2734 let k5_p = v5;
2735
2736 let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
2737 let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
2738 let k6_v = self.calculate_acceleration(&p6, &v6, wind_vector, resolved_atmo);
2739 let k6_p = v6;
2740
2741 let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
2742 let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
2743 let k7_v = self.calculate_acceleration(&p7, &v7, wind_vector, resolved_atmo);
2744 let k7_p = v7;
2745
2746 let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
2748 let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
2749
2750 let pos_err = position
2752 + dt * (B1_ERR * k1_p
2753 + B3_ERR * k3_p
2754 + B4_ERR * k4_p
2755 + B5_ERR * k5_p
2756 + B6_ERR * k6_p
2757 + B7_ERR * k7_p);
2758 let vel_err = velocity
2759 + dt * (B1_ERR * k1_v
2760 + B3_ERR * k3_v
2761 + B4_ERR * k4_v
2762 + B5_ERR * k5_v
2763 + B6_ERR * k6_v
2764 + B7_ERR * k7_v);
2765
2766 let error = cli_rk45_error_norm(position, velocity, &new_pos, &new_vel, &pos_err, &vel_err);
2768
2769 let dt_new = if error < tolerance {
2771 dt * (tolerance / error).powf(0.2).min(2.0)
2772 } else {
2773 dt * (tolerance / error).powf(0.25).max(0.1)
2774 };
2775
2776 Rk45Trial {
2777 position: new_pos,
2778 velocity: new_vel,
2779 suggested_dt: dt_new,
2780 error,
2781 }
2782 }
2783
2784 fn apply_cluster_bc_correction(&self, base_bc: f64, velocity_fps: f64) -> f64 {
2785 if let Some(ref cluster_bc) = self.cluster_bc {
2786 cluster_bc.apply_correction_for_drag_model(
2787 base_bc,
2788 self.inputs.caliber_inches,
2789 self.inputs.weight_grains,
2790 velocity_fps,
2791 self.inputs.bc_type,
2792 )
2793 } else {
2794 base_bc
2795 }
2796 }
2797
2798 fn calculate_acceleration(
2799 &self,
2800 position: &Vector3<f64>,
2801 velocity: &Vector3<f64>,
2802 wind_vector: &Vector3<f64>,
2803 resolved_atmo: (f64, f64, f64), ) -> Vector3<f64> {
2805 let actual_wind = if let Some(ref sock) = self.wind_sock {
2811 sock.vector_for_range_stateless(position.x)
2812 } else if self.inputs.enable_wind_shear {
2813 self.get_wind_at_altitude(position.y)
2814 } else {
2815 *wind_vector
2816 };
2817 let actual_wind =
2818 crate::derivatives::level_vector_to_shot_frame(actual_wind, self.inputs.shooting_angle);
2819
2820 let relative_velocity = velocity - actual_wind;
2821 let velocity_magnitude = relative_velocity.magnitude();
2822
2823 if velocity_magnitude < 0.001 {
2824 return self.gravity_acceleration();
2825 }
2826
2827 let (base_temp_c, base_press_hpa, station_ratio) = resolved_atmo;
2838
2839 let (drag_base_temp_c, drag_base_press_hpa, drag_base_ratio, drag_humidity_percent) =
2847 if let Some(ref sock) = self.atmo_sock {
2848 let (zone_temp_c, zone_press_hpa, zone_humidity) = sock.atmo_for_range(position.x);
2849 let zone_base_ratio = crate::atmosphere::calculate_air_density_cimp(
2850 zone_temp_c,
2851 zone_press_hpa,
2852 zone_humidity,
2853 ) / 1.225;
2854 (zone_temp_c, zone_press_hpa, zone_base_ratio, zone_humidity)
2855 } else {
2856 (
2857 base_temp_c,
2858 base_press_hpa,
2859 station_ratio,
2860 self.atmosphere.humidity,
2861 )
2862 };
2863 let local_alt = crate::atmosphere::shot_frame_altitude(
2864 self.atmosphere.altitude,
2865 position.x,
2866 position.y,
2867 self.inputs.shooting_angle,
2868 );
2869 let (air_density, speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
2870 local_alt,
2871 self.atmosphere.altitude,
2872 drag_base_temp_c,
2873 drag_base_press_hpa,
2874 drag_base_ratio,
2875 drag_humidity_percent,
2876 );
2877
2878 let cd = self.calculate_drag_coefficient(velocity_magnitude, speed_of_sound);
2880
2881 let velocity_fps = velocity_magnitude * 3.28084;
2883
2884 let (base_bc, bc_from_segments) = if let Some(segments) = self
2889 .inputs
2890 .bc_segments_data
2891 .as_ref()
2892 .filter(|segments| self.inputs.use_bc_segments && !segments.is_empty())
2893 {
2894 (
2896 crate::bc_estimation::velocity_segment_bc(
2897 velocity_fps,
2898 segments,
2899 self.inputs.bc_value,
2900 ),
2901 true,
2902 )
2903 } else if let Some(segments) = self
2904 .inputs
2905 .bc_segments
2906 .as_ref()
2907 .filter(|segments| !segments.is_empty())
2908 {
2909 (
2910 crate::derivatives::interpolated_bc(
2911 velocity_magnitude / speed_of_sound,
2912 segments,
2913 Some(&self.inputs),
2914 ),
2915 true,
2916 )
2917 } else {
2918 (self.inputs.bc_value, false)
2919 };
2920
2921 let effective_bc = if bc_from_segments {
2926 base_bc
2927 } else {
2928 self.apply_cluster_bc_correction(base_bc, velocity_fps)
2929 };
2930 let effective_bc = effective_bc.max(1e-6);
2933
2934 let retard_denom = if self.inputs.custom_drag_table.is_some() {
2939 self.inputs.custom_drag_denominator(effective_bc)
2940 } else {
2941 effective_bc
2942 };
2943
2944 let cd_to_retard = crate::constants::CD_TO_RETARD;
2949 let standard_factor = cd * cd_to_retard;
2950 let density_scale = air_density / 1.225; let a_drag_ft_s2 =
2954 (velocity_fps * velocity_fps) * standard_factor * density_scale / retard_denom;
2955 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
2959
2960 let mut accel = drag_acceleration + self.gravity_acceleration();
2963
2964 if self.inputs.enable_coriolis {
2967 if let Some(lat_deg) = self.inputs.latitude {
2968 let omega_earth = 7.2921159e-5_f64; let lat = lat_deg.to_radians();
2970 let az = self.inputs.shot_azimuth; let omega = Vector3::new(
2977 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), );
2981 let omega = crate::derivatives::level_vector_to_shot_frame(
2982 omega,
2983 self.inputs.shooting_angle,
2984 );
2985 accel += -2.0 * omega.cross(velocity);
2990 }
2991 }
2992
2993 if self.inputs.enable_magnus
3000 && !self.inputs.use_enhanced_spin_drift
3001 && self.inputs.bullet_diameter > 0.0
3002 && self.inputs.twist_rate > 0.0
3003 {
3004 let diameter_m = self.inputs.bullet_diameter;
3005 let (spin_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
3006 self.inputs.muzzle_velocity,
3007 velocity_magnitude,
3008 self.inputs.twist_rate,
3009 diameter_m,
3010 );
3011 let mach = velocity_magnitude / speed_of_sound;
3013
3014 let d_in = self.inputs.bullet_diameter / 0.0254;
3016 let m_gr = self.inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
3017 let l_in = if self.inputs.bullet_length > 0.0 {
3018 self.inputs.bullet_length / 0.0254
3019 } else {
3020 let est_m = crate::stability::estimate_bullet_length_m(
3022 self.inputs.bullet_diameter,
3023 self.inputs.bullet_mass,
3024 );
3025 if est_m > 0.0 {
3026 est_m / 0.0254
3027 } else {
3028 4.5 * d_in
3029 }
3030 };
3031 let sg = crate::spin_drift::calculate_dynamic_stability(
3035 m_gr,
3036 velocity_magnitude,
3037 spin_rad_s,
3038 d_in,
3039 l_in,
3040 air_density,
3041 );
3042
3043 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
3045 sg,
3046 velocity_magnitude,
3047 spin_rad_s,
3048 0.0, 0.0, air_density,
3051 d_in,
3052 l_in,
3053 m_gr,
3054 mach,
3055 "match",
3056 false,
3057 );
3058
3059 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
3061 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
3062 let magnus_force = 0.5
3063 * air_density
3064 * velocity_magnitude.powi(2)
3065 * area
3066 * c_np
3067 * spin_param
3068 * yaw_rad.sin();
3069
3070 if magnus_force.abs() > 1e-12 {
3074 if let Some(dir) = crate::derivatives::yaw_of_repose_magnus_direction(
3075 relative_velocity,
3076 self.gravity_acceleration(),
3077 self.inputs.is_twist_right,
3078 ) {
3079 accel += (magnus_force / self.inputs.bullet_mass) * dir;
3080 }
3081 }
3082 }
3083
3084 accel
3085 }
3086
3087 fn calculate_drag_coefficient(&self, velocity: f64, speed_of_sound: f64) -> f64 {
3088 let mach = velocity / speed_of_sound;
3089
3090 if let Some(ref table) = self.inputs.custom_drag_table {
3094 return table.interpolate(mach) * self.inputs.cd_scale;
3099 }
3100
3101 crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type)
3104 }
3105}
3106
3107#[derive(Debug, Clone)]
3109pub struct MonteCarloParams {
3110 pub num_simulations: usize,
3111 pub velocity_std_dev: f64,
3112 pub angle_std_dev: f64,
3113 pub bc_std_dev: f64,
3114 pub wind_speed_std_dev: f64,
3115 pub target_distance: Option<f64>,
3116 pub base_wind_speed: f64,
3117 pub base_wind_direction: f64,
3118 pub azimuth_std_dev: f64, }
3120
3121impl Default for MonteCarloParams {
3122 fn default() -> Self {
3123 Self {
3124 num_simulations: 1000,
3125 velocity_std_dev: 1.0,
3126 angle_std_dev: 0.001,
3127 bc_std_dev: 0.01,
3128 wind_speed_std_dev: 1.0,
3129 target_distance: None,
3130 base_wind_speed: 0.0,
3131 base_wind_direction: 0.0,
3132 azimuth_std_dev: 0.001, }
3134 }
3135}
3136
3137#[derive(Debug, Clone)]
3139pub struct MonteCarloResults {
3140 pub ranges: Vec<f64>,
3141 pub impact_velocities: Vec<f64>,
3142 pub impact_positions: Vec<Vector3<f64>>,
3148}
3149
3150pub const DEFAULT_HIT_RADIUS_M: f64 = 0.3;
3153
3154pub const TARGET_NOT_REACHED_SENTINEL_M: f64 = -1.0e9;
3160
3161impl MonteCarloResults {
3162 pub fn position_reached_target(position: &Vector3<f64>) -> bool {
3164 position.iter().all(|component| component.is_finite())
3165 && position.y != TARGET_NOT_REACHED_SENTINEL_M
3166 }
3167
3168 pub fn target_arrival_count(&self) -> usize {
3170 self.impact_positions
3171 .iter()
3172 .filter(|position| Self::position_reached_target(position))
3173 .count()
3174 }
3175
3176 pub fn target_shortfall_fraction(&self) -> f64 {
3179 if self.impact_positions.is_empty() {
3180 return 0.0;
3181 }
3182 (self.impact_positions.len() - self.target_arrival_count()) as f64
3183 / self.impact_positions.len() as f64
3184 }
3185
3186 pub fn target_plane_cep(&self) -> Option<f64> {
3192 let mut radial_misses: Vec<f64> = self
3193 .impact_positions
3194 .iter()
3195 .filter(|position| Self::position_reached_target(position))
3196 .map(Vector3::norm)
3197 .filter(|miss| miss.is_finite())
3198 .collect();
3199 radial_misses.sort_by(f64::total_cmp);
3200 if radial_misses.is_empty() {
3201 None
3202 } else {
3203 Some(radial_misses[radial_misses.len() / 2])
3204 }
3205 }
3206
3207 pub fn hit_probability(&self, hit_radius_m: f64) -> f64 {
3216 if self.impact_positions.is_empty() {
3217 return 0.0;
3218 }
3219 let hits = self
3220 .impact_positions
3221 .iter()
3222 .filter(|position| {
3223 Self::position_reached_target(position) && position.norm() < hit_radius_m
3224 })
3225 .count();
3226 hits as f64 / self.impact_positions.len() as f64
3227 }
3228
3229 pub fn rect_hit_probability(&self, width_m: f64, height_m: f64) -> f64 {
3241 let dimensions_invalid = width_m.is_nan()
3242 || width_m <= 0.0
3243 || height_m.is_nan()
3244 || height_m <= 0.0;
3245 if self.impact_positions.is_empty() || dimensions_invalid {
3246 return 0.0;
3247 }
3248 let half_width = width_m / 2.0;
3249 let half_height = height_m / 2.0;
3250 let hits = self
3251 .impact_positions
3252 .iter()
3253 .filter(|position| {
3254 Self::position_reached_target(position)
3255 && position.z.abs() <= half_width
3256 && position.y.abs() <= half_height
3257 })
3258 .count();
3259 hits as f64 / self.impact_positions.len() as f64
3260 }
3261}
3262
3263fn wind_from_signed_speed_sample(
3264 signed_speed: f64,
3265 sampled_direction: f64,
3266 vertical_speed: f64,
3267) -> WindConditions {
3268 if signed_speed < 0.0 {
3273 WindConditions {
3274 speed: -signed_speed,
3275 direction: sampled_direction + std::f64::consts::PI,
3276 vertical_speed,
3277 }
3278 } else {
3279 WindConditions {
3280 speed: signed_speed,
3281 direction: sampled_direction,
3282 vertical_speed,
3283 }
3284 }
3285}
3286
3287struct MonteCarloWindSampler {
3288 speed: rand_distr::Normal<f64>,
3289 direction: rand_distr::Normal<f64>,
3290 vertical_speed: f64,
3292}
3293
3294impl MonteCarloWindSampler {
3295 fn new(
3296 base_wind: &WindConditions,
3297 wind_speed_std_dev: f64,
3298 wind_direction_std_dev: f64,
3299 ) -> Result<Self, BallisticsError> {
3300 use rand_distr::Normal;
3301
3302 if !wind_direction_std_dev.is_finite() || wind_direction_std_dev < 0.0 {
3303 return Err("Wind direction standard deviation must be finite and non-negative".into());
3304 }
3305
3306 let speed = Normal::new(base_wind.speed, wind_speed_std_dev)
3307 .map_err(|e| format!("Invalid wind speed distribution: {e}"))?;
3308 let direction = Normal::new(base_wind.direction, wind_direction_std_dev)
3309 .map_err(|e| format!("Invalid wind direction distribution: {e}"))?;
3310 Ok(Self { speed, direction, vertical_speed: base_wind.vertical_speed })
3311 }
3312
3313 fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> WindConditions {
3314 use rand_distr::Distribution;
3315
3316 wind_from_signed_speed_sample(
3317 self.speed.sample(rng),
3318 self.direction.sample(rng),
3319 self.vertical_speed,
3320 )
3321 }
3322}
3323
3324pub fn run_monte_carlo(
3326 base_inputs: BallisticInputs,
3327 params: MonteCarloParams,
3328) -> Result<MonteCarloResults, BallisticsError> {
3329 run_monte_carlo_with_direction_std_dev(base_inputs, params, 0.0)
3330}
3331
3332pub fn run_monte_carlo_with_direction_std_dev(
3337 base_inputs: BallisticInputs,
3338 params: MonteCarloParams,
3339 wind_direction_std_dev: f64,
3340) -> Result<MonteCarloResults, BallisticsError> {
3341 let base_wind = WindConditions {
3342 speed: params.base_wind_speed,
3343 direction: params.base_wind_direction,
3344 vertical_speed: 0.0,
3345 };
3346 run_monte_carlo_with_wind_and_direction_std_dev(
3347 base_inputs,
3348 base_wind,
3349 params,
3350 wind_direction_std_dev,
3351 )
3352}
3353
3354pub fn run_monte_carlo_with_wind(
3356 base_inputs: BallisticInputs,
3357 base_wind: WindConditions,
3358 params: MonteCarloParams,
3359) -> Result<MonteCarloResults, BallisticsError> {
3360 run_monte_carlo_with_wind_and_direction_std_dev(base_inputs, base_wind, params, 0.0)
3361}
3362
3363pub fn run_monte_carlo_with_wind_and_direction_std_dev(
3368 base_inputs: BallisticInputs,
3369 base_wind: WindConditions,
3370 params: MonteCarloParams,
3371 wind_direction_std_dev: f64,
3372) -> Result<MonteCarloResults, BallisticsError> {
3373 let mut rng = rand::rng();
3374 run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
3375 base_inputs,
3376 base_wind,
3377 params,
3378 wind_direction_std_dev,
3379 &mut rng,
3380 )
3381}
3382
3383pub fn run_monte_carlo_with_wind_and_direction_std_dev_seeded(
3390 base_inputs: BallisticInputs,
3391 base_wind: WindConditions,
3392 params: MonteCarloParams,
3393 wind_direction_std_dev: f64,
3394 seed: u64,
3395) -> Result<MonteCarloResults, BallisticsError> {
3396 use rand::{rngs::StdRng, SeedableRng};
3397 let mut rng = StdRng::seed_from_u64(seed);
3398 run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
3399 base_inputs,
3400 base_wind,
3401 params,
3402 wind_direction_std_dev,
3403 &mut rng,
3404 )
3405}
3406
3407fn run_monte_carlo_with_wind_and_direction_std_dev_using_rng<R: rand::Rng + ?Sized>(
3408 base_inputs: BallisticInputs,
3409 base_wind: WindConditions,
3410 params: MonteCarloParams,
3411 wind_direction_std_dev: f64,
3412 rng: &mut R,
3413) -> Result<MonteCarloResults, BallisticsError> {
3414 use rand_distr::{Distribution, Normal};
3415
3416 let mut ranges = Vec::new();
3417 let mut impact_velocities = Vec::new();
3418 let mut impact_positions = Vec::new();
3419
3420 let atmosphere = AtmosphericConditions {
3421 temperature: base_inputs.temperature,
3422 pressure: base_inputs.pressure,
3423 humidity: base_inputs.humidity_percent(),
3424 altitude: base_inputs.altitude,
3425 };
3426 let target_hint = params
3427 .target_distance
3428 .unwrap_or(base_inputs.target_distance);
3429 let solver_max_range = target_hint.max(1000.0) * 2.0;
3430
3431 let mut baseline_solver =
3433 TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), atmosphere.clone());
3434 baseline_solver.set_max_range(solver_max_range);
3435 let baseline_result = baseline_solver.solve()?;
3436
3437 let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
3439
3440 let baseline_at_target = baseline_result
3442 .position_at_range(target_distance)
3443 .ok_or("Could not interpolate baseline at target distance")?;
3444
3445 let velocity_delta_dist = Normal::new(0.0, params.velocity_std_dev)
3450 .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
3451 let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
3452 .map_err(|e| format!("Invalid angle distribution: {}", e))?;
3453 let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
3454 .map_err(|e| format!("Invalid BC distribution: {}", e))?;
3455 let wind_sampler = MonteCarloWindSampler::new(
3458 &base_wind,
3459 params.wind_speed_std_dev,
3460 wind_direction_std_dev,
3461 )?;
3462 let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
3463 .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
3464
3465 for _ in 0..params.num_simulations {
3466 let mut inputs = base_inputs.clone();
3468 let muzzle_velocity_delta = velocity_delta_dist.sample(&mut *rng);
3469 inputs.muzzle_angle = angle_dist.sample(&mut *rng);
3470 inputs.bc_value = bc_dist.sample(&mut *rng).max(0.01);
3471 inputs.azimuth_angle = azimuth_dist.sample(&mut *rng); let wind = wind_sampler.sample(&mut *rng);
3475
3476 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere.clone());
3478 solver.inputs.muzzle_velocity =
3479 (solver.inputs.muzzle_velocity + muzzle_velocity_delta).max(0.0);
3480 solver.set_max_range(solver_max_range);
3481 match solver.solve() {
3482 Ok(result) => {
3483 let deviation = if result.max_range < target_distance {
3489 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)
3492 } else {
3493 let pos_at_target = match result.position_at_range(target_distance) {
3494 Some(p) => p,
3495 None => continue, };
3497 Vector3::new(
3502 0.0,
3503 pos_at_target.y - baseline_at_target.y,
3504 pos_at_target.z - baseline_at_target.z,
3505 )
3506 };
3507
3508 ranges.push(result.max_range);
3509 impact_velocities.push(result.impact_velocity);
3510 impact_positions.push(deviation);
3511 }
3512 Err(_) => {
3513 continue;
3515 }
3516 }
3517 }
3518
3519 if ranges.is_empty() {
3520 return Err("No successful simulations".into());
3521 }
3522
3523 Ok(MonteCarloResults {
3524 ranges,
3525 impact_velocities,
3526 impact_positions,
3527 })
3528}
3529
3530pub fn calculate_zero_angle(
3532 inputs: BallisticInputs,
3533 target_distance: f64,
3534 target_height: f64,
3535) -> Result<f64, BallisticsError> {
3536 calculate_zero_angle_with_conditions(
3537 inputs,
3538 target_distance,
3539 target_height,
3540 WindConditions::default(),
3541 AtmosphericConditions::default(),
3542 )
3543}
3544
3545pub fn calculate_zero_angle_with_conditions(
3546 inputs: BallisticInputs,
3547 target_distance: f64,
3548 target_height: f64,
3549 wind: WindConditions,
3550 atmosphere: AtmosphericConditions,
3551) -> Result<f64, BallisticsError> {
3552 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
3553 solver.calculate_and_set_zero_angle(target_distance, target_height, ZeroTargetFrame::SightLine)
3554}
3555
3556#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3558pub enum BcFitMode {
3559 Drop,
3561 Velocity,
3564}
3565
3566#[derive(Debug, Clone, Copy)]
3568pub struct BcEstimate {
3569 pub bc: f64,
3571 pub rms_error: f64,
3573 pub drag_model: DragModel,
3575 pub mode: BcFitMode,
3577 pub at_bound: bool,
3581}
3582
3583fn fit_value_at(
3591 points: &[TrajectoryPoint],
3592 target_dist: f64,
3593 mode: BcFitMode,
3594 drop_offset: f64,
3595) -> Option<f64> {
3596 let val = |p: &TrajectoryPoint| match mode {
3597 BcFitMode::Drop => drop_offset - p.position.y,
3598 BcFitMode::Velocity => p.velocity_magnitude,
3599 };
3600 for i in 0..points.len() {
3601 if points[i].position.x >= target_dist {
3602 if i == 0 {
3603 return Some(val(&points[0]));
3604 }
3605 let p1 = &points[i - 1];
3606 let p2 = &points[i];
3607 let dx = p2.position.x - p1.position.x;
3608 if dx.abs() < 1e-9 {
3609 return Some(val(p2));
3610 }
3611 let t = (target_dist - p1.position.x) / dx;
3612 return Some(val(p1) + t * (val(p2) - val(p1)));
3613 }
3614 }
3615 None
3616}
3617
3618fn fit_residual_sse(
3619 trajectory: &[TrajectoryPoint],
3620 observations: &[(f64, f64)],
3621 mode: BcFitMode,
3622 drop_offset: f64,
3623) -> Option<f64> {
3624 if observations.is_empty() {
3625 return None;
3626 }
3627 let mut total = 0.0;
3628 for (target_dist, target_val) in observations {
3629 let value = fit_value_at(trajectory, *target_dist, mode, drop_offset)?;
3632 let error = value - target_val;
3633 total += error * error;
3634 }
3635 Some(total)
3636}
3637
3638#[allow(clippy::too_many_arguments)] pub fn estimate_bc_fit(
3656 velocity: f64,
3657 mass: f64,
3658 diameter: f64,
3659 points: &[(f64, f64)],
3660 drag_model: DragModel,
3661 mode: BcFitMode,
3662 atmosphere: AtmosphericConditions,
3663 zero_range: Option<f64>,
3664 sight_height: f64,
3665) -> Result<BcEstimate, BallisticsError> {
3666 if points.is_empty() {
3667 return Err(BallisticsError::from(
3668 "No data points provided for BC estimation.".to_string(),
3669 ));
3670 }
3671 let max_dist = points.iter().map(|(d, _)| *d).fold(0.0_f64, f64::max);
3672 let drop_offset = if zero_range.is_some() { sight_height } else { 0.0 };
3675
3676 let sse = |bc_value: f64| -> Option<f64> {
3678 let mut inputs = BallisticInputs {
3679 muzzle_velocity: velocity,
3680 bc_value,
3681 bc_type: drag_model,
3682 bullet_mass: mass,
3683 bullet_diameter: diameter,
3684 sight_height,
3685 ..Default::default()
3686 };
3687 if let Some(zr) = zero_range {
3690 let za = calculate_zero_angle_with_conditions(
3696 inputs.clone(),
3697 zr,
3698 sight_height,
3699 WindConditions::default(),
3700 atmosphere.clone(),
3701 )
3702 .ok()?;
3703 inputs.muzzle_angle = za;
3704 }
3705 let mut solver =
3706 TrajectorySolver::new(inputs, WindConditions::default(), atmosphere.clone());
3707 solver.set_max_range(max_dist * 1.5);
3708 let result = solver.solve().ok()?;
3709 fit_residual_sse(&result.points, points, mode, drop_offset)
3710 };
3711
3712 let (bc_min, bc_max) = match drag_model {
3716 DragModel::G7 => (0.05, 0.70),
3717 _ => (0.10, 1.20),
3718 };
3719
3720 let mut best_bc = f64::NAN;
3722 let mut best_sse = f64::MAX;
3723 let mut bc = bc_min;
3724 while bc <= bc_max + 1e-9 {
3725 if let Some(s) = sse(bc) {
3726 if s < best_sse {
3727 best_sse = s;
3728 best_bc = bc;
3729 }
3730 }
3731 bc += 0.01;
3732 }
3733 if !best_bc.is_finite() {
3734 return Err(BallisticsError::from(
3735 "Unable to estimate BC from provided data. Check that the values and units are correct."
3736 .to_string(),
3737 ));
3738 }
3739
3740 let lo = (best_bc - 0.01).max(bc_min);
3742 let hi = (best_bc + 0.01).min(bc_max);
3743 let mut bc = lo;
3744 while bc <= hi + 1e-9 {
3745 if let Some(s) = sse(bc) {
3746 if s < best_sse {
3747 best_sse = s;
3748 best_bc = bc;
3749 }
3750 }
3751 bc += 0.001;
3752 }
3753
3754 let at_bound = best_bc <= bc_min + 0.011 || best_bc >= bc_max - 0.011;
3757 let rms_error = (best_sse / points.len() as f64).sqrt();
3760 Ok(BcEstimate {
3761 bc: best_bc,
3762 rms_error,
3763 drag_model,
3764 mode,
3765 at_bound,
3766 })
3767}
3768
3769pub fn estimate_bc_from_trajectory(
3772 velocity: f64,
3773 mass: f64,
3774 diameter: f64,
3775 points: &[(f64, f64)], ) -> Result<f64, BallisticsError> {
3777 estimate_bc_fit(
3778 velocity,
3779 mass,
3780 diameter,
3781 points,
3782 DragModel::G1,
3783 BcFitMode::Drop,
3784 AtmosphericConditions::default(),
3785 None,
3786 0.05,
3787 )
3788 .map(|e| e.bc)
3789}
3790
3791use rand;
3793use rand_distr;
3794
3795#[cfg(test)]
3796mod mba737_powder_resolution_tests {
3797 use super::*;
3798
3799 #[test]
3800 fn linear_model_cold_powder_subtracts() {
3801 let v = resolve_powder_adjusted_velocity(823.0, 11.1, true, 0.5486, 21.1, None, None);
3803 assert!((v - (823.0 + 0.5486 * (11.1 - 21.1))).abs() < 1e-12);
3804 assert!(v < 823.0);
3805 }
3806
3807 #[test]
3808 fn linear_model_hot_powder_adds() {
3809 let v = resolve_powder_adjusted_velocity(823.0, 31.1, true, 0.5486, 21.1, None, None);
3810 assert!((v - (823.0 + 0.5486 * 10.0)).abs() < 1e-12);
3811 }
3812
3813 #[test]
3814 fn disabled_flag_is_passthrough() {
3815 let v = resolve_powder_adjusted_velocity(823.0, 40.0, false, 0.5486, 21.1, None, None);
3816 assert_eq!(v, 823.0);
3817 }
3818
3819 #[test]
3820 fn curve_overrides_linear_and_interpolates_at_powder_temp() {
3821 let curve = [(4.4, 798.6), (21.1, 823.0), (37.8, 841.2)];
3822 let v = resolve_powder_adjusted_velocity(823.0, 30.0, true, 99.0, 21.1, Some(&curve), Some(4.4));
3824 assert!((v - 798.6).abs() < 1e-9);
3825 }
3826
3827 #[test]
3828 fn curve_falls_back_to_ambient_and_clamps() {
3829 let curve = [(4.4, 798.6), (37.8, 841.2)];
3830 let v = resolve_powder_adjusted_velocity(823.0, -40.0, true, 1.0, 21.1, Some(&curve), None);
3832 assert!((v - 798.6).abs() < 1e-9);
3833 let v_hot = resolve_powder_adjusted_velocity(823.0, 60.0, true, 1.0, 21.1, Some(&curve), None);
3834 assert!((v_hot - 841.2).abs() < 1e-9);
3835 }
3836
3837 #[test]
3838 fn empty_curve_suppresses_linear_fallback() {
3839 let v = resolve_powder_adjusted_velocity(823.0, 40.0, true, 0.5486, 21.1, Some(&[]), None);
3841 assert_eq!(v, 823.0);
3842 }
3843
3844 #[test]
3845 fn sweep_huge_range_errors_instead_of_overflowing() {
3846 assert!(parse_powder_sweep("0:1e20:1").is_err());
3849 assert!(parse_powder_sweep("0:1e308:1e-3").is_err());
3850 }
3851
3852 #[test]
3853 fn sweep_fractional_step_keeps_end_row() {
3854 let rows = parse_powder_sweep("0:0.3:0.1").unwrap();
3856 assert_eq!(rows.len(), 4);
3857 assert!((rows[3] - 0.3).abs() < 1e-9);
3858 }
3859
3860 #[test]
3861 fn solver_and_helper_agree_on_linear_model() {
3862 let inputs = BallisticInputs {
3864 use_powder_sensitivity: true,
3865 powder_temp_sensitivity: 0.5486,
3866 powder_temp: 21.1,
3867 temperature: 4.4,
3868 ..Default::default()
3869 };
3870 let expected = resolve_powder_adjusted_velocity(
3871 inputs.muzzle_velocity,
3872 inputs.temperature,
3873 true,
3874 0.5486,
3875 21.1,
3876 None,
3877 None,
3878 );
3879 let solver = TrajectorySolver::new(
3880 inputs,
3881 WindConditions::default(),
3882 AtmosphericConditions::default(),
3883 );
3884 assert!((solver.inputs.muzzle_velocity - expected).abs() < 1e-12);
3885 }
3886}
3887
3888#[cfg(test)]
3889mod mba1302_solver_seam_tests {
3890 use super::*;
3891 use crate::wind::WindSegment;
3892
3893 #[test]
3894 fn authoritative_station_atmosphere_preserves_explicit_standard_values_at_altitude() {
3895 let atmosphere = AtmosphericConditions {
3896 temperature: 15.0,
3897 pressure: 1013.25,
3898 humidity: 50.0,
3899 altitude: 2_000.0,
3900 };
3901 let legacy = TrajectorySolver::new(
3902 BallisticInputs::default(),
3903 WindConditions::default(),
3904 atmosphere.clone(),
3905 );
3906 let authoritative = TrajectorySolver::new_with_resolved_station_atmosphere(
3907 BallisticInputs::default(),
3908 WindConditions::default(),
3909 atmosphere,
3910 );
3911
3912 let (legacy_density, _, legacy_temp_c, legacy_pressure_hpa) = legacy.resolved_atmosphere();
3913 let (authoritative_density, _, authoritative_temp_c, authoritative_pressure_hpa) =
3914 authoritative.resolved_atmosphere();
3915 let (icao_temp_k, icao_pressure_pa) =
3916 crate::atmosphere::calculate_icao_standard_atmosphere(2_000.0);
3917 let (expected_authoritative_density, _) =
3918 crate::atmosphere::calculate_atmosphere(2_000.0, Some(15.0), Some(1013.25), 50.0);
3919
3920 assert!((legacy_temp_c - (icao_temp_k - 273.15)).abs() < 1e-12);
3921 assert!((legacy_pressure_hpa - icao_pressure_pa / 100.0).abs() < 1e-12);
3922 assert_eq!(authoritative_temp_c.to_bits(), 15.0_f64.to_bits());
3923 assert_eq!(authoritative_pressure_hpa.to_bits(), 1013.25_f64.to_bits());
3924 assert_eq!(
3925 authoritative_density.to_bits(),
3926 expected_authoritative_density.to_bits()
3927 );
3928 assert!(
3929 (authoritative_density - legacy_density).abs() > 0.1,
3930 "explicit standard values at altitude must differ from ICAO-at-altitude: explicit={authoritative_density}, ICAO={legacy_density}"
3931 );
3932 }
3933
3934 fn configured_euler_zero(vertical_wind_mps: f64, time_step_s: f64) -> TrajectorySolver {
3935 let inputs = BallisticInputs {
3936 muzzle_velocity: 800.0,
3937 bc_value: 0.5,
3938 bc_type: DragModel::G7,
3939 bullet_mass: 0.0109,
3940 bullet_diameter: 0.00782,
3941 bullet_length: 0.0309,
3942 sight_height: 0.05,
3943 ground_threshold: -100.0,
3944 use_rk4: false,
3945 use_adaptive_rk45: false,
3946 ..BallisticInputs::default()
3947 };
3948 let mut solver = TrajectorySolver::new_with_resolved_station_atmosphere(
3949 inputs,
3950 WindConditions::default(),
3951 AtmosphericConditions::default(),
3952 );
3953 solver.set_max_range(300.0);
3954 solver.set_time_step(time_step_s);
3955 if vertical_wind_mps != 0.0 {
3956 solver.set_wind_segments(vec![WindSegment {
3957 speed_kmh: 0.0,
3958 angle_deg: 0.0,
3959 until_m: 400.0,
3960 vertical_mps: vertical_wind_mps,
3961 }]);
3962 }
3963 solver
3964 }
3965
3966 #[test]
3967 fn inclined_shot_zeroes_like_a_level_rifle() {
3968 const ZERO_DISTANCE_M: f64 = 91.44; const SIGHT_HEIGHT_M: f64 = 0.0381; let inputs = BallisticInputs {
3977 bc_value: 0.5,
3978 bullet_mass: 150.0 * 0.06479891 / 1000.0,
3979 muzzle_velocity: 2700.0 * 0.3048,
3980 sight_height: SIGHT_HEIGHT_M,
3981 ..Default::default()
3982 };
3983
3984 let mut level = inputs.clone();
3985 level.shooting_angle = 0.0;
3986 let level_angle = TrajectorySolver::new(level, Default::default(), Default::default())
3987 .find_zero_angle(ZERO_DISTANCE_M, SIGHT_HEIGHT_M, ZeroTargetFrame::SightLine)
3988 .expect("level zero must solve");
3989
3990 let mut inclined = inputs;
3991 inclined.shooting_angle = 5.71_f64.to_radians();
3992 let inclined_angle =
3993 TrajectorySolver::new(inclined, Default::default(), Default::default())
3994 .find_zero_angle(ZERO_DISTANCE_M, SIGHT_HEIGHT_M, ZeroTargetFrame::SightLine)
3995 .expect("MBA-1412: a 5.71 deg incline at a 100 yd zero must be solvable");
3996
3997 assert!(
3998 (inclined_angle - level_angle).abs() < 1e-9,
3999 "zeroing is level-rifle sight geometry; incline must not move the solved zero: \
4000 level={level_angle}, inclined={inclined_angle}"
4001 );
4002 }
4003
4004 #[test]
4005 fn configured_zero_keeps_segments_method_and_time_step_then_sets_base_angle() {
4006 const TARGET_DISTANCE_M: f64 = 150.0;
4007 const TARGET_HEIGHT_M: f64 = 0.05;
4008
4009 let mut segmented = configured_euler_zero(-10.0, 0.02);
4012 let coarse_height = segmented
4013 .zero_trial_height_at(0.0, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
4014 .expect("coarse configured trial")
4015 .expect("coarse trial reaches target");
4016 let mut fine = segmented.clone();
4017 fine.set_time_step(0.001);
4018 let fine_height = fine
4019 .zero_trial_height_at(0.0, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
4020 .expect("fine configured trial")
4021 .expect("fine trial reaches target");
4022 assert!(
4023 (coarse_height - fine_height).abs() > 1e-5,
4024 "configured Euler step must affect zero trials: coarse={coarse_height}, fine={fine_height}"
4025 );
4026
4027 let segmented_angle = segmented
4028 .calculate_and_set_zero_angle(TARGET_DISTANCE_M, TARGET_HEIGHT_M, ZeroTargetFrame::SightLine)
4029 .expect("segmented zero");
4030 assert_eq!(
4031 segmented.inputs.muzzle_angle.to_bits(),
4032 segmented_angle.to_bits(),
4033 "successful zero must install its angle on the configured solver"
4034 );
4035 assert_eq!(segmented.time_step.to_bits(), 0.02_f64.to_bits());
4036 assert_eq!(segmented.max_range.to_bits(), 300.0_f64.to_bits());
4037 assert!(segmented.wind_sock.is_some());
4038 assert_eq!(
4039 segmented.station_atmosphere_resolution,
4040 StationAtmosphereResolution::Authoritative
4041 );
4042 let zero_height = segmented
4043 .zero_trial_height_at(segmented_angle, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
4044 .expect("verify segmented zero")
4045 .expect("zeroed trial reaches target");
4046 assert!(
4047 (zero_height - TARGET_HEIGHT_M).abs() < 0.0001,
4048 "configured zero missed target: height={zero_height}"
4049 );
4050
4051 let mut calm = configured_euler_zero(0.0, 0.02);
4052 let calm_angle = calm
4053 .calculate_and_set_zero_angle(TARGET_DISTANCE_M, TARGET_HEIGHT_M, ZeroTargetFrame::SightLine)
4054 .expect("calm zero");
4055 assert!(
4056 (segmented_angle - calm_angle).abs() > 1e-5,
4057 "segmented vertical wind must participate in zero trials: segmented={segmented_angle}, calm={calm_angle}"
4058 );
4059 }
4060}
4061
4062#[cfg(test)]
4063mod result_sanity_tests {
4064 use super::*;
4065
4066 fn default_solver() -> TrajectorySolver {
4067 TrajectorySolver::new(
4068 BallisticInputs::default(),
4069 WindConditions::default(),
4070 AtmosphericConditions::default(),
4071 )
4072 }
4073
4074 fn minimal_result() -> TrajectoryResult {
4075 TrajectoryResult {
4076 max_range: 100.0,
4077 max_height: 1.0,
4078 time_of_flight: 0.5,
4079 impact_velocity: 700.0,
4080 impact_energy: 2450.0,
4081 projectile_mass_kg: 0.01,
4082 line_of_sight_height_m: 1.5,
4083 station_speed_of_sound_mps: 340.0,
4084 termination: TrajectoryTermination::MaxRange,
4085 points: vec![],
4086 sampled_points: None,
4087 min_pitch_damping: None,
4088 transonic_mach: None,
4089 angular_state: None,
4090 max_yaw_angle: None,
4091 max_precession_angle: None,
4092 aerodynamic_jump: None,
4093 mach_1_2_distance_m: None,
4094 mach_1_0_distance_m: None,
4095 mach_0_9_distance_m: None,
4096 }
4097 }
4098
4099 #[test]
4100 fn mba1293_negative_scalars_fail_the_result_postcondition() {
4101 let solver = default_solver();
4102 solver
4103 .validate_result_sanity(&minimal_result())
4104 .expect("a sane result must pass");
4105
4106 for (name, mutate) in [
4107 ("max_range", (|r| r.max_range = -50.588) as fn(&mut TrajectoryResult)),
4108 ("time_of_flight", |r| r.time_of_flight = -1.0),
4109 ("impact_velocity", |r| r.impact_velocity = -700.0),
4110 ("impact_energy", |r| r.impact_energy = -1.0),
4111 ] {
4112 let mut result = minimal_result();
4113 mutate(&mut result);
4114 let error = solver
4115 .validate_result_sanity(&result)
4116 .expect_err("negative scalar must fail");
4117 assert!(
4118 error.to_string().contains(name),
4119 "error for {name} did not name the field: {error}"
4120 );
4121 }
4122 }
4123
4124 #[test]
4125 fn mba1293_speed_budget_bounds_legitimate_states_and_rejects_divergence() {
4126 let solver = default_solver();
4127 let mv = solver.inputs.muzzle_velocity;
4128
4129 let position = Vector3::new(10.0, 0.0, 0.0);
4131 solver
4132 .validate_integration_state(&position, &Vector3::new(mv, 0.0, 0.0), 0.01)
4133 .expect("muzzle-speed state must pass");
4134
4135 let error = solver
4137 .validate_integration_state(&position, &Vector3::new(-13.0 * mv, 0.0, 0.0), 0.01)
4138 .expect_err("13x muzzle speed must fail the budget");
4139 assert!(error.to_string().contains("diverged"), "{error}");
4140
4141 let after_fall = mv + crate::constants::G_ACCEL_MPS2 * 60.0;
4143 solver
4144 .validate_integration_state(&position, &Vector3::new(0.0, -after_fall, 0.0), 60.0)
4145 .expect("gravity-accelerated speed within g*t must pass");
4146 }
4147}
4148
4149#[cfg(test)]
4150mod trajectory_point_budget_tests {
4151 use super::*;
4152 use crate::MAX_TRAJECTORY_SAMPLES;
4153
4154 fn solver_with_budget(
4155 use_rk4: bool,
4156 use_adaptive_rk45: bool,
4157 point_budget: usize,
4158 max_range: f64,
4159 ) -> TrajectorySolver {
4160 let inputs = BallisticInputs {
4161 use_rk4,
4162 use_adaptive_rk45,
4163 ground_threshold: f64::NEG_INFINITY,
4164 ..BallisticInputs::default()
4165 };
4166 let mut solver = TrajectorySolver::new(
4167 inputs,
4168 WindConditions::default(),
4169 AtmosphericConditions::default(),
4170 );
4171 solver.max_trajectory_points = point_budget;
4172 solver.set_max_range(max_range);
4173 solver.set_time_step(0.001);
4174 solver
4175 }
4176
4177 #[test]
4178 fn mba1283_every_solver_errors_instead_of_exceeding_point_budget() {
4179 for (mode, use_rk4, use_adaptive_rk45) in [
4180 ("Euler", false, false),
4181 ("RK4", true, false),
4182 ("RK45", true, true),
4183 ] {
4184 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 3, 10.0)
4185 .solve()
4186 .expect_err("a solve requiring more than three points must fail");
4187 assert!(
4188 error.to_string().contains("point limit of 3"),
4189 "unexpected {mode} point-budget error: {error}"
4190 );
4191 }
4192 }
4193
4194 #[test]
4195 fn mba1283_interpolated_endpoint_counts_toward_point_budget() {
4196 for (mode, use_rk4, use_adaptive_rk45) in [
4197 ("Euler", false, false),
4198 ("RK4", true, false),
4199 ("RK45", true, true),
4200 ] {
4201 let result = solver_with_budget(use_rk4, use_adaptive_rk45, 2, 0.1)
4202 .solve()
4203 .expect("the initial point plus exact endpoint fit a two-point budget");
4204 assert_eq!(result.points.len(), 2, "unexpected {mode} point count");
4205
4206 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 1, 0.1)
4207 .solve()
4208 .expect_err("the exact endpoint must not exceed a one-point budget");
4209 assert!(
4210 error.to_string().contains("point limit of 1"),
4211 "unexpected {mode} endpoint-budget error: {error}"
4212 );
4213 }
4214 }
4215
4216 #[test]
4217 fn mba1299_every_solver_preflights_the_sample_budget() {
4218 for (mode, use_rk4, use_adaptive_rk45) in [
4219 ("Euler", false, false),
4220 ("RK4", true, false),
4221 ("RK45", true, true),
4222 ] {
4223 let inputs = BallisticInputs {
4224 use_rk4,
4225 use_adaptive_rk45,
4226 enable_trajectory_sampling: true,
4227 sample_interval: 1.0,
4228 ground_threshold: f64::NEG_INFINITY,
4229 ..BallisticInputs::default()
4230 };
4231 let mut solver = TrajectorySolver::new(
4232 inputs,
4233 WindConditions::default(),
4234 AtmosphericConditions::default(),
4235 );
4236 solver.set_max_range(MAX_TRAJECTORY_SAMPLES as f64);
4237 solver.max_trajectory_points = 0;
4240
4241 let error = solver
4242 .solve()
4243 .expect_err("an over-limit sample grid must fail before integration");
4244 assert!(
4245 error
4246 .to_string()
4247 .contains("trajectory sample limit of 250000 exceeded"),
4248 "unexpected {mode} sample-budget error: {error}"
4249 );
4250 }
4251 }
4252
4253 #[test]
4254 fn mba1299_normal_sampling_does_not_change_solver_results() {
4255 for (mode, use_rk4, use_adaptive_rk45) in [
4256 ("Euler", false, false),
4257 ("RK4", true, false),
4258 ("RK45", true, true),
4259 ] {
4260 let solve = |enable_trajectory_sampling| {
4261 let inputs = BallisticInputs {
4262 use_rk4,
4263 use_adaptive_rk45,
4264 enable_trajectory_sampling,
4265 sample_interval: 0.5,
4266 ground_threshold: f64::NEG_INFINITY,
4267 ..BallisticInputs::default()
4268 };
4269 let mut solver = TrajectorySolver::new(
4270 inputs,
4271 WindConditions::default(),
4272 AtmosphericConditions::default(),
4273 );
4274 solver.set_max_range(2.0);
4275 solver.solve().expect("normal short-range solve")
4276 };
4277
4278 let baseline = solve(false);
4279 let sampled = solve(true);
4280 for (field, left, right) in [
4281 ("max_range", baseline.max_range, sampled.max_range),
4282 ("max_height", baseline.max_height, sampled.max_height),
4283 (
4284 "time_of_flight",
4285 baseline.time_of_flight,
4286 sampled.time_of_flight,
4287 ),
4288 (
4289 "impact_velocity",
4290 baseline.impact_velocity,
4291 sampled.impact_velocity,
4292 ),
4293 (
4294 "impact_energy",
4295 baseline.impact_energy,
4296 sampled.impact_energy,
4297 ),
4298 ] {
4299 assert_eq!(
4300 left.to_bits(),
4301 right.to_bits(),
4302 "{mode} sampling changed {field}"
4303 );
4304 }
4305 assert_eq!(baseline.points.len(), sampled.points.len());
4306 for (index, (left, right)) in baseline
4307 .points
4308 .iter()
4309 .zip(&sampled.points)
4310 .enumerate()
4311 {
4312 assert_eq!(left.time.to_bits(), right.time.to_bits(), "{mode} point {index}");
4313 assert_eq!(
4314 left.position.map(f64::to_bits),
4315 right.position.map(f64::to_bits),
4316 "{mode} point {index} position"
4317 );
4318 assert_eq!(
4319 left.velocity_magnitude.to_bits(),
4320 right.velocity_magnitude.to_bits(),
4321 "{mode} point {index} velocity"
4322 );
4323 assert_eq!(
4324 left.kinetic_energy.to_bits(),
4325 right.kinetic_energy.to_bits(),
4326 "{mode} point {index} energy"
4327 );
4328 }
4329 assert!(baseline.sampled_points.is_none());
4330 let samples = sampled
4331 .sampled_points
4332 .expect("sampling-enabled solve should return observations");
4333 assert_eq!(
4334 samples
4335 .iter()
4336 .map(|sample| sample.distance_m)
4337 .collect::<Vec<_>>(),
4338 vec![0.0, 0.5, 1.0, 1.5, 2.0],
4339 "{mode} normal sampling grid changed"
4340 );
4341 }
4342 }
4343}
4344
4345#[cfg(test)]
4346mod monte_carlo_result_tests {
4347 use super::*;
4348
4349 fn make_results(impact_positions: Vec<Vector3<f64>>) -> MonteCarloResults {
4350 let count = impact_positions.len();
4351 MonteCarloResults {
4352 ranges: vec![500.0; count],
4353 impact_velocities: vec![300.0; count],
4354 impact_positions,
4355 }
4356 }
4357
4358 #[test]
4359 fn target_plane_cep_excludes_shortfall_markers() {
4360 let mut positions: Vec<Vector3<f64>> = (1..=5)
4361 .map(|radius| Vector3::new(0.0, radius as f64, 0.0))
4362 .collect();
4363 positions.extend(
4364 (0..5).map(|_| Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)),
4365 );
4366 let results = make_results(positions);
4367
4368 assert_eq!(results.target_arrival_count(), 5);
4369 assert_eq!(results.target_shortfall_fraction(), 0.5);
4370 assert_eq!(results.target_plane_cep(), Some(3.0));
4371
4372 let one_shortfall = make_results(vec![
4373 Vector3::new(0.0, 1.0, 0.0),
4374 Vector3::new(0.0, 2.0, 0.0),
4375 Vector3::new(0.0, 3.0, 0.0),
4376 Vector3::new(0.0, 4.0, 0.0),
4377 Vector3::new(0.0, 5.0, 0.0),
4378 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4379 ]);
4380 assert_eq!(one_shortfall.target_plane_cep(), Some(3.0));
4381 }
4382
4383 #[test]
4384 fn all_shortfalls_have_no_cep_but_still_count_as_misses() {
4385 let all_shortfalls = make_results(vec![
4386 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4387 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4388 ]);
4389 assert_eq!(all_shortfalls.target_arrival_count(), 0);
4390 assert_eq!(all_shortfalls.target_shortfall_fraction(), 1.0);
4391 assert_eq!(all_shortfalls.target_plane_cep(), None);
4392 assert_eq!(all_shortfalls.hit_probability(0.3), 0.0);
4393
4394 let one_hit_one_shortfall = make_results(vec![
4395 Vector3::new(0.0, 0.1, 0.0),
4396 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4397 ]);
4398 assert_eq!(one_hit_one_shortfall.hit_probability(0.3), 0.5);
4399 }
4400
4401 #[test]
4403 fn rect_hit_probability_checks_independent_axis_halves() {
4404 let results = make_results(vec![
4405 Vector3::new(0.0, 0.1, 0.1),
4407 Vector3::new(0.0, 0.0, 0.2),
4409 Vector3::new(0.0, 0.0, 0.201),
4411 Vector3::new(0.0, 0.301, 0.0),
4413 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4415 ]);
4416 assert!((results.rect_hit_probability(0.4, 0.6) - 0.4).abs() < 1e-12);
4418 }
4419
4420 #[test]
4421 fn rect_hit_probability_matches_circular_hit_probability_for_a_centered_hit() {
4422 let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
4423 assert_eq!(results.rect_hit_probability(0.5, 0.5), 1.0);
4424 assert_eq!(results.hit_probability(0.3), 1.0);
4425 }
4426
4427 #[test]
4428 fn rect_hit_probability_is_zero_for_empty_or_nonpositive_dimensions() {
4429 let empty = make_results(vec![]);
4430 assert_eq!(empty.rect_hit_probability(1.0, 1.0), 0.0);
4431
4432 let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
4433 assert_eq!(results.rect_hit_probability(0.0, 1.0), 0.0);
4434 assert_eq!(results.rect_hit_probability(1.0, 0.0), 0.0);
4435 assert_eq!(results.rect_hit_probability(-1.0, 1.0), 0.0);
4436 }
4437}
4438
4439#[cfg(test)]
4440mod monte_carlo_seeded_tests {
4441 use super::*;
4442
4443 #[test]
4444 fn seeded_runs_are_deterministic_and_match_the_using_rng_path() {
4445 let inputs = BallisticInputs {
4446 muzzle_velocity: 800.0,
4447 ..BallisticInputs::default()
4448 };
4449 let params = MonteCarloParams {
4450 num_simulations: 64,
4451 target_distance: Some(200.0),
4452 ..MonteCarloParams::default()
4453 };
4454
4455 let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4456 inputs.clone(),
4457 WindConditions::default(),
4458 params.clone(),
4459 0.01,
4460 42,
4461 )
4462 .expect("seeded run a");
4463 let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4464 inputs,
4465 WindConditions::default(),
4466 params,
4467 0.01,
4468 42,
4469 )
4470 .expect("seeded run b");
4471
4472 assert_eq!(a.ranges.len(), b.ranges.len());
4473 for (ra, rb) in a.ranges.iter().zip(b.ranges.iter()) {
4474 assert_eq!(ra.to_bits(), rb.to_bits());
4475 }
4476 for (pa, pb) in a.impact_positions.iter().zip(b.impact_positions.iter()) {
4477 assert_eq!(pa.x.to_bits(), pb.x.to_bits());
4478 assert_eq!(pa.y.to_bits(), pb.y.to_bits());
4479 assert_eq!(pa.z.to_bits(), pb.z.to_bits());
4480 }
4481 }
4482
4483 #[test]
4484 fn different_seeds_generally_produce_different_draws() {
4485 let inputs = BallisticInputs {
4486 muzzle_velocity: 800.0,
4487 ..BallisticInputs::default()
4488 };
4489 let params = MonteCarloParams {
4490 num_simulations: 32,
4491 velocity_std_dev: 5.0,
4492 target_distance: Some(200.0),
4493 ..MonteCarloParams::default()
4494 };
4495
4496 let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4497 inputs.clone(),
4498 WindConditions::default(),
4499 params.clone(),
4500 0.0,
4501 1,
4502 )
4503 .expect("seeded run a");
4504 let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4505 inputs,
4506 WindConditions::default(),
4507 params,
4508 0.0,
4509 2,
4510 )
4511 .expect("seeded run b");
4512
4513 assert_ne!(a.impact_velocities, b.impact_velocities);
4514 }
4515}
4516
4517#[cfg(test)]
4518mod monte_carlo_powder_curve_tests {
4519 use super::*;
4520 use rand::{rngs::StdRng, SeedableRng};
4521
4522 #[test]
4523 fn powder_curve_preserves_sampled_muzzle_velocity_dispersion() {
4524 let inputs = BallisticInputs {
4525 muzzle_velocity: 700.0,
4526 powder_temp_curve: Some(vec![(15.0, 800.0)]),
4527 powder_curve_temp_c: Some(15.0),
4528 ..BallisticInputs::default()
4529 };
4530 let params = MonteCarloParams {
4531 num_simulations: 16,
4532 velocity_std_dev: 20.0,
4533 angle_std_dev: 1e-12,
4534 bc_std_dev: 1e-12,
4535 wind_speed_std_dev: 1e-12,
4536 target_distance: Some(100.0),
4537 azimuth_std_dev: 1e-12,
4538 ..MonteCarloParams::default()
4539 };
4540
4541 let mut rng = StdRng::seed_from_u64(0x5EED_1176);
4542 let results = run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
4543 inputs,
4544 WindConditions::default(),
4545 params,
4546 0.0,
4547 &mut rng,
4548 )
4549 .expect("Monte Carlo solve");
4550 let min_velocity = results
4551 .impact_velocities
4552 .iter()
4553 .copied()
4554 .fold(f64::INFINITY, f64::min);
4555 let max_velocity = results
4556 .impact_velocities
4557 .iter()
4558 .copied()
4559 .fold(f64::NEG_INFINITY, f64::max);
4560
4561 assert!(
4562 max_velocity - min_velocity > 1.0,
4563 "20 m/s muzzle spread collapsed after curve resolution: impact-velocity span={} m/s",
4564 max_velocity - min_velocity
4565 );
4566 }
4567}
4568
4569#[cfg(test)]
4570mod monte_carlo_wind_sampling_tests {
4571 use super::*;
4572 use rand::{rngs::StdRng, SeedableRng};
4573
4574 #[test]
4575 fn wind_speed_sigma_does_not_change_seeded_direction_draws() {
4576 let base_wind = WindConditions {
4577 speed: 100.0,
4578 direction: 0.37,
4579 vertical_speed: 0.0,
4580 };
4581 let narrow_speed = MonteCarloWindSampler::new(&base_wind, 0.5, 0.2).unwrap();
4582 let wide_speed = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
4583 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
4584 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
4585 let mut speed_changed = false;
4586
4587 for _ in 0..32 {
4588 let narrow = narrow_speed.sample(&mut narrow_rng);
4589 let wide = wide_speed.sample(&mut wide_rng);
4590 assert!(narrow.speed > 0.0 && wide.speed > 0.0);
4591 assert_eq!(narrow.direction.to_bits(), wide.direction.to_bits());
4592 speed_changed |= narrow.speed.to_bits() != wide.speed.to_bits();
4593 }
4594 assert!(
4595 speed_changed,
4596 "different speed sigmas must still vary speed draws"
4597 );
4598 }
4599
4600 #[test]
4601 fn zero_direction_sigma_has_no_angular_jitter() {
4602 let base_wind = WindConditions {
4603 speed: 100.0,
4604 direction: 0.37,
4605 vertical_speed: 0.0,
4606 };
4607 let sampler = MonteCarloWindSampler::new(&base_wind, 4.0, 0.0).unwrap();
4608 let mut rng = StdRng::seed_from_u64(0x5EED_1223);
4609 let mut speed_changed = false;
4610
4611 for _ in 0..32 {
4612 let wind = sampler.sample(&mut rng);
4613 speed_changed |= wind.speed.to_bits() != base_wind.speed.to_bits();
4614 assert_eq!(wind.direction.to_bits(), base_wind.direction.to_bits());
4615 }
4616 assert!(speed_changed, "speed uncertainty should remain active");
4617 }
4618
4619 #[test]
4620 fn direction_sigma_controls_seeded_angular_spread_in_radians() {
4621 let base_wind = WindConditions {
4622 speed: 100.0,
4623 direction: 0.37,
4624 vertical_speed: 0.0,
4625 };
4626 let narrow = MonteCarloWindSampler::new(&base_wind, 4.0, 0.1).unwrap();
4627 let wide = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
4628 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
4629 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
4630 let mut nonzero_direction_draw = false;
4631
4632 for _ in 0..32 {
4633 let narrow_wind = narrow.sample(&mut narrow_rng);
4634 let wide_wind = wide.sample(&mut wide_rng);
4635 assert_eq!(narrow_wind.speed.to_bits(), wide_wind.speed.to_bits());
4636
4637 let narrow_delta = narrow_wind.direction - base_wind.direction;
4638 let wide_delta = wide_wind.direction - base_wind.direction;
4639 assert!((wide_delta - 2.0 * narrow_delta).abs() < 1e-12);
4640 nonzero_direction_draw |= narrow_delta.abs() > 1e-6;
4641 }
4642 assert!(
4643 nonzero_direction_draw,
4644 "positive radians sigma must vary direction"
4645 );
4646 }
4647
4648 #[test]
4649 fn direction_sigma_rejects_negative_or_nonfinite_values() {
4650 let base_wind = WindConditions::default();
4651 for sigma in [-0.1, f64::NAN, f64::INFINITY] {
4652 assert!(MonteCarloWindSampler::new(&base_wind, 1.0, sigma).is_err());
4653 }
4654 }
4655
4656 #[test]
4657 fn base_vertical_wind_rides_into_every_mc_sample() {
4658 use rand::SeedableRng;
4662 let base_wind = WindConditions { vertical_speed: 4.2, ..Default::default() };
4663 let sampler = MonteCarloWindSampler::new(&base_wind, 1.0, 0.2).unwrap();
4664 let mut rng = rand::rngs::StdRng::seed_from_u64(7);
4665 for _ in 0..32 {
4666 let w = sampler.sample(&mut rng);
4667 assert_eq!(w.vertical_speed, 4.2);
4668 }
4669 }
4670
4671 #[test]
4672 fn negative_speed_sample_reverses_wind_direction() {
4673 let direction = 0.25;
4674 let signed_speed = -2.5;
4675 let wind = wind_from_signed_speed_sample(signed_speed, direction, 0.0);
4676 let positive_wind = wind_from_signed_speed_sample(2.5, direction, 0.0);
4677
4678 assert_eq!(wind.speed, 2.5);
4679 assert!(
4680 (wind.direction - (direction + std::f64::consts::PI)).abs() < f64::EPSILON,
4681 "negative speed must reverse direction by pi: got {}",
4682 wind.direction
4683 );
4684 assert_eq!(positive_wind.speed, 2.5);
4685 assert_eq!(positive_wind.direction, direction);
4686
4687 let normalized_x = -wind.speed * wind.direction.cos();
4688 let normalized_z = -wind.speed * wind.direction.sin();
4689 let signed_x = -signed_speed * direction.cos();
4690 let signed_z = -signed_speed * direction.sin();
4691 assert!((normalized_x - signed_x).abs() < 1e-12);
4692 assert!((normalized_z - signed_z).abs() < 1e-12);
4693 }
4694}
4695
4696#[cfg(test)]
4697mod bc_fit_objective_tests {
4698 use super::*;
4699
4700 fn velocity_point(range_m: f64, velocity_mps: f64) -> TrajectoryPoint {
4701 TrajectoryPoint {
4702 time: 0.0,
4703 position: Vector3::new(range_m, 0.0, 0.0),
4704 velocity_magnitude: velocity_mps,
4705 kinetic_energy: 0.0,
4706 }
4707 }
4708
4709 #[test]
4710 fn candidate_that_misses_an_observation_has_no_score() {
4711 let trajectory = vec![velocity_point(0.0, 800.0), velocity_point(100.0, 700.0)];
4712 let observations = vec![(50.0, 750.0), (150.0, 600.0)];
4713
4714 assert!(
4715 fit_residual_sse(&trajectory, &observations, BcFitMode::Velocity, 0.0).is_none(),
4716 "a candidate that reaches only one of two observations must not compete on partial SSE"
4717 );
4718
4719 let complete_observations = vec![(50.0, 740.0), (100.0, 680.0)];
4720 assert_eq!(
4721 fit_residual_sse(
4722 &trajectory,
4723 &complete_observations,
4724 BcFitMode::Velocity,
4725 0.0,
4726 ),
4727 Some(500.0)
4728 );
4729 }
4730}
4731
4732#[cfg(test)]
4733mod cluster_bc_reference_space_tests {
4734 use super::*;
4735
4736 fn acceleration_at_1100_fps(inputs: BallisticInputs) -> Vector3<f64> {
4737 let solver = TrajectorySolver::new(
4738 inputs,
4739 WindConditions::default(),
4740 AtmosphericConditions::default(),
4741 );
4742 let position = Vector3::zeros();
4743 let velocity = Vector3::new(1100.0 / 3.28084, 0.0, 0.0);
4744 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4745 solver.calculate_acceleration(
4746 &position,
4747 &velocity,
4748 &Vector3::zeros(),
4749 (temp_c, pressure_hpa, density / 1.225),
4750 )
4751 }
4752
4753 #[test]
4754 fn solver_passes_g7_reference_model_to_cluster_classifier() {
4755 let inputs = BallisticInputs {
4756 bc_value: 0.190,
4757 bc_type: DragModel::G7,
4758 bullet_mass: 77.0 * crate::constants::GRAINS_TO_KG,
4759 bullet_diameter: 0.224 * 0.0254,
4760 use_cluster_bc: true,
4761 ..BallisticInputs::default()
4762 };
4763
4764 let solver = TrajectorySolver::new(
4765 inputs,
4766 WindConditions::default(),
4767 AtmosphericConditions::default(),
4768 );
4769 let corrected = solver.apply_cluster_bc_correction(0.190, 2800.0);
4770
4771 assert!(
4772 (corrected / 0.190 - 1.004).abs() < 1e-12,
4773 "solver selected the wrong G7 cluster multiplier: {}",
4774 corrected / 0.190
4775 );
4776 }
4777
4778 #[test]
4779 fn velocity_bc_segments_are_not_cluster_corrected_twice() {
4780 let segmented_clustered = BallisticInputs {
4781 bc_value: 0.5,
4782 bc_type: DragModel::G7,
4783 use_bc_segments: true,
4784 bc_segments_data: Some(vec![
4785 crate::BCSegmentData {
4786 velocity_min: 0.0,
4787 velocity_max: 1_600.0,
4788 bc_value: 0.4,
4789 },
4790 crate::BCSegmentData {
4791 velocity_min: 1_600.0,
4792 velocity_max: 5_000.0,
4793 bc_value: 0.45,
4794 },
4795 ]),
4796 use_cluster_bc: true,
4797 ..BallisticInputs::default()
4798 };
4799 let mut segmented_only = segmented_clustered.clone();
4800 segmented_only.use_cluster_bc = false;
4801 let mut constant_clustered = segmented_clustered.clone();
4802 constant_clustered.bc_value = 0.4;
4803 constant_clustered.bc_segments_data = None;
4804
4805 let stacked = acceleration_at_1100_fps(segmented_clustered);
4806 let segment_only = acceleration_at_1100_fps(segmented_only);
4807 let cluster_only = acceleration_at_1100_fps(constant_clustered);
4808
4809 assert!(
4810 (stacked.x - segment_only.x).abs() < 1e-12,
4811 "segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
4812 stacked.x,
4813 segment_only.x
4814 );
4815 assert!(
4816 (cluster_only.x - segment_only.x).abs() > 1e-6,
4817 "cluster correction must remain active for a constant BC"
4818 );
4819 }
4820
4821 #[test]
4822 fn mach_bc_segments_are_not_cluster_corrected_twice() {
4823 let mach_segmented_clustered = BallisticInputs {
4824 bc_value: 0.5,
4825 bc_type: DragModel::G7,
4826 use_bc_segments: false,
4827 bc_segments: Some(vec![(0.5, 0.3), (1.5, 0.5)]),
4828 use_cluster_bc: true,
4829 ..BallisticInputs::default()
4830 };
4831 let mut mach_segmented_only = mach_segmented_clustered.clone();
4832 mach_segmented_only.use_cluster_bc = false;
4833
4834 let stacked = acceleration_at_1100_fps(mach_segmented_clustered);
4835 let segment_only = acceleration_at_1100_fps(mach_segmented_only);
4836
4837 assert!(
4838 (stacked.x - segment_only.x).abs() < 1e-12,
4839 "Mach segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
4840 stacked.x,
4841 segment_only.x
4842 );
4843 }
4844}
4845
4846#[cfg(test)]
4847mod velocity_bc_flag_tests {
4848 use super::*;
4849
4850 fn acceleration_at_600_mps(inputs: BallisticInputs) -> Vector3<f64> {
4851 let solver = TrajectorySolver::new(
4852 inputs,
4853 WindConditions::default(),
4854 AtmosphericConditions::default(),
4855 );
4856 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4857 solver.calculate_acceleration(
4858 &Vector3::zeros(),
4859 &Vector3::new(600.0, 0.0, 0.0),
4860 &Vector3::zeros(),
4861 (temp_c, pressure_hpa, density / 1.225),
4862 )
4863 }
4864
4865 #[test]
4866 fn velocity_bc_data_requires_opt_in_in_trajectory_solver() {
4867 let scalar_inputs = BallisticInputs {
4868 bc_value: 0.5,
4869 bc_type: DragModel::G7,
4870 ..BallisticInputs::default()
4871 };
4872 let mut disabled_inputs = scalar_inputs.clone();
4873 disabled_inputs.bc_segments_data = Some(vec![crate::BCSegmentData {
4874 velocity_min: 0.0,
4875 velocity_max: 4_000.0,
4876 bc_value: 0.46,
4877 }]);
4878 disabled_inputs.use_bc_segments = false;
4879 let mut enabled_inputs = disabled_inputs.clone();
4880 enabled_inputs.use_bc_segments = true;
4881 let mut mach_only_inputs = scalar_inputs.clone();
4882 mach_only_inputs.bc_segments = Some(vec![(0.0, 0.4), (3.0, 0.4)]);
4883 let mut disabled_with_both = mach_only_inputs.clone();
4884 disabled_with_both.bc_segments_data = disabled_inputs.bc_segments_data.clone();
4885
4886 let scalar = acceleration_at_600_mps(scalar_inputs);
4887 let disabled = acceleration_at_600_mps(disabled_inputs);
4888 let enabled = acceleration_at_600_mps(enabled_inputs);
4889 let mach_only = acceleration_at_600_mps(mach_only_inputs);
4890 let disabled_with_both = acceleration_at_600_mps(disabled_with_both);
4891
4892 assert_eq!(
4893 disabled.x.to_bits(),
4894 scalar.x.to_bits(),
4895 "a populated velocity table must not change drag while use_bc_segments is false"
4896 );
4897 assert!(
4898 enabled.x < disabled.x - 1.0,
4899 "enabling the lower BC table must increase drag: disabled ax={} enabled ax={}",
4900 disabled.x,
4901 enabled.x
4902 );
4903 assert_eq!(
4904 disabled_with_both.x.to_bits(),
4905 mach_only.x.to_bits(),
4906 "disabling velocity data must fall through to an explicit Mach table"
4907 );
4908 }
4909}
4910
4911#[cfg(test)]
4912mod mach_bc_segment_tests {
4913 use super::*;
4914
4915 #[test]
4916 fn trajectory_solver_interpolates_explicit_mach_bc_segments() {
4917 let segmented_inputs = BallisticInputs {
4918 bc_value: 0.8,
4919 use_bc_segments: false,
4920 bc_segments: Some(vec![(1.0, 0.2), (2.0, 0.4)]),
4921 bc_segments_data: None,
4922 ..BallisticInputs::default()
4923 };
4924
4925 let mut expected_inputs = segmented_inputs.clone();
4926 expected_inputs.bc_value = 0.3;
4927 expected_inputs.bc_segments = None;
4928
4929 let atmosphere = AtmosphericConditions::default();
4930 let segmented_solver = TrajectorySolver::new(
4931 segmented_inputs,
4932 WindConditions::default(),
4933 atmosphere.clone(),
4934 );
4935 let expected_solver = TrajectorySolver::new(
4936 expected_inputs,
4937 WindConditions::default(),
4938 atmosphere,
4939 );
4940 let position = Vector3::zeros();
4941 let (density, _, temp_c, pressure_hpa) = segmented_solver.resolved_atmosphere();
4942 let (_, local_speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
4943 segmented_solver.atmosphere.altitude,
4944 segmented_solver.atmosphere.altitude,
4945 temp_c,
4946 pressure_hpa,
4947 density / 1.225,
4948 segmented_solver.atmosphere.humidity,
4949 );
4950 let velocity = Vector3::new(1.5 * local_speed_of_sound, 0.0, 0.0);
4951 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
4952
4953 let segmented_acceleration = segmented_solver.calculate_acceleration(
4954 &position,
4955 &velocity,
4956 &Vector3::zeros(),
4957 resolved_atmo,
4958 );
4959 let expected_acceleration = expected_solver.calculate_acceleration(
4960 &position,
4961 &velocity,
4962 &Vector3::zeros(),
4963 resolved_atmo,
4964 );
4965
4966 assert!(
4967 (segmented_acceleration.x - expected_acceleration.x).abs() < 1e-12,
4968 "Mach 1.5 must interpolate BC 0.3: segmented ax={} expected ax={}",
4969 segmented_acceleration.x,
4970 expected_acceleration.x
4971 );
4972 }
4973}
4974
4975#[cfg(test)]
4976mod custom_drag_table_validation_tests {
4977 use super::*;
4978
4979 #[test]
4980 fn solve_accepts_zero_bc_when_custom_table_present() {
4981 let inputs = BallisticInputs {
4982 bc_value: 0.0, bullet_mass: 0.0106,
4984 bullet_diameter: 0.00782,
4985 muzzle_velocity: 850.0,
4986 custom_drag_table: Some(crate::drag::DragTable::new(
4987 vec![0.5, 1.0, 2.0, 3.0],
4988 vec![0.23, 0.40, 0.30, 0.26],
4989 )),
4990 ..BallisticInputs::default()
4991 };
4992 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
4993 assert!(solver.solve().is_ok());
4995 }
4996
4997 #[test]
4998 fn solve_still_requires_bc_without_table() {
4999 let inputs = BallisticInputs {
5000 bc_value: 0.0,
5001 bullet_mass: 0.0106,
5002 bullet_diameter: 0.00782,
5003 muzzle_velocity: 850.0,
5004 ..BallisticInputs::default()
5005 };
5006 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
5007 assert!(solver.solve().is_err());
5008 }
5009}
5010
5011#[cfg(test)]
5013mod cd_scale_tests {
5014 use super::*;
5015
5016 fn deck() -> crate::drag::DragTable {
5017 crate::drag::DragTable::new(vec![0.5, 1.0, 2.0, 3.0], vec![0.23, 0.40, 0.30, 0.26])
5018 }
5019
5020 fn deck_inputs(cd_scale: f64) -> BallisticInputs {
5021 BallisticInputs {
5022 bullet_mass: 0.0106,
5023 bullet_diameter: 0.00782,
5024 muzzle_velocity: 850.0,
5025 custom_drag_table: Some(deck()),
5026 cd_scale,
5027 ..BallisticInputs::default()
5028 }
5029 }
5030
5031 #[test]
5032 fn default_cd_scale_is_one() {
5033 assert_eq!(BallisticInputs::default().cd_scale, 1.0);
5034 }
5035
5036 #[test]
5039 fn cd_scale_absent_is_byte_identical_to_explicit_one() {
5040 let omitted = BallisticInputs {
5041 bullet_mass: 0.0106,
5042 bullet_diameter: 0.00782,
5043 muzzle_velocity: 850.0,
5044 custom_drag_table: Some(deck()),
5045 ..BallisticInputs::default()
5046 };
5047 let explicit = BallisticInputs {
5048 cd_scale: 1.0,
5049 ..omitted.clone()
5050 };
5051
5052 let solver_omitted =
5053 TrajectorySolver::new(omitted, WindConditions::default(), AtmosphericConditions::default());
5054 let solver_explicit =
5055 TrajectorySolver::new(explicit, WindConditions::default(), AtmosphericConditions::default());
5056
5057 let cd_omitted = solver_omitted.calculate_drag_coefficient(700.0, 340.0);
5058 let cd_explicit = solver_explicit.calculate_drag_coefficient(700.0, 340.0);
5059 assert_eq!(
5060 cd_omitted.to_bits(),
5061 cd_explicit.to_bits(),
5062 "default cd_scale must be bit-identical to an explicit 1.0"
5063 );
5064
5065 let result = solver_omitted.solve();
5068 assert!(result.is_ok(), "existing custom-deck solves must pass unchanged");
5069 }
5070
5071 #[test]
5073 fn cd_scale_multiplies_the_interpolated_cd_exactly() {
5074 let velocity = 700.0;
5075 let speed_of_sound = 340.0;
5076 let mach = velocity / speed_of_sound;
5077 let expected_unscaled = deck().interpolate(mach);
5078
5079 for &scale in &[0.90, 1.0, 1.10, 1.5] {
5080 let solver = TrajectorySolver::new(
5081 deck_inputs(scale),
5082 WindConditions::default(),
5083 AtmosphericConditions::default(),
5084 );
5085 let cd = solver.calculate_drag_coefficient(velocity, speed_of_sound);
5086 assert!(
5087 (cd - expected_unscaled * scale).abs() < 1e-12,
5088 "scale={scale}: cd={cd} expected={}",
5089 expected_unscaled * scale
5090 );
5091 }
5092 }
5093
5094 #[test]
5098 fn cd_scale_direction_on_cli_api_solver() {
5099 let solve = |scale: f64| {
5100 TrajectorySolver::new(
5101 deck_inputs(scale),
5102 WindConditions::default(),
5103 AtmosphericConditions::default(),
5104 )
5105 .solve()
5106 .expect("custom-deck solve should succeed")
5107 };
5108
5109 let baseline = solve(1.0);
5110 let scaled_up = solve(1.10);
5111 let scaled_down = solve(0.90);
5112
5113 assert!(
5114 scaled_up.impact_velocity < baseline.impact_velocity,
5115 "cd_scale=1.10 must increase drag -> lower impact velocity: base={} up={}",
5116 baseline.impact_velocity,
5117 scaled_up.impact_velocity
5118 );
5119 assert!(
5120 scaled_down.impact_velocity > baseline.impact_velocity,
5121 "cd_scale=0.90 must decrease drag -> higher impact velocity: base={} down={}",
5122 baseline.impact_velocity,
5123 scaled_down.impact_velocity
5124 );
5125 }
5126
5127 #[test]
5129 fn validate_for_solve_rejects_invalid_cd_scale() {
5130 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
5131 let solver = TrajectorySolver::new(
5132 deck_inputs(bad),
5133 WindConditions::default(),
5134 AtmosphericConditions::default(),
5135 );
5136 assert!(
5137 solver.solve().is_err(),
5138 "cd_scale={bad} must be rejected by validate_for_solve"
5139 );
5140 }
5141 }
5142
5143 #[test]
5150 fn validate_for_solve_rejects_invalid_cd_scale_without_a_custom_drag_table() {
5151 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
5152 let inputs = BallisticInputs {
5153 bc_value: 0.5,
5154 bc_type: crate::DragModel::G1,
5155 bullet_mass: 0.0106,
5156 bullet_diameter: 0.00782,
5157 muzzle_velocity: 850.0,
5158 cd_scale: bad,
5159 ..BallisticInputs::default()
5160 };
5161 assert!(inputs.custom_drag_table.is_none(), "precondition: no custom deck");
5162 let solver = TrajectorySolver::new(
5163 inputs,
5164 WindConditions::default(),
5165 AtmosphericConditions::default(),
5166 );
5167 assert!(
5168 solver.solve().is_err(),
5169 "cd_scale={bad} must be rejected by validate_for_solve even without a custom \
5170 drag table"
5171 );
5172 }
5173 }
5174
5175 #[test]
5179 fn cd_scale_is_inert_without_a_custom_drag_table() {
5180 let make = |cd_scale: f64| BallisticInputs {
5181 bc_value: 0.5,
5182 bc_type: crate::DragModel::G1,
5183 bullet_mass: 0.0106,
5184 bullet_diameter: 0.00782,
5185 muzzle_velocity: 850.0,
5186 cd_scale,
5187 ..BallisticInputs::default()
5188 };
5189 let solver_neutral = TrajectorySolver::new(
5190 make(1.0),
5191 WindConditions::default(),
5192 AtmosphericConditions::default(),
5193 );
5194 let solver_far = TrajectorySolver::new(
5195 make(1.5),
5196 WindConditions::default(),
5197 AtmosphericConditions::default(),
5198 );
5199 let cd_neutral = solver_neutral.calculate_drag_coefficient(700.0, 340.0);
5200 let cd_far = solver_far.calculate_drag_coefficient(700.0, 340.0);
5201 assert_eq!(
5202 cd_neutral.to_bits(),
5203 cd_far.to_bits(),
5204 "cd_scale must not affect the G-model/BC drag path"
5205 );
5206 }
5207
5208 #[test]
5211 fn cd_scale_shifts_all_three_solver_paths_in_the_same_direction() {
5212 let cli_solve = |scale: f64| {
5214 TrajectorySolver::new(
5215 deck_inputs(scale),
5216 WindConditions::default(),
5217 AtmosphericConditions::default(),
5218 )
5219 .solve()
5220 .expect("cli_api custom-deck solve should succeed")
5221 };
5222 let cli_baseline = cli_solve(1.0);
5223 let cli_scaled = cli_solve(1.10);
5224 assert!(
5225 cli_scaled.impact_velocity < cli_baseline.impact_velocity,
5226 "cli_api: cd_scale=1.10 must lower impact velocity"
5227 );
5228
5229 let derivatives_accel_x = |scale: f64| {
5231 let inputs = deck_inputs(scale);
5232 crate::derivatives::compute_derivatives(
5233 nalgebra::Vector3::zeros(),
5234 nalgebra::Vector3::new(700.0, 0.0, 0.0),
5235 &inputs,
5236 nalgebra::Vector3::zeros(),
5237 (1.225, 340.0, 0.0, 0.0),
5238 inputs.bc_value,
5239 None,
5240 0.0,
5241 None,
5242 )[3]
5243 };
5244 let deriv_baseline = derivatives_accel_x(1.0);
5245 let deriv_scaled = derivatives_accel_x(1.10);
5246 assert!(
5247 deriv_scaled < deriv_baseline,
5248 "derivatives: cd_scale=1.10 must make x-acceleration more negative (more drag): \
5249 base={deriv_baseline} scaled={deriv_scaled}"
5250 );
5251
5252 let fast_final_speed = |scale: f64| {
5254 let inputs = deck_inputs(scale);
5255 let wind_sock = crate::wind::WindSock::new(vec![]);
5256 let params = crate::fast_trajectory::FastIntegrationParams {
5257 horiz: 500.0,
5258 vert: 0.0,
5259 initial_state: [0.0, 0.0, 0.0, 850.0, 0.0, 0.0],
5260 t_span: (0.0, 5.0),
5261 atmo_params: (0.0, 15.0, 1013.25, 1.0),
5262 atmo_sock: None,
5263 };
5264 let solution = crate::fast_trajectory::fast_integrate(&inputs, &wind_sock, params);
5265 assert!(solution.success, "fast_integrate must succeed for scale={scale}");
5266 let last = solution.t.len() - 1;
5267 let (vx, vy, vz) = (
5268 solution.y[3][last],
5269 solution.y[4][last],
5270 solution.y[5][last],
5271 );
5272 (vx * vx + vy * vy + vz * vz).sqrt()
5273 };
5274 let fast_baseline = fast_final_speed(1.0);
5275 let fast_scaled = fast_final_speed(1.10);
5276 assert!(
5277 fast_scaled < fast_baseline,
5278 "fast_trajectory: cd_scale=1.10 must lower final speed: base={fast_baseline} scaled={fast_scaled}"
5279 );
5280 }
5281}
5282
5283#[cfg(test)]
5284mod humid_local_mach_tests {
5285 use super::*;
5286
5287 fn solver_with_station_humidity(humidity_percent: f64) -> TrajectorySolver {
5288 let inputs = BallisticInputs {
5289 custom_drag_table: Some(crate::drag::DragTable::new(vec![0.5, 1.5], vec![0.1, 1.1])),
5290 ..BallisticInputs::default()
5291 };
5292 TrajectorySolver::new(
5293 inputs,
5294 WindConditions::default(),
5295 AtmosphericConditions {
5296 temperature: 30.0,
5297 pressure: 1013.25,
5298 humidity: humidity_percent,
5299 altitude: 0.0,
5300 },
5301 )
5302 }
5303
5304 fn acceleration(solver: &TrajectorySolver, base_ratio: f64) -> Vector3<f64> {
5305 solver.calculate_acceleration(
5306 &Vector3::zeros(),
5307 &Vector3::new(350.0, 0.0, 0.0),
5308 &Vector3::zeros(),
5309 (30.0, 1013.25, base_ratio),
5310 )
5311 }
5312
5313 #[test]
5314 fn local_mach_uses_station_humidity_when_density_is_held_constant() {
5315 let dry = acceleration(&solver_with_station_humidity(0.0), 1.0);
5316 let humid = acceleration(&solver_with_station_humidity(100.0), 1.0);
5317
5318 assert!(
5319 humid.x > dry.x,
5320 "humid sound speed should lower Mach and drag on the rising test curve: dry ax={} humid ax={}",
5321 dry.x,
5322 humid.x
5323 );
5324 }
5325
5326 #[test]
5327 fn active_atmosphere_zone_uses_zone_humidity_instead_of_station_humidity() {
5328 let zone_humidity = 80.0;
5329 let zone_ratio =
5330 crate::atmosphere::calculate_air_density_cimp(30.0, 1013.25, zone_humidity) / 1.225;
5331 let station_solver = solver_with_station_humidity(zone_humidity);
5332 let mut zoned_solver = solver_with_station_humidity(0.0);
5333 zoned_solver.set_atmo_segments(vec![(30.0, 1013.25, zone_humidity, 1_000.0)]);
5334
5335 let station = acceleration(&station_solver, zone_ratio);
5336 let zoned = acceleration(&zoned_solver, zone_ratio);
5337
5338 assert!(
5339 (zoned - station).norm() < 1e-12,
5340 "active zone T/P/RH should override the station atmosphere: station={station:?} zoned={zoned:?}"
5341 );
5342 }
5343}
5344
5345#[cfg(test)]
5346mod inclined_atmosphere_frame_tests {
5347 use super::*;
5348
5349 fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
5350 let (sin_angle, cos_angle) = angle.sin_cos();
5351 Vector3::new(
5352 level.x * cos_angle + level.y * sin_angle,
5353 -level.x * sin_angle + level.y * cos_angle,
5354 level.z,
5355 )
5356 }
5357
5358 #[test]
5359 fn inclined_positions_at_same_world_altitude_have_same_solver_acceleration() {
5360 let angle = std::f64::consts::FRAC_PI_6;
5361 let inputs = BallisticInputs {
5362 shooting_angle: angle,
5363 ..BallisticInputs::default()
5364 };
5365 let atmosphere = AtmosphericConditions {
5366 altitude: 100.0,
5367 ..AtmosphericConditions::default()
5368 };
5369 let solver = TrajectorySolver::new(inputs, WindConditions::default(), atmosphere);
5370 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5371 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
5372 let velocity = Vector3::new(600.0, 0.0, 0.0);
5373 let along_slant = Vector3::new(1_000.0, 0.0, 0.0);
5374 let across_slant = Vector3::new(0.0, 500.0 / angle.cos(), 0.0);
5375
5376 let a = solver.calculate_acceleration(
5377 &along_slant,
5378 &velocity,
5379 &Vector3::zeros(),
5380 resolved_atmo,
5381 );
5382 let b = solver.calculate_acceleration(
5383 &across_slant,
5384 &velocity,
5385 &Vector3::zeros(),
5386 resolved_atmo,
5387 );
5388
5389 assert!(
5390 (a - b).norm() < 1e-10,
5391 "solver acceleration differs at equal world altitude: {a:?} vs {b:?}"
5392 );
5393 }
5394
5395 #[test]
5396 fn inclined_headwind_is_rotated_into_solver_frame() {
5397 let angle = std::f64::consts::FRAC_PI_6;
5398 let inputs = BallisticInputs {
5399 shooting_angle: angle,
5400 ..BallisticInputs::default()
5401 };
5402 let solver = TrajectorySolver::new(
5403 inputs,
5404 WindConditions::default(),
5405 AtmosphericConditions::default(),
5406 );
5407 let level_headwind = Vector3::new(-100.0, 0.0, 0.0);
5408 let velocity = expected_shot_frame_vector(level_headwind, angle);
5409 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5410 let actual = solver.calculate_acceleration(
5411 &Vector3::zeros(),
5412 &velocity,
5413 &level_headwind,
5414 (temp_c, pressure_hpa, density / 1.225),
5415 );
5416
5417 assert!(
5418 (actual - solver.gravity_acceleration()).norm() < 1e-12,
5419 "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
5420 );
5421 }
5422
5423 #[test]
5424 fn inclined_coriolis_is_rotated_into_solver_frame() {
5425 let angle = std::f64::consts::FRAC_PI_6;
5426 let latitude_deg = 45.0_f64;
5427 let shot_azimuth = 0.4_f64;
5428 let velocity = Vector3::new(600.0, 20.0, 5.0);
5429 let base_inputs = BallisticInputs {
5430 shooting_angle: angle,
5431 latitude: Some(latitude_deg),
5432 shot_azimuth,
5433 ..BallisticInputs::default()
5434 };
5435 let acceleration = |enable_coriolis| {
5436 let mut inputs = base_inputs.clone();
5437 inputs.enable_coriolis = enable_coriolis;
5438 let solver = TrajectorySolver::new(
5439 inputs,
5440 WindConditions::default(),
5441 AtmosphericConditions::default(),
5442 );
5443 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5444 solver.calculate_acceleration(
5445 &Vector3::zeros(),
5446 &velocity,
5447 &Vector3::zeros(),
5448 (temp_c, pressure_hpa, density / 1.225),
5449 )
5450 };
5451
5452 let omega_earth = 7.2921159e-5_f64;
5453 let latitude = latitude_deg.to_radians();
5454 let level_omega = Vector3::new(
5455 omega_earth * latitude.cos() * shot_azimuth.cos(),
5456 omega_earth * latitude.sin(),
5457 -omega_earth * latitude.cos() * shot_azimuth.sin(),
5458 );
5459 let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
5460 let actual = acceleration(true) - acceleration(false);
5461
5462 assert!(
5463 (actual - expected).norm() < 1e-12,
5464 "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
5465 );
5466 }
5467}
5468
5469#[cfg(test)]
5470mod terminal_range_interpolation_tests {
5471 use super::*;
5472
5473 #[test]
5474 fn terminal_finalizer_selects_the_earliest_crossed_boundary() {
5475 let inputs = BallisticInputs {
5476 ground_threshold: 0.0,
5477 ..BallisticInputs::default()
5478 };
5479 let mut solver = TrajectorySolver::new(
5480 inputs,
5481 WindConditions::default(),
5482 AtmosphericConditions::default(),
5483 );
5484 solver.set_max_range(120.0);
5485
5486 let previous_speed = 700.0;
5487 let mut points = vec![TrajectoryPoint {
5488 time: 99.0,
5489 position: Vector3::new(90.0, 1.0, -1.0),
5490 velocity_magnitude: previous_speed,
5491 kinetic_energy: 0.5 * solver.inputs.bullet_mass * previous_speed.powi(2),
5492 }];
5493 let mut max_height = 1.0;
5494 let termination = solver
5495 .append_terminal_endpoint(
5496 &mut points,
5497 Vector3::new(130.0, -3.0, 3.0),
5498 Vector3::new(600.0, 0.0, 0.0),
5499 101.0,
5500 &mut max_height,
5501 )
5502 .expect("the final step brackets supported boundaries");
5503
5504 assert_eq!(termination, TrajectoryTermination::GroundThreshold);
5505 assert_eq!(points.len(), 2);
5506 let terminal = points.last().expect("terminal point");
5507 assert_eq!(terminal.time, 99.5);
5508 assert_eq!(terminal.position, Vector3::new(100.0, 0.0, 0.0));
5509 assert_eq!(terminal.velocity_magnitude, 675.0);
5510 assert_eq!(
5511 terminal.kinetic_energy,
5512 0.5 * solver.inputs.bullet_mass * 675.0_f64.powi(2)
5513 );
5514
5515 solver.set_max_range(100.0);
5517 let mut tied_points = vec![points[0].clone()];
5518 assert_eq!(
5519 solver
5520 .append_terminal_endpoint(
5521 &mut tied_points,
5522 Vector3::new(130.0, -3.0, 3.0),
5523 Vector3::new(600.0, 0.0, 0.0),
5524 101.0,
5525 &mut max_height,
5526 )
5527 .expect("tied boundaries remain a valid terminal"),
5528 TrajectoryTermination::GroundThreshold
5529 );
5530 }
5531
5532 #[test]
5533 fn sub_ulp_terminal_crossing_replaces_instead_of_duplicating_range() {
5534 let ground_threshold = f64::from_bits(1.0_f64.to_bits() - 1);
5535 let inputs = BallisticInputs {
5536 ground_threshold,
5537 ..BallisticInputs::default()
5538 };
5539 let mut solver = TrajectorySolver::new(
5540 inputs,
5541 WindConditions::default(),
5542 AtmosphericConditions::default(),
5543 );
5544 solver.set_max_range(1_000.0);
5545
5546 let speed = 700.0;
5547 let mut points = vec![TrajectoryPoint {
5548 time: 0.0,
5549 position: Vector3::new(100.0, 1.0, 0.0),
5550 velocity_magnitude: speed,
5551 kinetic_energy: 0.5 * solver.inputs.bullet_mass * speed.powi(2),
5552 }];
5553 let mut max_height = 1.0;
5554 let termination = solver
5555 .append_terminal_endpoint(
5556 &mut points,
5557 Vector3::new(101.0, 0.0, 0.0),
5558 Vector3::new(699.0, 0.0, 0.0),
5559 1.0,
5560 &mut max_height,
5561 )
5562 .expect("sub-ULP ground crossing remains representable as one terminal state");
5563
5564 assert_eq!(termination, TrajectoryTermination::GroundThreshold);
5565 assert_eq!(points.len(), 1);
5566 assert_eq!(points[0].position.x, 100.0);
5567 assert_eq!(points[0].position.y.to_bits(), ground_threshold.to_bits());
5568 assert!(points[0].time > 0.0);
5569 }
5570
5571 #[test]
5572 fn every_solver_appends_an_exact_max_range_endpoint() {
5573 let target_range = 0.1;
5574 let modes = [
5575 ("Euler", false, false),
5576 ("RK4", true, false),
5577 ("RK45", true, true),
5578 ];
5579
5580 for (name, use_rk4, use_adaptive_rk45) in modes {
5581 let inputs = BallisticInputs {
5582 use_rk4,
5583 use_adaptive_rk45,
5584 ground_threshold: f64::NEG_INFINITY,
5585 enable_trajectory_sampling: true,
5586 sample_interval: target_range,
5587 ..BallisticInputs::default()
5588 };
5589 let mut solver = TrajectorySolver::new(
5590 inputs,
5591 WindConditions::default(),
5592 AtmosphericConditions::default(),
5593 );
5594 solver.set_max_range(target_range);
5595
5596 let result = solver.solve().expect("short-range solve should succeed");
5597 let terminal = result.points.last().expect("terminal point is missing");
5598 let muzzle = result.points.first().expect("muzzle point is missing");
5599
5600 assert_eq!(result.termination, TrajectoryTermination::MaxRange);
5601 assert_eq!(
5602 terminal.position.x.to_bits(),
5603 target_range.to_bits(),
5604 "{name} did not terminate exactly at max_range"
5605 );
5606 assert_eq!(result.max_range.to_bits(), target_range.to_bits());
5607 assert!(
5608 result.time_of_flight > 0.0 && result.time_of_flight < solver.time_step,
5609 "{name} terminal time was not interpolated within the crossing step: {}",
5610 result.time_of_flight
5611 );
5612 assert_eq!(result.time_of_flight.to_bits(), terminal.time.to_bits());
5613 assert_eq!(
5614 result.impact_velocity.to_bits(),
5615 terminal.velocity_magnitude.to_bits()
5616 );
5617 assert_eq!(
5618 result.impact_energy.to_bits(),
5619 terminal.kinetic_energy.to_bits()
5620 );
5621 let expected_energy = 0.5 * solver.inputs.bullet_mass * result.impact_velocity.powi(2);
5622 assert!((result.impact_energy - expected_energy).abs() < 1e-12);
5623 assert!(terminal.velocity_magnitude < muzzle.velocity_magnitude);
5624 assert!(terminal.kinetic_energy < muzzle.kinetic_energy);
5625
5626 let terminal_sample = result
5627 .sampled_points
5628 .as_ref()
5629 .and_then(|samples| samples.last())
5630 .expect("terminal trajectory sample is missing");
5631 assert_eq!(
5632 terminal_sample.distance_m.to_bits(),
5633 target_range.to_bits(),
5634 "{name} sampling did not include max_range"
5635 );
5636 assert_eq!(
5637 terminal_sample.time_s.to_bits(),
5638 result.time_of_flight.to_bits()
5639 );
5640 assert_eq!(
5641 terminal_sample.velocity_mps.to_bits(),
5642 result.impact_velocity.to_bits()
5643 );
5644 assert!((terminal_sample.energy_j - result.impact_energy).abs() < 1e-12);
5645 }
5646 }
5647}
5648
5649#[cfg(test)]
5650mod precession_inertia_wiring_tests {
5651 use super::*;
5652
5653 #[test]
5654 fn solver_uses_projectile_specific_moments_of_inertia() {
5655 let mass_kg = 55.0 * crate::constants::GRAINS_TO_KG;
5656 let caliber_m = 0.224 * 0.0254;
5657 let length_m = 0.75 * 0.0254;
5658 let inputs = BallisticInputs {
5659 bullet_mass: mass_kg,
5660 bullet_diameter: caliber_m,
5661 bullet_length: length_m,
5662 muzzle_velocity: 800.0,
5663 twist_rate: 7.0,
5664 enable_precession_nutation: true,
5665 use_rk4: false,
5666 use_adaptive_rk45: false,
5667 ..BallisticInputs::default()
5668 };
5669 let mut solver = TrajectorySolver::new(
5670 inputs,
5671 WindConditions::default(),
5672 AtmosphericConditions::default(),
5673 );
5674 solver.set_max_range(0.1);
5675
5676 let (air_density, speed_of_sound, _, _) = solver.resolved_atmosphere();
5677 let velocity_mps = solver.inputs.muzzle_velocity;
5678 let velocity_fps = velocity_mps * 3.28084;
5679 let twist_rate_ft = solver.inputs.twist_rate / 12.0;
5680 let spin_rate_rad_s = (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI;
5681 let initial_state = AngularState {
5682 pitch_angle: 0.001,
5683 yaw_angle: 0.001,
5684 pitch_rate: 0.0,
5685 yaw_rate: 0.0,
5686 precession_angle: 0.0,
5687 nutation_phase: 0.0,
5688 };
5689 let params = PrecessionNutationParams {
5690 mass_kg,
5691 caliber_m,
5692 length_m,
5693 spin_rate_rad_s,
5694 spin_inertia: crate::spin_decay::calculate_moment_of_inertia(
5695 mass_kg, caliber_m, length_m, "ogive",
5696 ),
5697 transverse_inertia: crate::pitch_damping::calculate_transverse_moment_of_inertia(
5698 mass_kg, caliber_m, length_m, "ogive",
5699 ),
5700 velocity_mps,
5701 air_density_kg_m3: air_density,
5702 mach: velocity_mps / speed_of_sound,
5703 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
5704 nutation_damping_factor: 0.05,
5705 };
5706 let expected = calculate_combined_angular_motion(
5707 ¶ms,
5708 &initial_state,
5709 0.0,
5710 solver.time_step,
5711 0.001,
5712 );
5713 let actual = solver
5714 .solve()
5715 .expect("one-step solve should succeed")
5716 .angular_state
5717 .expect("precession/nutation was enabled");
5718
5719 assert!(
5720 (actual.precession_angle - expected.precession_angle).abs() < 1e-15,
5721 "precession phase used the wrong inertia: actual={}, expected={}",
5722 actual.precession_angle,
5723 expected.precession_angle
5724 );
5725 assert!(
5726 (actual.nutation_phase - expected.nutation_phase).abs() < 1e-15,
5727 "nutation phase used the wrong inertia: actual={}, expected={}",
5728 actual.nutation_phase,
5729 expected.nutation_phase
5730 );
5731 }
5732}
5733
5734#[cfg(test)]
5735mod form_factor_drag_tests {
5736 use super::*;
5737
5738 fn acceleration_with_form_factor_flag(enabled: bool) -> Vector3<f64> {
5739 let inputs = BallisticInputs {
5740 bc_value: 0.462,
5741 bc_type: DragModel::G1,
5742 bullet_model: Some("168gr SMK Match".to_string()),
5743 use_form_factor: enabled,
5744 ..BallisticInputs::default()
5745 };
5746 let solver = TrajectorySolver::new(
5747 inputs,
5748 WindConditions::default(),
5749 AtmosphericConditions::default(),
5750 );
5751 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5752 solver.calculate_acceleration(
5753 &Vector3::zeros(),
5754 &Vector3::new(600.0, 0.0, 0.0),
5755 &Vector3::zeros(),
5756 (temp_c, pressure_hpa, density / 1.225),
5757 )
5758 }
5759
5760 #[test]
5761 fn measured_bc_drag_does_not_apply_name_based_form_factor_again() {
5762 let baseline = acceleration_with_form_factor_flag(false);
5763 let flagged = acceleration_with_form_factor_flag(true);
5764
5765 assert!(
5766 (flagged - baseline).norm() < 1e-12,
5767 "published BC already encodes form factor: baseline={baseline:?} flagged={flagged:?}"
5768 );
5769 }
5770}
5771
5772#[cfg(test)]
5773mod rk45_adaptivity_tests {
5774 use super::*;
5775
5776 #[test]
5777 fn cli_rk45_error_norm_scales_components_independently() {
5778 let position = Vector3::new(1.0e9, 0.0, 0.0);
5779 let velocity = Vector3::new(800.0, 0.0, 0.0);
5780 let fifth_position = position;
5781 let fifth_velocity = velocity;
5782 let fourth_position = position;
5783 let fourth_velocity = Vector3::new(800.0, 1.0e-3, 0.0);
5784
5785 let error = cli_rk45_error_norm(
5786 &position,
5787 &velocity,
5788 &fifth_position,
5789 &fifth_velocity,
5790 &fourth_position,
5791 &fourth_velocity,
5792 );
5793 let expected = 1.0e-3 / 6.0_f64.sqrt();
5794
5795 assert!(
5796 (error - expected).abs() <= 1e-15,
5797 "large downrange position masked a velocity-component error: {error}"
5798 );
5799 }
5800
5801 fn discontinuous_wind_solver() -> TrajectorySolver {
5802 let inputs = BallisticInputs::default();
5803 let mut solver = TrajectorySolver::new(
5804 inputs,
5805 WindConditions::default(),
5806 AtmosphericConditions::default(),
5807 );
5808 solver.set_wind_segments(vec![
5809 crate::wind::WindSegment::new(0.0, 90.0, 4.0),
5810 crate::wind::WindSegment::new(1_000.0, 90.0, 10_000.0),
5811 ]);
5812 solver
5813 }
5814
5815 #[test]
5816 fn rk45_retries_discontinuous_trial_before_advancing() {
5817 let solver = discontinuous_wind_solver();
5818 let position = Vector3::new(0.0, solver.inputs.muzzle_height, 0.0);
5819 let velocity = Vector3::new(solver.inputs.muzzle_velocity, 0.0, 0.0);
5820 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5821 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
5822 let dt = 0.01;
5823
5824 let rejected_trial = solver.rk45_step(
5825 &position,
5826 &velocity,
5827 dt,
5828 &Vector3::zeros(),
5829 RK45_TOLERANCE,
5830 resolved_atmo,
5831 );
5832 assert!(
5833 rejected_trial.error > RK45_TOLERANCE,
5834 "discontinuous full step must exceed tolerance, got {}",
5835 rejected_trial.error
5836 );
5837
5838 let accepted = solver.adaptive_rk45_step(
5839 &position,
5840 &velocity,
5841 dt,
5842 &Vector3::zeros(),
5843 resolved_atmo,
5844 );
5845 assert!(accepted.used_dt < dt, "oversized trial was not retried");
5846 assert!(
5847 accepted.error <= RK45_TOLERANCE || accepted.used_dt <= RK45_MIN_DT,
5848 "accepted error {} exceeds tolerance at dt {}",
5849 accepted.error,
5850 accepted.used_dt
5851 );
5852
5853 let accepted_trial = solver.rk45_step(
5854 &position,
5855 &velocity,
5856 accepted.used_dt,
5857 &Vector3::zeros(),
5858 RK45_TOLERANCE,
5859 resolved_atmo,
5860 );
5861 assert_eq!(accepted.position, accepted_trial.position);
5862 assert_eq!(accepted.velocity, accepted_trial.velocity);
5863 assert!((RK45_MIN_DT..=RK45_MAX_DT).contains(&accepted.next_dt));
5864 }
5865}
5866
5867#[cfg(test)]
5868mod ground_termination_tests {
5869 use super::*;
5870 use crate::trajectory_observation::TrajectoryObservationFlag;
5871
5872 #[test]
5873 fn every_solver_reports_one_exact_early_ground_endpoint() {
5874 for (name, use_rk4, use_adaptive_rk45) in [
5875 ("Euler", false, false),
5876 ("RK4", true, false),
5877 ("RK45", true, true),
5878 ] {
5879 let inputs = BallisticInputs {
5880 muzzle_height: 1.0,
5881 muzzle_angle: -0.2,
5882 ground_threshold: 0.0,
5883 use_rk4,
5884 use_adaptive_rk45,
5885 ..BallisticInputs::default()
5886 };
5887 let mut solver = TrajectorySolver::new(
5888 inputs,
5889 WindConditions::default(),
5890 AtmosphericConditions::default(),
5891 );
5892 solver.set_max_range(1_000.0);
5893
5894 let result = solver.solve().expect("early-ground solve should succeed");
5895 let terminal = result.points.last().expect("terminal point is missing");
5896
5897 assert_eq!(result.termination, TrajectoryTermination::GroundThreshold);
5898 assert_eq!(terminal.position.y.to_bits(), 0.0_f64.to_bits());
5899 assert!(
5900 terminal.position.x < 1_000.0,
5901 "{name} incorrectly reached max range"
5902 );
5903 assert_eq!(result.max_range.to_bits(), terminal.position.x.to_bits());
5904 assert_eq!(
5905 result
5906 .points
5907 .iter()
5908 .filter(|point| point.position.y == 0.0)
5909 .count(),
5910 1,
5911 "{name} did not retain exactly one ground endpoint"
5912 );
5913
5914 let observations = result
5915 .sample_observations(1.0, 100)
5916 .expect("checked early-ground sampling should succeed");
5917 assert!(observations[..observations.len() - 1]
5918 .iter()
5919 .all(|observation| observation.distance_m < terminal.position.x));
5920 let terminal_observation = observations.last().expect("terminal observation");
5921 assert_eq!(
5922 terminal_observation.distance_m.to_bits(),
5923 terminal.position.x.to_bits()
5924 );
5925 assert!(terminal_observation
5926 .flags
5927 .contains(&TrajectoryObservationFlag::Terminal));
5928 assert!(terminal_observation
5929 .flags
5930 .contains(&TrajectoryObservationFlag::GroundThreshold));
5931 assert_eq!(
5932 observations
5933 .iter()
5934 .filter(|observation| observation
5935 .flags
5936 .contains(&TrajectoryObservationFlag::Terminal))
5937 .count(),
5938 1,
5939 "{name} repeated the terminal observation"
5940 );
5941 }
5942 }
5943
5944 #[test]
5949 fn rk4_and_rk45_descend_to_ground_threshold() {
5950 for adaptive in [false, true] {
5951 let inputs = BallisticInputs {
5952 muzzle_angle: 0.1, use_rk4: true,
5954 use_adaptive_rk45: adaptive,
5955 ..BallisticInputs::default()
5956 };
5957 assert_eq!(
5958 inputs.ground_threshold, -100.0,
5959 "default ground_threshold is -100 m"
5960 );
5961
5962 let mut solver = TrajectorySolver::new(
5963 inputs,
5964 WindConditions::default(),
5965 AtmosphericConditions::default(),
5966 );
5967 solver.set_max_range(1.0e7);
5969
5970 let result = solver.solve().expect("solve should succeed");
5971 let final_y = result
5972 .points
5973 .last()
5974 .expect("trajectory has points")
5975 .position
5976 .y;
5977 assert!(
5978 final_y < -1.0,
5979 "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
5980 past launch level toward the ground_threshold floor, not stop at y = 0"
5981 );
5982 }
5983 }
5984}
5985
5986#[cfg(test)]
5987mod magnus_stability_tests {
5988 use super::*;
5989
5990 #[test]
5991 fn yaw_of_repose_magnus_force_is_vertical_and_twist_signed() {
5992 let acceleration = |enable_magnus, is_twist_right| {
5993 let inputs = BallisticInputs {
5994 muzzle_velocity: 822.96,
5995 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
5996 bullet_diameter: 0.308 * 0.0254,
5997 bullet_length: 1.215 * 0.0254,
5998 twist_rate: 10.0,
5999 is_twist_right,
6000 enable_magnus,
6001 ..BallisticInputs::default()
6002 };
6003 let solver = TrajectorySolver::new(
6004 inputs,
6005 WindConditions::default(),
6006 AtmosphericConditions::default(),
6007 );
6008 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
6009 solver.calculate_acceleration(
6010 &Vector3::zeros(),
6011 &Vector3::new(822.96, 0.0, 0.0),
6012 &Vector3::zeros(),
6013 (temp_c, pressure_hpa, density / 1.225),
6014 )
6015 };
6016
6017 let baseline = acceleration(false, true);
6018 let right_twist = acceleration(true, true) - baseline;
6019 let left_twist = acceleration(true, false) - baseline;
6020
6021 assert!(
6022 right_twist.y < 0.0,
6023 "right-hand Magnus must point down, got {right_twist:?}"
6024 );
6025 assert!(
6026 left_twist.y > 0.0,
6027 "left-hand Magnus must point up, got {left_twist:?}"
6028 );
6029 assert!((right_twist.y + left_twist.y).abs() < 1e-12);
6030 assert!(right_twist.x.abs() < 1e-12 && right_twist.z.abs() < 1e-12);
6031 assert!(left_twist.x.abs() < 1e-12 && left_twist.z.abs() < 1e-12);
6032 }
6033
6034 #[test]
6035 fn magnus_uses_velocity_corrected_muzzle_stability_gate() {
6036 let muzzle_velocity = 1_400.0 / 3.28084;
6037 let inputs = BallisticInputs {
6038 muzzle_velocity,
6039 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
6040 bullet_diameter: 0.308 * 0.0254,
6041 bullet_length: 1.215 * 0.0254,
6042 twist_rate: 15.0,
6043 enable_magnus: true,
6044 ..BallisticInputs::default()
6045 };
6046 let solver = TrajectorySolver::new(
6047 inputs.clone(),
6048 WindConditions::default(),
6049 AtmosphericConditions::default(),
6050 );
6051
6052 let bare_sg = crate::spin_drift::miller_stability(0.308, 168.0, 15.0, 1.215);
6053 let canonical_sg = solver.effective_spin_drift_sg();
6054 assert!(bare_sg > 1.0, "test requires bare Sg above the Magnus gate");
6055 assert!(
6056 canonical_sg < 1.0,
6057 "velocity-corrected Sg must be below the gate, got {canonical_sg}"
6058 );
6059
6060 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
6061 let acceleration = solver.calculate_acceleration(
6062 &Vector3::zeros(),
6063 &Vector3::new(muzzle_velocity, 0.0, 0.0),
6064 &Vector3::zeros(),
6065 (temp_c, pressure_hpa, density / 1.225),
6066 );
6067 let mut baseline_inputs = inputs;
6068 baseline_inputs.enable_magnus = false;
6069 let baseline_solver = TrajectorySolver::new(
6070 baseline_inputs,
6071 WindConditions::default(),
6072 AtmosphericConditions::default(),
6073 );
6074 let baseline = baseline_solver.calculate_acceleration(
6075 &Vector3::zeros(),
6076 &Vector3::new(muzzle_velocity, 0.0, 0.0),
6077 &Vector3::zeros(),
6078 (temp_c, pressure_hpa, density / 1.225),
6079 );
6080
6081 assert_eq!(
6082 acceleration, baseline,
6083 "canonical Sg below 1 must suppress every Magnus acceleration component"
6084 );
6085 }
6086
6087 #[test]
6088 fn magnus_force_grows_as_fixed_spin_projectile_slows() {
6089 let inputs = BallisticInputs {
6090 muzzle_velocity: 800.0,
6091 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
6092 bullet_diameter: 0.308 * 0.0254,
6093 bullet_length: 1.215 * 0.0254,
6094 twist_rate: 12.0,
6095 enable_magnus: true,
6096 ..BallisticInputs::default()
6097 };
6098
6099 let magnus_acceleration = |speed_mps| {
6100 let evaluate = |enable_magnus| {
6101 let mut run_inputs = inputs.clone();
6102 run_inputs.enable_magnus = enable_magnus;
6103 let solver = TrajectorySolver::new(
6104 run_inputs,
6105 WindConditions::default(),
6106 AtmosphericConditions::default(),
6107 );
6108 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
6109 solver
6110 .calculate_acceleration(
6111 &Vector3::zeros(),
6112 &Vector3::new(speed_mps, 0.0, 0.0),
6113 &Vector3::zeros(),
6114 (temp_c, pressure_hpa, density / 1.225),
6115 )
6116 .y
6117 };
6118 (evaluate(true) - evaluate(false)).abs()
6119 };
6120
6121 let fast = magnus_acceleration(200.0);
6122 let slow = magnus_acceleration(100.0);
6123 let ratio = slow / fast;
6124 let expected_ratio = 2.0_f64.powf(5.0 / 3.0);
6125
6126 assert!(fast > 0.0 && slow > 0.0, "fast={fast}, slow={slow}");
6127 assert!(
6128 (ratio - expected_ratio).abs() < 1e-3,
6129 "fixed-spin Magnus acceleration must grow downrange; slow/fast={ratio}, \
6130 expected={expected_ratio}"
6131 );
6132 }
6133}
6134
6135#[cfg(test)]
6136mod coriolis_direction_tests {
6137 use super::*;
6138 use std::f64::consts::FRAC_PI_2;
6139
6140 #[test]
6141 fn supersonic_crossing_flags_a_positive_range_sample() {
6142 use crate::trajectory_sampling::TrajectoryFlag;
6146
6147 for (solver_name, use_rk4, use_adaptive_rk45) in [
6148 ("Euler", false, false),
6149 ("RK4", true, false),
6150 ("RK45", true, true),
6151 ] {
6152 let inputs = BallisticInputs {
6153 muzzle_velocity: 850.0,
6154 bc_value: 0.2,
6155 bc_type: DragModel::G7,
6156 muzzle_angle: 0.03,
6157 enable_trajectory_sampling: true,
6158 sample_interval: 50.0,
6159 use_rk4,
6160 use_adaptive_rk45,
6161 ..BallisticInputs::default()
6162 };
6163 let mut solver = TrajectorySolver::new(
6164 inputs,
6165 WindConditions::default(),
6166 AtmosphericConditions::default(),
6167 );
6168 solver.set_max_range(2000.0);
6169 let samples = solver
6170 .solve()
6171 .expect("supersonic solve should succeed")
6172 .sampled_points
6173 .expect("sampling was enabled");
6174 let flagged_distances: Vec<_> = samples
6175 .iter()
6176 .filter(|sample| sample.flags.contains(&TrajectoryFlag::MachTransition))
6177 .map(|sample| sample.distance_m)
6178 .collect();
6179
6180 assert!(
6181 !flagged_distances.is_empty()
6182 && flagged_distances.iter().all(|distance| *distance > 0.0),
6183 "{solver_name} must flag genuine crossings only at positive range: {flagged_distances:?}"
6184 );
6185 }
6186 }
6187
6188 #[test]
6189 fn subsonic_launch_does_not_flag_a_muzzle_transition() {
6190 use crate::trajectory_sampling::TrajectoryFlag;
6191
6192 for (solver_name, use_rk4, use_adaptive_rk45) in [
6193 ("Euler", false, false),
6194 ("RK4", true, false),
6195 ("RK45", true, true),
6196 ] {
6197 let inputs = BallisticInputs {
6198 muzzle_velocity: 250.0,
6199 muzzle_angle: 0.02,
6200 enable_trajectory_sampling: true,
6201 sample_interval: 25.0,
6202 use_rk4,
6203 use_adaptive_rk45,
6204 ..BallisticInputs::default()
6205 };
6206 let mut solver = TrajectorySolver::new(
6207 inputs,
6208 WindConditions::default(),
6209 AtmosphericConditions::default(),
6210 );
6211 solver.set_max_range(300.0);
6212 let samples = solver
6213 .solve()
6214 .expect("subsonic solve should succeed")
6215 .sampled_points
6216 .expect("sampling was enabled");
6217
6218 assert!(
6219 samples
6220 .iter()
6221 .all(|sample| !sample.flags.contains(&TrajectoryFlag::MachTransition)),
6222 "{solver_name} marked a Mach transition for a launch already below Mach 1"
6223 );
6224 }
6225 }
6226
6227 #[test]
6228 fn mach_transition_tracker_requires_a_downward_crossing() {
6229 fn record(mach_values: &[f64]) -> Vec<f64> {
6230 let mut tracker = MachTransitionTracker::default();
6231 let mut distances = Vec::new();
6232 for (index, mach) in mach_values.iter().copied().enumerate() {
6233 tracker.record_downward_crossings(mach, index as f64 * 10.0, &mut distances);
6234 }
6235 distances
6236 }
6237
6238 assert!(record(&[0.9, 0.8, 0.7]).is_empty());
6239 assert_eq!(record(&[1.1, 1.05, 0.99]), vec![20.0]);
6240 assert_eq!(record(&[1.2, 1.19, 1.0, 0.99]), vec![10.0, 30.0]);
6241 assert_eq!(record(&[0.9, 1.3, 1.1, 0.9, 1.3, 0.8]), vec![20.0, 30.0]);
6242 assert!(record(&[1.3, f64::NAN, 1.1]).is_empty());
6243 }
6244
6245 #[test]
6246 fn mach_transition_tracker_labels_0_9_without_touching_the_flat_vec() {
6247 fn record(mach_values: &[f64]) -> (Vec<f64>, MachTransitionTracker) {
6253 let mut tracker = MachTransitionTracker::default();
6254 let mut distances = Vec::new();
6255 for (index, mach) in mach_values.iter().copied().enumerate() {
6256 tracker.record_downward_crossings(mach, index as f64 * 10.0, &mut distances);
6257 }
6258 (distances, tracker)
6259 }
6260
6261 let (distances, tracker) = record(&[0.9, 0.8, 0.7]);
6264 assert!(distances.is_empty()); assert_eq!(tracker.mach_1_2_distance_m, None);
6266 assert_eq!(tracker.mach_1_0_distance_m, None);
6267 assert_eq!(tracker.mach_0_9_distance_m, Some(10.0));
6268
6269 let (distances, tracker) = record(&[1.1, 1.05, 0.99]);
6271 assert_eq!(distances, vec![20.0]);
6272 assert_eq!(tracker.mach_1_2_distance_m, None);
6273 assert_eq!(tracker.mach_1_0_distance_m, Some(20.0));
6274 assert_eq!(tracker.mach_0_9_distance_m, None);
6275
6276 let (distances, tracker) = record(&[1.2, 1.19, 1.0, 0.99]);
6278 assert_eq!(distances, vec![10.0, 30.0]); assert_eq!(tracker.mach_1_2_distance_m, Some(10.0));
6280 assert_eq!(tracker.mach_1_0_distance_m, Some(30.0));
6281 assert_eq!(tracker.mach_0_9_distance_m, None);
6282
6283 let (distances, tracker) = record(&[0.9, 1.3, 1.1, 0.9, 1.3, 0.8]);
6286 assert_eq!(distances, vec![20.0, 30.0]); assert_eq!(tracker.mach_1_2_distance_m, Some(20.0));
6288 assert_eq!(tracker.mach_1_0_distance_m, Some(30.0));
6289 assert_eq!(tracker.mach_0_9_distance_m, Some(50.0));
6290 assert!(
6291 tracker.mach_1_2_distance_m < tracker.mach_1_0_distance_m
6292 && tracker.mach_1_0_distance_m < tracker.mach_0_9_distance_m,
6293 "labeled crossings must be strictly increasing downrange"
6294 );
6295
6296 let (distances, tracker) = record(&[1.3, f64::NAN, 1.1]);
6298 assert!(distances.is_empty());
6299 assert_eq!(tracker.mach_1_2_distance_m, None);
6300 assert_eq!(tracker.mach_1_0_distance_m, None);
6301 assert_eq!(tracker.mach_0_9_distance_m, None);
6302 }
6303
6304 #[test]
6305 fn humidity_percent_converts_and_clamps() {
6306 let mut i = BallisticInputs {
6308 humidity: 0.5,
6309 ..BallisticInputs::default()
6310 };
6311 assert!((i.humidity_percent() - 50.0).abs() < 1e-9, "0.5 -> 50%");
6312 i.humidity = 0.0;
6313 assert_eq!(i.humidity_percent(), 0.0);
6314 i.humidity = 1.0;
6315 assert_eq!(i.humidity_percent(), 100.0);
6316 i.humidity = 1.5; assert_eq!(i.humidity_percent(), 100.0);
6318 }
6319
6320 fn vertical_at(shot_azimuth: f64, range_m: f64) -> f64 {
6323 let inputs = BallisticInputs {
6324 muzzle_velocity: 800.0,
6325 bc_value: 0.5,
6326 bc_type: DragModel::G7,
6327 muzzle_angle: 0.02, enable_coriolis: true,
6329 latitude: Some(45.0),
6330 shot_azimuth,
6331 ground_threshold: f64::NEG_INFINITY, ..BallisticInputs::default()
6333 };
6334 let mut solver = TrajectorySolver::new(
6335 inputs,
6336 WindConditions::default(),
6337 AtmosphericConditions::default(),
6338 );
6339 solver.set_max_range(range_m + 50.0);
6340 let r = solver.solve().expect("solve");
6341 let pts = &r.points;
6342 for i in 1..pts.len() {
6343 if pts[i].position.x >= range_m {
6344 let p1 = &pts[i - 1];
6345 let p2 = &pts[i];
6346 let t = (range_m - p1.position.x) / (p2.position.x - p1.position.x);
6347 return p1.position.y + t * (p2.position.y - p1.position.y);
6348 }
6349 }
6350 panic!("range {range_m} not reached");
6351 }
6352
6353 #[test]
6358 fn eotvos_east_higher_than_west() {
6359 let range = 600.0;
6360 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!(
6364 east > west,
6365 "east ({east:.5}) must be higher than west ({west:.5}) at {range} m (Eötvös)"
6366 );
6367 assert!(
6368 east > north && north > west,
6369 "north ({north:.5}) must lie between east ({east:.5}) and west ({west:.5})"
6370 );
6371 assert!(
6372 (east - west) > 1e-3,
6373 "E-W vertical separation ({:.6} m) should be physically meaningful, not FP noise",
6374 east - west
6375 );
6376 }
6377
6378 #[test]
6386 fn labeled_mach_crossings_match_pinned_pre_change_flat_vec_across_solvers() {
6387 let cases = [
6389 ("Euler", false, false, 670.9878683238721_f64, 805.5274119916264_f64),
6390 ("RK4", true, false, 671.7257336844475_f64, 805.933409072171_f64),
6391 ("RK45", true, true, 672.4905711917901_f64, 806.5709746782849_f64),
6392 ];
6393
6394 for (solver_name, use_rk4, use_adaptive_rk45, expected_1_2, expected_1_0) in cases {
6395 let inputs = BallisticInputs {
6396 muzzle_velocity: 850.0,
6397 bc_value: 0.2,
6398 bc_type: DragModel::G7,
6399 muzzle_angle: 0.03,
6400 use_rk4,
6401 use_adaptive_rk45,
6402 ..BallisticInputs::default()
6403 };
6404 let mut solver = TrajectorySolver::new(
6405 inputs,
6406 WindConditions::default(),
6407 AtmosphericConditions::default(),
6408 );
6409 solver.set_max_range(2000.0);
6410 let result = solver.solve().expect("solve should succeed");
6411
6412 assert_eq!(
6413 result.mach_1_2_distance_m,
6414 Some(expected_1_2),
6415 "{solver_name}: mach_1_2_distance_m must match the pinned pre-change flat-Vec value"
6416 );
6417 assert_eq!(
6418 result.mach_1_0_distance_m,
6419 Some(expected_1_0),
6420 "{solver_name}: mach_1_0_distance_m must match the pinned pre-change flat-Vec value"
6421 );
6422
6423 let mach_1_2 = result.mach_1_2_distance_m.expect("crosses 1.2");
6424 let mach_1_0 = result.mach_1_0_distance_m.expect("crosses 1.0");
6425 let mach_0_9 = result
6426 .mach_0_9_distance_m
6427 .expect("this trajectory also goes past 0.9 within 2000 m");
6428 assert!(
6429 mach_1_2 < mach_1_0 && mach_1_0 < mach_0_9,
6430 "{solver_name}: labeled crossings must be strictly increasing downrange \
6431 (1.2={mach_1_2}, 1.0={mach_1_0}, 0.9={mach_0_9})"
6432 );
6433 }
6434 }
6435
6436 #[test]
6439 fn labeled_mach_crossings_are_none_for_a_fully_supersonic_trajectory() {
6440 for (solver_name, use_rk4, use_adaptive_rk45) in [
6441 ("Euler", false, false),
6442 ("RK4", true, false),
6443 ("RK45", true, true),
6444 ] {
6445 let inputs = BallisticInputs {
6446 muzzle_velocity: 850.0,
6447 bc_value: 0.2,
6448 bc_type: DragModel::G7,
6449 muzzle_angle: 0.03,
6450 use_rk4,
6451 use_adaptive_rk45,
6452 ..BallisticInputs::default()
6453 };
6454 let mut solver = TrajectorySolver::new(
6455 inputs,
6456 WindConditions::default(),
6457 AtmosphericConditions::default(),
6458 );
6459 solver.set_max_range(200.0);
6461 let result = solver.solve().expect("solve should succeed");
6462
6463 assert_eq!(
6464 result.mach_1_2_distance_m, None,
6465 "{solver_name}: must not report a 1.2 crossing that never happens"
6466 );
6467 assert_eq!(
6468 result.mach_1_0_distance_m, None,
6469 "{solver_name}: must not report a 1.0 crossing that never happens"
6470 );
6471 assert_eq!(
6472 result.mach_0_9_distance_m, None,
6473 "{solver_name}: must not report a 0.9 crossing that never happens"
6474 );
6475 }
6476 }
6477}
6478
6479#[cfg(test)]
6480mod cant_tests {
6481 use super::*;
6482
6483 fn base_inputs() -> BallisticInputs {
6484 BallisticInputs {
6485 muzzle_velocity: 800.0,
6486 bc_value: 0.5,
6487 bc_type: DragModel::G7,
6488 bullet_mass: 0.0109,
6489 bullet_diameter: 0.00782,
6490 bullet_length: 0.0309,
6491 sight_height: 0.05,
6492 twist_rate: 10.0,
6493 use_rk4: true,
6494 ..BallisticInputs::default()
6495 }
6496 }
6497
6498 fn solve_with(inputs: BallisticInputs, max_range: f64) -> TrajectoryResult {
6499 let mut s = TrajectorySolver::new(
6500 inputs,
6501 WindConditions::default(),
6502 AtmosphericConditions::default(),
6503 );
6504 s.set_max_range(max_range);
6505 s.solve().expect("solve")
6506 }
6507
6508 fn yz_at(result: &TrajectoryResult, x: f64) -> (f64, f64) {
6510 let pts = &result.points;
6511 for i in 1..pts.len() {
6512 if pts[i].position.x >= x {
6513 let (p1, p2) = (&pts[i - 1], &pts[i]);
6514 let dx = p2.position.x - p1.position.x;
6515 let t = if dx.abs() < 1e-12 { 0.0 } else { (x - p1.position.x) / dx };
6516 return (
6517 p1.position.y + t * (p2.position.y - p1.position.y),
6518 p1.position.z + t * (p2.position.z - p1.position.z),
6519 );
6520 }
6521 }
6522 panic!("trajectory never reached {x} m");
6523 }
6524
6525 #[test]
6526 fn cant_sign_clockwise_up_offset_goes_right_and_low() {
6527 let mut level = base_inputs();
6529 level.muzzle_angle = 0.003; let mut canted = level.clone();
6531 canted.cant_angle = 10f64.to_radians();
6532
6533 let (y0, z0) = yz_at(&solve_with(level, 400.0), 300.0);
6534 let (y1, z1) = yz_at(&solve_with(canted, 400.0), 300.0);
6535 assert!(z1 > z0 + 0.01, "clockwise cant must move POI right: z0={z0} z1={z1}");
6536 assert!(y1 < y0 - 0.001, "clockwise cant must move POI low: y0={y0} y1={y1}");
6537 }
6538
6539 #[test]
6540 fn pure_cant_shows_bore_offset_near_range() {
6541 let mut i = base_inputs();
6544 i.muzzle_angle = 0.0;
6545 i.cant_angle = 10f64.to_radians();
6546 let sh = i.sight_height;
6547 let r = solve_with(i, 60.0);
6548 let first = &r.points[1]; let expected = -sh * 10f64.to_radians().sin();
6550 assert!(
6551 (first.position.z - expected).abs() < 0.005,
6552 "near-muzzle lateral {} should be ~bore offset {expected}",
6553 first.position.z
6554 );
6555 }
6556
6557 #[test]
6558 fn zero_angle_is_independent_of_cant() {
6559 let a = base_inputs();
6560 let mut b = base_inputs();
6561 b.cant_angle = 15f64.to_radians();
6562 let za = calculate_zero_angle(a.clone(), 100.0, 0.0).expect("zero a");
6563 let zb = calculate_zero_angle(b.clone(), 100.0, 0.0).expect("zero b");
6564 assert_eq!(za.to_bits(), zb.to_bits(), "zeroing must ignore cant: {za} vs {zb}");
6565 let _ = (a.cant_angle, b.cant_angle);
6567 }
6568
6569 #[test]
6570 fn nonfinite_cant_is_rejected() {
6571 let mut i = base_inputs();
6572 i.cant_angle = f64::NAN;
6573 let s = TrajectorySolver::new(i, WindConditions::default(), AtmosphericConditions::default());
6574 assert!(s.solve().is_err());
6575 }
6576
6577 #[test]
6578 fn incline_and_cant_compose_without_breaking() {
6579 let mut flat = base_inputs();
6581 flat.muzzle_angle = 0.003;
6582 flat.shooting_angle = 15f64.to_radians();
6583 let mut canted = flat.clone();
6584 canted.cant_angle = 10f64.to_radians();
6585 let (_, z_flat) = yz_at(&solve_with(flat, 400.0), 300.0);
6586 let (_, z_cant) = yz_at(&solve_with(canted, 400.0), 300.0);
6587 assert!(z_cant > z_flat, "cant must still deflect right on an incline");
6588 }
6589}
6590
6591#[cfg(test)]
6592mod vertical_wind_tests {
6593 use super::*;
6594
6595 fn base_inputs() -> BallisticInputs {
6596 BallisticInputs {
6597 muzzle_velocity: 800.0,
6598 bc_value: 0.5,
6599 bc_type: DragModel::G7,
6600 bullet_mass: 0.0109,
6601 bullet_diameter: 0.00782,
6602 bullet_length: 0.0309,
6603 sight_height: 0.05,
6604 twist_rate: 10.0,
6605 use_rk4: true,
6606 ..BallisticInputs::default()
6607 }
6608 }
6609
6610 fn y_at(result: &TrajectoryResult, x: f64) -> f64 {
6612 let pts = &result.points;
6613 for i in 1..pts.len() {
6614 if pts[i].position.x >= x {
6615 let (p1, p2) = (&pts[i - 1], &pts[i]);
6616 let dx = p2.position.x - p1.position.x;
6617 let t = if dx.abs() < 1e-12 { 0.0 } else { (x - p1.position.x) / dx };
6618 return p1.position.y + t * (p2.position.y - p1.position.y);
6619 }
6620 }
6621 panic!("trajectory never reached {x} m");
6622 }
6623
6624 fn solve_with(inputs: BallisticInputs, wind: WindConditions, max_range: f64) -> TrajectoryResult {
6625 let mut s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
6626 s.set_max_range(max_range);
6627 s.solve().expect("solve")
6628 }
6629
6630 #[test]
6631 fn updraft_raises_poi_downrange() {
6632 let calm_inputs = base_inputs();
6635 let calm_wind = WindConditions::default();
6636 let updraft = WindConditions {
6637 vertical_speed: 5.0,
6638 ..Default::default()
6639 };
6640
6641 let calm = solve_with(calm_inputs.clone(), calm_wind, 500.0);
6642 let updraft_result = solve_with(calm_inputs, updraft, 500.0);
6643
6644 let y_calm = y_at(&calm, 400.0);
6645 let y_updraft = y_at(&updraft_result, 400.0);
6646 assert!(
6647 y_updraft > y_calm,
6648 "5 m/s updraft must raise POI at 400m: calm={y_calm}, updraft={y_updraft}"
6649 );
6650 }
6651
6652 #[test]
6653 fn zero_vertical_is_default_and_finite_required() {
6654 assert_eq!(WindConditions::default().vertical_speed, 0.0);
6655
6656 let inputs = base_inputs();
6657 let wind = WindConditions {
6658 vertical_speed: f64::NAN,
6659 ..Default::default()
6660 };
6661 let s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
6662 assert!(
6663 s.solve().is_err(),
6664 "NaN wind.vertical_speed must be rejected by validate_for_solve"
6665 );
6666 }
6667}