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)]
21pub enum UnitSystem {
22 Imperial,
23 Metric,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq)]
28pub enum OutputFormat {
29 Table,
30 Json,
31 Csv,
32}
33
34#[derive(Debug)]
36pub struct BallisticsError {
37 message: String,
38}
39
40impl fmt::Display for BallisticsError {
41 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
42 write!(f, "{}", self.message)
43 }
44}
45
46impl Error for BallisticsError {}
47
48impl From<String> for BallisticsError {
49 fn from(msg: String) -> Self {
50 BallisticsError { message: msg }
51 }
52}
53
54impl From<&str> for BallisticsError {
55 fn from(msg: &str) -> Self {
56 BallisticsError {
57 message: msg.to_string(),
58 }
59 }
60}
61
62#[derive(Debug, Clone)]
66pub struct BallisticInputs {
67 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,
84 pub shooting_angle: f64, pub cant_angle: f64,
94 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,
108 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,
130 pub enable_magnus: bool, pub enable_coriolis: bool, pub use_powder_sensitivity: bool,
133 pub powder_temp_sensitivity: f64, pub powder_temp: f64, pub powder_temp_curve: Option<Vec<(f64, f64)>>,
142 pub powder_curve_temp_c: Option<f64>,
146 pub tipoff_yaw: f64, pub tipoff_decay_distance: f64, pub use_bc_segments: bool,
151 pub bc_segments: Option<Vec<(f64, f64)>>, pub bc_segments_data: Option<Vec<crate::BCSegmentData>>, pub use_enhanced_spin_drift: bool,
154 pub use_form_factor: bool,
157 pub enable_wind_shear: bool,
158 pub wind_shear_model: String,
159 pub enable_trajectory_sampling: bool,
160 pub sample_interval: f64, pub enable_pitch_damping: bool,
162 pub enable_precession_nutation: bool,
163 pub enable_aerodynamic_jump: bool,
166 pub use_cluster_bc: bool, pub custom_drag_table: Option<crate::drag::DragTable>,
170
171 pub bc_type_str: Option<String>,
173}
174
175impl BallisticInputs {
176 pub fn humidity_percent(&self) -> f64 {
181 (self.humidity * 100.0).clamp(0.0, 100.0)
182 }
183
184 pub fn sectional_density_lb_in2(&self) -> Option<f64> {
190 let weight_gr = if self.weight_grains > 0.0 {
191 self.weight_grains
192 } else {
193 self.bullet_mass / 0.00006479891 };
195 let diameter_in = if self.caliber_inches > 0.0 {
196 self.caliber_inches
197 } else {
198 self.bullet_diameter / 0.0254 };
200 if weight_gr > 0.0 && diameter_in > 0.0 {
201 Some(weight_gr / 7000.0 / (diameter_in * diameter_in))
202 } else {
203 None
204 }
205 }
206
207 pub fn custom_drag_denominator(&self, fallback_bc: f64) -> f64 {
219 match self.sectional_density_lb_in2() {
220 Some(sd) => sd,
221 None => {
222 static WARN_ONCE: std::sync::Once = std::sync::Once::new();
223 WARN_ONCE.call_once(|| {
224 eprintln!(
225 "Warning: custom drag table active but bullet mass/diameter are \
226 unavailable; falling back to bc_value for the retardation denominator"
227 );
228 });
229 fallback_bc
230 }
231 }
232 }
233}
234
235impl Default for BallisticInputs {
236 fn default() -> Self {
237 let mass_kg = 0.01;
238 let diameter_m = 0.00762;
239 let bc = 0.5;
240 let muzzle_angle_rad = 0.0;
241 let bc_type = DragModel::G1;
242
243 Self {
244 bc_value: bc,
246 bc_type,
247 bullet_mass: mass_kg,
248 muzzle_velocity: 800.0,
249 bullet_diameter: diameter_m,
250 bullet_length: crate::stability::estimate_bullet_length_m(diameter_m, mass_kg),
254
255 muzzle_angle: muzzle_angle_rad,
257 target_distance: 100.0,
258 azimuth_angle: 0.0,
259 shot_azimuth: 0.0,
260 shooting_angle: 0.0,
261 cant_angle: 0.0,
262 sight_height: 0.05,
263 muzzle_height: 0.0, target_height: 0.0, ground_threshold: -100.0, altitude: 0.0,
269 temperature: 15.0,
270 pressure: 1013.25, humidity: 0.5, latitude: None,
273
274 wind_speed: 0.0,
276 wind_angle: 0.0,
277
278 twist_rate: 12.0, is_twist_right: true,
281 caliber_inches: diameter_m / 0.0254, weight_grains: mass_kg / 0.00006479891, manufacturer: None,
284 bullet_model: None,
285 bullet_id: None,
286 bullet_cluster: None,
287
288 use_rk4: true, use_adaptive_rk45: true, enable_advanced_effects: false,
294 enable_magnus: false,
295 enable_coriolis: false,
296 use_powder_sensitivity: false,
297 powder_temp_sensitivity: 0.0,
298 powder_temp: 15.0,
299 powder_temp_curve: None,
300 powder_curve_temp_c: None,
301 tipoff_yaw: 0.0,
302 tipoff_decay_distance: 50.0,
303 use_bc_segments: false,
304 bc_segments: None,
305 bc_segments_data: None,
306 use_enhanced_spin_drift: false,
307 use_form_factor: false,
308 enable_wind_shear: false,
309 wind_shear_model: "none".to_string(),
310 enable_trajectory_sampling: false,
311 sample_interval: 10.0, enable_pitch_damping: false,
313 enable_precession_nutation: false,
314 enable_aerodynamic_jump: false,
315 use_cluster_bc: false, custom_drag_table: None,
319
320 bc_type_str: None,
322 }
323 }
324}
325
326pub fn interpolate_powder_temp_curve(curve: &[(f64, f64)], temp_c: f64) -> f64 {
332 debug_assert!(!curve.is_empty());
333 if curve.is_empty() {
334 return 0.0;
335 }
336 let mut sorted;
339 let pts: &[(f64, f64)] = if curve.windows(2).all(|w| w[0].0 <= w[1].0) {
340 curve
341 } else {
342 sorted = curve.to_vec();
343 sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
344 &sorted
345 };
346 let n = pts.len();
347 if temp_c <= pts[0].0 {
348 return pts[0].1; }
350 if temp_c >= pts[n - 1].0 {
351 return pts[n - 1].1; }
353 for i in 1..n {
354 let (t0, v0) = pts[i - 1];
355 let (t1, v1) = pts[i];
356 if temp_c <= t1 {
357 let span = t1 - t0;
358 if span.abs() < f64::EPSILON {
359 return v1; }
361 let f = (temp_c - t0) / span;
362 return v0 + f * (v1 - v0);
363 }
364 }
365 pts[n - 1].1
366}
367
368#[derive(Debug, Clone)]
370pub struct WindConditions {
371 pub speed: f64, pub direction: f64,
375 pub vertical_speed: f64,
383}
384
385impl Default for WindConditions {
386 fn default() -> Self {
387 Self {
388 speed: 0.0,
389 direction: 0.0,
390 vertical_speed: 0.0,
391 }
392 }
393}
394
395#[derive(Debug, Clone)]
397pub struct AtmosphericConditions {
398 pub temperature: f64, pub pressure: f64, pub humidity: f64,
404 pub altitude: f64, }
406
407impl Default for AtmosphericConditions {
408 fn default() -> Self {
409 Self {
410 temperature: 15.0,
411 pressure: 1013.25,
412 humidity: 50.0,
413 altitude: 0.0,
414 }
415 }
416}
417
418#[derive(Debug, Clone)]
420pub struct TrajectoryPoint {
421 pub time: f64,
422 pub position: Vector3<f64>,
423 pub velocity_magnitude: f64,
424 pub kinetic_energy: f64,
425}
426
427#[derive(Debug, Clone)]
429pub struct TrajectoryResult {
430 pub max_range: f64,
431 pub max_height: f64,
432 pub time_of_flight: f64,
433 pub impact_velocity: f64,
434 pub impact_energy: f64,
435 pub projectile_mass_kg: f64,
437 pub line_of_sight_height_m: f64,
439 pub station_speed_of_sound_mps: f64,
441 pub termination: TrajectoryTermination,
443 pub points: Vec<TrajectoryPoint>,
444 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>,
453}
454
455const RK45_TOLERANCE: f64 = 1e-6;
456const RK45_SAFETY_FACTOR: f64 = 0.9;
457const RK45_MAX_DT: f64 = 0.01;
458const RK45_MIN_DT: f64 = 1e-6;
459const TRAJECTORY_TIME_LIMIT_S: f64 = 100.0;
460
461pub const MAX_TRAJECTORY_POINTS: usize = 250_000;
467
468fn cli_rk45_error_norm(
470 position: &Vector3<f64>,
471 velocity: &Vector3<f64>,
472 fifth_position: &Vector3<f64>,
473 fifth_velocity: &Vector3<f64>,
474 fourth_position: &Vector3<f64>,
475 fourth_velocity: &Vector3<f64>,
476) -> f64 {
477 let pack_state = |position: &Vector3<f64>, velocity: &Vector3<f64>| {
478 Vector6::new(
479 position.x, position.y, position.z, velocity.x, velocity.y, velocity.z,
480 )
481 };
482 let state = pack_state(position, velocity);
483 let fifth_order = pack_state(fifth_position, fifth_velocity);
484 let fourth_order = pack_state(fourth_position, fourth_velocity);
485
486 crate::trajectory_integration::rk45_error_norm(&state, &fifth_order, &fourth_order)
487}
488
489struct Rk45Trial {
490 position: Vector3<f64>,
491 velocity: Vector3<f64>,
492 suggested_dt: f64,
493 error: f64,
494}
495
496struct Rk45AcceptedStep {
497 position: Vector3<f64>,
498 velocity: Vector3<f64>,
499 used_dt: f64,
500 next_dt: f64,
501 error: f64,
502}
503
504#[derive(Default)]
505struct MachTransitionTracker {
506 previous_mach: Option<f64>,
507 crossed_transonic: bool,
508 crossed_subsonic: bool,
509}
510
511impl MachTransitionTracker {
512 fn record_downward_crossings(&mut self, mach: f64, downrange_m: f64, distances: &mut Vec<f64>) {
513 if !mach.is_finite() {
514 self.previous_mach = None;
515 return;
516 }
517
518 if let Some(previous_mach) = self.previous_mach {
519 if !self.crossed_transonic && previous_mach >= 1.2 && mach < 1.2 {
520 self.crossed_transonic = true;
521 distances.push(downrange_m);
522 }
523 if !self.crossed_subsonic && previous_mach >= 1.0 && mach < 1.0 {
524 self.crossed_subsonic = true;
525 distances.push(downrange_m);
526 }
527 }
528 self.previous_mach = Some(mach);
529 }
530}
531
532impl TrajectoryResult {
533 pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
537 if self.points.is_empty() {
538 return None;
539 }
540
541 for i in 0..self.points.len() - 1 {
543 let p1 = &self.points[i];
544 let p2 = &self.points[i + 1];
545
546 if p1.position.x <= target_range && p2.position.x >= target_range {
548 let dx = p2.position.x - p1.position.x;
550 if dx.abs() < 1e-10 {
551 return Some(p1.position);
552 }
553 let t = (target_range - p1.position.x) / dx;
554
555 return Some(Vector3::new(
557 target_range,
558 p1.position.y + t * (p2.position.y - p1.position.y),
559 p1.position.z + t * (p2.position.z - p1.position.z),
560 ));
561 }
562 }
563
564 self.points.last().map(|p| p.position)
566 }
567}
568
569#[derive(Debug, Clone, Copy, PartialEq, Eq)]
571enum StationAtmosphereResolution {
572 LegacyDefaultSentinels,
575 Authoritative,
578}
579
580#[derive(Clone)]
581pub struct TrajectorySolver {
582 inputs: BallisticInputs,
583 wind: WindConditions,
584 atmosphere: AtmosphericConditions,
585 station_atmosphere_resolution: StationAtmosphereResolution,
586 max_range: f64,
587 time_step: f64,
588 max_trajectory_points: usize,
589 cluster_bc: Option<ClusterBCDegradation>,
590 precession_nutation_inertias: (f64, f64),
592 wind_sock: Option<crate::wind::WindSock>,
597 atmo_sock: Option<crate::atmosphere::AtmoSock>,
604}
605
606impl TrajectorySolver {
607 pub fn new(
608 inputs: BallisticInputs,
609 wind: WindConditions,
610 atmosphere: AtmosphericConditions,
611 ) -> Self {
612 Self::new_with_station_atmosphere_resolution(
613 inputs,
614 wind,
615 atmosphere,
616 StationAtmosphereResolution::LegacyDefaultSentinels,
617 )
618 }
619
620 pub(crate) fn new_with_resolved_station_atmosphere(
624 inputs: BallisticInputs,
625 wind: WindConditions,
626 atmosphere: AtmosphericConditions,
627 ) -> Self {
628 Self::new_with_station_atmosphere_resolution(
629 inputs,
630 wind,
631 atmosphere,
632 StationAtmosphereResolution::Authoritative,
633 )
634 }
635
636 fn new_with_station_atmosphere_resolution(
637 mut inputs: BallisticInputs,
638 wind: WindConditions,
639 atmosphere: AtmosphericConditions,
640 station_atmosphere_resolution: StationAtmosphereResolution,
641 ) -> Self {
642 inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
644 inputs.weight_grains = inputs.bullet_mass / 0.00006479891;
645
646 if let Some(curve) = inputs.powder_temp_curve.as_ref() {
655 if !curve.is_empty() {
656 let lookup_c = inputs.powder_curve_temp_c.unwrap_or(inputs.temperature);
661 inputs.muzzle_velocity = interpolate_powder_temp_curve(curve, lookup_c);
662 }
663 } else if inputs.use_powder_sensitivity {
664 let temp_delta_c = inputs.temperature - inputs.powder_temp;
665 inputs.muzzle_velocity += inputs.powder_temp_sensitivity * temp_delta_c;
666 }
667
668 let cluster_bc = if inputs.use_cluster_bc {
670 Some(ClusterBCDegradation::new())
671 } else {
672 None
673 };
674 let precession_nutation_inertias = projectile_moments_of_inertia(
675 inputs.bullet_mass,
676 inputs.bullet_diameter,
677 inputs.bullet_length,
678 );
679
680 Self {
681 inputs,
682 wind,
683 atmosphere,
684 station_atmosphere_resolution,
685 max_range: 1000.0,
686 time_step: 0.001,
687 max_trajectory_points: MAX_TRAJECTORY_POINTS,
688 cluster_bc,
689 precession_nutation_inertias,
690 wind_sock: None,
691 atmo_sock: None,
692 }
693 }
694
695 pub fn set_max_range(&mut self, range: f64) {
696 self.max_range = range;
697 }
698
699 pub fn set_time_step(&mut self, step: f64) {
700 self.time_step = step;
701 }
702
703 pub(crate) fn calculate_and_set_zero_angle(
707 &mut self,
708 target_distance_m: f64,
709 target_height_m: f64,
710 ) -> Result<f64, BallisticsError> {
711 let angle = self.find_zero_angle(target_distance_m, target_height_m)?;
712 self.inputs.muzzle_angle = angle;
713 Ok(angle)
714 }
715
716 fn find_zero_angle(
717 &self,
718 target_distance_m: f64,
719 target_height_m: f64,
720 ) -> Result<f64, BallisticsError> {
721 let mut low_angle = 0.0;
724 let mut high_angle = 0.2; let tolerance = 1e-7;
726 let max_iterations = 60;
727
728 let low_height = self.zero_trial_height_at(low_angle, target_distance_m)?;
730 let high_height = self.zero_trial_height_at(high_angle, target_distance_m)?;
731
732 match (low_height, high_height) {
733 (Some(low_height), Some(high_height)) => {
734 let low_error = low_height - target_height_m;
735 let high_error = high_height - target_height_m;
736
737 if low_error > 0.0 && high_error > 0.0 {
738 } else if low_error < 0.0 && high_error < 0.0 {
741 let mut expanded = false;
743 for multiplier in [2.0, 3.0, 4.0] {
744 let new_high = (high_angle * multiplier).min(0.785);
745 if let Ok(Some(height)) =
746 self.zero_trial_height_at(new_high, target_distance_m)
747 {
748 if height - target_height_m > 0.0 {
749 high_angle = new_high;
750 expanded = true;
751 break;
752 }
753 }
754 if new_high >= 0.785 {
755 break;
756 }
757 }
758 if !expanded {
759 return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
760 }
761 }
762 }
763 (None, Some(_)) => {
764 }
767 (Some(_), None) => {
768 return Err(
769 "Cannot find zero angle: high angle trajectory doesn't reach target distance"
770 .into(),
771 );
772 }
773 (None, None) => {
774 return Err(
775 "Cannot find zero angle: trajectory cannot reach target distance at any angle"
776 .into(),
777 );
778 }
779 }
780
781 for _ in 0..max_iterations {
782 let mid_angle = (low_angle + high_angle) / 2.0;
783 match self.zero_trial_height_at(mid_angle, target_distance_m)? {
784 Some(height) => {
785 let error = height - target_height_m;
786 if error.abs() < 0.0001 {
789 return Ok(mid_angle);
790 }
791
792 if (high_angle - low_angle).abs() < tolerance {
795 if error.abs() < 0.01 {
796 return Ok(mid_angle);
797 }
798 return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
799 }
800
801 if error > 0.0 {
802 high_angle = mid_angle;
803 } else {
804 low_angle = mid_angle;
805 }
806 }
807 None => {
808 low_angle = mid_angle;
809 if (high_angle - low_angle).abs() < tolerance {
810 return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
811 }
812 }
813 }
814 }
815
816 Err("Failed to find zero angle".into())
817 }
818
819 fn zero_trial_height_at(
822 &self,
823 angle_rad: f64,
824 target_distance_m: f64,
825 ) -> Result<Option<f64>, BallisticsError> {
826 let mut trial = self.clone();
827 trial.inputs.muzzle_angle = angle_rad;
828 trial.inputs.enable_aerodynamic_jump = false;
831 trial.inputs.cant_angle = 0.0;
834 trial.set_max_range(target_distance_m * 2.0);
835 let result = trial.solve()?;
836
837 for (index, point) in result.points.iter().enumerate() {
838 if point.position.x >= target_distance_m {
839 let shot_y_m = if index == 0 {
840 point.position.y
841 } else {
842 let previous = &result.points[index - 1];
843 let span = point.position.x - previous.position.x;
844 let fraction = (target_distance_m - previous.position.x) / span;
845 previous.position.y + fraction * (point.position.y - previous.position.y)
846 };
847 return Ok(Some(crate::atmosphere::shot_frame_altitude(
848 0.0,
849 target_distance_m,
850 shot_y_m,
851 trial.inputs.shooting_angle,
852 )));
853 }
854 }
855 Ok(None)
856 }
857
858 fn validate_for_solve(&self) -> Result<(), BallisticsError> {
864 let require_finite = |name: &str, value: f64| {
865 if value.is_finite() {
866 Ok(())
867 } else {
868 Err(BallisticsError::from(format!("{name} must be finite")))
869 }
870 };
871 let require_positive = |name: &str, value: f64| {
872 if value.is_finite() && value > 0.0 {
873 Ok(())
874 } else {
875 Err(BallisticsError::from(format!(
876 "{name} must be finite and greater than zero"
877 )))
878 }
879 };
880
881 if self.inputs.custom_drag_table.is_none() {
887 require_positive("bc_value", self.inputs.bc_value)?;
888 }
889 require_positive("bullet_mass", self.inputs.bullet_mass)?;
890 require_positive("bullet_diameter", self.inputs.bullet_diameter)?;
891 require_positive("muzzle_velocity", self.inputs.muzzle_velocity)?;
892
893 require_finite("muzzle_angle", self.inputs.muzzle_angle)?;
894 require_finite("azimuth_angle", self.inputs.azimuth_angle)?;
895 require_finite("shooting_angle", self.inputs.shooting_angle)?;
896 require_finite("cant_angle", self.inputs.cant_angle)?;
897 require_finite("muzzle_height", self.inputs.muzzle_height)?;
898
899 if !(self.inputs.ground_threshold.is_finite()
902 || self.inputs.ground_threshold == f64::NEG_INFINITY)
903 {
904 return Err(BallisticsError::from(
905 "ground_threshold must be finite or negative infinity",
906 ));
907 }
908
909 match &self.wind_sock {
910 Some(wind_sock) => wind_sock
911 .validate_segments()
912 .map_err(BallisticsError::from)?,
913 None => {
914 require_finite("wind.speed", self.wind.speed)?;
915 require_finite("wind.direction", self.wind.direction)?;
916 require_finite("wind.vertical_speed", self.wind.vertical_speed)?;
917 }
918 }
919
920 require_finite("atmosphere.temperature", self.atmosphere.temperature)?;
921 require_finite("atmosphere.pressure", self.atmosphere.pressure)?;
922 require_finite("atmosphere.humidity", self.atmosphere.humidity)?;
923 require_finite("atmosphere.altitude", self.atmosphere.altitude)?;
924
925 require_positive("max_range", self.max_range)?;
926 if !self.inputs.use_rk4 || !self.inputs.use_adaptive_rk45 {
929 require_positive("time_step", self.time_step)?;
930 }
931
932 if self.inputs.enable_trajectory_sampling {
933 require_finite("sight_height", self.inputs.sight_height)?;
934 require_positive("sample_interval", self.inputs.sample_interval)?;
935 projected_sample_count(self.max_range, self.inputs.sample_interval)?;
936 }
937
938 if self.inputs.enable_coriolis {
939 require_finite("shot_azimuth", self.inputs.shot_azimuth)?;
940 if let Some(latitude) = self.inputs.latitude {
941 require_finite("latitude", latitude)?;
942 }
943 }
944
945 Ok(())
946 }
947
948 fn validate_result_sanity(&self, result: &TrajectoryResult) -> Result<(), BallisticsError> {
955 let require_finite = |name: &str, value: f64| {
956 if value.is_finite() {
957 Ok(())
958 } else {
959 Err(BallisticsError::from(format!(
960 "trajectory result contains non-finite {name}"
961 )))
962 }
963 };
964 let require_non_negative = |name: &str, value: f64| {
965 if value >= 0.0 {
966 Ok(())
967 } else {
968 Err(BallisticsError::from(format!(
969 "trajectory result contains non-physical negative {name} ({value})"
970 )))
971 }
972 };
973 let require_indexed_finite = |collection: &str, index: usize, field: &str, value: f64| {
974 if value.is_finite() {
975 Ok(())
976 } else {
977 Err(BallisticsError::from(format!(
978 "trajectory result contains non-finite {collection}[{index}].{field}"
979 )))
980 }
981 };
982 let require_indexed_non_negative =
983 |collection: &str, index: usize, field: &str, value: f64| {
984 if value >= 0.0 {
985 Ok(())
986 } else {
987 Err(BallisticsError::from(format!(
988 "trajectory result contains non-physical negative {collection}[{index}].{field} ({value})"
989 )))
990 }
991 };
992
993 require_finite("max_range", result.max_range)?;
994 require_finite("max_height", result.max_height)?;
995 require_finite("time_of_flight", result.time_of_flight)?;
996 require_finite("impact_velocity", result.impact_velocity)?;
997 require_finite("impact_energy", result.impact_energy)?;
998 require_finite("projectile_mass_kg", result.projectile_mass_kg)?;
999 require_finite(
1000 "line_of_sight_height_m",
1001 result.line_of_sight_height_m,
1002 )?;
1003 require_finite(
1004 "station_speed_of_sound_mps",
1005 result.station_speed_of_sound_mps,
1006 )?;
1007
1008 require_non_negative("max_range", result.max_range)?;
1012 require_non_negative("time_of_flight", result.time_of_flight)?;
1013 require_non_negative("impact_velocity", result.impact_velocity)?;
1014 require_non_negative("impact_energy", result.impact_energy)?;
1015 require_non_negative("projectile_mass_kg", result.projectile_mass_kg)?;
1016 require_non_negative(
1017 "station_speed_of_sound_mps",
1018 result.station_speed_of_sound_mps,
1019 )?;
1020
1021 for (index, point) in result.points.iter().enumerate() {
1022 require_indexed_finite("points", index, "time", point.time)?;
1023 require_indexed_finite("points", index, "position.x", point.position.x)?;
1024 require_indexed_finite("points", index, "position.y", point.position.y)?;
1025 require_indexed_finite("points", index, "position.z", point.position.z)?;
1026 require_indexed_finite(
1027 "points",
1028 index,
1029 "velocity_magnitude",
1030 point.velocity_magnitude,
1031 )?;
1032 require_indexed_finite("points", index, "kinetic_energy", point.kinetic_energy)?;
1033 require_indexed_non_negative("points", index, "time", point.time)?;
1034 require_indexed_non_negative(
1035 "points",
1036 index,
1037 "velocity_magnitude",
1038 point.velocity_magnitude,
1039 )?;
1040 require_indexed_non_negative("points", index, "kinetic_energy", point.kinetic_energy)?;
1041 }
1042
1043 if let Some(samples) = &result.sampled_points {
1044 for (index, sample) in samples.iter().enumerate() {
1045 require_indexed_finite("sampled_points", index, "distance_m", sample.distance_m)?;
1046 require_indexed_finite("sampled_points", index, "drop_m", sample.drop_m)?;
1047 require_indexed_finite(
1048 "sampled_points",
1049 index,
1050 "wind_drift_m",
1051 sample.wind_drift_m,
1052 )?;
1053 require_indexed_finite(
1054 "sampled_points",
1055 index,
1056 "velocity_mps",
1057 sample.velocity_mps,
1058 )?;
1059 require_indexed_finite("sampled_points", index, "energy_j", sample.energy_j)?;
1060 require_indexed_finite("sampled_points", index, "time_s", sample.time_s)?;
1061 }
1062 }
1063
1064 for (name, value) in [
1065 ("min_pitch_damping", result.min_pitch_damping),
1066 ("transonic_mach", result.transonic_mach),
1067 ("max_yaw_angle", result.max_yaw_angle),
1068 ("max_precession_angle", result.max_precession_angle),
1069 ] {
1070 if let Some(value) = value {
1071 require_finite(name, value)?;
1072 }
1073 }
1074
1075 if let Some(state) = result.angular_state {
1076 for (name, value) in [
1077 ("angular_state.pitch_angle", state.pitch_angle),
1078 ("angular_state.yaw_angle", state.yaw_angle),
1079 ("angular_state.pitch_rate", state.pitch_rate),
1080 ("angular_state.yaw_rate", state.yaw_rate),
1081 ("angular_state.precession_angle", state.precession_angle),
1082 ("angular_state.nutation_phase", state.nutation_phase),
1083 ] {
1084 require_finite(name, value)?;
1085 }
1086 }
1087
1088 if let Some(jump) = result.aerodynamic_jump {
1089 for (name, value) in [
1090 ("aerodynamic_jump.vertical_jump_moa", jump.vertical_jump_moa),
1091 (
1092 "aerodynamic_jump.horizontal_jump_moa",
1093 jump.horizontal_jump_moa,
1094 ),
1095 ("aerodynamic_jump.jump_angle_rad", jump.jump_angle_rad),
1096 (
1097 "aerodynamic_jump.magnus_component_moa",
1098 jump.magnus_component_moa,
1099 ),
1100 ("aerodynamic_jump.yaw_component_moa", jump.yaw_component_moa),
1101 (
1102 "aerodynamic_jump.stabilization_factor",
1103 jump.stabilization_factor,
1104 ),
1105 ] {
1106 require_finite(name, value)?;
1107 }
1108 }
1109
1110 Ok(())
1111 }
1112
1113 fn validate_integration_state(
1126 &self,
1127 position: &Vector3<f64>,
1128 velocity: &Vector3<f64>,
1129 time: f64,
1130 ) -> Result<(), BallisticsError> {
1131 if !(position.iter().all(|value| value.is_finite())
1132 && velocity.iter().all(|value| value.is_finite())
1133 && time.is_finite())
1134 {
1135 return Err(BallisticsError::from(
1136 "trajectory integration produced a non-finite state",
1137 ));
1138 }
1139
1140 let speed = velocity.magnitude();
1141 let budget = self.speed_budget(time);
1142 if speed > budget {
1143 return Err(BallisticsError::from(format!(
1144 "trajectory integration diverged: speed {speed:.3e} m/s at t={time:.6}s exceeds \
1145 the physical budget of {budget:.3e} m/s"
1146 )));
1147 }
1148 Ok(())
1149 }
1150
1151 fn speed_budget(&self, time: f64) -> f64 {
1156 let scalar_wind = self.wind.speed.abs() + self.wind.vertical_speed.abs();
1157 let wind_bound = match &self.wind_sock {
1158 Some(sock) => scalar_wind.max(sock.max_speed_mps()),
1159 None => scalar_wind,
1160 };
1161 2.0 * (self.inputs.muzzle_velocity + wind_bound + 10.0)
1162 + crate::constants::G_ACCEL_MPS2 * time
1163 }
1164
1165 fn push_trajectory_point(
1167 &self,
1168 points: &mut Vec<TrajectoryPoint>,
1169 point: TrajectoryPoint,
1170 ) -> Result<(), BallisticsError> {
1171 if points.len() >= self.max_trajectory_points {
1172 return Err(BallisticsError::from(format!(
1173 "trajectory point limit of {} exceeded",
1174 self.max_trajectory_points
1175 )));
1176 }
1177 points.push(point);
1178 Ok(())
1179 }
1180
1181 pub fn set_wind_segments(&mut self, segments: Vec<crate::wind::WindSegment>) {
1188 self.wind_sock = if segments.is_empty() {
1189 None
1190 } else {
1191 Some(crate::wind::WindSock::new(segments))
1192 };
1193 }
1194
1195 pub fn set_atmo_segments(&mut self, segments: Vec<crate::atmosphere::AtmoSegment>) {
1204 self.atmo_sock = if segments.is_empty() {
1205 None
1206 } else {
1207 Some(crate::atmosphere::AtmoSock::new(segments))
1208 };
1209 }
1210
1211 fn launch_angles_from(
1220 &self,
1221 aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
1222 ) -> (f64, f64) {
1223 let (mut elev, mut azim) = (self.inputs.muzzle_angle, self.inputs.azimuth_angle);
1224 if self.inputs.cant_angle != 0.0 {
1231 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
1232 let (e0, a0) = (elev, azim);
1233 elev = e0 * cos_c - a0 * sin_c;
1234 azim = a0 * cos_c + e0 * sin_c;
1235 }
1236 match aj {
1237 Some(c) => {
1238 const MOA_PER_RAD: f64 = 3437.7467707849;
1240 (
1241 elev + c.vertical_jump_moa / MOA_PER_RAD,
1242 azim + c.horizontal_jump_moa / MOA_PER_RAD,
1243 )
1244 }
1245 None => (elev, azim),
1246 }
1247 }
1248
1249 fn aerodynamic_jump_components(
1257 &self,
1258 ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
1259 if !self.inputs.enable_aerodynamic_jump {
1260 return None;
1261 }
1262 let diameter_m = self.inputs.bullet_diameter;
1266 if !(self.inputs.twist_rate.is_finite()
1267 && self.inputs.twist_rate != 0.0
1268 && diameter_m.is_finite()
1269 && diameter_m > 0.0
1270 && self.inputs.bullet_length.is_finite()
1271 && self.inputs.bullet_length > 0.0
1272 && self.inputs.muzzle_velocity.is_finite())
1273 {
1274 return None;
1275 }
1276
1277 let (_, _, temp_c, pressure_hpa) = self.resolved_atmosphere();
1279 let sg = crate::stability::compute_stability_coefficient(
1280 &self.inputs,
1281 (self.atmosphere.altitude, temp_c, pressure_hpa, 0.0),
1282 );
1283 if !(sg.is_finite() && sg > 0.0) {
1284 return None;
1285 }
1286 let length_calibers = self.inputs.bullet_length / diameter_m;
1287
1288 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
1294 let crosswind_from_right_mps = if let Some(sock) = &self.wind_sock {
1295 -sock.vector_for_range_stateless(0.0)[2]
1296 } else {
1297 self.wind.speed * self.wind.direction.sin()
1298 };
1299 let crosswind_from_right_mph = crosswind_from_right_mps * MS_TO_MPH;
1300
1301 let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
1302 sg,
1303 length_calibers,
1304 crosswind_from_right_mph,
1305 self.inputs.is_twist_right,
1306 );
1307 if !vertical_jump_moa.is_finite() {
1308 return None;
1309 }
1310
1311 const MOA_PER_RAD: f64 = 3437.7467707849;
1312 Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
1313 vertical_jump_moa,
1314 horizontal_jump_moa: 0.0,
1316 jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
1317 magnus_component_moa: 0.0,
1318 yaw_component_moa: 0.0,
1319 stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
1320 })
1321 }
1322
1323 fn resolved_atmosphere(&self) -> (f64, f64, f64, f64) {
1324 let (temp_c, pressure_hpa) = match self.station_atmosphere_resolution {
1325 StationAtmosphereResolution::LegacyDefaultSentinels => {
1326 crate::atmosphere::resolve_station_conditions(
1327 self.atmosphere.temperature,
1328 self.atmosphere.pressure,
1329 self.atmosphere.altitude,
1330 )
1331 }
1332 StationAtmosphereResolution::Authoritative => {
1333 (self.atmosphere.temperature, self.atmosphere.pressure)
1334 }
1335 };
1336 let (density, speed_of_sound) = crate::atmosphere::calculate_atmosphere(
1337 self.atmosphere.altitude,
1338 Some(temp_c),
1339 Some(pressure_hpa),
1340 self.atmosphere.humidity,
1341 );
1342 (density, speed_of_sound, temp_c, pressure_hpa)
1343 }
1344
1345 fn precession_nutation_params(
1346 &self,
1347 velocity_mps: f64,
1348 air_density_kg_m3: f64,
1349 speed_of_sound_mps: f64,
1350 ) -> PrecessionNutationParams {
1351 let (spin_inertia, transverse_inertia) = self.precession_nutation_inertias;
1352 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
1353 let velocity_fps = velocity_mps * 3.28084;
1354 let twist_rate_ft = self.inputs.twist_rate / 12.0;
1355 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
1356 } else {
1357 0.0
1358 };
1359
1360 PrecessionNutationParams {
1361 mass_kg: self.inputs.bullet_mass,
1362 caliber_m: self.inputs.bullet_diameter,
1363 length_m: self.inputs.bullet_length,
1364 spin_rate_rad_s,
1365 spin_inertia,
1366 transverse_inertia,
1367 velocity_mps,
1368 air_density_kg_m3,
1369 mach: velocity_mps / speed_of_sound_mps,
1370 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
1371 nutation_damping_factor: 0.05,
1372 }
1373 }
1374
1375 fn append_terminal_endpoint(
1382 &self,
1383 points: &mut Vec<TrajectoryPoint>,
1384 post_position: Vector3<f64>,
1385 post_velocity: Vector3<f64>,
1386 post_time: f64,
1387 max_height: &mut f64,
1388 ) -> Result<TrajectoryTermination, BallisticsError> {
1389 let previous = points
1390 .last()
1391 .cloned()
1392 .ok_or_else(|| BallisticsError::from("No trajectory points generated"))?;
1393
1394 let mut crossings = Vec::with_capacity(3);
1395 if previous.position.x < self.max_range && post_position.x >= self.max_range {
1396 let span = post_position.x - previous.position.x;
1397 if span.is_finite() && span > 0.0 {
1398 crossings.push((
1399 (self.max_range - previous.position.x) / span,
1400 TrajectoryTermination::MaxRange,
1401 ));
1402 }
1403 }
1404 if self.inputs.ground_threshold.is_finite()
1405 && previous.position.y > self.inputs.ground_threshold
1406 && post_position.y <= self.inputs.ground_threshold
1407 {
1408 let span = post_position.y - previous.position.y;
1409 if span.is_finite() && span < 0.0 {
1410 crossings.push((
1411 (self.inputs.ground_threshold - previous.position.y) / span,
1412 TrajectoryTermination::GroundThreshold,
1413 ));
1414 }
1415 }
1416 if previous.time < TRAJECTORY_TIME_LIMIT_S && post_time >= TRAJECTORY_TIME_LIMIT_S {
1417 let span = post_time - previous.time;
1418 if span.is_finite() && span > 0.0 {
1419 crossings.push((
1420 (TRAJECTORY_TIME_LIMIT_S - previous.time) / span,
1421 TrajectoryTermination::TimeLimit,
1422 ));
1423 }
1424 }
1425
1426 let (fraction, termination) = crossings
1427 .into_iter()
1428 .filter(|(fraction, _)| fraction.is_finite() && (0.0..=1.0).contains(fraction))
1429 .min_by(|left, right| {
1430 let priority = |termination: TrajectoryTermination| match termination {
1431 TrajectoryTermination::GroundThreshold => 0,
1432 TrajectoryTermination::MaxRange => 1,
1433 TrajectoryTermination::TimeLimit => 2,
1434 TrajectoryTermination::VelocityFloor => 3,
1435 };
1436 left.0
1437 .total_cmp(&right.0)
1438 .then_with(|| priority(left.1).cmp(&priority(right.1)))
1439 })
1440 .ok_or_else(|| {
1441 BallisticsError::from(
1442 "trajectory integration stopped without crossing a supported boundary",
1443 )
1444 })?;
1445
1446 let mut position = previous.position + (post_position - previous.position) * fraction;
1447 match termination {
1448 TrajectoryTermination::MaxRange => position.x = self.max_range,
1449 TrajectoryTermination::GroundThreshold => {
1450 position.y = self.inputs.ground_threshold;
1451 }
1452 TrajectoryTermination::TimeLimit | TrajectoryTermination::VelocityFloor => {}
1453 }
1454 let velocity_magnitude = previous.velocity_magnitude
1455 + (post_velocity.magnitude() - previous.velocity_magnitude) * fraction;
1456 let mut time = previous.time + (post_time - previous.time) * fraction;
1457 if termination == TrajectoryTermination::TimeLimit {
1458 time = TRAJECTORY_TIME_LIMIT_S;
1459 }
1460 let kinetic_energy =
1461 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1462
1463 if position.y > *max_height {
1464 *max_height = position.y;
1465 }
1466 let terminal_point = TrajectoryPoint {
1467 time,
1468 position,
1469 velocity_magnitude,
1470 kinetic_energy,
1471 };
1472 if terminal_point.position.x < previous.position.x {
1473 return Err(BallisticsError::from(
1474 "trajectory terminal state reversed downrange before the crossed boundary",
1475 ));
1476 }
1477 if terminal_point.position.x == previous.position.x {
1478 let last = points.last_mut().ok_or_else(|| {
1483 BallisticsError::from("trajectory points disappeared during terminal finalization")
1484 })?;
1485 *last = terminal_point;
1486 } else {
1487 self.push_trajectory_point(points, terminal_point)?;
1488 }
1489 Ok(termination)
1490 }
1491
1492 fn gravity_acceleration(&self) -> Vector3<f64> {
1493 let theta = self.inputs.shooting_angle;
1494 Vector3::new(
1495 -crate::constants::G_ACCEL_MPS2 * theta.sin(),
1496 -crate::constants::G_ACCEL_MPS2 * theta.cos(),
1497 0.0,
1498 )
1499 }
1500
1501 fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
1502 let model = match self.inputs.wind_shear_model.as_str() {
1517 "logarithmic" => WindShearModel::Logarithmic,
1518 "power_law" | "powerlaw" | "exponential" => WindShearModel::PowerLaw,
1519 "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
1520 "custom_layers" | "custom" => WindShearModel::CustomLayers,
1521 _ => WindShearModel::PowerLaw,
1522 };
1523 let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
1524
1525 crate::wind::wind_vector(self.wind.speed, self.wind.direction, 0.0) * speed_ratio
1532 + Vector3::new(0.0, self.wind.vertical_speed, 0.0)
1533 }
1534
1535 pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
1536 self.validate_for_solve()?;
1537 let mut result = if self.inputs.use_rk4 {
1538 if self.inputs.use_adaptive_rk45 {
1539 self.solve_rk45()?
1540 } else {
1541 self.solve_rk4()?
1542 }
1543 } else {
1544 self.solve_euler()?
1545 };
1546 self.apply_spin_drift(&mut result);
1547 self.validate_result_sanity(&result)?;
1548 Ok(result)
1549 }
1550
1551 fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
1557 if !self.inputs.use_enhanced_spin_drift {
1558 return;
1559 }
1560 let d_in = self.inputs.bullet_diameter / 0.0254; let m_gr = self.inputs.bullet_mass / 0.00006479891; let twist_in = self.inputs.twist_rate; if d_in <= 0.0 || m_gr <= 0.0 || twist_in <= 0.0 {
1564 return;
1565 }
1566
1567 let sg = self.effective_spin_drift_sg();
1574
1575 for p in result.points.iter_mut() {
1576 if p.time <= 0.0 {
1577 continue;
1578 }
1579 p.position.z +=
1581 crate::spin_drift::litz_drift_meters(sg, p.time, self.inputs.is_twist_right);
1582 }
1583
1584 if let Some(samples) = result.sampled_points.as_mut() {
1588 for s in samples.iter_mut() {
1589 if s.time_s <= 0.0 {
1590 continue;
1591 }
1592 s.wind_drift_m +=
1593 crate::spin_drift::litz_drift_meters(sg, s.time_s, self.inputs.is_twist_right);
1594 }
1595 }
1596 }
1597
1598 fn effective_spin_drift_sg(&self) -> f64 {
1603 let (_, _, temp_c, press_hpa) = self.resolved_atmosphere();
1604 crate::spin_drift::effective_sg_from_inputs(&self.inputs, temp_c, press_hpa)
1605 }
1606
1607 fn initial_position(&self) -> Vector3<f64> {
1613 if self.inputs.cant_angle == 0.0 {
1614 return Vector3::new(0.0, self.inputs.muzzle_height, 0.0);
1615 }
1616 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
1617 let sh = self.inputs.sight_height;
1618 Vector3::new(
1619 0.0,
1620 self.inputs.muzzle_height + sh * (1.0 - cos_c),
1621 -sh * sin_c,
1622 )
1623 }
1624
1625 fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
1626 let mut time = 0.0;
1628 let mut position = self.initial_position();
1632 let aj_components = self.aerodynamic_jump_components();
1638 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1639 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1640 let mut velocity = Vector3::new(
1641 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1645
1646 let mut points = Vec::new();
1647 let mut max_height = position.y;
1648 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
1654 let mut mach_transitions = MachTransitionTracker::default();
1655
1656 let mut angular_state = if self.inputs.enable_precession_nutation {
1658 Some(AngularState {
1659 pitch_angle: 0.001, yaw_angle: 0.001,
1661 pitch_rate: 0.0,
1662 yaw_rate: 0.0,
1663 precession_angle: 0.0,
1664 nutation_phase: 0.0,
1665 })
1666 } else {
1667 None
1668 };
1669 let mut max_yaw_angle = 0.0;
1670 let mut max_precession_angle = 0.0;
1671
1672 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
1674 self.resolved_atmosphere();
1675 let base_ratio = air_density / 1.225;
1680
1681 let wind_vector =
1687 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
1688
1689 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1692 self.inputs.bullet_model.as_deref().unwrap_or("default"),
1693 );
1694
1695 while position.x < self.max_range
1697 && position.y > self.inputs.ground_threshold
1698 && time < TRAJECTORY_TIME_LIMIT_S
1699 {
1700 let velocity_magnitude = velocity.magnitude();
1702 let kinetic_energy =
1703 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1704
1705 self.push_trajectory_point(
1706 &mut points,
1707 TrajectoryPoint {
1708 time,
1709 position,
1710 velocity_magnitude,
1711 kinetic_energy,
1712 },
1713 )?;
1714
1715 {
1718 let mach_here = if speed_of_sound > 0.0 {
1719 velocity_magnitude / speed_of_sound
1720 } else {
1721 0.0
1722 };
1723 mach_transitions.record_downward_crossings(
1724 mach_here,
1725 position.x,
1726 &mut transonic_distances,
1727 );
1728 }
1729
1730 if position.y > max_height {
1732 max_height = position.y;
1733 }
1734
1735 if self.inputs.enable_pitch_damping {
1737 let mach = velocity_magnitude / speed_of_sound;
1738
1739 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1741 transonic_mach = Some(mach);
1742 }
1743
1744 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1746
1747 if pitch_damping < min_pitch_damping {
1749 min_pitch_damping = pitch_damping;
1750 }
1751 }
1752
1753 if self.inputs.enable_precession_nutation {
1755 if let Some(ref mut state) = angular_state {
1756 let velocity_magnitude = velocity.magnitude();
1757 let params = self.precession_nutation_params(
1758 velocity_magnitude,
1759 air_density,
1760 speed_of_sound,
1761 );
1762
1763 *state = calculate_combined_angular_motion(
1765 ¶ms,
1766 state,
1767 time,
1768 self.time_step,
1769 0.001, );
1771
1772 if state.yaw_angle.abs() > max_yaw_angle {
1774 max_yaw_angle = state.yaw_angle.abs();
1775 }
1776 if state.precession_angle.abs() > max_precession_angle {
1777 max_precession_angle = state.precession_angle.abs();
1778 }
1779 }
1780 }
1781
1782 let acceleration = self.calculate_acceleration(
1789 &position,
1790 &velocity,
1791 &wind_vector,
1792 (resolved_temp_c, resolved_press_hpa, base_ratio),
1793 );
1794
1795 velocity += acceleration * self.time_step;
1797 position += velocity * self.time_step;
1798 time += self.time_step;
1799 self.validate_integration_state(&position, &velocity, time)?;
1800 }
1801
1802 let termination =
1803 self.append_terminal_endpoint(&mut points, position, velocity, time, &mut max_height)?;
1804
1805 let last_point = points.last().ok_or("No trajectory points generated")?;
1807
1808 let sampled_points = if self.inputs.enable_trajectory_sampling {
1810 let trajectory_data = TrajectoryData {
1811 times: points.iter().map(|p| p.time).collect(),
1812 positions: points.iter().map(|p| p.position).collect(),
1813 velocities: points
1814 .iter()
1815 .map(|p| {
1816 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1818 })
1819 .collect(),
1820 transonic_distances, };
1822
1823 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1828 let outputs = TrajectoryOutputs {
1829 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
1831 time_of_flight_s: last_point.time,
1832 max_ord_dist_horiz_m: max_height,
1833 sight_height_m: sight_position_m,
1834 };
1835
1836 let samples = sample_trajectory(
1838 &trajectory_data,
1839 &outputs,
1840 self.inputs.sample_interval,
1841 self.inputs.bullet_mass,
1842 )?;
1843 Some(samples)
1844 } else {
1845 None
1846 };
1847
1848 Ok(TrajectoryResult {
1849 max_range: last_point.position.x, max_height,
1851 time_of_flight: last_point.time,
1852 impact_velocity: last_point.velocity_magnitude,
1853 impact_energy: last_point.kinetic_energy,
1854 projectile_mass_kg: self.inputs.bullet_mass,
1855 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
1856 station_speed_of_sound_mps: speed_of_sound,
1857 termination,
1858 points,
1859 sampled_points,
1860 min_pitch_damping: if self.inputs.enable_pitch_damping {
1861 Some(min_pitch_damping)
1862 } else {
1863 None
1864 },
1865 transonic_mach,
1866 angular_state,
1867 max_yaw_angle: if self.inputs.enable_precession_nutation {
1868 Some(max_yaw_angle)
1869 } else {
1870 None
1871 },
1872 max_precession_angle: if self.inputs.enable_precession_nutation {
1873 Some(max_precession_angle)
1874 } else {
1875 None
1876 },
1877 aerodynamic_jump: aj_components,
1878 })
1879 }
1880
1881 fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
1882 let mut time = 0.0;
1884 let mut position = self.initial_position();
1889
1890 let aj_components = self.aerodynamic_jump_components();
1896 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1897 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1898 let mut velocity = Vector3::new(
1899 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1903
1904 let mut points = Vec::new();
1905 let mut max_height = position.y;
1906 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
1912 let mut mach_transitions = MachTransitionTracker::default();
1913
1914 let mut angular_state = if self.inputs.enable_precession_nutation {
1916 Some(AngularState {
1917 pitch_angle: 0.001, yaw_angle: 0.001,
1919 pitch_rate: 0.0,
1920 yaw_rate: 0.0,
1921 precession_angle: 0.0,
1922 nutation_phase: 0.0,
1923 })
1924 } else {
1925 None
1926 };
1927 let mut max_yaw_angle = 0.0;
1928 let mut max_precession_angle = 0.0;
1929
1930 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
1932 self.resolved_atmosphere();
1933 let base_ratio = air_density / 1.225;
1938
1939 let wind_vector =
1945 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
1946
1947 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1950 self.inputs.bullet_model.as_deref().unwrap_or("default"),
1951 );
1952
1953 while position.x < self.max_range
1955 && position.y > self.inputs.ground_threshold
1956 && time < TRAJECTORY_TIME_LIMIT_S
1957 {
1958 let velocity_magnitude = velocity.magnitude();
1960 let kinetic_energy =
1961 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1962
1963 self.push_trajectory_point(
1964 &mut points,
1965 TrajectoryPoint {
1966 time,
1967 position,
1968 velocity_magnitude,
1969 kinetic_energy,
1970 },
1971 )?;
1972
1973 {
1976 let mach_here = if speed_of_sound > 0.0 {
1977 velocity_magnitude / speed_of_sound
1978 } else {
1979 0.0
1980 };
1981 mach_transitions.record_downward_crossings(
1982 mach_here,
1983 position.x,
1984 &mut transonic_distances,
1985 );
1986 }
1987
1988 if position.y > max_height {
1989 max_height = position.y;
1990 }
1991
1992 if self.inputs.enable_pitch_damping {
1994 let mach = velocity_magnitude / speed_of_sound;
1995
1996 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1998 transonic_mach = Some(mach);
1999 }
2000
2001 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2003
2004 if pitch_damping < min_pitch_damping {
2006 min_pitch_damping = pitch_damping;
2007 }
2008 }
2009
2010 if self.inputs.enable_precession_nutation {
2012 if let Some(ref mut state) = angular_state {
2013 let velocity_magnitude = velocity.magnitude();
2014 let params = self.precession_nutation_params(
2015 velocity_magnitude,
2016 air_density,
2017 speed_of_sound,
2018 );
2019
2020 *state = calculate_combined_angular_motion(
2022 ¶ms,
2023 state,
2024 time,
2025 self.time_step,
2026 0.001, );
2028
2029 if state.yaw_angle.abs() > max_yaw_angle {
2031 max_yaw_angle = state.yaw_angle.abs();
2032 }
2033 if state.precession_angle.abs() > max_precession_angle {
2034 max_precession_angle = state.precession_angle.abs();
2035 }
2036 }
2037 }
2038
2039 let dt = self.time_step;
2041
2042 let acc1 = self.calculate_acceleration(
2044 &position,
2045 &velocity,
2046 &wind_vector,
2047 (resolved_temp_c, resolved_press_hpa, base_ratio),
2048 );
2049
2050 let pos2 = position + velocity * (dt * 0.5);
2052 let vel2 = velocity + acc1 * (dt * 0.5);
2053 let acc2 = self.calculate_acceleration(
2054 &pos2,
2055 &vel2,
2056 &wind_vector,
2057 (resolved_temp_c, resolved_press_hpa, base_ratio),
2058 );
2059
2060 let pos3 = position + vel2 * (dt * 0.5);
2062 let vel3 = velocity + acc2 * (dt * 0.5);
2063 let acc3 = self.calculate_acceleration(
2064 &pos3,
2065 &vel3,
2066 &wind_vector,
2067 (resolved_temp_c, resolved_press_hpa, base_ratio),
2068 );
2069
2070 let pos4 = position + vel3 * dt;
2072 let vel4 = velocity + acc3 * dt;
2073 let acc4 = self.calculate_acceleration(
2074 &pos4,
2075 &vel4,
2076 &wind_vector,
2077 (resolved_temp_c, resolved_press_hpa, base_ratio),
2078 );
2079
2080 position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
2082 velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
2083 time += dt;
2084 self.validate_integration_state(&position, &velocity, time)?;
2085 }
2086
2087 let termination =
2088 self.append_terminal_endpoint(&mut points, position, velocity, time, &mut max_height)?;
2089
2090 let last_point = points.last().ok_or("No trajectory points generated")?;
2092
2093 let sampled_points = if self.inputs.enable_trajectory_sampling {
2095 let trajectory_data = TrajectoryData {
2096 times: points.iter().map(|p| p.time).collect(),
2097 positions: points.iter().map(|p| p.position).collect(),
2098 velocities: points
2099 .iter()
2100 .map(|p| {
2101 Vector3::new(0.0, 0.0, p.velocity_magnitude)
2103 })
2104 .collect(),
2105 transonic_distances, };
2107
2108 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
2113 let outputs = TrajectoryOutputs {
2114 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
2116 time_of_flight_s: last_point.time,
2117 max_ord_dist_horiz_m: max_height,
2118 sight_height_m: sight_position_m,
2119 };
2120
2121 let samples = sample_trajectory(
2123 &trajectory_data,
2124 &outputs,
2125 self.inputs.sample_interval,
2126 self.inputs.bullet_mass,
2127 )?;
2128 Some(samples)
2129 } else {
2130 None
2131 };
2132
2133 Ok(TrajectoryResult {
2134 max_range: last_point.position.x, max_height,
2136 time_of_flight: last_point.time,
2137 impact_velocity: last_point.velocity_magnitude,
2138 impact_energy: last_point.kinetic_energy,
2139 projectile_mass_kg: self.inputs.bullet_mass,
2140 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
2141 station_speed_of_sound_mps: speed_of_sound,
2142 termination,
2143 points,
2144 sampled_points,
2145 min_pitch_damping: if self.inputs.enable_pitch_damping {
2146 Some(min_pitch_damping)
2147 } else {
2148 None
2149 },
2150 transonic_mach,
2151 angular_state,
2152 max_yaw_angle: if self.inputs.enable_precession_nutation {
2153 Some(max_yaw_angle)
2154 } else {
2155 None
2156 },
2157 max_precession_angle: if self.inputs.enable_precession_nutation {
2158 Some(max_precession_angle)
2159 } else {
2160 None
2161 },
2162 aerodynamic_jump: aj_components,
2163 })
2164 }
2165
2166 fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
2167 let mut time = 0.0;
2169 let mut position = self.initial_position();
2173
2174 let aj_components = self.aerodynamic_jump_components();
2180 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
2181 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
2182 let mut velocity = Vector3::new(
2183 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
2187
2188 let mut points = Vec::new();
2189 let mut max_height = position.y;
2190 let mut dt = 0.001; let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2195 self.resolved_atmosphere();
2196 let base_ratio = air_density / 1.225;
2201 let wind_vector =
2206 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
2207
2208 let mut transonic_distances: Vec<f64> = Vec::new();
2210 let mut mach_transitions = MachTransitionTracker::default();
2211
2212 let mut min_pitch_damping = f64::INFINITY;
2217 let mut transonic_mach: Option<f64> = None;
2218 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2219 self.inputs.bullet_model.as_deref().unwrap_or("default"),
2220 );
2221 let mut angular_state = if self.inputs.enable_precession_nutation {
2222 Some(AngularState {
2223 pitch_angle: 0.001,
2224 yaw_angle: 0.001,
2225 pitch_rate: 0.0,
2226 yaw_rate: 0.0,
2227 precession_angle: 0.0,
2228 nutation_phase: 0.0,
2229 })
2230 } else {
2231 None
2232 };
2233 let mut max_yaw_angle = 0.0;
2234 let mut max_precession_angle = 0.0;
2235
2236 while position.x < self.max_range
2237 && position.y > self.inputs.ground_threshold
2238 && time < TRAJECTORY_TIME_LIMIT_S
2239 {
2240 let velocity_magnitude = velocity.magnitude();
2242 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
2243
2244 self.push_trajectory_point(
2245 &mut points,
2246 TrajectoryPoint {
2247 time,
2248 position,
2249 velocity_magnitude,
2250 kinetic_energy,
2251 },
2252 )?;
2253
2254 {
2257 let mach_here = if speed_of_sound > 0.0 {
2258 velocity_magnitude / speed_of_sound
2259 } else {
2260 0.0
2261 };
2262 mach_transitions.record_downward_crossings(
2263 mach_here,
2264 position.x,
2265 &mut transonic_distances,
2266 );
2267 }
2268
2269 if position.y > max_height {
2270 max_height = position.y;
2271 }
2272
2273 if self.inputs.enable_pitch_damping {
2276 let mach = velocity_magnitude / speed_of_sound;
2277 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2278 transonic_mach = Some(mach);
2279 }
2280 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2281 if pitch_damping < min_pitch_damping {
2282 min_pitch_damping = pitch_damping;
2283 }
2284 }
2285
2286 let accepted_step = self.adaptive_rk45_step(
2289 &position,
2290 &velocity,
2291 dt,
2292 &wind_vector,
2293 (resolved_temp_c, resolved_press_hpa, base_ratio),
2294 );
2295 debug_assert!(
2296 accepted_step.error <= RK45_TOLERANCE || accepted_step.used_dt <= RK45_MIN_DT
2297 );
2298
2299 if self.inputs.enable_precession_nutation {
2303 if let Some(ref mut state) = angular_state {
2304 let params = self.precession_nutation_params(
2305 velocity_magnitude,
2306 air_density,
2307 speed_of_sound,
2308 );
2309
2310 *state = calculate_combined_angular_motion(
2311 ¶ms,
2312 state,
2313 time,
2314 accepted_step.used_dt,
2315 0.001,
2316 );
2317
2318 if state.yaw_angle.abs() > max_yaw_angle {
2319 max_yaw_angle = state.yaw_angle.abs();
2320 }
2321 if state.precession_angle.abs() > max_precession_angle {
2322 max_precession_angle = state.precession_angle.abs();
2323 }
2324 }
2325 }
2326
2327 position = accepted_step.position;
2328 velocity = accepted_step.velocity;
2329 time += accepted_step.used_dt;
2330 self.validate_integration_state(&position, &velocity, time)?;
2331
2332 dt = accepted_step.next_dt;
2334 }
2335
2336 if points.is_empty() {
2338 return Err(BallisticsError::from("No trajectory points calculated"));
2339 }
2340
2341 let termination =
2343 self.append_terminal_endpoint(&mut points, position, velocity, time, &mut max_height)?;
2344
2345 let last_point = points.last().unwrap();
2346
2347 let sampled_points = if self.inputs.enable_trajectory_sampling {
2349 let trajectory_data = TrajectoryData {
2351 times: points.iter().map(|p| p.time).collect(),
2352 positions: points.iter().map(|p| p.position).collect(),
2353 velocities: points
2354 .iter()
2355 .map(|p| {
2356 Vector3::new(0.0, 0.0, p.velocity_magnitude)
2358 })
2359 .collect(),
2360 transonic_distances, };
2362
2363 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
2368 let outputs = TrajectoryOutputs {
2369 target_distance_horiz_m: last_point.position.x,
2370 target_vertical_height_m: sight_position_m,
2371 time_of_flight_s: last_point.time,
2372 max_ord_dist_horiz_m: max_height,
2373 sight_height_m: sight_position_m,
2374 };
2375
2376 let samples = sample_trajectory(
2377 &trajectory_data,
2378 &outputs,
2379 self.inputs.sample_interval,
2380 self.inputs.bullet_mass,
2381 )?;
2382 Some(samples)
2383 } else {
2384 None
2385 };
2386
2387 Ok(TrajectoryResult {
2388 max_range: last_point.position.x, max_height,
2390 time_of_flight: last_point.time,
2391 impact_velocity: last_point.velocity_magnitude,
2392 impact_energy: last_point.kinetic_energy,
2393 projectile_mass_kg: self.inputs.bullet_mass,
2394 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
2395 station_speed_of_sound_mps: speed_of_sound,
2396 termination,
2397 points,
2398 sampled_points,
2399 min_pitch_damping: if self.inputs.enable_pitch_damping {
2400 Some(min_pitch_damping)
2401 } else {
2402 None
2403 },
2404 transonic_mach,
2405 angular_state,
2406 max_yaw_angle: if self.inputs.enable_precession_nutation {
2407 Some(max_yaw_angle)
2408 } else {
2409 None
2410 },
2411 max_precession_angle: if self.inputs.enable_precession_nutation {
2412 Some(max_precession_angle)
2413 } else {
2414 None
2415 },
2416 aerodynamic_jump: aj_components,
2417 })
2418 }
2419
2420 fn adaptive_rk45_step(
2421 &self,
2422 position: &Vector3<f64>,
2423 velocity: &Vector3<f64>,
2424 initial_dt: f64,
2425 wind_vector: &Vector3<f64>,
2426 resolved_atmo: (f64, f64, f64),
2427 ) -> Rk45AcceptedStep {
2428 let mut trial_dt = initial_dt;
2429
2430 loop {
2431 let trial = self.rk45_step(
2432 position,
2433 velocity,
2434 trial_dt,
2435 wind_vector,
2436 RK45_TOLERANCE,
2437 resolved_atmo,
2438 );
2439 let next_dt = if trial.suggested_dt.is_finite() {
2444 (RK45_SAFETY_FACTOR * trial.suggested_dt).clamp(RK45_MIN_DT, RK45_MAX_DT)
2445 } else {
2446 RK45_MIN_DT
2447 };
2448
2449 if trial.error <= RK45_TOLERANCE || trial_dt <= RK45_MIN_DT {
2450 return Rk45AcceptedStep {
2451 position: trial.position,
2452 velocity: trial.velocity,
2453 used_dt: trial_dt,
2454 next_dt,
2455 error: trial.error,
2456 };
2457 }
2458
2459 trial_dt = next_dt;
2460 }
2461 }
2462
2463 fn rk45_step(
2464 &self,
2465 position: &Vector3<f64>,
2466 velocity: &Vector3<f64>,
2467 dt: f64,
2468 wind_vector: &Vector3<f64>,
2469 tolerance: f64,
2470 resolved_atmo: (f64, f64, f64), ) -> Rk45Trial {
2472 const A21: f64 = 1.0 / 5.0;
2474 const A31: f64 = 3.0 / 40.0;
2475 const A32: f64 = 9.0 / 40.0;
2476 const A41: f64 = 44.0 / 45.0;
2477 const A42: f64 = -56.0 / 15.0;
2478 const A43: f64 = 32.0 / 9.0;
2479 const A51: f64 = 19372.0 / 6561.0;
2480 const A52: f64 = -25360.0 / 2187.0;
2481 const A53: f64 = 64448.0 / 6561.0;
2482 const A54: f64 = -212.0 / 729.0;
2483 const A61: f64 = 9017.0 / 3168.0;
2484 const A62: f64 = -355.0 / 33.0;
2485 const A63: f64 = 46732.0 / 5247.0;
2486 const A64: f64 = 49.0 / 176.0;
2487 const A65: f64 = -5103.0 / 18656.0;
2488 const A71: f64 = 35.0 / 384.0;
2489 const A73: f64 = 500.0 / 1113.0;
2490 const A74: f64 = 125.0 / 192.0;
2491 const A75: f64 = -2187.0 / 6784.0;
2492 const A76: f64 = 11.0 / 84.0;
2493
2494 const B1: f64 = 35.0 / 384.0;
2496 const B3: f64 = 500.0 / 1113.0;
2497 const B4: f64 = 125.0 / 192.0;
2498 const B5: f64 = -2187.0 / 6784.0;
2499 const B6: f64 = 11.0 / 84.0;
2500
2501 const B1_ERR: f64 = 5179.0 / 57600.0;
2503 const B3_ERR: f64 = 7571.0 / 16695.0;
2504 const B4_ERR: f64 = 393.0 / 640.0;
2505 const B5_ERR: f64 = -92097.0 / 339200.0;
2506 const B6_ERR: f64 = 187.0 / 2100.0;
2507 const B7_ERR: f64 = 1.0 / 40.0;
2508
2509 let k1_v = self.calculate_acceleration(position, velocity, wind_vector, resolved_atmo);
2511 let k1_p = *velocity;
2512
2513 let p2 = position + dt * A21 * k1_p;
2514 let v2 = velocity + dt * A21 * k1_v;
2515 let k2_v = self.calculate_acceleration(&p2, &v2, wind_vector, resolved_atmo);
2516 let k2_p = v2;
2517
2518 let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
2519 let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
2520 let k3_v = self.calculate_acceleration(&p3, &v3, wind_vector, resolved_atmo);
2521 let k3_p = v3;
2522
2523 let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
2524 let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
2525 let k4_v = self.calculate_acceleration(&p4, &v4, wind_vector, resolved_atmo);
2526 let k4_p = v4;
2527
2528 let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
2529 let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
2530 let k5_v = self.calculate_acceleration(&p5, &v5, wind_vector, resolved_atmo);
2531 let k5_p = v5;
2532
2533 let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
2534 let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
2535 let k6_v = self.calculate_acceleration(&p6, &v6, wind_vector, resolved_atmo);
2536 let k6_p = v6;
2537
2538 let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
2539 let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
2540 let k7_v = self.calculate_acceleration(&p7, &v7, wind_vector, resolved_atmo);
2541 let k7_p = v7;
2542
2543 let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
2545 let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
2546
2547 let pos_err = position
2549 + dt * (B1_ERR * k1_p
2550 + B3_ERR * k3_p
2551 + B4_ERR * k4_p
2552 + B5_ERR * k5_p
2553 + B6_ERR * k6_p
2554 + B7_ERR * k7_p);
2555 let vel_err = velocity
2556 + dt * (B1_ERR * k1_v
2557 + B3_ERR * k3_v
2558 + B4_ERR * k4_v
2559 + B5_ERR * k5_v
2560 + B6_ERR * k6_v
2561 + B7_ERR * k7_v);
2562
2563 let error = cli_rk45_error_norm(position, velocity, &new_pos, &new_vel, &pos_err, &vel_err);
2565
2566 let dt_new = if error < tolerance {
2568 dt * (tolerance / error).powf(0.2).min(2.0)
2569 } else {
2570 dt * (tolerance / error).powf(0.25).max(0.1)
2571 };
2572
2573 Rk45Trial {
2574 position: new_pos,
2575 velocity: new_vel,
2576 suggested_dt: dt_new,
2577 error,
2578 }
2579 }
2580
2581 fn apply_cluster_bc_correction(&self, base_bc: f64, velocity_fps: f64) -> f64 {
2582 if let Some(ref cluster_bc) = self.cluster_bc {
2583 cluster_bc.apply_correction_for_drag_model(
2584 base_bc,
2585 self.inputs.caliber_inches,
2586 self.inputs.weight_grains,
2587 velocity_fps,
2588 self.inputs.bc_type,
2589 )
2590 } else {
2591 base_bc
2592 }
2593 }
2594
2595 fn calculate_acceleration(
2596 &self,
2597 position: &Vector3<f64>,
2598 velocity: &Vector3<f64>,
2599 wind_vector: &Vector3<f64>,
2600 resolved_atmo: (f64, f64, f64), ) -> Vector3<f64> {
2602 let actual_wind = if let Some(ref sock) = self.wind_sock {
2608 sock.vector_for_range_stateless(position.x)
2609 } else if self.inputs.enable_wind_shear {
2610 self.get_wind_at_altitude(position.y)
2611 } else {
2612 *wind_vector
2613 };
2614 let actual_wind =
2615 crate::derivatives::level_vector_to_shot_frame(actual_wind, self.inputs.shooting_angle);
2616
2617 let relative_velocity = velocity - actual_wind;
2618 let velocity_magnitude = relative_velocity.magnitude();
2619
2620 if velocity_magnitude < 0.001 {
2621 return self.gravity_acceleration();
2622 }
2623
2624 let (base_temp_c, base_press_hpa, station_ratio) = resolved_atmo;
2635
2636 let (drag_base_temp_c, drag_base_press_hpa, drag_base_ratio, drag_humidity_percent) =
2644 if let Some(ref sock) = self.atmo_sock {
2645 let (zone_temp_c, zone_press_hpa, zone_humidity) = sock.atmo_for_range(position.x);
2646 let zone_base_ratio = crate::atmosphere::calculate_air_density_cimp(
2647 zone_temp_c,
2648 zone_press_hpa,
2649 zone_humidity,
2650 ) / 1.225;
2651 (zone_temp_c, zone_press_hpa, zone_base_ratio, zone_humidity)
2652 } else {
2653 (
2654 base_temp_c,
2655 base_press_hpa,
2656 station_ratio,
2657 self.atmosphere.humidity,
2658 )
2659 };
2660 let local_alt = crate::atmosphere::shot_frame_altitude(
2661 self.atmosphere.altitude,
2662 position.x,
2663 position.y,
2664 self.inputs.shooting_angle,
2665 );
2666 let (air_density, speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
2667 local_alt,
2668 self.atmosphere.altitude,
2669 drag_base_temp_c,
2670 drag_base_press_hpa,
2671 drag_base_ratio,
2672 drag_humidity_percent,
2673 );
2674
2675 let cd = self.calculate_drag_coefficient(velocity_magnitude, speed_of_sound);
2677
2678 let velocity_fps = velocity_magnitude * 3.28084;
2680
2681 let (base_bc, bc_from_segments) = if let Some(segments) = self
2686 .inputs
2687 .bc_segments_data
2688 .as_ref()
2689 .filter(|segments| self.inputs.use_bc_segments && !segments.is_empty())
2690 {
2691 (
2693 crate::bc_estimation::velocity_segment_bc(
2694 velocity_fps,
2695 segments,
2696 self.inputs.bc_value,
2697 ),
2698 true,
2699 )
2700 } else if let Some(segments) = self
2701 .inputs
2702 .bc_segments
2703 .as_ref()
2704 .filter(|segments| !segments.is_empty())
2705 {
2706 (
2707 crate::derivatives::interpolated_bc(
2708 velocity_magnitude / speed_of_sound,
2709 segments,
2710 Some(&self.inputs),
2711 ),
2712 true,
2713 )
2714 } else {
2715 (self.inputs.bc_value, false)
2716 };
2717
2718 let effective_bc = if bc_from_segments {
2723 base_bc
2724 } else {
2725 self.apply_cluster_bc_correction(base_bc, velocity_fps)
2726 };
2727 let effective_bc = effective_bc.max(1e-6);
2730
2731 let retard_denom = if self.inputs.custom_drag_table.is_some() {
2736 self.inputs.custom_drag_denominator(effective_bc)
2737 } else {
2738 effective_bc
2739 };
2740
2741 let cd_to_retard = crate::constants::CD_TO_RETARD;
2746 let standard_factor = cd * cd_to_retard;
2747 let density_scale = air_density / 1.225; let a_drag_ft_s2 =
2751 (velocity_fps * velocity_fps) * standard_factor * density_scale / retard_denom;
2752 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
2756
2757 let mut accel = drag_acceleration + self.gravity_acceleration();
2760
2761 if self.inputs.enable_coriolis {
2764 if let Some(lat_deg) = self.inputs.latitude {
2765 let omega_earth = 7.2921159e-5_f64; let lat = lat_deg.to_radians();
2767 let az = self.inputs.shot_azimuth; let omega = Vector3::new(
2774 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), );
2778 let omega = crate::derivatives::level_vector_to_shot_frame(
2779 omega,
2780 self.inputs.shooting_angle,
2781 );
2782 accel += -2.0 * omega.cross(velocity);
2787 }
2788 }
2789
2790 if self.inputs.enable_magnus
2797 && !self.inputs.use_enhanced_spin_drift
2798 && self.inputs.bullet_diameter > 0.0
2799 && self.inputs.twist_rate > 0.0
2800 {
2801 let diameter_m = self.inputs.bullet_diameter;
2802 let (spin_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
2803 self.inputs.muzzle_velocity,
2804 velocity_magnitude,
2805 self.inputs.twist_rate,
2806 diameter_m,
2807 );
2808 let mach = velocity_magnitude / speed_of_sound;
2810
2811 let d_in = self.inputs.bullet_diameter / 0.0254;
2813 let m_gr = self.inputs.bullet_mass / 0.00006479891;
2814 let l_in = if self.inputs.bullet_length > 0.0 {
2815 self.inputs.bullet_length / 0.0254
2816 } else {
2817 let est_m = crate::stability::estimate_bullet_length_m(
2819 self.inputs.bullet_diameter,
2820 self.inputs.bullet_mass,
2821 );
2822 if est_m > 0.0 {
2823 est_m / 0.0254
2824 } else {
2825 4.5 * d_in
2826 }
2827 };
2828 let sg = crate::spin_drift::calculate_dynamic_stability(
2832 m_gr,
2833 velocity_magnitude,
2834 spin_rad_s,
2835 d_in,
2836 l_in,
2837 air_density,
2838 );
2839
2840 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
2842 sg,
2843 velocity_magnitude,
2844 spin_rad_s,
2845 0.0, 0.0, air_density,
2848 d_in,
2849 l_in,
2850 m_gr,
2851 mach,
2852 "match",
2853 false,
2854 );
2855
2856 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
2858 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
2859 let magnus_force = 0.5
2860 * air_density
2861 * velocity_magnitude.powi(2)
2862 * area
2863 * c_np
2864 * spin_param
2865 * yaw_rad.sin();
2866
2867 if magnus_force.abs() > 1e-12 {
2871 if let Some(dir) = crate::derivatives::yaw_of_repose_magnus_direction(
2872 relative_velocity,
2873 self.gravity_acceleration(),
2874 self.inputs.is_twist_right,
2875 ) {
2876 accel += (magnus_force / self.inputs.bullet_mass) * dir;
2877 }
2878 }
2879 }
2880
2881 accel
2882 }
2883
2884 fn calculate_drag_coefficient(&self, velocity: f64, speed_of_sound: f64) -> f64 {
2885 let mach = velocity / speed_of_sound;
2886
2887 if let Some(ref table) = self.inputs.custom_drag_table {
2891 return table.interpolate(mach);
2892 }
2893
2894 crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type)
2897 }
2898}
2899
2900#[derive(Debug, Clone)]
2902pub struct MonteCarloParams {
2903 pub num_simulations: usize,
2904 pub velocity_std_dev: f64,
2905 pub angle_std_dev: f64,
2906 pub bc_std_dev: f64,
2907 pub wind_speed_std_dev: f64,
2908 pub target_distance: Option<f64>,
2909 pub base_wind_speed: f64,
2910 pub base_wind_direction: f64,
2911 pub azimuth_std_dev: f64, }
2913
2914impl Default for MonteCarloParams {
2915 fn default() -> Self {
2916 Self {
2917 num_simulations: 1000,
2918 velocity_std_dev: 1.0,
2919 angle_std_dev: 0.001,
2920 bc_std_dev: 0.01,
2921 wind_speed_std_dev: 1.0,
2922 target_distance: None,
2923 base_wind_speed: 0.0,
2924 base_wind_direction: 0.0,
2925 azimuth_std_dev: 0.001, }
2927 }
2928}
2929
2930#[derive(Debug, Clone)]
2932pub struct MonteCarloResults {
2933 pub ranges: Vec<f64>,
2934 pub impact_velocities: Vec<f64>,
2935 pub impact_positions: Vec<Vector3<f64>>,
2941}
2942
2943pub const DEFAULT_HIT_RADIUS_M: f64 = 0.3;
2946
2947pub const TARGET_NOT_REACHED_SENTINEL_M: f64 = -1.0e9;
2953
2954impl MonteCarloResults {
2955 pub fn position_reached_target(position: &Vector3<f64>) -> bool {
2957 position.iter().all(|component| component.is_finite())
2958 && position.y != TARGET_NOT_REACHED_SENTINEL_M
2959 }
2960
2961 pub fn target_arrival_count(&self) -> usize {
2963 self.impact_positions
2964 .iter()
2965 .filter(|position| Self::position_reached_target(position))
2966 .count()
2967 }
2968
2969 pub fn target_shortfall_fraction(&self) -> f64 {
2972 if self.impact_positions.is_empty() {
2973 return 0.0;
2974 }
2975 (self.impact_positions.len() - self.target_arrival_count()) as f64
2976 / self.impact_positions.len() as f64
2977 }
2978
2979 pub fn target_plane_cep(&self) -> Option<f64> {
2985 let mut radial_misses: Vec<f64> = self
2986 .impact_positions
2987 .iter()
2988 .filter(|position| Self::position_reached_target(position))
2989 .map(Vector3::norm)
2990 .filter(|miss| miss.is_finite())
2991 .collect();
2992 radial_misses.sort_by(f64::total_cmp);
2993 if radial_misses.is_empty() {
2994 None
2995 } else {
2996 Some(radial_misses[radial_misses.len() / 2])
2997 }
2998 }
2999
3000 pub fn hit_probability(&self, hit_radius_m: f64) -> f64 {
3009 if self.impact_positions.is_empty() {
3010 return 0.0;
3011 }
3012 let hits = self
3013 .impact_positions
3014 .iter()
3015 .filter(|position| {
3016 Self::position_reached_target(position) && position.norm() < hit_radius_m
3017 })
3018 .count();
3019 hits as f64 / self.impact_positions.len() as f64
3020 }
3021
3022 pub fn rect_hit_probability(&self, width_m: f64, height_m: f64) -> f64 {
3034 let dimensions_invalid = width_m.is_nan()
3035 || width_m <= 0.0
3036 || height_m.is_nan()
3037 || height_m <= 0.0;
3038 if self.impact_positions.is_empty() || dimensions_invalid {
3039 return 0.0;
3040 }
3041 let half_width = width_m / 2.0;
3042 let half_height = height_m / 2.0;
3043 let hits = self
3044 .impact_positions
3045 .iter()
3046 .filter(|position| {
3047 Self::position_reached_target(position)
3048 && position.z.abs() <= half_width
3049 && position.y.abs() <= half_height
3050 })
3051 .count();
3052 hits as f64 / self.impact_positions.len() as f64
3053 }
3054}
3055
3056fn wind_from_signed_speed_sample(
3057 signed_speed: f64,
3058 sampled_direction: f64,
3059 vertical_speed: f64,
3060) -> WindConditions {
3061 if signed_speed < 0.0 {
3066 WindConditions {
3067 speed: -signed_speed,
3068 direction: sampled_direction + std::f64::consts::PI,
3069 vertical_speed,
3070 }
3071 } else {
3072 WindConditions {
3073 speed: signed_speed,
3074 direction: sampled_direction,
3075 vertical_speed,
3076 }
3077 }
3078}
3079
3080struct MonteCarloWindSampler {
3081 speed: rand_distr::Normal<f64>,
3082 direction: rand_distr::Normal<f64>,
3083 vertical_speed: f64,
3085}
3086
3087impl MonteCarloWindSampler {
3088 fn new(
3089 base_wind: &WindConditions,
3090 wind_speed_std_dev: f64,
3091 wind_direction_std_dev: f64,
3092 ) -> Result<Self, BallisticsError> {
3093 use rand_distr::Normal;
3094
3095 if !wind_direction_std_dev.is_finite() || wind_direction_std_dev < 0.0 {
3096 return Err("Wind direction standard deviation must be finite and non-negative".into());
3097 }
3098
3099 let speed = Normal::new(base_wind.speed, wind_speed_std_dev)
3100 .map_err(|e| format!("Invalid wind speed distribution: {e}"))?;
3101 let direction = Normal::new(base_wind.direction, wind_direction_std_dev)
3102 .map_err(|e| format!("Invalid wind direction distribution: {e}"))?;
3103 Ok(Self { speed, direction, vertical_speed: base_wind.vertical_speed })
3104 }
3105
3106 fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> WindConditions {
3107 use rand_distr::Distribution;
3108
3109 wind_from_signed_speed_sample(
3110 self.speed.sample(rng),
3111 self.direction.sample(rng),
3112 self.vertical_speed,
3113 )
3114 }
3115}
3116
3117pub fn run_monte_carlo(
3119 base_inputs: BallisticInputs,
3120 params: MonteCarloParams,
3121) -> Result<MonteCarloResults, BallisticsError> {
3122 run_monte_carlo_with_direction_std_dev(base_inputs, params, 0.0)
3123}
3124
3125pub fn run_monte_carlo_with_direction_std_dev(
3130 base_inputs: BallisticInputs,
3131 params: MonteCarloParams,
3132 wind_direction_std_dev: f64,
3133) -> Result<MonteCarloResults, BallisticsError> {
3134 let base_wind = WindConditions {
3135 speed: params.base_wind_speed,
3136 direction: params.base_wind_direction,
3137 vertical_speed: 0.0,
3138 };
3139 run_monte_carlo_with_wind_and_direction_std_dev(
3140 base_inputs,
3141 base_wind,
3142 params,
3143 wind_direction_std_dev,
3144 )
3145}
3146
3147pub fn run_monte_carlo_with_wind(
3149 base_inputs: BallisticInputs,
3150 base_wind: WindConditions,
3151 params: MonteCarloParams,
3152) -> Result<MonteCarloResults, BallisticsError> {
3153 run_monte_carlo_with_wind_and_direction_std_dev(base_inputs, base_wind, params, 0.0)
3154}
3155
3156pub fn run_monte_carlo_with_wind_and_direction_std_dev(
3161 base_inputs: BallisticInputs,
3162 base_wind: WindConditions,
3163 params: MonteCarloParams,
3164 wind_direction_std_dev: f64,
3165) -> Result<MonteCarloResults, BallisticsError> {
3166 let mut rng = rand::rng();
3167 run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
3168 base_inputs,
3169 base_wind,
3170 params,
3171 wind_direction_std_dev,
3172 &mut rng,
3173 )
3174}
3175
3176pub fn run_monte_carlo_with_wind_and_direction_std_dev_seeded(
3183 base_inputs: BallisticInputs,
3184 base_wind: WindConditions,
3185 params: MonteCarloParams,
3186 wind_direction_std_dev: f64,
3187 seed: u64,
3188) -> Result<MonteCarloResults, BallisticsError> {
3189 use rand::{rngs::StdRng, SeedableRng};
3190 let mut rng = StdRng::seed_from_u64(seed);
3191 run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
3192 base_inputs,
3193 base_wind,
3194 params,
3195 wind_direction_std_dev,
3196 &mut rng,
3197 )
3198}
3199
3200fn run_monte_carlo_with_wind_and_direction_std_dev_using_rng<R: rand::Rng + ?Sized>(
3201 base_inputs: BallisticInputs,
3202 base_wind: WindConditions,
3203 params: MonteCarloParams,
3204 wind_direction_std_dev: f64,
3205 rng: &mut R,
3206) -> Result<MonteCarloResults, BallisticsError> {
3207 use rand_distr::{Distribution, Normal};
3208
3209 let mut ranges = Vec::new();
3210 let mut impact_velocities = Vec::new();
3211 let mut impact_positions = Vec::new();
3212
3213 let atmosphere = AtmosphericConditions {
3214 temperature: base_inputs.temperature,
3215 pressure: base_inputs.pressure,
3216 humidity: base_inputs.humidity_percent(),
3217 altitude: base_inputs.altitude,
3218 };
3219 let target_hint = params
3220 .target_distance
3221 .unwrap_or(base_inputs.target_distance);
3222 let solver_max_range = target_hint.max(1000.0) * 2.0;
3223
3224 let mut baseline_solver =
3226 TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), atmosphere.clone());
3227 baseline_solver.set_max_range(solver_max_range);
3228 let baseline_result = baseline_solver.solve()?;
3229
3230 let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
3232
3233 let baseline_at_target = baseline_result
3235 .position_at_range(target_distance)
3236 .ok_or("Could not interpolate baseline at target distance")?;
3237
3238 let velocity_delta_dist = Normal::new(0.0, params.velocity_std_dev)
3243 .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
3244 let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
3245 .map_err(|e| format!("Invalid angle distribution: {}", e))?;
3246 let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
3247 .map_err(|e| format!("Invalid BC distribution: {}", e))?;
3248 let wind_sampler = MonteCarloWindSampler::new(
3251 &base_wind,
3252 params.wind_speed_std_dev,
3253 wind_direction_std_dev,
3254 )?;
3255 let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
3256 .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
3257
3258 for _ in 0..params.num_simulations {
3259 let mut inputs = base_inputs.clone();
3261 let muzzle_velocity_delta = velocity_delta_dist.sample(&mut *rng);
3262 inputs.muzzle_angle = angle_dist.sample(&mut *rng);
3263 inputs.bc_value = bc_dist.sample(&mut *rng).max(0.01);
3264 inputs.azimuth_angle = azimuth_dist.sample(&mut *rng); let wind = wind_sampler.sample(&mut *rng);
3268
3269 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere.clone());
3271 solver.inputs.muzzle_velocity =
3272 (solver.inputs.muzzle_velocity + muzzle_velocity_delta).max(0.0);
3273 solver.set_max_range(solver_max_range);
3274 match solver.solve() {
3275 Ok(result) => {
3276 let deviation = if result.max_range < target_distance {
3282 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)
3285 } else {
3286 let pos_at_target = match result.position_at_range(target_distance) {
3287 Some(p) => p,
3288 None => continue, };
3290 Vector3::new(
3295 0.0,
3296 pos_at_target.y - baseline_at_target.y,
3297 pos_at_target.z - baseline_at_target.z,
3298 )
3299 };
3300
3301 ranges.push(result.max_range);
3302 impact_velocities.push(result.impact_velocity);
3303 impact_positions.push(deviation);
3304 }
3305 Err(_) => {
3306 continue;
3308 }
3309 }
3310 }
3311
3312 if ranges.is_empty() {
3313 return Err("No successful simulations".into());
3314 }
3315
3316 Ok(MonteCarloResults {
3317 ranges,
3318 impact_velocities,
3319 impact_positions,
3320 })
3321}
3322
3323pub fn calculate_zero_angle(
3325 inputs: BallisticInputs,
3326 target_distance: f64,
3327 target_height: f64,
3328) -> Result<f64, BallisticsError> {
3329 calculate_zero_angle_with_conditions(
3330 inputs,
3331 target_distance,
3332 target_height,
3333 WindConditions::default(),
3334 AtmosphericConditions::default(),
3335 )
3336}
3337
3338pub fn calculate_zero_angle_with_conditions(
3339 inputs: BallisticInputs,
3340 target_distance: f64,
3341 target_height: f64,
3342 wind: WindConditions,
3343 atmosphere: AtmosphericConditions,
3344) -> Result<f64, BallisticsError> {
3345 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
3346 solver.calculate_and_set_zero_angle(target_distance, target_height)
3347}
3348
3349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3351pub enum BcFitMode {
3352 Drop,
3354 Velocity,
3357}
3358
3359#[derive(Debug, Clone, Copy)]
3361pub struct BcEstimate {
3362 pub bc: f64,
3364 pub rms_error: f64,
3366 pub drag_model: DragModel,
3368 pub mode: BcFitMode,
3370 pub at_bound: bool,
3374}
3375
3376fn fit_value_at(
3384 points: &[TrajectoryPoint],
3385 target_dist: f64,
3386 mode: BcFitMode,
3387 drop_offset: f64,
3388) -> Option<f64> {
3389 let val = |p: &TrajectoryPoint| match mode {
3390 BcFitMode::Drop => drop_offset - p.position.y,
3391 BcFitMode::Velocity => p.velocity_magnitude,
3392 };
3393 for i in 0..points.len() {
3394 if points[i].position.x >= target_dist {
3395 if i == 0 {
3396 return Some(val(&points[0]));
3397 }
3398 let p1 = &points[i - 1];
3399 let p2 = &points[i];
3400 let dx = p2.position.x - p1.position.x;
3401 if dx.abs() < 1e-9 {
3402 return Some(val(p2));
3403 }
3404 let t = (target_dist - p1.position.x) / dx;
3405 return Some(val(p1) + t * (val(p2) - val(p1)));
3406 }
3407 }
3408 None
3409}
3410
3411fn fit_residual_sse(
3412 trajectory: &[TrajectoryPoint],
3413 observations: &[(f64, f64)],
3414 mode: BcFitMode,
3415 drop_offset: f64,
3416) -> Option<f64> {
3417 if observations.is_empty() {
3418 return None;
3419 }
3420 let mut total = 0.0;
3421 for (target_dist, target_val) in observations {
3422 let value = fit_value_at(trajectory, *target_dist, mode, drop_offset)?;
3425 let error = value - target_val;
3426 total += error * error;
3427 }
3428 Some(total)
3429}
3430
3431#[allow(clippy::too_many_arguments)] pub fn estimate_bc_fit(
3449 velocity: f64,
3450 mass: f64,
3451 diameter: f64,
3452 points: &[(f64, f64)],
3453 drag_model: DragModel,
3454 mode: BcFitMode,
3455 atmosphere: AtmosphericConditions,
3456 zero_range: Option<f64>,
3457 sight_height: f64,
3458) -> Result<BcEstimate, BallisticsError> {
3459 if points.is_empty() {
3460 return Err(BallisticsError::from(
3461 "No data points provided for BC estimation.".to_string(),
3462 ));
3463 }
3464 let max_dist = points.iter().map(|(d, _)| *d).fold(0.0_f64, f64::max);
3465 let drop_offset = if zero_range.is_some() { sight_height } else { 0.0 };
3468
3469 let sse = |bc_value: f64| -> Option<f64> {
3471 let mut inputs = BallisticInputs {
3472 muzzle_velocity: velocity,
3473 bc_value,
3474 bc_type: drag_model,
3475 bullet_mass: mass,
3476 bullet_diameter: diameter,
3477 sight_height,
3478 ..Default::default()
3479 };
3480 if let Some(zr) = zero_range {
3483 let za = calculate_zero_angle_with_conditions(
3489 inputs.clone(),
3490 zr,
3491 sight_height,
3492 WindConditions::default(),
3493 atmosphere.clone(),
3494 )
3495 .ok()?;
3496 inputs.muzzle_angle = za;
3497 }
3498 let mut solver =
3499 TrajectorySolver::new(inputs, WindConditions::default(), atmosphere.clone());
3500 solver.set_max_range(max_dist * 1.5);
3501 let result = solver.solve().ok()?;
3502 fit_residual_sse(&result.points, points, mode, drop_offset)
3503 };
3504
3505 let (bc_min, bc_max) = match drag_model {
3509 DragModel::G7 => (0.05, 0.70),
3510 _ => (0.10, 1.20),
3511 };
3512
3513 let mut best_bc = f64::NAN;
3515 let mut best_sse = f64::MAX;
3516 let mut bc = bc_min;
3517 while bc <= bc_max + 1e-9 {
3518 if let Some(s) = sse(bc) {
3519 if s < best_sse {
3520 best_sse = s;
3521 best_bc = bc;
3522 }
3523 }
3524 bc += 0.01;
3525 }
3526 if !best_bc.is_finite() {
3527 return Err(BallisticsError::from(
3528 "Unable to estimate BC from provided data. Check that the values and units are correct."
3529 .to_string(),
3530 ));
3531 }
3532
3533 let lo = (best_bc - 0.01).max(bc_min);
3535 let hi = (best_bc + 0.01).min(bc_max);
3536 let mut bc = lo;
3537 while bc <= hi + 1e-9 {
3538 if let Some(s) = sse(bc) {
3539 if s < best_sse {
3540 best_sse = s;
3541 best_bc = bc;
3542 }
3543 }
3544 bc += 0.001;
3545 }
3546
3547 let at_bound = best_bc <= bc_min + 0.011 || best_bc >= bc_max - 0.011;
3550 let rms_error = (best_sse / points.len() as f64).sqrt();
3553 Ok(BcEstimate {
3554 bc: best_bc,
3555 rms_error,
3556 drag_model,
3557 mode,
3558 at_bound,
3559 })
3560}
3561
3562pub fn estimate_bc_from_trajectory(
3565 velocity: f64,
3566 mass: f64,
3567 diameter: f64,
3568 points: &[(f64, f64)], ) -> Result<f64, BallisticsError> {
3570 estimate_bc_fit(
3571 velocity,
3572 mass,
3573 diameter,
3574 points,
3575 DragModel::G1,
3576 BcFitMode::Drop,
3577 AtmosphericConditions::default(),
3578 None,
3579 0.05,
3580 )
3581 .map(|e| e.bc)
3582}
3583
3584use rand;
3586use rand_distr;
3587
3588#[cfg(test)]
3589mod mba1302_solver_seam_tests {
3590 use super::*;
3591 use crate::wind::WindSegment;
3592
3593 #[test]
3594 fn authoritative_station_atmosphere_preserves_explicit_standard_values_at_altitude() {
3595 let atmosphere = AtmosphericConditions {
3596 temperature: 15.0,
3597 pressure: 1013.25,
3598 humidity: 50.0,
3599 altitude: 2_000.0,
3600 };
3601 let legacy = TrajectorySolver::new(
3602 BallisticInputs::default(),
3603 WindConditions::default(),
3604 atmosphere.clone(),
3605 );
3606 let authoritative = TrajectorySolver::new_with_resolved_station_atmosphere(
3607 BallisticInputs::default(),
3608 WindConditions::default(),
3609 atmosphere,
3610 );
3611
3612 let (legacy_density, _, legacy_temp_c, legacy_pressure_hpa) = legacy.resolved_atmosphere();
3613 let (authoritative_density, _, authoritative_temp_c, authoritative_pressure_hpa) =
3614 authoritative.resolved_atmosphere();
3615 let (icao_temp_k, icao_pressure_pa) =
3616 crate::atmosphere::calculate_icao_standard_atmosphere(2_000.0);
3617 let (expected_authoritative_density, _) =
3618 crate::atmosphere::calculate_atmosphere(2_000.0, Some(15.0), Some(1013.25), 50.0);
3619
3620 assert!((legacy_temp_c - (icao_temp_k - 273.15)).abs() < 1e-12);
3621 assert!((legacy_pressure_hpa - icao_pressure_pa / 100.0).abs() < 1e-12);
3622 assert_eq!(authoritative_temp_c.to_bits(), 15.0_f64.to_bits());
3623 assert_eq!(authoritative_pressure_hpa.to_bits(), 1013.25_f64.to_bits());
3624 assert_eq!(
3625 authoritative_density.to_bits(),
3626 expected_authoritative_density.to_bits()
3627 );
3628 assert!(
3629 (authoritative_density - legacy_density).abs() > 0.1,
3630 "explicit standard values at altitude must differ from ICAO-at-altitude: explicit={authoritative_density}, ICAO={legacy_density}"
3631 );
3632 }
3633
3634 fn configured_euler_zero(vertical_wind_mps: f64, time_step_s: f64) -> TrajectorySolver {
3635 let inputs = BallisticInputs {
3636 muzzle_velocity: 800.0,
3637 bc_value: 0.5,
3638 bc_type: DragModel::G7,
3639 bullet_mass: 0.0109,
3640 bullet_diameter: 0.00782,
3641 bullet_length: 0.0309,
3642 sight_height: 0.05,
3643 ground_threshold: -100.0,
3644 use_rk4: false,
3645 use_adaptive_rk45: false,
3646 ..BallisticInputs::default()
3647 };
3648 let mut solver = TrajectorySolver::new_with_resolved_station_atmosphere(
3649 inputs,
3650 WindConditions::default(),
3651 AtmosphericConditions::default(),
3652 );
3653 solver.set_max_range(300.0);
3654 solver.set_time_step(time_step_s);
3655 if vertical_wind_mps != 0.0 {
3656 solver.set_wind_segments(vec![WindSegment {
3657 speed_kmh: 0.0,
3658 angle_deg: 0.0,
3659 until_m: 400.0,
3660 vertical_mps: vertical_wind_mps,
3661 }]);
3662 }
3663 solver
3664 }
3665
3666 #[test]
3667 fn configured_zero_keeps_segments_method_and_time_step_then_sets_base_angle() {
3668 const TARGET_DISTANCE_M: f64 = 150.0;
3669 const TARGET_HEIGHT_M: f64 = 0.05;
3670
3671 let mut segmented = configured_euler_zero(-10.0, 0.02);
3674 let coarse_height = segmented
3675 .zero_trial_height_at(0.0, TARGET_DISTANCE_M)
3676 .expect("coarse configured trial")
3677 .expect("coarse trial reaches target");
3678 let mut fine = segmented.clone();
3679 fine.set_time_step(0.001);
3680 let fine_height = fine
3681 .zero_trial_height_at(0.0, TARGET_DISTANCE_M)
3682 .expect("fine configured trial")
3683 .expect("fine trial reaches target");
3684 assert!(
3685 (coarse_height - fine_height).abs() > 1e-5,
3686 "configured Euler step must affect zero trials: coarse={coarse_height}, fine={fine_height}"
3687 );
3688
3689 let segmented_angle = segmented
3690 .calculate_and_set_zero_angle(TARGET_DISTANCE_M, TARGET_HEIGHT_M)
3691 .expect("segmented zero");
3692 assert_eq!(
3693 segmented.inputs.muzzle_angle.to_bits(),
3694 segmented_angle.to_bits(),
3695 "successful zero must install its angle on the configured solver"
3696 );
3697 assert_eq!(segmented.time_step.to_bits(), 0.02_f64.to_bits());
3698 assert_eq!(segmented.max_range.to_bits(), 300.0_f64.to_bits());
3699 assert!(segmented.wind_sock.is_some());
3700 assert_eq!(
3701 segmented.station_atmosphere_resolution,
3702 StationAtmosphereResolution::Authoritative
3703 );
3704 let zero_height = segmented
3705 .zero_trial_height_at(segmented_angle, TARGET_DISTANCE_M)
3706 .expect("verify segmented zero")
3707 .expect("zeroed trial reaches target");
3708 assert!(
3709 (zero_height - TARGET_HEIGHT_M).abs() < 0.0001,
3710 "configured zero missed target: height={zero_height}"
3711 );
3712
3713 let mut calm = configured_euler_zero(0.0, 0.02);
3714 let calm_angle = calm
3715 .calculate_and_set_zero_angle(TARGET_DISTANCE_M, TARGET_HEIGHT_M)
3716 .expect("calm zero");
3717 assert!(
3718 (segmented_angle - calm_angle).abs() > 1e-5,
3719 "segmented vertical wind must participate in zero trials: segmented={segmented_angle}, calm={calm_angle}"
3720 );
3721 }
3722}
3723
3724#[cfg(test)]
3725mod result_sanity_tests {
3726 use super::*;
3727
3728 fn default_solver() -> TrajectorySolver {
3729 TrajectorySolver::new(
3730 BallisticInputs::default(),
3731 WindConditions::default(),
3732 AtmosphericConditions::default(),
3733 )
3734 }
3735
3736 fn minimal_result() -> TrajectoryResult {
3737 TrajectoryResult {
3738 max_range: 100.0,
3739 max_height: 1.0,
3740 time_of_flight: 0.5,
3741 impact_velocity: 700.0,
3742 impact_energy: 2450.0,
3743 projectile_mass_kg: 0.01,
3744 line_of_sight_height_m: 1.5,
3745 station_speed_of_sound_mps: 340.0,
3746 termination: TrajectoryTermination::MaxRange,
3747 points: vec![],
3748 sampled_points: None,
3749 min_pitch_damping: None,
3750 transonic_mach: None,
3751 angular_state: None,
3752 max_yaw_angle: None,
3753 max_precession_angle: None,
3754 aerodynamic_jump: None,
3755 }
3756 }
3757
3758 #[test]
3759 fn mba1293_negative_scalars_fail_the_result_postcondition() {
3760 let solver = default_solver();
3761 solver
3762 .validate_result_sanity(&minimal_result())
3763 .expect("a sane result must pass");
3764
3765 for (name, mutate) in [
3766 ("max_range", (|r| r.max_range = -50.588) as fn(&mut TrajectoryResult)),
3767 ("time_of_flight", |r| r.time_of_flight = -1.0),
3768 ("impact_velocity", |r| r.impact_velocity = -700.0),
3769 ("impact_energy", |r| r.impact_energy = -1.0),
3770 ] {
3771 let mut result = minimal_result();
3772 mutate(&mut result);
3773 let error = solver
3774 .validate_result_sanity(&result)
3775 .expect_err("negative scalar must fail");
3776 assert!(
3777 error.to_string().contains(name),
3778 "error for {name} did not name the field: {error}"
3779 );
3780 }
3781 }
3782
3783 #[test]
3784 fn mba1293_speed_budget_bounds_legitimate_states_and_rejects_divergence() {
3785 let solver = default_solver();
3786 let mv = solver.inputs.muzzle_velocity;
3787
3788 let position = Vector3::new(10.0, 0.0, 0.0);
3790 solver
3791 .validate_integration_state(&position, &Vector3::new(mv, 0.0, 0.0), 0.01)
3792 .expect("muzzle-speed state must pass");
3793
3794 let error = solver
3796 .validate_integration_state(&position, &Vector3::new(-13.0 * mv, 0.0, 0.0), 0.01)
3797 .expect_err("13x muzzle speed must fail the budget");
3798 assert!(error.to_string().contains("diverged"), "{error}");
3799
3800 let after_fall = mv + crate::constants::G_ACCEL_MPS2 * 60.0;
3802 solver
3803 .validate_integration_state(&position, &Vector3::new(0.0, -after_fall, 0.0), 60.0)
3804 .expect("gravity-accelerated speed within g*t must pass");
3805 }
3806}
3807
3808#[cfg(test)]
3809mod trajectory_point_budget_tests {
3810 use super::*;
3811 use crate::MAX_TRAJECTORY_SAMPLES;
3812
3813 fn solver_with_budget(
3814 use_rk4: bool,
3815 use_adaptive_rk45: bool,
3816 point_budget: usize,
3817 max_range: f64,
3818 ) -> TrajectorySolver {
3819 let inputs = BallisticInputs {
3820 use_rk4,
3821 use_adaptive_rk45,
3822 ground_threshold: f64::NEG_INFINITY,
3823 ..BallisticInputs::default()
3824 };
3825 let mut solver = TrajectorySolver::new(
3826 inputs,
3827 WindConditions::default(),
3828 AtmosphericConditions::default(),
3829 );
3830 solver.max_trajectory_points = point_budget;
3831 solver.set_max_range(max_range);
3832 solver.set_time_step(0.001);
3833 solver
3834 }
3835
3836 #[test]
3837 fn mba1283_every_solver_errors_instead_of_exceeding_point_budget() {
3838 for (mode, use_rk4, use_adaptive_rk45) in [
3839 ("Euler", false, false),
3840 ("RK4", true, false),
3841 ("RK45", true, true),
3842 ] {
3843 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 3, 10.0)
3844 .solve()
3845 .expect_err("a solve requiring more than three points must fail");
3846 assert!(
3847 error.to_string().contains("point limit of 3"),
3848 "unexpected {mode} point-budget error: {error}"
3849 );
3850 }
3851 }
3852
3853 #[test]
3854 fn mba1283_interpolated_endpoint_counts_toward_point_budget() {
3855 for (mode, use_rk4, use_adaptive_rk45) in [
3856 ("Euler", false, false),
3857 ("RK4", true, false),
3858 ("RK45", true, true),
3859 ] {
3860 let result = solver_with_budget(use_rk4, use_adaptive_rk45, 2, 0.1)
3861 .solve()
3862 .expect("the initial point plus exact endpoint fit a two-point budget");
3863 assert_eq!(result.points.len(), 2, "unexpected {mode} point count");
3864
3865 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 1, 0.1)
3866 .solve()
3867 .expect_err("the exact endpoint must not exceed a one-point budget");
3868 assert!(
3869 error.to_string().contains("point limit of 1"),
3870 "unexpected {mode} endpoint-budget error: {error}"
3871 );
3872 }
3873 }
3874
3875 #[test]
3876 fn mba1299_every_solver_preflights_the_sample_budget() {
3877 for (mode, use_rk4, use_adaptive_rk45) in [
3878 ("Euler", false, false),
3879 ("RK4", true, false),
3880 ("RK45", true, true),
3881 ] {
3882 let inputs = BallisticInputs {
3883 use_rk4,
3884 use_adaptive_rk45,
3885 enable_trajectory_sampling: true,
3886 sample_interval: 1.0,
3887 ground_threshold: f64::NEG_INFINITY,
3888 ..BallisticInputs::default()
3889 };
3890 let mut solver = TrajectorySolver::new(
3891 inputs,
3892 WindConditions::default(),
3893 AtmosphericConditions::default(),
3894 );
3895 solver.set_max_range(MAX_TRAJECTORY_SAMPLES as f64);
3896 solver.max_trajectory_points = 0;
3899
3900 let error = solver
3901 .solve()
3902 .expect_err("an over-limit sample grid must fail before integration");
3903 assert!(
3904 error
3905 .to_string()
3906 .contains("trajectory sample limit of 250000 exceeded"),
3907 "unexpected {mode} sample-budget error: {error}"
3908 );
3909 }
3910 }
3911
3912 #[test]
3913 fn mba1299_normal_sampling_does_not_change_solver_results() {
3914 for (mode, use_rk4, use_adaptive_rk45) in [
3915 ("Euler", false, false),
3916 ("RK4", true, false),
3917 ("RK45", true, true),
3918 ] {
3919 let solve = |enable_trajectory_sampling| {
3920 let inputs = BallisticInputs {
3921 use_rk4,
3922 use_adaptive_rk45,
3923 enable_trajectory_sampling,
3924 sample_interval: 0.5,
3925 ground_threshold: f64::NEG_INFINITY,
3926 ..BallisticInputs::default()
3927 };
3928 let mut solver = TrajectorySolver::new(
3929 inputs,
3930 WindConditions::default(),
3931 AtmosphericConditions::default(),
3932 );
3933 solver.set_max_range(2.0);
3934 solver.solve().expect("normal short-range solve")
3935 };
3936
3937 let baseline = solve(false);
3938 let sampled = solve(true);
3939 for (field, left, right) in [
3940 ("max_range", baseline.max_range, sampled.max_range),
3941 ("max_height", baseline.max_height, sampled.max_height),
3942 (
3943 "time_of_flight",
3944 baseline.time_of_flight,
3945 sampled.time_of_flight,
3946 ),
3947 (
3948 "impact_velocity",
3949 baseline.impact_velocity,
3950 sampled.impact_velocity,
3951 ),
3952 (
3953 "impact_energy",
3954 baseline.impact_energy,
3955 sampled.impact_energy,
3956 ),
3957 ] {
3958 assert_eq!(
3959 left.to_bits(),
3960 right.to_bits(),
3961 "{mode} sampling changed {field}"
3962 );
3963 }
3964 assert_eq!(baseline.points.len(), sampled.points.len());
3965 for (index, (left, right)) in baseline
3966 .points
3967 .iter()
3968 .zip(&sampled.points)
3969 .enumerate()
3970 {
3971 assert_eq!(left.time.to_bits(), right.time.to_bits(), "{mode} point {index}");
3972 assert_eq!(
3973 left.position.map(f64::to_bits),
3974 right.position.map(f64::to_bits),
3975 "{mode} point {index} position"
3976 );
3977 assert_eq!(
3978 left.velocity_magnitude.to_bits(),
3979 right.velocity_magnitude.to_bits(),
3980 "{mode} point {index} velocity"
3981 );
3982 assert_eq!(
3983 left.kinetic_energy.to_bits(),
3984 right.kinetic_energy.to_bits(),
3985 "{mode} point {index} energy"
3986 );
3987 }
3988 assert!(baseline.sampled_points.is_none());
3989 let samples = sampled
3990 .sampled_points
3991 .expect("sampling-enabled solve should return observations");
3992 assert_eq!(
3993 samples
3994 .iter()
3995 .map(|sample| sample.distance_m)
3996 .collect::<Vec<_>>(),
3997 vec![0.0, 0.5, 1.0, 1.5, 2.0],
3998 "{mode} normal sampling grid changed"
3999 );
4000 }
4001 }
4002}
4003
4004#[cfg(test)]
4005mod monte_carlo_result_tests {
4006 use super::*;
4007
4008 fn make_results(impact_positions: Vec<Vector3<f64>>) -> MonteCarloResults {
4009 let count = impact_positions.len();
4010 MonteCarloResults {
4011 ranges: vec![500.0; count],
4012 impact_velocities: vec![300.0; count],
4013 impact_positions,
4014 }
4015 }
4016
4017 #[test]
4018 fn target_plane_cep_excludes_shortfall_markers() {
4019 let mut positions: Vec<Vector3<f64>> = (1..=5)
4020 .map(|radius| Vector3::new(0.0, radius as f64, 0.0))
4021 .collect();
4022 positions.extend(
4023 (0..5).map(|_| Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)),
4024 );
4025 let results = make_results(positions);
4026
4027 assert_eq!(results.target_arrival_count(), 5);
4028 assert_eq!(results.target_shortfall_fraction(), 0.5);
4029 assert_eq!(results.target_plane_cep(), Some(3.0));
4030
4031 let one_shortfall = make_results(vec![
4032 Vector3::new(0.0, 1.0, 0.0),
4033 Vector3::new(0.0, 2.0, 0.0),
4034 Vector3::new(0.0, 3.0, 0.0),
4035 Vector3::new(0.0, 4.0, 0.0),
4036 Vector3::new(0.0, 5.0, 0.0),
4037 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4038 ]);
4039 assert_eq!(one_shortfall.target_plane_cep(), Some(3.0));
4040 }
4041
4042 #[test]
4043 fn all_shortfalls_have_no_cep_but_still_count_as_misses() {
4044 let all_shortfalls = make_results(vec![
4045 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4046 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4047 ]);
4048 assert_eq!(all_shortfalls.target_arrival_count(), 0);
4049 assert_eq!(all_shortfalls.target_shortfall_fraction(), 1.0);
4050 assert_eq!(all_shortfalls.target_plane_cep(), None);
4051 assert_eq!(all_shortfalls.hit_probability(0.3), 0.0);
4052
4053 let one_hit_one_shortfall = make_results(vec![
4054 Vector3::new(0.0, 0.1, 0.0),
4055 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4056 ]);
4057 assert_eq!(one_hit_one_shortfall.hit_probability(0.3), 0.5);
4058 }
4059
4060 #[test]
4062 fn rect_hit_probability_checks_independent_axis_halves() {
4063 let results = make_results(vec![
4064 Vector3::new(0.0, 0.1, 0.1),
4066 Vector3::new(0.0, 0.0, 0.2),
4068 Vector3::new(0.0, 0.0, 0.201),
4070 Vector3::new(0.0, 0.301, 0.0),
4072 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4074 ]);
4075 assert!((results.rect_hit_probability(0.4, 0.6) - 0.4).abs() < 1e-12);
4077 }
4078
4079 #[test]
4080 fn rect_hit_probability_matches_circular_hit_probability_for_a_centered_hit() {
4081 let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
4082 assert_eq!(results.rect_hit_probability(0.5, 0.5), 1.0);
4083 assert_eq!(results.hit_probability(0.3), 1.0);
4084 }
4085
4086 #[test]
4087 fn rect_hit_probability_is_zero_for_empty_or_nonpositive_dimensions() {
4088 let empty = make_results(vec![]);
4089 assert_eq!(empty.rect_hit_probability(1.0, 1.0), 0.0);
4090
4091 let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
4092 assert_eq!(results.rect_hit_probability(0.0, 1.0), 0.0);
4093 assert_eq!(results.rect_hit_probability(1.0, 0.0), 0.0);
4094 assert_eq!(results.rect_hit_probability(-1.0, 1.0), 0.0);
4095 }
4096}
4097
4098#[cfg(test)]
4099mod monte_carlo_seeded_tests {
4100 use super::*;
4101
4102 #[test]
4103 fn seeded_runs_are_deterministic_and_match_the_using_rng_path() {
4104 let inputs = BallisticInputs {
4105 muzzle_velocity: 800.0,
4106 ..BallisticInputs::default()
4107 };
4108 let params = MonteCarloParams {
4109 num_simulations: 64,
4110 target_distance: Some(200.0),
4111 ..MonteCarloParams::default()
4112 };
4113
4114 let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4115 inputs.clone(),
4116 WindConditions::default(),
4117 params.clone(),
4118 0.01,
4119 42,
4120 )
4121 .expect("seeded run a");
4122 let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4123 inputs,
4124 WindConditions::default(),
4125 params,
4126 0.01,
4127 42,
4128 )
4129 .expect("seeded run b");
4130
4131 assert_eq!(a.ranges.len(), b.ranges.len());
4132 for (ra, rb) in a.ranges.iter().zip(b.ranges.iter()) {
4133 assert_eq!(ra.to_bits(), rb.to_bits());
4134 }
4135 for (pa, pb) in a.impact_positions.iter().zip(b.impact_positions.iter()) {
4136 assert_eq!(pa.x.to_bits(), pb.x.to_bits());
4137 assert_eq!(pa.y.to_bits(), pb.y.to_bits());
4138 assert_eq!(pa.z.to_bits(), pb.z.to_bits());
4139 }
4140 }
4141
4142 #[test]
4143 fn different_seeds_generally_produce_different_draws() {
4144 let inputs = BallisticInputs {
4145 muzzle_velocity: 800.0,
4146 ..BallisticInputs::default()
4147 };
4148 let params = MonteCarloParams {
4149 num_simulations: 32,
4150 velocity_std_dev: 5.0,
4151 target_distance: Some(200.0),
4152 ..MonteCarloParams::default()
4153 };
4154
4155 let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4156 inputs.clone(),
4157 WindConditions::default(),
4158 params.clone(),
4159 0.0,
4160 1,
4161 )
4162 .expect("seeded run a");
4163 let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4164 inputs,
4165 WindConditions::default(),
4166 params,
4167 0.0,
4168 2,
4169 )
4170 .expect("seeded run b");
4171
4172 assert_ne!(a.impact_velocities, b.impact_velocities);
4173 }
4174}
4175
4176#[cfg(test)]
4177mod monte_carlo_powder_curve_tests {
4178 use super::*;
4179 use rand::{rngs::StdRng, SeedableRng};
4180
4181 #[test]
4182 fn powder_curve_preserves_sampled_muzzle_velocity_dispersion() {
4183 let inputs = BallisticInputs {
4184 muzzle_velocity: 700.0,
4185 powder_temp_curve: Some(vec![(15.0, 800.0)]),
4186 powder_curve_temp_c: Some(15.0),
4187 ..BallisticInputs::default()
4188 };
4189 let params = MonteCarloParams {
4190 num_simulations: 16,
4191 velocity_std_dev: 20.0,
4192 angle_std_dev: 1e-12,
4193 bc_std_dev: 1e-12,
4194 wind_speed_std_dev: 1e-12,
4195 target_distance: Some(100.0),
4196 azimuth_std_dev: 1e-12,
4197 ..MonteCarloParams::default()
4198 };
4199
4200 let mut rng = StdRng::seed_from_u64(0x5EED_1176);
4201 let results = run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
4202 inputs,
4203 WindConditions::default(),
4204 params,
4205 0.0,
4206 &mut rng,
4207 )
4208 .expect("Monte Carlo solve");
4209 let min_velocity = results
4210 .impact_velocities
4211 .iter()
4212 .copied()
4213 .fold(f64::INFINITY, f64::min);
4214 let max_velocity = results
4215 .impact_velocities
4216 .iter()
4217 .copied()
4218 .fold(f64::NEG_INFINITY, f64::max);
4219
4220 assert!(
4221 max_velocity - min_velocity > 1.0,
4222 "20 m/s muzzle spread collapsed after curve resolution: impact-velocity span={} m/s",
4223 max_velocity - min_velocity
4224 );
4225 }
4226}
4227
4228#[cfg(test)]
4229mod monte_carlo_wind_sampling_tests {
4230 use super::*;
4231 use rand::{rngs::StdRng, SeedableRng};
4232
4233 #[test]
4234 fn wind_speed_sigma_does_not_change_seeded_direction_draws() {
4235 let base_wind = WindConditions {
4236 speed: 100.0,
4237 direction: 0.37,
4238 vertical_speed: 0.0,
4239 };
4240 let narrow_speed = MonteCarloWindSampler::new(&base_wind, 0.5, 0.2).unwrap();
4241 let wide_speed = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
4242 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
4243 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
4244 let mut speed_changed = false;
4245
4246 for _ in 0..32 {
4247 let narrow = narrow_speed.sample(&mut narrow_rng);
4248 let wide = wide_speed.sample(&mut wide_rng);
4249 assert!(narrow.speed > 0.0 && wide.speed > 0.0);
4250 assert_eq!(narrow.direction.to_bits(), wide.direction.to_bits());
4251 speed_changed |= narrow.speed.to_bits() != wide.speed.to_bits();
4252 }
4253 assert!(
4254 speed_changed,
4255 "different speed sigmas must still vary speed draws"
4256 );
4257 }
4258
4259 #[test]
4260 fn zero_direction_sigma_has_no_angular_jitter() {
4261 let base_wind = WindConditions {
4262 speed: 100.0,
4263 direction: 0.37,
4264 vertical_speed: 0.0,
4265 };
4266 let sampler = MonteCarloWindSampler::new(&base_wind, 4.0, 0.0).unwrap();
4267 let mut rng = StdRng::seed_from_u64(0x5EED_1223);
4268 let mut speed_changed = false;
4269
4270 for _ in 0..32 {
4271 let wind = sampler.sample(&mut rng);
4272 speed_changed |= wind.speed.to_bits() != base_wind.speed.to_bits();
4273 assert_eq!(wind.direction.to_bits(), base_wind.direction.to_bits());
4274 }
4275 assert!(speed_changed, "speed uncertainty should remain active");
4276 }
4277
4278 #[test]
4279 fn direction_sigma_controls_seeded_angular_spread_in_radians() {
4280 let base_wind = WindConditions {
4281 speed: 100.0,
4282 direction: 0.37,
4283 vertical_speed: 0.0,
4284 };
4285 let narrow = MonteCarloWindSampler::new(&base_wind, 4.0, 0.1).unwrap();
4286 let wide = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
4287 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
4288 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
4289 let mut nonzero_direction_draw = false;
4290
4291 for _ in 0..32 {
4292 let narrow_wind = narrow.sample(&mut narrow_rng);
4293 let wide_wind = wide.sample(&mut wide_rng);
4294 assert_eq!(narrow_wind.speed.to_bits(), wide_wind.speed.to_bits());
4295
4296 let narrow_delta = narrow_wind.direction - base_wind.direction;
4297 let wide_delta = wide_wind.direction - base_wind.direction;
4298 assert!((wide_delta - 2.0 * narrow_delta).abs() < 1e-12);
4299 nonzero_direction_draw |= narrow_delta.abs() > 1e-6;
4300 }
4301 assert!(
4302 nonzero_direction_draw,
4303 "positive radians sigma must vary direction"
4304 );
4305 }
4306
4307 #[test]
4308 fn direction_sigma_rejects_negative_or_nonfinite_values() {
4309 let base_wind = WindConditions::default();
4310 for sigma in [-0.1, f64::NAN, f64::INFINITY] {
4311 assert!(MonteCarloWindSampler::new(&base_wind, 1.0, sigma).is_err());
4312 }
4313 }
4314
4315 #[test]
4316 fn base_vertical_wind_rides_into_every_mc_sample() {
4317 use rand::SeedableRng;
4321 let base_wind = WindConditions { vertical_speed: 4.2, ..Default::default() };
4322 let sampler = MonteCarloWindSampler::new(&base_wind, 1.0, 0.2).unwrap();
4323 let mut rng = rand::rngs::StdRng::seed_from_u64(7);
4324 for _ in 0..32 {
4325 let w = sampler.sample(&mut rng);
4326 assert_eq!(w.vertical_speed, 4.2);
4327 }
4328 }
4329
4330 #[test]
4331 fn negative_speed_sample_reverses_wind_direction() {
4332 let direction = 0.25;
4333 let signed_speed = -2.5;
4334 let wind = wind_from_signed_speed_sample(signed_speed, direction, 0.0);
4335 let positive_wind = wind_from_signed_speed_sample(2.5, direction, 0.0);
4336
4337 assert_eq!(wind.speed, 2.5);
4338 assert!(
4339 (wind.direction - (direction + std::f64::consts::PI)).abs() < f64::EPSILON,
4340 "negative speed must reverse direction by pi: got {}",
4341 wind.direction
4342 );
4343 assert_eq!(positive_wind.speed, 2.5);
4344 assert_eq!(positive_wind.direction, direction);
4345
4346 let normalized_x = -wind.speed * wind.direction.cos();
4347 let normalized_z = -wind.speed * wind.direction.sin();
4348 let signed_x = -signed_speed * direction.cos();
4349 let signed_z = -signed_speed * direction.sin();
4350 assert!((normalized_x - signed_x).abs() < 1e-12);
4351 assert!((normalized_z - signed_z).abs() < 1e-12);
4352 }
4353}
4354
4355#[cfg(test)]
4356mod bc_fit_objective_tests {
4357 use super::*;
4358
4359 fn velocity_point(range_m: f64, velocity_mps: f64) -> TrajectoryPoint {
4360 TrajectoryPoint {
4361 time: 0.0,
4362 position: Vector3::new(range_m, 0.0, 0.0),
4363 velocity_magnitude: velocity_mps,
4364 kinetic_energy: 0.0,
4365 }
4366 }
4367
4368 #[test]
4369 fn candidate_that_misses_an_observation_has_no_score() {
4370 let trajectory = vec![velocity_point(0.0, 800.0), velocity_point(100.0, 700.0)];
4371 let observations = vec![(50.0, 750.0), (150.0, 600.0)];
4372
4373 assert!(
4374 fit_residual_sse(&trajectory, &observations, BcFitMode::Velocity, 0.0).is_none(),
4375 "a candidate that reaches only one of two observations must not compete on partial SSE"
4376 );
4377
4378 let complete_observations = vec![(50.0, 740.0), (100.0, 680.0)];
4379 assert_eq!(
4380 fit_residual_sse(
4381 &trajectory,
4382 &complete_observations,
4383 BcFitMode::Velocity,
4384 0.0,
4385 ),
4386 Some(500.0)
4387 );
4388 }
4389}
4390
4391#[cfg(test)]
4392mod cluster_bc_reference_space_tests {
4393 use super::*;
4394
4395 fn acceleration_at_1100_fps(inputs: BallisticInputs) -> Vector3<f64> {
4396 let solver = TrajectorySolver::new(
4397 inputs,
4398 WindConditions::default(),
4399 AtmosphericConditions::default(),
4400 );
4401 let position = Vector3::zeros();
4402 let velocity = Vector3::new(1100.0 / 3.28084, 0.0, 0.0);
4403 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4404 solver.calculate_acceleration(
4405 &position,
4406 &velocity,
4407 &Vector3::zeros(),
4408 (temp_c, pressure_hpa, density / 1.225),
4409 )
4410 }
4411
4412 #[test]
4413 fn solver_passes_g7_reference_model_to_cluster_classifier() {
4414 let inputs = BallisticInputs {
4415 bc_value: 0.190,
4416 bc_type: DragModel::G7,
4417 bullet_mass: 77.0 * 0.00006479891,
4418 bullet_diameter: 0.224 * 0.0254,
4419 use_cluster_bc: true,
4420 ..BallisticInputs::default()
4421 };
4422
4423 let solver = TrajectorySolver::new(
4424 inputs,
4425 WindConditions::default(),
4426 AtmosphericConditions::default(),
4427 );
4428 let corrected = solver.apply_cluster_bc_correction(0.190, 2800.0);
4429
4430 assert!(
4431 (corrected / 0.190 - 1.004).abs() < 1e-12,
4432 "solver selected the wrong G7 cluster multiplier: {}",
4433 corrected / 0.190
4434 );
4435 }
4436
4437 #[test]
4438 fn velocity_bc_segments_are_not_cluster_corrected_twice() {
4439 let segmented_clustered = BallisticInputs {
4440 bc_value: 0.5,
4441 bc_type: DragModel::G7,
4442 use_bc_segments: true,
4443 bc_segments_data: Some(vec![
4444 crate::BCSegmentData {
4445 velocity_min: 0.0,
4446 velocity_max: 1_600.0,
4447 bc_value: 0.4,
4448 },
4449 crate::BCSegmentData {
4450 velocity_min: 1_600.0,
4451 velocity_max: 5_000.0,
4452 bc_value: 0.45,
4453 },
4454 ]),
4455 use_cluster_bc: true,
4456 ..BallisticInputs::default()
4457 };
4458 let mut segmented_only = segmented_clustered.clone();
4459 segmented_only.use_cluster_bc = false;
4460 let mut constant_clustered = segmented_clustered.clone();
4461 constant_clustered.bc_value = 0.4;
4462 constant_clustered.bc_segments_data = None;
4463
4464 let stacked = acceleration_at_1100_fps(segmented_clustered);
4465 let segment_only = acceleration_at_1100_fps(segmented_only);
4466 let cluster_only = acceleration_at_1100_fps(constant_clustered);
4467
4468 assert!(
4469 (stacked.x - segment_only.x).abs() < 1e-12,
4470 "segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
4471 stacked.x,
4472 segment_only.x
4473 );
4474 assert!(
4475 (cluster_only.x - segment_only.x).abs() > 1e-6,
4476 "cluster correction must remain active for a constant BC"
4477 );
4478 }
4479
4480 #[test]
4481 fn mach_bc_segments_are_not_cluster_corrected_twice() {
4482 let mach_segmented_clustered = BallisticInputs {
4483 bc_value: 0.5,
4484 bc_type: DragModel::G7,
4485 use_bc_segments: false,
4486 bc_segments: Some(vec![(0.5, 0.3), (1.5, 0.5)]),
4487 use_cluster_bc: true,
4488 ..BallisticInputs::default()
4489 };
4490 let mut mach_segmented_only = mach_segmented_clustered.clone();
4491 mach_segmented_only.use_cluster_bc = false;
4492
4493 let stacked = acceleration_at_1100_fps(mach_segmented_clustered);
4494 let segment_only = acceleration_at_1100_fps(mach_segmented_only);
4495
4496 assert!(
4497 (stacked.x - segment_only.x).abs() < 1e-12,
4498 "Mach segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
4499 stacked.x,
4500 segment_only.x
4501 );
4502 }
4503}
4504
4505#[cfg(test)]
4506mod velocity_bc_flag_tests {
4507 use super::*;
4508
4509 fn acceleration_at_600_mps(inputs: BallisticInputs) -> Vector3<f64> {
4510 let solver = TrajectorySolver::new(
4511 inputs,
4512 WindConditions::default(),
4513 AtmosphericConditions::default(),
4514 );
4515 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4516 solver.calculate_acceleration(
4517 &Vector3::zeros(),
4518 &Vector3::new(600.0, 0.0, 0.0),
4519 &Vector3::zeros(),
4520 (temp_c, pressure_hpa, density / 1.225),
4521 )
4522 }
4523
4524 #[test]
4525 fn velocity_bc_data_requires_opt_in_in_trajectory_solver() {
4526 let scalar_inputs = BallisticInputs {
4527 bc_value: 0.5,
4528 bc_type: DragModel::G7,
4529 ..BallisticInputs::default()
4530 };
4531 let mut disabled_inputs = scalar_inputs.clone();
4532 disabled_inputs.bc_segments_data = Some(vec![crate::BCSegmentData {
4533 velocity_min: 0.0,
4534 velocity_max: 4_000.0,
4535 bc_value: 0.46,
4536 }]);
4537 disabled_inputs.use_bc_segments = false;
4538 let mut enabled_inputs = disabled_inputs.clone();
4539 enabled_inputs.use_bc_segments = true;
4540 let mut mach_only_inputs = scalar_inputs.clone();
4541 mach_only_inputs.bc_segments = Some(vec![(0.0, 0.4), (3.0, 0.4)]);
4542 let mut disabled_with_both = mach_only_inputs.clone();
4543 disabled_with_both.bc_segments_data = disabled_inputs.bc_segments_data.clone();
4544
4545 let scalar = acceleration_at_600_mps(scalar_inputs);
4546 let disabled = acceleration_at_600_mps(disabled_inputs);
4547 let enabled = acceleration_at_600_mps(enabled_inputs);
4548 let mach_only = acceleration_at_600_mps(mach_only_inputs);
4549 let disabled_with_both = acceleration_at_600_mps(disabled_with_both);
4550
4551 assert_eq!(
4552 disabled.x.to_bits(),
4553 scalar.x.to_bits(),
4554 "a populated velocity table must not change drag while use_bc_segments is false"
4555 );
4556 assert!(
4557 enabled.x < disabled.x - 1.0,
4558 "enabling the lower BC table must increase drag: disabled ax={} enabled ax={}",
4559 disabled.x,
4560 enabled.x
4561 );
4562 assert_eq!(
4563 disabled_with_both.x.to_bits(),
4564 mach_only.x.to_bits(),
4565 "disabling velocity data must fall through to an explicit Mach table"
4566 );
4567 }
4568}
4569
4570#[cfg(test)]
4571mod mach_bc_segment_tests {
4572 use super::*;
4573
4574 #[test]
4575 fn trajectory_solver_interpolates_explicit_mach_bc_segments() {
4576 let segmented_inputs = BallisticInputs {
4577 bc_value: 0.8,
4578 use_bc_segments: false,
4579 bc_segments: Some(vec![(1.0, 0.2), (2.0, 0.4)]),
4580 bc_segments_data: None,
4581 ..BallisticInputs::default()
4582 };
4583
4584 let mut expected_inputs = segmented_inputs.clone();
4585 expected_inputs.bc_value = 0.3;
4586 expected_inputs.bc_segments = None;
4587
4588 let atmosphere = AtmosphericConditions::default();
4589 let segmented_solver = TrajectorySolver::new(
4590 segmented_inputs,
4591 WindConditions::default(),
4592 atmosphere.clone(),
4593 );
4594 let expected_solver = TrajectorySolver::new(
4595 expected_inputs,
4596 WindConditions::default(),
4597 atmosphere,
4598 );
4599 let position = Vector3::zeros();
4600 let (density, _, temp_c, pressure_hpa) = segmented_solver.resolved_atmosphere();
4601 let (_, local_speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
4602 segmented_solver.atmosphere.altitude,
4603 segmented_solver.atmosphere.altitude,
4604 temp_c,
4605 pressure_hpa,
4606 density / 1.225,
4607 segmented_solver.atmosphere.humidity,
4608 );
4609 let velocity = Vector3::new(1.5 * local_speed_of_sound, 0.0, 0.0);
4610 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
4611
4612 let segmented_acceleration = segmented_solver.calculate_acceleration(
4613 &position,
4614 &velocity,
4615 &Vector3::zeros(),
4616 resolved_atmo,
4617 );
4618 let expected_acceleration = expected_solver.calculate_acceleration(
4619 &position,
4620 &velocity,
4621 &Vector3::zeros(),
4622 resolved_atmo,
4623 );
4624
4625 assert!(
4626 (segmented_acceleration.x - expected_acceleration.x).abs() < 1e-12,
4627 "Mach 1.5 must interpolate BC 0.3: segmented ax={} expected ax={}",
4628 segmented_acceleration.x,
4629 expected_acceleration.x
4630 );
4631 }
4632}
4633
4634#[cfg(test)]
4635mod custom_drag_table_validation_tests {
4636 use super::*;
4637
4638 #[test]
4639 fn solve_accepts_zero_bc_when_custom_table_present() {
4640 let inputs = BallisticInputs {
4641 bc_value: 0.0, bullet_mass: 0.0106,
4643 bullet_diameter: 0.00782,
4644 muzzle_velocity: 850.0,
4645 custom_drag_table: Some(crate::drag::DragTable::new(
4646 vec![0.5, 1.0, 2.0, 3.0],
4647 vec![0.23, 0.40, 0.30, 0.26],
4648 )),
4649 ..BallisticInputs::default()
4650 };
4651 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
4652 assert!(solver.solve().is_ok());
4654 }
4655
4656 #[test]
4657 fn solve_still_requires_bc_without_table() {
4658 let inputs = BallisticInputs {
4659 bc_value: 0.0,
4660 bullet_mass: 0.0106,
4661 bullet_diameter: 0.00782,
4662 muzzle_velocity: 850.0,
4663 ..BallisticInputs::default()
4664 };
4665 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
4666 assert!(solver.solve().is_err());
4667 }
4668}
4669
4670#[cfg(test)]
4671mod humid_local_mach_tests {
4672 use super::*;
4673
4674 fn solver_with_station_humidity(humidity_percent: f64) -> TrajectorySolver {
4675 let inputs = BallisticInputs {
4676 custom_drag_table: Some(crate::drag::DragTable::new(vec![0.5, 1.5], vec![0.1, 1.1])),
4677 ..BallisticInputs::default()
4678 };
4679 TrajectorySolver::new(
4680 inputs,
4681 WindConditions::default(),
4682 AtmosphericConditions {
4683 temperature: 30.0,
4684 pressure: 1013.25,
4685 humidity: humidity_percent,
4686 altitude: 0.0,
4687 },
4688 )
4689 }
4690
4691 fn acceleration(solver: &TrajectorySolver, base_ratio: f64) -> Vector3<f64> {
4692 solver.calculate_acceleration(
4693 &Vector3::zeros(),
4694 &Vector3::new(350.0, 0.0, 0.0),
4695 &Vector3::zeros(),
4696 (30.0, 1013.25, base_ratio),
4697 )
4698 }
4699
4700 #[test]
4701 fn local_mach_uses_station_humidity_when_density_is_held_constant() {
4702 let dry = acceleration(&solver_with_station_humidity(0.0), 1.0);
4703 let humid = acceleration(&solver_with_station_humidity(100.0), 1.0);
4704
4705 assert!(
4706 humid.x > dry.x,
4707 "humid sound speed should lower Mach and drag on the rising test curve: dry ax={} humid ax={}",
4708 dry.x,
4709 humid.x
4710 );
4711 }
4712
4713 #[test]
4714 fn active_atmosphere_zone_uses_zone_humidity_instead_of_station_humidity() {
4715 let zone_humidity = 80.0;
4716 let zone_ratio =
4717 crate::atmosphere::calculate_air_density_cimp(30.0, 1013.25, zone_humidity) / 1.225;
4718 let station_solver = solver_with_station_humidity(zone_humidity);
4719 let mut zoned_solver = solver_with_station_humidity(0.0);
4720 zoned_solver.set_atmo_segments(vec![(30.0, 1013.25, zone_humidity, 1_000.0)]);
4721
4722 let station = acceleration(&station_solver, zone_ratio);
4723 let zoned = acceleration(&zoned_solver, zone_ratio);
4724
4725 assert!(
4726 (zoned - station).norm() < 1e-12,
4727 "active zone T/P/RH should override the station atmosphere: station={station:?} zoned={zoned:?}"
4728 );
4729 }
4730}
4731
4732#[cfg(test)]
4733mod inclined_atmosphere_frame_tests {
4734 use super::*;
4735
4736 fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
4737 let (sin_angle, cos_angle) = angle.sin_cos();
4738 Vector3::new(
4739 level.x * cos_angle + level.y * sin_angle,
4740 -level.x * sin_angle + level.y * cos_angle,
4741 level.z,
4742 )
4743 }
4744
4745 #[test]
4746 fn inclined_positions_at_same_world_altitude_have_same_solver_acceleration() {
4747 let angle = std::f64::consts::FRAC_PI_6;
4748 let inputs = BallisticInputs {
4749 shooting_angle: angle,
4750 ..BallisticInputs::default()
4751 };
4752 let atmosphere = AtmosphericConditions {
4753 altitude: 100.0,
4754 ..AtmosphericConditions::default()
4755 };
4756 let solver = TrajectorySolver::new(inputs, WindConditions::default(), atmosphere);
4757 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4758 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
4759 let velocity = Vector3::new(600.0, 0.0, 0.0);
4760 let along_slant = Vector3::new(1_000.0, 0.0, 0.0);
4761 let across_slant = Vector3::new(0.0, 500.0 / angle.cos(), 0.0);
4762
4763 let a = solver.calculate_acceleration(
4764 &along_slant,
4765 &velocity,
4766 &Vector3::zeros(),
4767 resolved_atmo,
4768 );
4769 let b = solver.calculate_acceleration(
4770 &across_slant,
4771 &velocity,
4772 &Vector3::zeros(),
4773 resolved_atmo,
4774 );
4775
4776 assert!(
4777 (a - b).norm() < 1e-10,
4778 "solver acceleration differs at equal world altitude: {a:?} vs {b:?}"
4779 );
4780 }
4781
4782 #[test]
4783 fn inclined_headwind_is_rotated_into_solver_frame() {
4784 let angle = std::f64::consts::FRAC_PI_6;
4785 let inputs = BallisticInputs {
4786 shooting_angle: angle,
4787 ..BallisticInputs::default()
4788 };
4789 let solver = TrajectorySolver::new(
4790 inputs,
4791 WindConditions::default(),
4792 AtmosphericConditions::default(),
4793 );
4794 let level_headwind = Vector3::new(-100.0, 0.0, 0.0);
4795 let velocity = expected_shot_frame_vector(level_headwind, angle);
4796 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4797 let actual = solver.calculate_acceleration(
4798 &Vector3::zeros(),
4799 &velocity,
4800 &level_headwind,
4801 (temp_c, pressure_hpa, density / 1.225),
4802 );
4803
4804 assert!(
4805 (actual - solver.gravity_acceleration()).norm() < 1e-12,
4806 "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
4807 );
4808 }
4809
4810 #[test]
4811 fn inclined_coriolis_is_rotated_into_solver_frame() {
4812 let angle = std::f64::consts::FRAC_PI_6;
4813 let latitude_deg = 45.0_f64;
4814 let shot_azimuth = 0.4_f64;
4815 let velocity = Vector3::new(600.0, 20.0, 5.0);
4816 let base_inputs = BallisticInputs {
4817 shooting_angle: angle,
4818 latitude: Some(latitude_deg),
4819 shot_azimuth,
4820 ..BallisticInputs::default()
4821 };
4822 let acceleration = |enable_coriolis| {
4823 let mut inputs = base_inputs.clone();
4824 inputs.enable_coriolis = enable_coriolis;
4825 let solver = TrajectorySolver::new(
4826 inputs,
4827 WindConditions::default(),
4828 AtmosphericConditions::default(),
4829 );
4830 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4831 solver.calculate_acceleration(
4832 &Vector3::zeros(),
4833 &velocity,
4834 &Vector3::zeros(),
4835 (temp_c, pressure_hpa, density / 1.225),
4836 )
4837 };
4838
4839 let omega_earth = 7.2921159e-5_f64;
4840 let latitude = latitude_deg.to_radians();
4841 let level_omega = Vector3::new(
4842 omega_earth * latitude.cos() * shot_azimuth.cos(),
4843 omega_earth * latitude.sin(),
4844 -omega_earth * latitude.cos() * shot_azimuth.sin(),
4845 );
4846 let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
4847 let actual = acceleration(true) - acceleration(false);
4848
4849 assert!(
4850 (actual - expected).norm() < 1e-12,
4851 "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
4852 );
4853 }
4854}
4855
4856#[cfg(test)]
4857mod terminal_range_interpolation_tests {
4858 use super::*;
4859
4860 #[test]
4861 fn terminal_finalizer_selects_the_earliest_crossed_boundary() {
4862 let inputs = BallisticInputs {
4863 ground_threshold: 0.0,
4864 ..BallisticInputs::default()
4865 };
4866 let mut solver = TrajectorySolver::new(
4867 inputs,
4868 WindConditions::default(),
4869 AtmosphericConditions::default(),
4870 );
4871 solver.set_max_range(120.0);
4872
4873 let previous_speed = 700.0;
4874 let mut points = vec![TrajectoryPoint {
4875 time: 99.0,
4876 position: Vector3::new(90.0, 1.0, -1.0),
4877 velocity_magnitude: previous_speed,
4878 kinetic_energy: 0.5 * solver.inputs.bullet_mass * previous_speed.powi(2),
4879 }];
4880 let mut max_height = 1.0;
4881 let termination = solver
4882 .append_terminal_endpoint(
4883 &mut points,
4884 Vector3::new(130.0, -3.0, 3.0),
4885 Vector3::new(600.0, 0.0, 0.0),
4886 101.0,
4887 &mut max_height,
4888 )
4889 .expect("the final step brackets supported boundaries");
4890
4891 assert_eq!(termination, TrajectoryTermination::GroundThreshold);
4892 assert_eq!(points.len(), 2);
4893 let terminal = points.last().expect("terminal point");
4894 assert_eq!(terminal.time, 99.5);
4895 assert_eq!(terminal.position, Vector3::new(100.0, 0.0, 0.0));
4896 assert_eq!(terminal.velocity_magnitude, 675.0);
4897 assert_eq!(
4898 terminal.kinetic_energy,
4899 0.5 * solver.inputs.bullet_mass * 675.0_f64.powi(2)
4900 );
4901
4902 solver.set_max_range(100.0);
4904 let mut tied_points = vec![points[0].clone()];
4905 assert_eq!(
4906 solver
4907 .append_terminal_endpoint(
4908 &mut tied_points,
4909 Vector3::new(130.0, -3.0, 3.0),
4910 Vector3::new(600.0, 0.0, 0.0),
4911 101.0,
4912 &mut max_height,
4913 )
4914 .expect("tied boundaries remain a valid terminal"),
4915 TrajectoryTermination::GroundThreshold
4916 );
4917 }
4918
4919 #[test]
4920 fn sub_ulp_terminal_crossing_replaces_instead_of_duplicating_range() {
4921 let ground_threshold = f64::from_bits(1.0_f64.to_bits() - 1);
4922 let inputs = BallisticInputs {
4923 ground_threshold,
4924 ..BallisticInputs::default()
4925 };
4926 let mut solver = TrajectorySolver::new(
4927 inputs,
4928 WindConditions::default(),
4929 AtmosphericConditions::default(),
4930 );
4931 solver.set_max_range(1_000.0);
4932
4933 let speed = 700.0;
4934 let mut points = vec![TrajectoryPoint {
4935 time: 0.0,
4936 position: Vector3::new(100.0, 1.0, 0.0),
4937 velocity_magnitude: speed,
4938 kinetic_energy: 0.5 * solver.inputs.bullet_mass * speed.powi(2),
4939 }];
4940 let mut max_height = 1.0;
4941 let termination = solver
4942 .append_terminal_endpoint(
4943 &mut points,
4944 Vector3::new(101.0, 0.0, 0.0),
4945 Vector3::new(699.0, 0.0, 0.0),
4946 1.0,
4947 &mut max_height,
4948 )
4949 .expect("sub-ULP ground crossing remains representable as one terminal state");
4950
4951 assert_eq!(termination, TrajectoryTermination::GroundThreshold);
4952 assert_eq!(points.len(), 1);
4953 assert_eq!(points[0].position.x, 100.0);
4954 assert_eq!(points[0].position.y.to_bits(), ground_threshold.to_bits());
4955 assert!(points[0].time > 0.0);
4956 }
4957
4958 #[test]
4959 fn every_solver_appends_an_exact_max_range_endpoint() {
4960 let target_range = 0.1;
4961 let modes = [
4962 ("Euler", false, false),
4963 ("RK4", true, false),
4964 ("RK45", true, true),
4965 ];
4966
4967 for (name, use_rk4, use_adaptive_rk45) in modes {
4968 let inputs = BallisticInputs {
4969 use_rk4,
4970 use_adaptive_rk45,
4971 ground_threshold: f64::NEG_INFINITY,
4972 enable_trajectory_sampling: true,
4973 sample_interval: target_range,
4974 ..BallisticInputs::default()
4975 };
4976 let mut solver = TrajectorySolver::new(
4977 inputs,
4978 WindConditions::default(),
4979 AtmosphericConditions::default(),
4980 );
4981 solver.set_max_range(target_range);
4982
4983 let result = solver.solve().expect("short-range solve should succeed");
4984 let terminal = result.points.last().expect("terminal point is missing");
4985 let muzzle = result.points.first().expect("muzzle point is missing");
4986
4987 assert_eq!(result.termination, TrajectoryTermination::MaxRange);
4988 assert_eq!(
4989 terminal.position.x.to_bits(),
4990 target_range.to_bits(),
4991 "{name} did not terminate exactly at max_range"
4992 );
4993 assert_eq!(result.max_range.to_bits(), target_range.to_bits());
4994 assert!(
4995 result.time_of_flight > 0.0 && result.time_of_flight < solver.time_step,
4996 "{name} terminal time was not interpolated within the crossing step: {}",
4997 result.time_of_flight
4998 );
4999 assert_eq!(result.time_of_flight.to_bits(), terminal.time.to_bits());
5000 assert_eq!(
5001 result.impact_velocity.to_bits(),
5002 terminal.velocity_magnitude.to_bits()
5003 );
5004 assert_eq!(
5005 result.impact_energy.to_bits(),
5006 terminal.kinetic_energy.to_bits()
5007 );
5008 let expected_energy = 0.5 * solver.inputs.bullet_mass * result.impact_velocity.powi(2);
5009 assert!((result.impact_energy - expected_energy).abs() < 1e-12);
5010 assert!(terminal.velocity_magnitude < muzzle.velocity_magnitude);
5011 assert!(terminal.kinetic_energy < muzzle.kinetic_energy);
5012
5013 let terminal_sample = result
5014 .sampled_points
5015 .as_ref()
5016 .and_then(|samples| samples.last())
5017 .expect("terminal trajectory sample is missing");
5018 assert_eq!(
5019 terminal_sample.distance_m.to_bits(),
5020 target_range.to_bits(),
5021 "{name} sampling did not include max_range"
5022 );
5023 assert_eq!(
5024 terminal_sample.time_s.to_bits(),
5025 result.time_of_flight.to_bits()
5026 );
5027 assert_eq!(
5028 terminal_sample.velocity_mps.to_bits(),
5029 result.impact_velocity.to_bits()
5030 );
5031 assert!((terminal_sample.energy_j - result.impact_energy).abs() < 1e-12);
5032 }
5033 }
5034}
5035
5036#[cfg(test)]
5037mod precession_inertia_wiring_tests {
5038 use super::*;
5039
5040 #[test]
5041 fn solver_uses_projectile_specific_moments_of_inertia() {
5042 let mass_kg = 55.0 * 0.00006479891;
5043 let caliber_m = 0.224 * 0.0254;
5044 let length_m = 0.75 * 0.0254;
5045 let inputs = BallisticInputs {
5046 bullet_mass: mass_kg,
5047 bullet_diameter: caliber_m,
5048 bullet_length: length_m,
5049 muzzle_velocity: 800.0,
5050 twist_rate: 7.0,
5051 enable_precession_nutation: true,
5052 use_rk4: false,
5053 use_adaptive_rk45: false,
5054 ..BallisticInputs::default()
5055 };
5056 let mut solver = TrajectorySolver::new(
5057 inputs,
5058 WindConditions::default(),
5059 AtmosphericConditions::default(),
5060 );
5061 solver.set_max_range(0.1);
5062
5063 let (air_density, speed_of_sound, _, _) = solver.resolved_atmosphere();
5064 let velocity_mps = solver.inputs.muzzle_velocity;
5065 let velocity_fps = velocity_mps * 3.28084;
5066 let twist_rate_ft = solver.inputs.twist_rate / 12.0;
5067 let spin_rate_rad_s = (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI;
5068 let initial_state = AngularState {
5069 pitch_angle: 0.001,
5070 yaw_angle: 0.001,
5071 pitch_rate: 0.0,
5072 yaw_rate: 0.0,
5073 precession_angle: 0.0,
5074 nutation_phase: 0.0,
5075 };
5076 let params = PrecessionNutationParams {
5077 mass_kg,
5078 caliber_m,
5079 length_m,
5080 spin_rate_rad_s,
5081 spin_inertia: crate::spin_decay::calculate_moment_of_inertia(
5082 mass_kg, caliber_m, length_m, "ogive",
5083 ),
5084 transverse_inertia: crate::pitch_damping::calculate_transverse_moment_of_inertia(
5085 mass_kg, caliber_m, length_m, "ogive",
5086 ),
5087 velocity_mps,
5088 air_density_kg_m3: air_density,
5089 mach: velocity_mps / speed_of_sound,
5090 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
5091 nutation_damping_factor: 0.05,
5092 };
5093 let expected = calculate_combined_angular_motion(
5094 ¶ms,
5095 &initial_state,
5096 0.0,
5097 solver.time_step,
5098 0.001,
5099 );
5100 let actual = solver
5101 .solve()
5102 .expect("one-step solve should succeed")
5103 .angular_state
5104 .expect("precession/nutation was enabled");
5105
5106 assert!(
5107 (actual.precession_angle - expected.precession_angle).abs() < 1e-15,
5108 "precession phase used the wrong inertia: actual={}, expected={}",
5109 actual.precession_angle,
5110 expected.precession_angle
5111 );
5112 assert!(
5113 (actual.nutation_phase - expected.nutation_phase).abs() < 1e-15,
5114 "nutation phase used the wrong inertia: actual={}, expected={}",
5115 actual.nutation_phase,
5116 expected.nutation_phase
5117 );
5118 }
5119}
5120
5121#[cfg(test)]
5122mod form_factor_drag_tests {
5123 use super::*;
5124
5125 fn acceleration_with_form_factor_flag(enabled: bool) -> Vector3<f64> {
5126 let inputs = BallisticInputs {
5127 bc_value: 0.462,
5128 bc_type: DragModel::G1,
5129 bullet_model: Some("168gr SMK Match".to_string()),
5130 use_form_factor: enabled,
5131 ..BallisticInputs::default()
5132 };
5133 let solver = TrajectorySolver::new(
5134 inputs,
5135 WindConditions::default(),
5136 AtmosphericConditions::default(),
5137 );
5138 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5139 solver.calculate_acceleration(
5140 &Vector3::zeros(),
5141 &Vector3::new(600.0, 0.0, 0.0),
5142 &Vector3::zeros(),
5143 (temp_c, pressure_hpa, density / 1.225),
5144 )
5145 }
5146
5147 #[test]
5148 fn measured_bc_drag_does_not_apply_name_based_form_factor_again() {
5149 let baseline = acceleration_with_form_factor_flag(false);
5150 let flagged = acceleration_with_form_factor_flag(true);
5151
5152 assert!(
5153 (flagged - baseline).norm() < 1e-12,
5154 "published BC already encodes form factor: baseline={baseline:?} flagged={flagged:?}"
5155 );
5156 }
5157}
5158
5159#[cfg(test)]
5160mod rk45_adaptivity_tests {
5161 use super::*;
5162
5163 #[test]
5164 fn cli_rk45_error_norm_scales_components_independently() {
5165 let position = Vector3::new(1.0e9, 0.0, 0.0);
5166 let velocity = Vector3::new(800.0, 0.0, 0.0);
5167 let fifth_position = position;
5168 let fifth_velocity = velocity;
5169 let fourth_position = position;
5170 let fourth_velocity = Vector3::new(800.0, 1.0e-3, 0.0);
5171
5172 let error = cli_rk45_error_norm(
5173 &position,
5174 &velocity,
5175 &fifth_position,
5176 &fifth_velocity,
5177 &fourth_position,
5178 &fourth_velocity,
5179 );
5180 let expected = 1.0e-3 / 6.0_f64.sqrt();
5181
5182 assert!(
5183 (error - expected).abs() <= 1e-15,
5184 "large downrange position masked a velocity-component error: {error}"
5185 );
5186 }
5187
5188 fn discontinuous_wind_solver() -> TrajectorySolver {
5189 let inputs = BallisticInputs::default();
5190 let mut solver = TrajectorySolver::new(
5191 inputs,
5192 WindConditions::default(),
5193 AtmosphericConditions::default(),
5194 );
5195 solver.set_wind_segments(vec![
5196 crate::wind::WindSegment::new(0.0, 90.0, 4.0),
5197 crate::wind::WindSegment::new(1_000.0, 90.0, 10_000.0),
5198 ]);
5199 solver
5200 }
5201
5202 #[test]
5203 fn rk45_retries_discontinuous_trial_before_advancing() {
5204 let solver = discontinuous_wind_solver();
5205 let position = Vector3::new(0.0, solver.inputs.muzzle_height, 0.0);
5206 let velocity = Vector3::new(solver.inputs.muzzle_velocity, 0.0, 0.0);
5207 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5208 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
5209 let dt = 0.01;
5210
5211 let rejected_trial = solver.rk45_step(
5212 &position,
5213 &velocity,
5214 dt,
5215 &Vector3::zeros(),
5216 RK45_TOLERANCE,
5217 resolved_atmo,
5218 );
5219 assert!(
5220 rejected_trial.error > RK45_TOLERANCE,
5221 "discontinuous full step must exceed tolerance, got {}",
5222 rejected_trial.error
5223 );
5224
5225 let accepted = solver.adaptive_rk45_step(
5226 &position,
5227 &velocity,
5228 dt,
5229 &Vector3::zeros(),
5230 resolved_atmo,
5231 );
5232 assert!(accepted.used_dt < dt, "oversized trial was not retried");
5233 assert!(
5234 accepted.error <= RK45_TOLERANCE || accepted.used_dt <= RK45_MIN_DT,
5235 "accepted error {} exceeds tolerance at dt {}",
5236 accepted.error,
5237 accepted.used_dt
5238 );
5239
5240 let accepted_trial = solver.rk45_step(
5241 &position,
5242 &velocity,
5243 accepted.used_dt,
5244 &Vector3::zeros(),
5245 RK45_TOLERANCE,
5246 resolved_atmo,
5247 );
5248 assert_eq!(accepted.position, accepted_trial.position);
5249 assert_eq!(accepted.velocity, accepted_trial.velocity);
5250 assert!((RK45_MIN_DT..=RK45_MAX_DT).contains(&accepted.next_dt));
5251 }
5252}
5253
5254#[cfg(test)]
5255mod ground_termination_tests {
5256 use super::*;
5257 use crate::trajectory_observation::TrajectoryObservationFlag;
5258
5259 #[test]
5260 fn every_solver_reports_one_exact_early_ground_endpoint() {
5261 for (name, use_rk4, use_adaptive_rk45) in [
5262 ("Euler", false, false),
5263 ("RK4", true, false),
5264 ("RK45", true, true),
5265 ] {
5266 let inputs = BallisticInputs {
5267 muzzle_height: 1.0,
5268 muzzle_angle: -0.2,
5269 ground_threshold: 0.0,
5270 use_rk4,
5271 use_adaptive_rk45,
5272 ..BallisticInputs::default()
5273 };
5274 let mut solver = TrajectorySolver::new(
5275 inputs,
5276 WindConditions::default(),
5277 AtmosphericConditions::default(),
5278 );
5279 solver.set_max_range(1_000.0);
5280
5281 let result = solver.solve().expect("early-ground solve should succeed");
5282 let terminal = result.points.last().expect("terminal point is missing");
5283
5284 assert_eq!(result.termination, TrajectoryTermination::GroundThreshold);
5285 assert_eq!(terminal.position.y.to_bits(), 0.0_f64.to_bits());
5286 assert!(
5287 terminal.position.x < 1_000.0,
5288 "{name} incorrectly reached max range"
5289 );
5290 assert_eq!(result.max_range.to_bits(), terminal.position.x.to_bits());
5291 assert_eq!(
5292 result
5293 .points
5294 .iter()
5295 .filter(|point| point.position.y == 0.0)
5296 .count(),
5297 1,
5298 "{name} did not retain exactly one ground endpoint"
5299 );
5300
5301 let observations = result
5302 .sample_observations(1.0, 100)
5303 .expect("checked early-ground sampling should succeed");
5304 assert!(observations[..observations.len() - 1]
5305 .iter()
5306 .all(|observation| observation.distance_m < terminal.position.x));
5307 let terminal_observation = observations.last().expect("terminal observation");
5308 assert_eq!(
5309 terminal_observation.distance_m.to_bits(),
5310 terminal.position.x.to_bits()
5311 );
5312 assert!(terminal_observation
5313 .flags
5314 .contains(&TrajectoryObservationFlag::Terminal));
5315 assert!(terminal_observation
5316 .flags
5317 .contains(&TrajectoryObservationFlag::GroundThreshold));
5318 assert_eq!(
5319 observations
5320 .iter()
5321 .filter(|observation| observation
5322 .flags
5323 .contains(&TrajectoryObservationFlag::Terminal))
5324 .count(),
5325 1,
5326 "{name} repeated the terminal observation"
5327 );
5328 }
5329 }
5330
5331 #[test]
5336 fn rk4_and_rk45_descend_to_ground_threshold() {
5337 for adaptive in [false, true] {
5338 let inputs = BallisticInputs {
5339 muzzle_angle: 0.1, use_rk4: true,
5341 use_adaptive_rk45: adaptive,
5342 ..BallisticInputs::default()
5343 };
5344 assert_eq!(
5345 inputs.ground_threshold, -100.0,
5346 "default ground_threshold is -100 m"
5347 );
5348
5349 let mut solver = TrajectorySolver::new(
5350 inputs,
5351 WindConditions::default(),
5352 AtmosphericConditions::default(),
5353 );
5354 solver.set_max_range(1.0e7);
5356
5357 let result = solver.solve().expect("solve should succeed");
5358 let final_y = result
5359 .points
5360 .last()
5361 .expect("trajectory has points")
5362 .position
5363 .y;
5364 assert!(
5365 final_y < -1.0,
5366 "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
5367 past launch level toward the ground_threshold floor, not stop at y = 0"
5368 );
5369 }
5370 }
5371}
5372
5373#[cfg(test)]
5374mod magnus_stability_tests {
5375 use super::*;
5376
5377 #[test]
5378 fn yaw_of_repose_magnus_force_is_vertical_and_twist_signed() {
5379 let acceleration = |enable_magnus, is_twist_right| {
5380 let inputs = BallisticInputs {
5381 muzzle_velocity: 822.96,
5382 bullet_mass: 168.0 * 0.00006479891,
5383 bullet_diameter: 0.308 * 0.0254,
5384 bullet_length: 1.215 * 0.0254,
5385 twist_rate: 10.0,
5386 is_twist_right,
5387 enable_magnus,
5388 ..BallisticInputs::default()
5389 };
5390 let solver = TrajectorySolver::new(
5391 inputs,
5392 WindConditions::default(),
5393 AtmosphericConditions::default(),
5394 );
5395 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5396 solver.calculate_acceleration(
5397 &Vector3::zeros(),
5398 &Vector3::new(822.96, 0.0, 0.0),
5399 &Vector3::zeros(),
5400 (temp_c, pressure_hpa, density / 1.225),
5401 )
5402 };
5403
5404 let baseline = acceleration(false, true);
5405 let right_twist = acceleration(true, true) - baseline;
5406 let left_twist = acceleration(true, false) - baseline;
5407
5408 assert!(
5409 right_twist.y < 0.0,
5410 "right-hand Magnus must point down, got {right_twist:?}"
5411 );
5412 assert!(
5413 left_twist.y > 0.0,
5414 "left-hand Magnus must point up, got {left_twist:?}"
5415 );
5416 assert!((right_twist.y + left_twist.y).abs() < 1e-12);
5417 assert!(right_twist.x.abs() < 1e-12 && right_twist.z.abs() < 1e-12);
5418 assert!(left_twist.x.abs() < 1e-12 && left_twist.z.abs() < 1e-12);
5419 }
5420
5421 #[test]
5422 fn magnus_uses_velocity_corrected_muzzle_stability_gate() {
5423 let muzzle_velocity = 1_400.0 / 3.28084;
5424 let inputs = BallisticInputs {
5425 muzzle_velocity,
5426 bullet_mass: 168.0 * 0.00006479891,
5427 bullet_diameter: 0.308 * 0.0254,
5428 bullet_length: 1.215 * 0.0254,
5429 twist_rate: 15.0,
5430 enable_magnus: true,
5431 ..BallisticInputs::default()
5432 };
5433 let solver = TrajectorySolver::new(
5434 inputs.clone(),
5435 WindConditions::default(),
5436 AtmosphericConditions::default(),
5437 );
5438
5439 let bare_sg = crate::spin_drift::miller_stability(0.308, 168.0, 15.0, 1.215);
5440 let canonical_sg = solver.effective_spin_drift_sg();
5441 assert!(bare_sg > 1.0, "test requires bare Sg above the Magnus gate");
5442 assert!(
5443 canonical_sg < 1.0,
5444 "velocity-corrected Sg must be below the gate, got {canonical_sg}"
5445 );
5446
5447 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5448 let acceleration = solver.calculate_acceleration(
5449 &Vector3::zeros(),
5450 &Vector3::new(muzzle_velocity, 0.0, 0.0),
5451 &Vector3::zeros(),
5452 (temp_c, pressure_hpa, density / 1.225),
5453 );
5454 let mut baseline_inputs = inputs;
5455 baseline_inputs.enable_magnus = false;
5456 let baseline_solver = TrajectorySolver::new(
5457 baseline_inputs,
5458 WindConditions::default(),
5459 AtmosphericConditions::default(),
5460 );
5461 let baseline = baseline_solver.calculate_acceleration(
5462 &Vector3::zeros(),
5463 &Vector3::new(muzzle_velocity, 0.0, 0.0),
5464 &Vector3::zeros(),
5465 (temp_c, pressure_hpa, density / 1.225),
5466 );
5467
5468 assert_eq!(
5469 acceleration, baseline,
5470 "canonical Sg below 1 must suppress every Magnus acceleration component"
5471 );
5472 }
5473
5474 #[test]
5475 fn magnus_force_grows_as_fixed_spin_projectile_slows() {
5476 let inputs = BallisticInputs {
5477 muzzle_velocity: 800.0,
5478 bullet_mass: 168.0 * 0.00006479891,
5479 bullet_diameter: 0.308 * 0.0254,
5480 bullet_length: 1.215 * 0.0254,
5481 twist_rate: 12.0,
5482 enable_magnus: true,
5483 ..BallisticInputs::default()
5484 };
5485
5486 let magnus_acceleration = |speed_mps| {
5487 let evaluate = |enable_magnus| {
5488 let mut run_inputs = inputs.clone();
5489 run_inputs.enable_magnus = enable_magnus;
5490 let solver = TrajectorySolver::new(
5491 run_inputs,
5492 WindConditions::default(),
5493 AtmosphericConditions::default(),
5494 );
5495 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5496 solver
5497 .calculate_acceleration(
5498 &Vector3::zeros(),
5499 &Vector3::new(speed_mps, 0.0, 0.0),
5500 &Vector3::zeros(),
5501 (temp_c, pressure_hpa, density / 1.225),
5502 )
5503 .y
5504 };
5505 (evaluate(true) - evaluate(false)).abs()
5506 };
5507
5508 let fast = magnus_acceleration(200.0);
5509 let slow = magnus_acceleration(100.0);
5510 let ratio = slow / fast;
5511 let expected_ratio = 2.0_f64.powf(5.0 / 3.0);
5512
5513 assert!(fast > 0.0 && slow > 0.0, "fast={fast}, slow={slow}");
5514 assert!(
5515 (ratio - expected_ratio).abs() < 1e-3,
5516 "fixed-spin Magnus acceleration must grow downrange; slow/fast={ratio}, \
5517 expected={expected_ratio}"
5518 );
5519 }
5520}
5521
5522#[cfg(test)]
5523mod coriolis_direction_tests {
5524 use super::*;
5525 use std::f64::consts::FRAC_PI_2;
5526
5527 #[test]
5528 fn supersonic_crossing_flags_a_positive_range_sample() {
5529 use crate::trajectory_sampling::TrajectoryFlag;
5533
5534 for (solver_name, use_rk4, use_adaptive_rk45) in [
5535 ("Euler", false, false),
5536 ("RK4", true, false),
5537 ("RK45", true, true),
5538 ] {
5539 let inputs = BallisticInputs {
5540 muzzle_velocity: 850.0,
5541 bc_value: 0.2,
5542 bc_type: DragModel::G7,
5543 muzzle_angle: 0.03,
5544 enable_trajectory_sampling: true,
5545 sample_interval: 50.0,
5546 use_rk4,
5547 use_adaptive_rk45,
5548 ..BallisticInputs::default()
5549 };
5550 let mut solver = TrajectorySolver::new(
5551 inputs,
5552 WindConditions::default(),
5553 AtmosphericConditions::default(),
5554 );
5555 solver.set_max_range(2000.0);
5556 let samples = solver
5557 .solve()
5558 .expect("supersonic solve should succeed")
5559 .sampled_points
5560 .expect("sampling was enabled");
5561 let flagged_distances: Vec<_> = samples
5562 .iter()
5563 .filter(|sample| sample.flags.contains(&TrajectoryFlag::MachTransition))
5564 .map(|sample| sample.distance_m)
5565 .collect();
5566
5567 assert!(
5568 !flagged_distances.is_empty()
5569 && flagged_distances.iter().all(|distance| *distance > 0.0),
5570 "{solver_name} must flag genuine crossings only at positive range: {flagged_distances:?}"
5571 );
5572 }
5573 }
5574
5575 #[test]
5576 fn subsonic_launch_does_not_flag_a_muzzle_transition() {
5577 use crate::trajectory_sampling::TrajectoryFlag;
5578
5579 for (solver_name, use_rk4, use_adaptive_rk45) in [
5580 ("Euler", false, false),
5581 ("RK4", true, false),
5582 ("RK45", true, true),
5583 ] {
5584 let inputs = BallisticInputs {
5585 muzzle_velocity: 250.0,
5586 muzzle_angle: 0.02,
5587 enable_trajectory_sampling: true,
5588 sample_interval: 25.0,
5589 use_rk4,
5590 use_adaptive_rk45,
5591 ..BallisticInputs::default()
5592 };
5593 let mut solver = TrajectorySolver::new(
5594 inputs,
5595 WindConditions::default(),
5596 AtmosphericConditions::default(),
5597 );
5598 solver.set_max_range(300.0);
5599 let samples = solver
5600 .solve()
5601 .expect("subsonic solve should succeed")
5602 .sampled_points
5603 .expect("sampling was enabled");
5604
5605 assert!(
5606 samples
5607 .iter()
5608 .all(|sample| !sample.flags.contains(&TrajectoryFlag::MachTransition)),
5609 "{solver_name} marked a Mach transition for a launch already below Mach 1"
5610 );
5611 }
5612 }
5613
5614 #[test]
5615 fn mach_transition_tracker_requires_a_downward_crossing() {
5616 fn record(mach_values: &[f64]) -> Vec<f64> {
5617 let mut tracker = MachTransitionTracker::default();
5618 let mut distances = Vec::new();
5619 for (index, mach) in mach_values.iter().copied().enumerate() {
5620 tracker.record_downward_crossings(mach, index as f64 * 10.0, &mut distances);
5621 }
5622 distances
5623 }
5624
5625 assert!(record(&[0.9, 0.8, 0.7]).is_empty());
5626 assert_eq!(record(&[1.1, 1.05, 0.99]), vec![20.0]);
5627 assert_eq!(record(&[1.2, 1.19, 1.0, 0.99]), vec![10.0, 30.0]);
5628 assert_eq!(record(&[0.9, 1.3, 1.1, 0.9, 1.3, 0.8]), vec![20.0, 30.0]);
5629 assert!(record(&[1.3, f64::NAN, 1.1]).is_empty());
5630 }
5631
5632 #[test]
5633 fn humidity_percent_converts_and_clamps() {
5634 let mut i = BallisticInputs {
5636 humidity: 0.5,
5637 ..BallisticInputs::default()
5638 };
5639 assert!((i.humidity_percent() - 50.0).abs() < 1e-9, "0.5 -> 50%");
5640 i.humidity = 0.0;
5641 assert_eq!(i.humidity_percent(), 0.0);
5642 i.humidity = 1.0;
5643 assert_eq!(i.humidity_percent(), 100.0);
5644 i.humidity = 1.5; assert_eq!(i.humidity_percent(), 100.0);
5646 }
5647
5648 fn vertical_at(shot_azimuth: f64, range_m: f64) -> f64 {
5651 let inputs = BallisticInputs {
5652 muzzle_velocity: 800.0,
5653 bc_value: 0.5,
5654 bc_type: DragModel::G7,
5655 muzzle_angle: 0.02, enable_coriolis: true,
5657 latitude: Some(45.0),
5658 shot_azimuth,
5659 ground_threshold: f64::NEG_INFINITY, ..BallisticInputs::default()
5661 };
5662 let mut solver = TrajectorySolver::new(
5663 inputs,
5664 WindConditions::default(),
5665 AtmosphericConditions::default(),
5666 );
5667 solver.set_max_range(range_m + 50.0);
5668 let r = solver.solve().expect("solve");
5669 let pts = &r.points;
5670 for i in 1..pts.len() {
5671 if pts[i].position.x >= range_m {
5672 let p1 = &pts[i - 1];
5673 let p2 = &pts[i];
5674 let t = (range_m - p1.position.x) / (p2.position.x - p1.position.x);
5675 return p1.position.y + t * (p2.position.y - p1.position.y);
5676 }
5677 }
5678 panic!("range {range_m} not reached");
5679 }
5680
5681 #[test]
5686 fn eotvos_east_higher_than_west() {
5687 let range = 600.0;
5688 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!(
5692 east > west,
5693 "east ({east:.5}) must be higher than west ({west:.5}) at {range} m (Eötvös)"
5694 );
5695 assert!(
5696 east > north && north > west,
5697 "north ({north:.5}) must lie between east ({east:.5}) and west ({west:.5})"
5698 );
5699 assert!(
5700 (east - west) > 1e-3,
5701 "E-W vertical separation ({:.6} m) should be physically meaningful, not FP noise",
5702 east - west
5703 );
5704 }
5705}
5706
5707#[cfg(test)]
5708mod cant_tests {
5709 use super::*;
5710
5711 fn base_inputs() -> BallisticInputs {
5712 BallisticInputs {
5713 muzzle_velocity: 800.0,
5714 bc_value: 0.5,
5715 bc_type: DragModel::G7,
5716 bullet_mass: 0.0109,
5717 bullet_diameter: 0.00782,
5718 bullet_length: 0.0309,
5719 sight_height: 0.05,
5720 twist_rate: 10.0,
5721 use_rk4: true,
5722 ..BallisticInputs::default()
5723 }
5724 }
5725
5726 fn solve_with(inputs: BallisticInputs, max_range: f64) -> TrajectoryResult {
5727 let mut s = TrajectorySolver::new(
5728 inputs,
5729 WindConditions::default(),
5730 AtmosphericConditions::default(),
5731 );
5732 s.set_max_range(max_range);
5733 s.solve().expect("solve")
5734 }
5735
5736 fn yz_at(result: &TrajectoryResult, x: f64) -> (f64, f64) {
5738 let pts = &result.points;
5739 for i in 1..pts.len() {
5740 if pts[i].position.x >= x {
5741 let (p1, p2) = (&pts[i - 1], &pts[i]);
5742 let dx = p2.position.x - p1.position.x;
5743 let t = if dx.abs() < 1e-12 { 0.0 } else { (x - p1.position.x) / dx };
5744 return (
5745 p1.position.y + t * (p2.position.y - p1.position.y),
5746 p1.position.z + t * (p2.position.z - p1.position.z),
5747 );
5748 }
5749 }
5750 panic!("trajectory never reached {x} m");
5751 }
5752
5753 #[test]
5754 fn cant_sign_clockwise_up_offset_goes_right_and_low() {
5755 let mut level = base_inputs();
5757 level.muzzle_angle = 0.003; let mut canted = level.clone();
5759 canted.cant_angle = 10f64.to_radians();
5760
5761 let (y0, z0) = yz_at(&solve_with(level, 400.0), 300.0);
5762 let (y1, z1) = yz_at(&solve_with(canted, 400.0), 300.0);
5763 assert!(z1 > z0 + 0.01, "clockwise cant must move POI right: z0={z0} z1={z1}");
5764 assert!(y1 < y0 - 0.001, "clockwise cant must move POI low: y0={y0} y1={y1}");
5765 }
5766
5767 #[test]
5768 fn pure_cant_shows_bore_offset_near_range() {
5769 let mut i = base_inputs();
5772 i.muzzle_angle = 0.0;
5773 i.cant_angle = 10f64.to_radians();
5774 let sh = i.sight_height;
5775 let r = solve_with(i, 60.0);
5776 let first = &r.points[1]; let expected = -sh * 10f64.to_radians().sin();
5778 assert!(
5779 (first.position.z - expected).abs() < 0.005,
5780 "near-muzzle lateral {} should be ~bore offset {expected}",
5781 first.position.z
5782 );
5783 }
5784
5785 #[test]
5786 fn zero_angle_is_independent_of_cant() {
5787 let a = base_inputs();
5788 let mut b = base_inputs();
5789 b.cant_angle = 15f64.to_radians();
5790 let za = calculate_zero_angle(a.clone(), 100.0, 0.0).expect("zero a");
5791 let zb = calculate_zero_angle(b.clone(), 100.0, 0.0).expect("zero b");
5792 assert_eq!(za.to_bits(), zb.to_bits(), "zeroing must ignore cant: {za} vs {zb}");
5793 let _ = (a.cant_angle, b.cant_angle);
5795 }
5796
5797 #[test]
5798 fn nonfinite_cant_is_rejected() {
5799 let mut i = base_inputs();
5800 i.cant_angle = f64::NAN;
5801 let s = TrajectorySolver::new(i, WindConditions::default(), AtmosphericConditions::default());
5802 assert!(s.solve().is_err());
5803 }
5804
5805 #[test]
5806 fn incline_and_cant_compose_without_breaking() {
5807 let mut flat = base_inputs();
5809 flat.muzzle_angle = 0.003;
5810 flat.shooting_angle = 15f64.to_radians();
5811 let mut canted = flat.clone();
5812 canted.cant_angle = 10f64.to_radians();
5813 let (_, z_flat) = yz_at(&solve_with(flat, 400.0), 300.0);
5814 let (_, z_cant) = yz_at(&solve_with(canted, 400.0), 300.0);
5815 assert!(z_cant > z_flat, "cant must still deflect right on an incline");
5816 }
5817}
5818
5819#[cfg(test)]
5820mod vertical_wind_tests {
5821 use super::*;
5822
5823 fn base_inputs() -> BallisticInputs {
5824 BallisticInputs {
5825 muzzle_velocity: 800.0,
5826 bc_value: 0.5,
5827 bc_type: DragModel::G7,
5828 bullet_mass: 0.0109,
5829 bullet_diameter: 0.00782,
5830 bullet_length: 0.0309,
5831 sight_height: 0.05,
5832 twist_rate: 10.0,
5833 use_rk4: true,
5834 ..BallisticInputs::default()
5835 }
5836 }
5837
5838 fn y_at(result: &TrajectoryResult, x: f64) -> f64 {
5840 let pts = &result.points;
5841 for i in 1..pts.len() {
5842 if pts[i].position.x >= x {
5843 let (p1, p2) = (&pts[i - 1], &pts[i]);
5844 let dx = p2.position.x - p1.position.x;
5845 let t = if dx.abs() < 1e-12 { 0.0 } else { (x - p1.position.x) / dx };
5846 return p1.position.y + t * (p2.position.y - p1.position.y);
5847 }
5848 }
5849 panic!("trajectory never reached {x} m");
5850 }
5851
5852 fn solve_with(inputs: BallisticInputs, wind: WindConditions, max_range: f64) -> TrajectoryResult {
5853 let mut s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
5854 s.set_max_range(max_range);
5855 s.solve().expect("solve")
5856 }
5857
5858 #[test]
5859 fn updraft_raises_poi_downrange() {
5860 let calm_inputs = base_inputs();
5863 let calm_wind = WindConditions::default();
5864 let updraft = WindConditions {
5865 vertical_speed: 5.0,
5866 ..Default::default()
5867 };
5868
5869 let calm = solve_with(calm_inputs.clone(), calm_wind, 500.0);
5870 let updraft_result = solve_with(calm_inputs, updraft, 500.0);
5871
5872 let y_calm = y_at(&calm, 400.0);
5873 let y_updraft = y_at(&updraft_result, 400.0);
5874 assert!(
5875 y_updraft > y_calm,
5876 "5 m/s updraft must raise POI at 400m: calm={y_calm}, updraft={y_updraft}"
5877 );
5878 }
5879
5880 #[test]
5881 fn zero_vertical_is_default_and_finite_required() {
5882 assert_eq!(WindConditions::default().vertical_speed, 0.0);
5883
5884 let inputs = base_inputs();
5885 let wind = WindConditions {
5886 vertical_speed: f64::NAN,
5887 ..Default::default()
5888 };
5889 let s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
5890 assert!(
5891 s.solve().is_err(),
5892 "NaN wind.vertical_speed must be rejected by validate_for_solve"
5893 );
5894 }
5895}