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 / crate::constants::GRAINS_TO_KG };
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 / crate::constants::GRAINS_TO_KG, 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 / crate::constants::GRAINS_TO_KG;
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 (often from physically \
1137 extreme inputs — e.g. an absurd bore/muzzle height placing the launch far \
1138 from sea level, or a degenerate atmosphere; check those inputs, or set \
1139 --altitude explicitly)",
1140 ));
1141 }
1142
1143 let speed = velocity.magnitude();
1144 let budget = self.speed_budget(time);
1145 if speed > budget {
1146 return Err(BallisticsError::from(format!(
1147 "trajectory integration diverged: speed {speed:.3e} m/s at t={time:.6}s exceeds \
1148 the physical budget of {budget:.3e} m/s"
1149 )));
1150 }
1151 Ok(())
1152 }
1153
1154 fn speed_budget(&self, time: f64) -> f64 {
1159 let scalar_wind = self.wind.speed.abs() + self.wind.vertical_speed.abs();
1160 let wind_bound = match &self.wind_sock {
1161 Some(sock) => scalar_wind.max(sock.max_speed_mps()),
1162 None => scalar_wind,
1163 };
1164 2.0 * (self.inputs.muzzle_velocity + wind_bound + 10.0)
1165 + crate::constants::G_ACCEL_MPS2 * time
1166 }
1167
1168 fn push_trajectory_point(
1170 &self,
1171 points: &mut Vec<TrajectoryPoint>,
1172 point: TrajectoryPoint,
1173 ) -> Result<(), BallisticsError> {
1174 if points.len() >= self.max_trajectory_points {
1175 return Err(BallisticsError::from(format!(
1176 "trajectory point limit of {} exceeded",
1177 self.max_trajectory_points
1178 )));
1179 }
1180 points.push(point);
1181 Ok(())
1182 }
1183
1184 pub fn set_wind_segments(&mut self, segments: Vec<crate::wind::WindSegment>) {
1191 self.wind_sock = if segments.is_empty() {
1192 None
1193 } else {
1194 Some(crate::wind::WindSock::new(segments))
1195 };
1196 }
1197
1198 pub fn set_atmo_segments(&mut self, segments: Vec<crate::atmosphere::AtmoSegment>) {
1207 self.atmo_sock = if segments.is_empty() {
1208 None
1209 } else {
1210 Some(crate::atmosphere::AtmoSock::new(segments))
1211 };
1212 }
1213
1214 fn launch_angles_from(
1223 &self,
1224 aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
1225 ) -> (f64, f64) {
1226 let (mut elev, mut azim) = (self.inputs.muzzle_angle, self.inputs.azimuth_angle);
1227 if self.inputs.cant_angle != 0.0 {
1234 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
1235 let (e0, a0) = (elev, azim);
1236 elev = e0 * cos_c - a0 * sin_c;
1237 azim = a0 * cos_c + e0 * sin_c;
1238 }
1239 match aj {
1240 Some(c) => {
1241 const MOA_PER_RAD: f64 = 3437.7467707849;
1243 (
1244 elev + c.vertical_jump_moa / MOA_PER_RAD,
1245 azim + c.horizontal_jump_moa / MOA_PER_RAD,
1246 )
1247 }
1248 None => (elev, azim),
1249 }
1250 }
1251
1252 fn aerodynamic_jump_components(
1260 &self,
1261 ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
1262 if !self.inputs.enable_aerodynamic_jump {
1263 return None;
1264 }
1265 let diameter_m = self.inputs.bullet_diameter;
1269 if !(self.inputs.twist_rate.is_finite()
1270 && self.inputs.twist_rate != 0.0
1271 && diameter_m.is_finite()
1272 && diameter_m > 0.0
1273 && self.inputs.bullet_length.is_finite()
1274 && self.inputs.bullet_length > 0.0
1275 && self.inputs.muzzle_velocity.is_finite())
1276 {
1277 return None;
1278 }
1279
1280 let (_, _, temp_c, pressure_hpa) = self.resolved_atmosphere();
1282 let sg = crate::stability::compute_stability_coefficient(
1283 &self.inputs,
1284 (self.atmosphere.altitude, temp_c, pressure_hpa, 0.0),
1285 );
1286 if !(sg.is_finite() && sg > 0.0) {
1287 return None;
1288 }
1289 let length_calibers = self.inputs.bullet_length / diameter_m;
1290
1291 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
1297 let crosswind_from_right_mps = if let Some(sock) = &self.wind_sock {
1298 -sock.vector_for_range_stateless(0.0)[2]
1299 } else {
1300 self.wind.speed * self.wind.direction.sin()
1301 };
1302 let crosswind_from_right_mph = crosswind_from_right_mps * MS_TO_MPH;
1303
1304 let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
1305 sg,
1306 length_calibers,
1307 crosswind_from_right_mph,
1308 self.inputs.is_twist_right,
1309 );
1310 if !vertical_jump_moa.is_finite() {
1311 return None;
1312 }
1313
1314 const MOA_PER_RAD: f64 = 3437.7467707849;
1315 Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
1316 vertical_jump_moa,
1317 horizontal_jump_moa: 0.0,
1319 jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
1320 magnus_component_moa: 0.0,
1321 yaw_component_moa: 0.0,
1322 stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
1323 })
1324 }
1325
1326 fn resolved_atmosphere(&self) -> (f64, f64, f64, f64) {
1327 let (temp_c, pressure_hpa) = match self.station_atmosphere_resolution {
1328 StationAtmosphereResolution::LegacyDefaultSentinels => {
1329 crate::atmosphere::resolve_station_conditions(
1330 self.atmosphere.temperature,
1331 self.atmosphere.pressure,
1332 self.atmosphere.altitude,
1333 )
1334 }
1335 StationAtmosphereResolution::Authoritative => {
1336 (self.atmosphere.temperature, self.atmosphere.pressure)
1337 }
1338 };
1339 let (density, speed_of_sound) = crate::atmosphere::calculate_atmosphere(
1340 self.atmosphere.altitude,
1341 Some(temp_c),
1342 Some(pressure_hpa),
1343 self.atmosphere.humidity,
1344 );
1345 (density, speed_of_sound, temp_c, pressure_hpa)
1346 }
1347
1348 fn precession_nutation_params(
1349 &self,
1350 velocity_mps: f64,
1351 air_density_kg_m3: f64,
1352 speed_of_sound_mps: f64,
1353 ) -> PrecessionNutationParams {
1354 let (spin_inertia, transverse_inertia) = self.precession_nutation_inertias;
1355 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
1356 let velocity_fps = velocity_mps * 3.28084;
1357 let twist_rate_ft = self.inputs.twist_rate / 12.0;
1358 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
1359 } else {
1360 0.0
1361 };
1362
1363 PrecessionNutationParams {
1364 mass_kg: self.inputs.bullet_mass,
1365 caliber_m: self.inputs.bullet_diameter,
1366 length_m: self.inputs.bullet_length,
1367 spin_rate_rad_s,
1368 spin_inertia,
1369 transverse_inertia,
1370 velocity_mps,
1371 air_density_kg_m3,
1372 mach: velocity_mps / speed_of_sound_mps,
1373 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
1374 nutation_damping_factor: 0.05,
1375 }
1376 }
1377
1378 fn append_terminal_endpoint(
1385 &self,
1386 points: &mut Vec<TrajectoryPoint>,
1387 post_position: Vector3<f64>,
1388 post_velocity: Vector3<f64>,
1389 post_time: f64,
1390 max_height: &mut f64,
1391 ) -> Result<TrajectoryTermination, BallisticsError> {
1392 let previous = points
1393 .last()
1394 .cloned()
1395 .ok_or_else(|| BallisticsError::from("No trajectory points generated"))?;
1396
1397 let mut crossings = Vec::with_capacity(3);
1398 if previous.position.x < self.max_range && post_position.x >= self.max_range {
1399 let span = post_position.x - previous.position.x;
1400 if span.is_finite() && span > 0.0 {
1401 crossings.push((
1402 (self.max_range - previous.position.x) / span,
1403 TrajectoryTermination::MaxRange,
1404 ));
1405 }
1406 }
1407 if self.inputs.ground_threshold.is_finite()
1408 && previous.position.y > self.inputs.ground_threshold
1409 && post_position.y <= self.inputs.ground_threshold
1410 {
1411 let span = post_position.y - previous.position.y;
1412 if span.is_finite() && span < 0.0 {
1413 crossings.push((
1414 (self.inputs.ground_threshold - previous.position.y) / span,
1415 TrajectoryTermination::GroundThreshold,
1416 ));
1417 }
1418 }
1419 if previous.time < TRAJECTORY_TIME_LIMIT_S && post_time >= TRAJECTORY_TIME_LIMIT_S {
1420 let span = post_time - previous.time;
1421 if span.is_finite() && span > 0.0 {
1422 crossings.push((
1423 (TRAJECTORY_TIME_LIMIT_S - previous.time) / span,
1424 TrajectoryTermination::TimeLimit,
1425 ));
1426 }
1427 }
1428
1429 let (fraction, termination) = crossings
1430 .into_iter()
1431 .filter(|(fraction, _)| fraction.is_finite() && (0.0..=1.0).contains(fraction))
1432 .min_by(|left, right| {
1433 let priority = |termination: TrajectoryTermination| match termination {
1434 TrajectoryTermination::GroundThreshold => 0,
1435 TrajectoryTermination::MaxRange => 1,
1436 TrajectoryTermination::TimeLimit => 2,
1437 TrajectoryTermination::VelocityFloor => 3,
1438 };
1439 left.0
1440 .total_cmp(&right.0)
1441 .then_with(|| priority(left.1).cmp(&priority(right.1)))
1442 })
1443 .ok_or_else(|| {
1444 BallisticsError::from(
1445 "trajectory integration stopped without crossing a supported boundary",
1446 )
1447 })?;
1448
1449 let mut position = previous.position + (post_position - previous.position) * fraction;
1450 match termination {
1451 TrajectoryTermination::MaxRange => position.x = self.max_range,
1452 TrajectoryTermination::GroundThreshold => {
1453 position.y = self.inputs.ground_threshold;
1454 }
1455 TrajectoryTermination::TimeLimit | TrajectoryTermination::VelocityFloor => {}
1456 }
1457 let velocity_magnitude = previous.velocity_magnitude
1458 + (post_velocity.magnitude() - previous.velocity_magnitude) * fraction;
1459 let mut time = previous.time + (post_time - previous.time) * fraction;
1460 if termination == TrajectoryTermination::TimeLimit {
1461 time = TRAJECTORY_TIME_LIMIT_S;
1462 }
1463 let kinetic_energy =
1464 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1465
1466 if position.y > *max_height {
1467 *max_height = position.y;
1468 }
1469 let terminal_point = TrajectoryPoint {
1470 time,
1471 position,
1472 velocity_magnitude,
1473 kinetic_energy,
1474 };
1475 if terminal_point.position.x < previous.position.x {
1476 return Err(BallisticsError::from(
1477 "trajectory terminal state reversed downrange before the crossed boundary",
1478 ));
1479 }
1480 if terminal_point.position.x == previous.position.x {
1481 let last = points.last_mut().ok_or_else(|| {
1486 BallisticsError::from("trajectory points disappeared during terminal finalization")
1487 })?;
1488 *last = terminal_point;
1489 } else {
1490 self.push_trajectory_point(points, terminal_point)?;
1491 }
1492 Ok(termination)
1493 }
1494
1495 fn gravity_acceleration(&self) -> Vector3<f64> {
1496 let theta = self.inputs.shooting_angle;
1497 Vector3::new(
1498 -crate::constants::G_ACCEL_MPS2 * theta.sin(),
1499 -crate::constants::G_ACCEL_MPS2 * theta.cos(),
1500 0.0,
1501 )
1502 }
1503
1504 fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
1505 let model = match self.inputs.wind_shear_model.as_str() {
1520 "logarithmic" => WindShearModel::Logarithmic,
1521 "power_law" | "powerlaw" | "exponential" => WindShearModel::PowerLaw,
1522 "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
1523 "custom_layers" | "custom" => WindShearModel::CustomLayers,
1524 _ => WindShearModel::PowerLaw,
1525 };
1526 let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
1527
1528 crate::wind::wind_vector(self.wind.speed, self.wind.direction, 0.0) * speed_ratio
1535 + Vector3::new(0.0, self.wind.vertical_speed, 0.0)
1536 }
1537
1538 pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
1539 self.validate_for_solve()?;
1540 let mut result = if self.inputs.use_rk4 {
1541 if self.inputs.use_adaptive_rk45 {
1542 self.solve_rk45()?
1543 } else {
1544 self.solve_rk4()?
1545 }
1546 } else {
1547 self.solve_euler()?
1548 };
1549 self.apply_spin_drift(&mut result);
1550 self.validate_result_sanity(&result)?;
1551 Ok(result)
1552 }
1553
1554 fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
1560 if !self.inputs.use_enhanced_spin_drift {
1561 return;
1562 }
1563 let d_in = self.inputs.bullet_diameter / 0.0254; let m_gr = self.inputs.bullet_mass / crate::constants::GRAINS_TO_KG; let twist_in = self.inputs.twist_rate; if d_in <= 0.0 || m_gr <= 0.0 || twist_in <= 0.0 {
1567 return;
1568 }
1569
1570 let sg = self.effective_spin_drift_sg();
1577
1578 for p in result.points.iter_mut() {
1579 if p.time <= 0.0 {
1580 continue;
1581 }
1582 p.position.z +=
1584 crate::spin_drift::litz_drift_meters(sg, p.time, self.inputs.is_twist_right);
1585 }
1586
1587 if let Some(samples) = result.sampled_points.as_mut() {
1591 for s in samples.iter_mut() {
1592 if s.time_s <= 0.0 {
1593 continue;
1594 }
1595 s.wind_drift_m +=
1596 crate::spin_drift::litz_drift_meters(sg, s.time_s, self.inputs.is_twist_right);
1597 }
1598 }
1599 }
1600
1601 fn effective_spin_drift_sg(&self) -> f64 {
1606 let (_, _, temp_c, press_hpa) = self.resolved_atmosphere();
1607 crate::spin_drift::effective_sg_from_inputs(&self.inputs, temp_c, press_hpa)
1608 }
1609
1610 fn initial_position(&self) -> Vector3<f64> {
1616 if self.inputs.cant_angle == 0.0 {
1617 return Vector3::new(0.0, self.inputs.muzzle_height, 0.0);
1618 }
1619 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
1620 let sh = self.inputs.sight_height;
1621 Vector3::new(
1622 0.0,
1623 self.inputs.muzzle_height + sh * (1.0 - cos_c),
1624 -sh * sin_c,
1625 )
1626 }
1627
1628 fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
1629 let mut time = 0.0;
1631 let mut position = self.initial_position();
1635 let aj_components = self.aerodynamic_jump_components();
1641 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1642 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1643 let mut velocity = Vector3::new(
1644 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1648
1649 let mut points = Vec::new();
1650 let mut max_height = position.y;
1651 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
1657 let mut mach_transitions = MachTransitionTracker::default();
1658
1659 let mut angular_state = if self.inputs.enable_precession_nutation {
1661 Some(AngularState {
1662 pitch_angle: 0.001, yaw_angle: 0.001,
1664 pitch_rate: 0.0,
1665 yaw_rate: 0.0,
1666 precession_angle: 0.0,
1667 nutation_phase: 0.0,
1668 })
1669 } else {
1670 None
1671 };
1672 let mut max_yaw_angle = 0.0;
1673 let mut max_precession_angle = 0.0;
1674
1675 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
1677 self.resolved_atmosphere();
1678 let base_ratio = air_density / 1.225;
1683
1684 let wind_vector =
1690 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
1691
1692 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1695 self.inputs.bullet_model.as_deref().unwrap_or("default"),
1696 );
1697
1698 while position.x < self.max_range
1700 && position.y > self.inputs.ground_threshold
1701 && time < TRAJECTORY_TIME_LIMIT_S
1702 {
1703 let velocity_magnitude = velocity.magnitude();
1705 let kinetic_energy =
1706 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1707
1708 self.push_trajectory_point(
1709 &mut points,
1710 TrajectoryPoint {
1711 time,
1712 position,
1713 velocity_magnitude,
1714 kinetic_energy,
1715 },
1716 )?;
1717
1718 {
1721 let mach_here = if speed_of_sound > 0.0 {
1722 velocity_magnitude / speed_of_sound
1723 } else {
1724 0.0
1725 };
1726 mach_transitions.record_downward_crossings(
1727 mach_here,
1728 position.x,
1729 &mut transonic_distances,
1730 );
1731 }
1732
1733 if position.y > max_height {
1735 max_height = position.y;
1736 }
1737
1738 if self.inputs.enable_pitch_damping {
1740 let mach = velocity_magnitude / speed_of_sound;
1741
1742 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1744 transonic_mach = Some(mach);
1745 }
1746
1747 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1749
1750 if pitch_damping < min_pitch_damping {
1752 min_pitch_damping = pitch_damping;
1753 }
1754 }
1755
1756 if self.inputs.enable_precession_nutation {
1758 if let Some(ref mut state) = angular_state {
1759 let velocity_magnitude = velocity.magnitude();
1760 let params = self.precession_nutation_params(
1761 velocity_magnitude,
1762 air_density,
1763 speed_of_sound,
1764 );
1765
1766 *state = calculate_combined_angular_motion(
1768 ¶ms,
1769 state,
1770 time,
1771 self.time_step,
1772 0.001, );
1774
1775 if state.yaw_angle.abs() > max_yaw_angle {
1777 max_yaw_angle = state.yaw_angle.abs();
1778 }
1779 if state.precession_angle.abs() > max_precession_angle {
1780 max_precession_angle = state.precession_angle.abs();
1781 }
1782 }
1783 }
1784
1785 let acceleration = self.calculate_acceleration(
1792 &position,
1793 &velocity,
1794 &wind_vector,
1795 (resolved_temp_c, resolved_press_hpa, base_ratio),
1796 );
1797
1798 velocity += acceleration * self.time_step;
1800 position += velocity * self.time_step;
1801 time += self.time_step;
1802 self.validate_integration_state(&position, &velocity, time)?;
1803 }
1804
1805 let termination =
1806 self.append_terminal_endpoint(&mut points, position, velocity, time, &mut max_height)?;
1807
1808 let last_point = points.last().ok_or("No trajectory points generated")?;
1810
1811 let sampled_points = if self.inputs.enable_trajectory_sampling {
1813 let trajectory_data = TrajectoryData {
1814 times: points.iter().map(|p| p.time).collect(),
1815 positions: points.iter().map(|p| p.position).collect(),
1816 velocities: points
1817 .iter()
1818 .map(|p| {
1819 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1821 })
1822 .collect(),
1823 transonic_distances, };
1825
1826 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1831 let outputs = TrajectoryOutputs {
1832 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
1834 time_of_flight_s: last_point.time,
1835 max_ord_dist_horiz_m: max_height,
1836 sight_height_m: sight_position_m,
1837 };
1838
1839 let samples = sample_trajectory(
1841 &trajectory_data,
1842 &outputs,
1843 self.inputs.sample_interval,
1844 self.inputs.bullet_mass,
1845 )?;
1846 Some(samples)
1847 } else {
1848 None
1849 };
1850
1851 Ok(TrajectoryResult {
1852 max_range: last_point.position.x, max_height,
1854 time_of_flight: last_point.time,
1855 impact_velocity: last_point.velocity_magnitude,
1856 impact_energy: last_point.kinetic_energy,
1857 projectile_mass_kg: self.inputs.bullet_mass,
1858 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
1859 station_speed_of_sound_mps: speed_of_sound,
1860 termination,
1861 points,
1862 sampled_points,
1863 min_pitch_damping: if self.inputs.enable_pitch_damping {
1864 Some(min_pitch_damping)
1865 } else {
1866 None
1867 },
1868 transonic_mach,
1869 angular_state,
1870 max_yaw_angle: if self.inputs.enable_precession_nutation {
1871 Some(max_yaw_angle)
1872 } else {
1873 None
1874 },
1875 max_precession_angle: if self.inputs.enable_precession_nutation {
1876 Some(max_precession_angle)
1877 } else {
1878 None
1879 },
1880 aerodynamic_jump: aj_components,
1881 })
1882 }
1883
1884 fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
1885 let mut time = 0.0;
1887 let mut position = self.initial_position();
1892
1893 let aj_components = self.aerodynamic_jump_components();
1899 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1900 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1901 let mut velocity = Vector3::new(
1902 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1906
1907 let mut points = Vec::new();
1908 let mut max_height = position.y;
1909 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
1915 let mut mach_transitions = MachTransitionTracker::default();
1916
1917 let mut angular_state = if self.inputs.enable_precession_nutation {
1919 Some(AngularState {
1920 pitch_angle: 0.001, yaw_angle: 0.001,
1922 pitch_rate: 0.0,
1923 yaw_rate: 0.0,
1924 precession_angle: 0.0,
1925 nutation_phase: 0.0,
1926 })
1927 } else {
1928 None
1929 };
1930 let mut max_yaw_angle = 0.0;
1931 let mut max_precession_angle = 0.0;
1932
1933 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
1935 self.resolved_atmosphere();
1936 let base_ratio = air_density / 1.225;
1941
1942 let wind_vector =
1948 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
1949
1950 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1953 self.inputs.bullet_model.as_deref().unwrap_or("default"),
1954 );
1955
1956 while position.x < self.max_range
1958 && position.y > self.inputs.ground_threshold
1959 && time < TRAJECTORY_TIME_LIMIT_S
1960 {
1961 let velocity_magnitude = velocity.magnitude();
1963 let kinetic_energy =
1964 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1965
1966 self.push_trajectory_point(
1967 &mut points,
1968 TrajectoryPoint {
1969 time,
1970 position,
1971 velocity_magnitude,
1972 kinetic_energy,
1973 },
1974 )?;
1975
1976 {
1979 let mach_here = if speed_of_sound > 0.0 {
1980 velocity_magnitude / speed_of_sound
1981 } else {
1982 0.0
1983 };
1984 mach_transitions.record_downward_crossings(
1985 mach_here,
1986 position.x,
1987 &mut transonic_distances,
1988 );
1989 }
1990
1991 if position.y > max_height {
1992 max_height = position.y;
1993 }
1994
1995 if self.inputs.enable_pitch_damping {
1997 let mach = velocity_magnitude / speed_of_sound;
1998
1999 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2001 transonic_mach = Some(mach);
2002 }
2003
2004 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2006
2007 if pitch_damping < min_pitch_damping {
2009 min_pitch_damping = pitch_damping;
2010 }
2011 }
2012
2013 if self.inputs.enable_precession_nutation {
2015 if let Some(ref mut state) = angular_state {
2016 let velocity_magnitude = velocity.magnitude();
2017 let params = self.precession_nutation_params(
2018 velocity_magnitude,
2019 air_density,
2020 speed_of_sound,
2021 );
2022
2023 *state = calculate_combined_angular_motion(
2025 ¶ms,
2026 state,
2027 time,
2028 self.time_step,
2029 0.001, );
2031
2032 if state.yaw_angle.abs() > max_yaw_angle {
2034 max_yaw_angle = state.yaw_angle.abs();
2035 }
2036 if state.precession_angle.abs() > max_precession_angle {
2037 max_precession_angle = state.precession_angle.abs();
2038 }
2039 }
2040 }
2041
2042 let dt = self.time_step;
2044
2045 let acc1 = self.calculate_acceleration(
2047 &position,
2048 &velocity,
2049 &wind_vector,
2050 (resolved_temp_c, resolved_press_hpa, base_ratio),
2051 );
2052
2053 let pos2 = position + velocity * (dt * 0.5);
2055 let vel2 = velocity + acc1 * (dt * 0.5);
2056 let acc2 = self.calculate_acceleration(
2057 &pos2,
2058 &vel2,
2059 &wind_vector,
2060 (resolved_temp_c, resolved_press_hpa, base_ratio),
2061 );
2062
2063 let pos3 = position + vel2 * (dt * 0.5);
2065 let vel3 = velocity + acc2 * (dt * 0.5);
2066 let acc3 = self.calculate_acceleration(
2067 &pos3,
2068 &vel3,
2069 &wind_vector,
2070 (resolved_temp_c, resolved_press_hpa, base_ratio),
2071 );
2072
2073 let pos4 = position + vel3 * dt;
2075 let vel4 = velocity + acc3 * dt;
2076 let acc4 = self.calculate_acceleration(
2077 &pos4,
2078 &vel4,
2079 &wind_vector,
2080 (resolved_temp_c, resolved_press_hpa, base_ratio),
2081 );
2082
2083 position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
2085 velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
2086 time += dt;
2087 self.validate_integration_state(&position, &velocity, time)?;
2088 }
2089
2090 let termination =
2091 self.append_terminal_endpoint(&mut points, position, velocity, time, &mut max_height)?;
2092
2093 let last_point = points.last().ok_or("No trajectory points generated")?;
2095
2096 let sampled_points = if self.inputs.enable_trajectory_sampling {
2098 let trajectory_data = TrajectoryData {
2099 times: points.iter().map(|p| p.time).collect(),
2100 positions: points.iter().map(|p| p.position).collect(),
2101 velocities: points
2102 .iter()
2103 .map(|p| {
2104 Vector3::new(0.0, 0.0, p.velocity_magnitude)
2106 })
2107 .collect(),
2108 transonic_distances, };
2110
2111 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
2116 let outputs = TrajectoryOutputs {
2117 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
2119 time_of_flight_s: last_point.time,
2120 max_ord_dist_horiz_m: max_height,
2121 sight_height_m: sight_position_m,
2122 };
2123
2124 let samples = sample_trajectory(
2126 &trajectory_data,
2127 &outputs,
2128 self.inputs.sample_interval,
2129 self.inputs.bullet_mass,
2130 )?;
2131 Some(samples)
2132 } else {
2133 None
2134 };
2135
2136 Ok(TrajectoryResult {
2137 max_range: last_point.position.x, max_height,
2139 time_of_flight: last_point.time,
2140 impact_velocity: last_point.velocity_magnitude,
2141 impact_energy: last_point.kinetic_energy,
2142 projectile_mass_kg: self.inputs.bullet_mass,
2143 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
2144 station_speed_of_sound_mps: speed_of_sound,
2145 termination,
2146 points,
2147 sampled_points,
2148 min_pitch_damping: if self.inputs.enable_pitch_damping {
2149 Some(min_pitch_damping)
2150 } else {
2151 None
2152 },
2153 transonic_mach,
2154 angular_state,
2155 max_yaw_angle: if self.inputs.enable_precession_nutation {
2156 Some(max_yaw_angle)
2157 } else {
2158 None
2159 },
2160 max_precession_angle: if self.inputs.enable_precession_nutation {
2161 Some(max_precession_angle)
2162 } else {
2163 None
2164 },
2165 aerodynamic_jump: aj_components,
2166 })
2167 }
2168
2169 fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
2170 let mut time = 0.0;
2172 let mut position = self.initial_position();
2176
2177 let aj_components = self.aerodynamic_jump_components();
2183 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
2184 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
2185 let mut velocity = Vector3::new(
2186 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
2190
2191 let mut points = Vec::new();
2192 let mut max_height = position.y;
2193 let mut dt = 0.001; let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2198 self.resolved_atmosphere();
2199 let base_ratio = air_density / 1.225;
2204 let wind_vector =
2209 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
2210
2211 let mut transonic_distances: Vec<f64> = Vec::new();
2213 let mut mach_transitions = MachTransitionTracker::default();
2214
2215 let mut min_pitch_damping = f64::INFINITY;
2220 let mut transonic_mach: Option<f64> = None;
2221 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2222 self.inputs.bullet_model.as_deref().unwrap_or("default"),
2223 );
2224 let mut angular_state = if self.inputs.enable_precession_nutation {
2225 Some(AngularState {
2226 pitch_angle: 0.001,
2227 yaw_angle: 0.001,
2228 pitch_rate: 0.0,
2229 yaw_rate: 0.0,
2230 precession_angle: 0.0,
2231 nutation_phase: 0.0,
2232 })
2233 } else {
2234 None
2235 };
2236 let mut max_yaw_angle = 0.0;
2237 let mut max_precession_angle = 0.0;
2238
2239 while position.x < self.max_range
2240 && position.y > self.inputs.ground_threshold
2241 && time < TRAJECTORY_TIME_LIMIT_S
2242 {
2243 let velocity_magnitude = velocity.magnitude();
2245 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
2246
2247 self.push_trajectory_point(
2248 &mut points,
2249 TrajectoryPoint {
2250 time,
2251 position,
2252 velocity_magnitude,
2253 kinetic_energy,
2254 },
2255 )?;
2256
2257 {
2260 let mach_here = if speed_of_sound > 0.0 {
2261 velocity_magnitude / speed_of_sound
2262 } else {
2263 0.0
2264 };
2265 mach_transitions.record_downward_crossings(
2266 mach_here,
2267 position.x,
2268 &mut transonic_distances,
2269 );
2270 }
2271
2272 if position.y > max_height {
2273 max_height = position.y;
2274 }
2275
2276 if self.inputs.enable_pitch_damping {
2279 let mach = velocity_magnitude / speed_of_sound;
2280 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2281 transonic_mach = Some(mach);
2282 }
2283 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2284 if pitch_damping < min_pitch_damping {
2285 min_pitch_damping = pitch_damping;
2286 }
2287 }
2288
2289 let accepted_step = self.adaptive_rk45_step(
2292 &position,
2293 &velocity,
2294 dt,
2295 &wind_vector,
2296 (resolved_temp_c, resolved_press_hpa, base_ratio),
2297 );
2298 debug_assert!(
2299 accepted_step.error <= RK45_TOLERANCE || accepted_step.used_dt <= RK45_MIN_DT
2300 );
2301
2302 if self.inputs.enable_precession_nutation {
2306 if let Some(ref mut state) = angular_state {
2307 let params = self.precession_nutation_params(
2308 velocity_magnitude,
2309 air_density,
2310 speed_of_sound,
2311 );
2312
2313 *state = calculate_combined_angular_motion(
2314 ¶ms,
2315 state,
2316 time,
2317 accepted_step.used_dt,
2318 0.001,
2319 );
2320
2321 if state.yaw_angle.abs() > max_yaw_angle {
2322 max_yaw_angle = state.yaw_angle.abs();
2323 }
2324 if state.precession_angle.abs() > max_precession_angle {
2325 max_precession_angle = state.precession_angle.abs();
2326 }
2327 }
2328 }
2329
2330 position = accepted_step.position;
2331 velocity = accepted_step.velocity;
2332 time += accepted_step.used_dt;
2333 self.validate_integration_state(&position, &velocity, time)?;
2334
2335 dt = accepted_step.next_dt;
2337 }
2338
2339 if points.is_empty() {
2341 return Err(BallisticsError::from("No trajectory points calculated"));
2342 }
2343
2344 let termination =
2346 self.append_terminal_endpoint(&mut points, position, velocity, time, &mut max_height)?;
2347
2348 let last_point = points.last().unwrap();
2349
2350 let sampled_points = if self.inputs.enable_trajectory_sampling {
2352 let trajectory_data = TrajectoryData {
2354 times: points.iter().map(|p| p.time).collect(),
2355 positions: points.iter().map(|p| p.position).collect(),
2356 velocities: points
2357 .iter()
2358 .map(|p| {
2359 Vector3::new(0.0, 0.0, p.velocity_magnitude)
2361 })
2362 .collect(),
2363 transonic_distances, };
2365
2366 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
2371 let outputs = TrajectoryOutputs {
2372 target_distance_horiz_m: last_point.position.x,
2373 target_vertical_height_m: sight_position_m,
2374 time_of_flight_s: last_point.time,
2375 max_ord_dist_horiz_m: max_height,
2376 sight_height_m: sight_position_m,
2377 };
2378
2379 let samples = sample_trajectory(
2380 &trajectory_data,
2381 &outputs,
2382 self.inputs.sample_interval,
2383 self.inputs.bullet_mass,
2384 )?;
2385 Some(samples)
2386 } else {
2387 None
2388 };
2389
2390 Ok(TrajectoryResult {
2391 max_range: last_point.position.x, max_height,
2393 time_of_flight: last_point.time,
2394 impact_velocity: last_point.velocity_magnitude,
2395 impact_energy: last_point.kinetic_energy,
2396 projectile_mass_kg: self.inputs.bullet_mass,
2397 line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
2398 station_speed_of_sound_mps: speed_of_sound,
2399 termination,
2400 points,
2401 sampled_points,
2402 min_pitch_damping: if self.inputs.enable_pitch_damping {
2403 Some(min_pitch_damping)
2404 } else {
2405 None
2406 },
2407 transonic_mach,
2408 angular_state,
2409 max_yaw_angle: if self.inputs.enable_precession_nutation {
2410 Some(max_yaw_angle)
2411 } else {
2412 None
2413 },
2414 max_precession_angle: if self.inputs.enable_precession_nutation {
2415 Some(max_precession_angle)
2416 } else {
2417 None
2418 },
2419 aerodynamic_jump: aj_components,
2420 })
2421 }
2422
2423 fn adaptive_rk45_step(
2424 &self,
2425 position: &Vector3<f64>,
2426 velocity: &Vector3<f64>,
2427 initial_dt: f64,
2428 wind_vector: &Vector3<f64>,
2429 resolved_atmo: (f64, f64, f64),
2430 ) -> Rk45AcceptedStep {
2431 let mut trial_dt = initial_dt;
2432
2433 loop {
2434 let trial = self.rk45_step(
2435 position,
2436 velocity,
2437 trial_dt,
2438 wind_vector,
2439 RK45_TOLERANCE,
2440 resolved_atmo,
2441 );
2442 let next_dt = if trial.suggested_dt.is_finite() {
2447 (RK45_SAFETY_FACTOR * trial.suggested_dt).clamp(RK45_MIN_DT, RK45_MAX_DT)
2448 } else {
2449 RK45_MIN_DT
2450 };
2451
2452 if trial.error <= RK45_TOLERANCE || trial_dt <= RK45_MIN_DT {
2453 return Rk45AcceptedStep {
2454 position: trial.position,
2455 velocity: trial.velocity,
2456 used_dt: trial_dt,
2457 next_dt,
2458 error: trial.error,
2459 };
2460 }
2461
2462 trial_dt = next_dt;
2463 }
2464 }
2465
2466 fn rk45_step(
2467 &self,
2468 position: &Vector3<f64>,
2469 velocity: &Vector3<f64>,
2470 dt: f64,
2471 wind_vector: &Vector3<f64>,
2472 tolerance: f64,
2473 resolved_atmo: (f64, f64, f64), ) -> Rk45Trial {
2475 const A21: f64 = 1.0 / 5.0;
2477 const A31: f64 = 3.0 / 40.0;
2478 const A32: f64 = 9.0 / 40.0;
2479 const A41: f64 = 44.0 / 45.0;
2480 const A42: f64 = -56.0 / 15.0;
2481 const A43: f64 = 32.0 / 9.0;
2482 const A51: f64 = 19372.0 / 6561.0;
2483 const A52: f64 = -25360.0 / 2187.0;
2484 const A53: f64 = 64448.0 / 6561.0;
2485 const A54: f64 = -212.0 / 729.0;
2486 const A61: f64 = 9017.0 / 3168.0;
2487 const A62: f64 = -355.0 / 33.0;
2488 const A63: f64 = 46732.0 / 5247.0;
2489 const A64: f64 = 49.0 / 176.0;
2490 const A65: f64 = -5103.0 / 18656.0;
2491 const A71: f64 = 35.0 / 384.0;
2492 const A73: f64 = 500.0 / 1113.0;
2493 const A74: f64 = 125.0 / 192.0;
2494 const A75: f64 = -2187.0 / 6784.0;
2495 const A76: f64 = 11.0 / 84.0;
2496
2497 const B1: f64 = 35.0 / 384.0;
2499 const B3: f64 = 500.0 / 1113.0;
2500 const B4: f64 = 125.0 / 192.0;
2501 const B5: f64 = -2187.0 / 6784.0;
2502 const B6: f64 = 11.0 / 84.0;
2503
2504 const B1_ERR: f64 = 5179.0 / 57600.0;
2506 const B3_ERR: f64 = 7571.0 / 16695.0;
2507 const B4_ERR: f64 = 393.0 / 640.0;
2508 const B5_ERR: f64 = -92097.0 / 339200.0;
2509 const B6_ERR: f64 = 187.0 / 2100.0;
2510 const B7_ERR: f64 = 1.0 / 40.0;
2511
2512 let k1_v = self.calculate_acceleration(position, velocity, wind_vector, resolved_atmo);
2514 let k1_p = *velocity;
2515
2516 let p2 = position + dt * A21 * k1_p;
2517 let v2 = velocity + dt * A21 * k1_v;
2518 let k2_v = self.calculate_acceleration(&p2, &v2, wind_vector, resolved_atmo);
2519 let k2_p = v2;
2520
2521 let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
2522 let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
2523 let k3_v = self.calculate_acceleration(&p3, &v3, wind_vector, resolved_atmo);
2524 let k3_p = v3;
2525
2526 let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
2527 let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
2528 let k4_v = self.calculate_acceleration(&p4, &v4, wind_vector, resolved_atmo);
2529 let k4_p = v4;
2530
2531 let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
2532 let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
2533 let k5_v = self.calculate_acceleration(&p5, &v5, wind_vector, resolved_atmo);
2534 let k5_p = v5;
2535
2536 let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
2537 let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
2538 let k6_v = self.calculate_acceleration(&p6, &v6, wind_vector, resolved_atmo);
2539 let k6_p = v6;
2540
2541 let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
2542 let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
2543 let k7_v = self.calculate_acceleration(&p7, &v7, wind_vector, resolved_atmo);
2544 let k7_p = v7;
2545
2546 let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
2548 let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
2549
2550 let pos_err = position
2552 + dt * (B1_ERR * k1_p
2553 + B3_ERR * k3_p
2554 + B4_ERR * k4_p
2555 + B5_ERR * k5_p
2556 + B6_ERR * k6_p
2557 + B7_ERR * k7_p);
2558 let vel_err = velocity
2559 + dt * (B1_ERR * k1_v
2560 + B3_ERR * k3_v
2561 + B4_ERR * k4_v
2562 + B5_ERR * k5_v
2563 + B6_ERR * k6_v
2564 + B7_ERR * k7_v);
2565
2566 let error = cli_rk45_error_norm(position, velocity, &new_pos, &new_vel, &pos_err, &vel_err);
2568
2569 let dt_new = if error < tolerance {
2571 dt * (tolerance / error).powf(0.2).min(2.0)
2572 } else {
2573 dt * (tolerance / error).powf(0.25).max(0.1)
2574 };
2575
2576 Rk45Trial {
2577 position: new_pos,
2578 velocity: new_vel,
2579 suggested_dt: dt_new,
2580 error,
2581 }
2582 }
2583
2584 fn apply_cluster_bc_correction(&self, base_bc: f64, velocity_fps: f64) -> f64 {
2585 if let Some(ref cluster_bc) = self.cluster_bc {
2586 cluster_bc.apply_correction_for_drag_model(
2587 base_bc,
2588 self.inputs.caliber_inches,
2589 self.inputs.weight_grains,
2590 velocity_fps,
2591 self.inputs.bc_type,
2592 )
2593 } else {
2594 base_bc
2595 }
2596 }
2597
2598 fn calculate_acceleration(
2599 &self,
2600 position: &Vector3<f64>,
2601 velocity: &Vector3<f64>,
2602 wind_vector: &Vector3<f64>,
2603 resolved_atmo: (f64, f64, f64), ) -> Vector3<f64> {
2605 let actual_wind = if let Some(ref sock) = self.wind_sock {
2611 sock.vector_for_range_stateless(position.x)
2612 } else if self.inputs.enable_wind_shear {
2613 self.get_wind_at_altitude(position.y)
2614 } else {
2615 *wind_vector
2616 };
2617 let actual_wind =
2618 crate::derivatives::level_vector_to_shot_frame(actual_wind, self.inputs.shooting_angle);
2619
2620 let relative_velocity = velocity - actual_wind;
2621 let velocity_magnitude = relative_velocity.magnitude();
2622
2623 if velocity_magnitude < 0.001 {
2624 return self.gravity_acceleration();
2625 }
2626
2627 let (base_temp_c, base_press_hpa, station_ratio) = resolved_atmo;
2638
2639 let (drag_base_temp_c, drag_base_press_hpa, drag_base_ratio, drag_humidity_percent) =
2647 if let Some(ref sock) = self.atmo_sock {
2648 let (zone_temp_c, zone_press_hpa, zone_humidity) = sock.atmo_for_range(position.x);
2649 let zone_base_ratio = crate::atmosphere::calculate_air_density_cimp(
2650 zone_temp_c,
2651 zone_press_hpa,
2652 zone_humidity,
2653 ) / 1.225;
2654 (zone_temp_c, zone_press_hpa, zone_base_ratio, zone_humidity)
2655 } else {
2656 (
2657 base_temp_c,
2658 base_press_hpa,
2659 station_ratio,
2660 self.atmosphere.humidity,
2661 )
2662 };
2663 let local_alt = crate::atmosphere::shot_frame_altitude(
2664 self.atmosphere.altitude,
2665 position.x,
2666 position.y,
2667 self.inputs.shooting_angle,
2668 );
2669 let (air_density, speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
2670 local_alt,
2671 self.atmosphere.altitude,
2672 drag_base_temp_c,
2673 drag_base_press_hpa,
2674 drag_base_ratio,
2675 drag_humidity_percent,
2676 );
2677
2678 let cd = self.calculate_drag_coefficient(velocity_magnitude, speed_of_sound);
2680
2681 let velocity_fps = velocity_magnitude * 3.28084;
2683
2684 let (base_bc, bc_from_segments) = if let Some(segments) = self
2689 .inputs
2690 .bc_segments_data
2691 .as_ref()
2692 .filter(|segments| self.inputs.use_bc_segments && !segments.is_empty())
2693 {
2694 (
2696 crate::bc_estimation::velocity_segment_bc(
2697 velocity_fps,
2698 segments,
2699 self.inputs.bc_value,
2700 ),
2701 true,
2702 )
2703 } else if let Some(segments) = self
2704 .inputs
2705 .bc_segments
2706 .as_ref()
2707 .filter(|segments| !segments.is_empty())
2708 {
2709 (
2710 crate::derivatives::interpolated_bc(
2711 velocity_magnitude / speed_of_sound,
2712 segments,
2713 Some(&self.inputs),
2714 ),
2715 true,
2716 )
2717 } else {
2718 (self.inputs.bc_value, false)
2719 };
2720
2721 let effective_bc = if bc_from_segments {
2726 base_bc
2727 } else {
2728 self.apply_cluster_bc_correction(base_bc, velocity_fps)
2729 };
2730 let effective_bc = effective_bc.max(1e-6);
2733
2734 let retard_denom = if self.inputs.custom_drag_table.is_some() {
2739 self.inputs.custom_drag_denominator(effective_bc)
2740 } else {
2741 effective_bc
2742 };
2743
2744 let cd_to_retard = crate::constants::CD_TO_RETARD;
2749 let standard_factor = cd * cd_to_retard;
2750 let density_scale = air_density / 1.225; let a_drag_ft_s2 =
2754 (velocity_fps * velocity_fps) * standard_factor * density_scale / retard_denom;
2755 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
2759
2760 let mut accel = drag_acceleration + self.gravity_acceleration();
2763
2764 if self.inputs.enable_coriolis {
2767 if let Some(lat_deg) = self.inputs.latitude {
2768 let omega_earth = 7.2921159e-5_f64; let lat = lat_deg.to_radians();
2770 let az = self.inputs.shot_azimuth; let omega = Vector3::new(
2777 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), );
2781 let omega = crate::derivatives::level_vector_to_shot_frame(
2782 omega,
2783 self.inputs.shooting_angle,
2784 );
2785 accel += -2.0 * omega.cross(velocity);
2790 }
2791 }
2792
2793 if self.inputs.enable_magnus
2800 && !self.inputs.use_enhanced_spin_drift
2801 && self.inputs.bullet_diameter > 0.0
2802 && self.inputs.twist_rate > 0.0
2803 {
2804 let diameter_m = self.inputs.bullet_diameter;
2805 let (spin_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
2806 self.inputs.muzzle_velocity,
2807 velocity_magnitude,
2808 self.inputs.twist_rate,
2809 diameter_m,
2810 );
2811 let mach = velocity_magnitude / speed_of_sound;
2813
2814 let d_in = self.inputs.bullet_diameter / 0.0254;
2816 let m_gr = self.inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
2817 let l_in = if self.inputs.bullet_length > 0.0 {
2818 self.inputs.bullet_length / 0.0254
2819 } else {
2820 let est_m = crate::stability::estimate_bullet_length_m(
2822 self.inputs.bullet_diameter,
2823 self.inputs.bullet_mass,
2824 );
2825 if est_m > 0.0 {
2826 est_m / 0.0254
2827 } else {
2828 4.5 * d_in
2829 }
2830 };
2831 let sg = crate::spin_drift::calculate_dynamic_stability(
2835 m_gr,
2836 velocity_magnitude,
2837 spin_rad_s,
2838 d_in,
2839 l_in,
2840 air_density,
2841 );
2842
2843 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
2845 sg,
2846 velocity_magnitude,
2847 spin_rad_s,
2848 0.0, 0.0, air_density,
2851 d_in,
2852 l_in,
2853 m_gr,
2854 mach,
2855 "match",
2856 false,
2857 );
2858
2859 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
2861 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
2862 let magnus_force = 0.5
2863 * air_density
2864 * velocity_magnitude.powi(2)
2865 * area
2866 * c_np
2867 * spin_param
2868 * yaw_rad.sin();
2869
2870 if magnus_force.abs() > 1e-12 {
2874 if let Some(dir) = crate::derivatives::yaw_of_repose_magnus_direction(
2875 relative_velocity,
2876 self.gravity_acceleration(),
2877 self.inputs.is_twist_right,
2878 ) {
2879 accel += (magnus_force / self.inputs.bullet_mass) * dir;
2880 }
2881 }
2882 }
2883
2884 accel
2885 }
2886
2887 fn calculate_drag_coefficient(&self, velocity: f64, speed_of_sound: f64) -> f64 {
2888 let mach = velocity / speed_of_sound;
2889
2890 if let Some(ref table) = self.inputs.custom_drag_table {
2894 return table.interpolate(mach);
2895 }
2896
2897 crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type)
2900 }
2901}
2902
2903#[derive(Debug, Clone)]
2905pub struct MonteCarloParams {
2906 pub num_simulations: usize,
2907 pub velocity_std_dev: f64,
2908 pub angle_std_dev: f64,
2909 pub bc_std_dev: f64,
2910 pub wind_speed_std_dev: f64,
2911 pub target_distance: Option<f64>,
2912 pub base_wind_speed: f64,
2913 pub base_wind_direction: f64,
2914 pub azimuth_std_dev: f64, }
2916
2917impl Default for MonteCarloParams {
2918 fn default() -> Self {
2919 Self {
2920 num_simulations: 1000,
2921 velocity_std_dev: 1.0,
2922 angle_std_dev: 0.001,
2923 bc_std_dev: 0.01,
2924 wind_speed_std_dev: 1.0,
2925 target_distance: None,
2926 base_wind_speed: 0.0,
2927 base_wind_direction: 0.0,
2928 azimuth_std_dev: 0.001, }
2930 }
2931}
2932
2933#[derive(Debug, Clone)]
2935pub struct MonteCarloResults {
2936 pub ranges: Vec<f64>,
2937 pub impact_velocities: Vec<f64>,
2938 pub impact_positions: Vec<Vector3<f64>>,
2944}
2945
2946pub const DEFAULT_HIT_RADIUS_M: f64 = 0.3;
2949
2950pub const TARGET_NOT_REACHED_SENTINEL_M: f64 = -1.0e9;
2956
2957impl MonteCarloResults {
2958 pub fn position_reached_target(position: &Vector3<f64>) -> bool {
2960 position.iter().all(|component| component.is_finite())
2961 && position.y != TARGET_NOT_REACHED_SENTINEL_M
2962 }
2963
2964 pub fn target_arrival_count(&self) -> usize {
2966 self.impact_positions
2967 .iter()
2968 .filter(|position| Self::position_reached_target(position))
2969 .count()
2970 }
2971
2972 pub fn target_shortfall_fraction(&self) -> f64 {
2975 if self.impact_positions.is_empty() {
2976 return 0.0;
2977 }
2978 (self.impact_positions.len() - self.target_arrival_count()) as f64
2979 / self.impact_positions.len() as f64
2980 }
2981
2982 pub fn target_plane_cep(&self) -> Option<f64> {
2988 let mut radial_misses: Vec<f64> = self
2989 .impact_positions
2990 .iter()
2991 .filter(|position| Self::position_reached_target(position))
2992 .map(Vector3::norm)
2993 .filter(|miss| miss.is_finite())
2994 .collect();
2995 radial_misses.sort_by(f64::total_cmp);
2996 if radial_misses.is_empty() {
2997 None
2998 } else {
2999 Some(radial_misses[radial_misses.len() / 2])
3000 }
3001 }
3002
3003 pub fn hit_probability(&self, hit_radius_m: f64) -> f64 {
3012 if self.impact_positions.is_empty() {
3013 return 0.0;
3014 }
3015 let hits = self
3016 .impact_positions
3017 .iter()
3018 .filter(|position| {
3019 Self::position_reached_target(position) && position.norm() < hit_radius_m
3020 })
3021 .count();
3022 hits as f64 / self.impact_positions.len() as f64
3023 }
3024
3025 pub fn rect_hit_probability(&self, width_m: f64, height_m: f64) -> f64 {
3037 let dimensions_invalid = width_m.is_nan()
3038 || width_m <= 0.0
3039 || height_m.is_nan()
3040 || height_m <= 0.0;
3041 if self.impact_positions.is_empty() || dimensions_invalid {
3042 return 0.0;
3043 }
3044 let half_width = width_m / 2.0;
3045 let half_height = height_m / 2.0;
3046 let hits = self
3047 .impact_positions
3048 .iter()
3049 .filter(|position| {
3050 Self::position_reached_target(position)
3051 && position.z.abs() <= half_width
3052 && position.y.abs() <= half_height
3053 })
3054 .count();
3055 hits as f64 / self.impact_positions.len() as f64
3056 }
3057}
3058
3059fn wind_from_signed_speed_sample(
3060 signed_speed: f64,
3061 sampled_direction: f64,
3062 vertical_speed: f64,
3063) -> WindConditions {
3064 if signed_speed < 0.0 {
3069 WindConditions {
3070 speed: -signed_speed,
3071 direction: sampled_direction + std::f64::consts::PI,
3072 vertical_speed,
3073 }
3074 } else {
3075 WindConditions {
3076 speed: signed_speed,
3077 direction: sampled_direction,
3078 vertical_speed,
3079 }
3080 }
3081}
3082
3083struct MonteCarloWindSampler {
3084 speed: rand_distr::Normal<f64>,
3085 direction: rand_distr::Normal<f64>,
3086 vertical_speed: f64,
3088}
3089
3090impl MonteCarloWindSampler {
3091 fn new(
3092 base_wind: &WindConditions,
3093 wind_speed_std_dev: f64,
3094 wind_direction_std_dev: f64,
3095 ) -> Result<Self, BallisticsError> {
3096 use rand_distr::Normal;
3097
3098 if !wind_direction_std_dev.is_finite() || wind_direction_std_dev < 0.0 {
3099 return Err("Wind direction standard deviation must be finite and non-negative".into());
3100 }
3101
3102 let speed = Normal::new(base_wind.speed, wind_speed_std_dev)
3103 .map_err(|e| format!("Invalid wind speed distribution: {e}"))?;
3104 let direction = Normal::new(base_wind.direction, wind_direction_std_dev)
3105 .map_err(|e| format!("Invalid wind direction distribution: {e}"))?;
3106 Ok(Self { speed, direction, vertical_speed: base_wind.vertical_speed })
3107 }
3108
3109 fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> WindConditions {
3110 use rand_distr::Distribution;
3111
3112 wind_from_signed_speed_sample(
3113 self.speed.sample(rng),
3114 self.direction.sample(rng),
3115 self.vertical_speed,
3116 )
3117 }
3118}
3119
3120pub fn run_monte_carlo(
3122 base_inputs: BallisticInputs,
3123 params: MonteCarloParams,
3124) -> Result<MonteCarloResults, BallisticsError> {
3125 run_monte_carlo_with_direction_std_dev(base_inputs, params, 0.0)
3126}
3127
3128pub fn run_monte_carlo_with_direction_std_dev(
3133 base_inputs: BallisticInputs,
3134 params: MonteCarloParams,
3135 wind_direction_std_dev: f64,
3136) -> Result<MonteCarloResults, BallisticsError> {
3137 let base_wind = WindConditions {
3138 speed: params.base_wind_speed,
3139 direction: params.base_wind_direction,
3140 vertical_speed: 0.0,
3141 };
3142 run_monte_carlo_with_wind_and_direction_std_dev(
3143 base_inputs,
3144 base_wind,
3145 params,
3146 wind_direction_std_dev,
3147 )
3148}
3149
3150pub fn run_monte_carlo_with_wind(
3152 base_inputs: BallisticInputs,
3153 base_wind: WindConditions,
3154 params: MonteCarloParams,
3155) -> Result<MonteCarloResults, BallisticsError> {
3156 run_monte_carlo_with_wind_and_direction_std_dev(base_inputs, base_wind, params, 0.0)
3157}
3158
3159pub fn run_monte_carlo_with_wind_and_direction_std_dev(
3164 base_inputs: BallisticInputs,
3165 base_wind: WindConditions,
3166 params: MonteCarloParams,
3167 wind_direction_std_dev: f64,
3168) -> Result<MonteCarloResults, BallisticsError> {
3169 let mut rng = rand::rng();
3170 run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
3171 base_inputs,
3172 base_wind,
3173 params,
3174 wind_direction_std_dev,
3175 &mut rng,
3176 )
3177}
3178
3179pub fn run_monte_carlo_with_wind_and_direction_std_dev_seeded(
3186 base_inputs: BallisticInputs,
3187 base_wind: WindConditions,
3188 params: MonteCarloParams,
3189 wind_direction_std_dev: f64,
3190 seed: u64,
3191) -> Result<MonteCarloResults, BallisticsError> {
3192 use rand::{rngs::StdRng, SeedableRng};
3193 let mut rng = StdRng::seed_from_u64(seed);
3194 run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
3195 base_inputs,
3196 base_wind,
3197 params,
3198 wind_direction_std_dev,
3199 &mut rng,
3200 )
3201}
3202
3203fn run_monte_carlo_with_wind_and_direction_std_dev_using_rng<R: rand::Rng + ?Sized>(
3204 base_inputs: BallisticInputs,
3205 base_wind: WindConditions,
3206 params: MonteCarloParams,
3207 wind_direction_std_dev: f64,
3208 rng: &mut R,
3209) -> Result<MonteCarloResults, BallisticsError> {
3210 use rand_distr::{Distribution, Normal};
3211
3212 let mut ranges = Vec::new();
3213 let mut impact_velocities = Vec::new();
3214 let mut impact_positions = Vec::new();
3215
3216 let atmosphere = AtmosphericConditions {
3217 temperature: base_inputs.temperature,
3218 pressure: base_inputs.pressure,
3219 humidity: base_inputs.humidity_percent(),
3220 altitude: base_inputs.altitude,
3221 };
3222 let target_hint = params
3223 .target_distance
3224 .unwrap_or(base_inputs.target_distance);
3225 let solver_max_range = target_hint.max(1000.0) * 2.0;
3226
3227 let mut baseline_solver =
3229 TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), atmosphere.clone());
3230 baseline_solver.set_max_range(solver_max_range);
3231 let baseline_result = baseline_solver.solve()?;
3232
3233 let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
3235
3236 let baseline_at_target = baseline_result
3238 .position_at_range(target_distance)
3239 .ok_or("Could not interpolate baseline at target distance")?;
3240
3241 let velocity_delta_dist = Normal::new(0.0, params.velocity_std_dev)
3246 .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
3247 let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
3248 .map_err(|e| format!("Invalid angle distribution: {}", e))?;
3249 let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
3250 .map_err(|e| format!("Invalid BC distribution: {}", e))?;
3251 let wind_sampler = MonteCarloWindSampler::new(
3254 &base_wind,
3255 params.wind_speed_std_dev,
3256 wind_direction_std_dev,
3257 )?;
3258 let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
3259 .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
3260
3261 for _ in 0..params.num_simulations {
3262 let mut inputs = base_inputs.clone();
3264 let muzzle_velocity_delta = velocity_delta_dist.sample(&mut *rng);
3265 inputs.muzzle_angle = angle_dist.sample(&mut *rng);
3266 inputs.bc_value = bc_dist.sample(&mut *rng).max(0.01);
3267 inputs.azimuth_angle = azimuth_dist.sample(&mut *rng); let wind = wind_sampler.sample(&mut *rng);
3271
3272 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere.clone());
3274 solver.inputs.muzzle_velocity =
3275 (solver.inputs.muzzle_velocity + muzzle_velocity_delta).max(0.0);
3276 solver.set_max_range(solver_max_range);
3277 match solver.solve() {
3278 Ok(result) => {
3279 let deviation = if result.max_range < target_distance {
3285 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)
3288 } else {
3289 let pos_at_target = match result.position_at_range(target_distance) {
3290 Some(p) => p,
3291 None => continue, };
3293 Vector3::new(
3298 0.0,
3299 pos_at_target.y - baseline_at_target.y,
3300 pos_at_target.z - baseline_at_target.z,
3301 )
3302 };
3303
3304 ranges.push(result.max_range);
3305 impact_velocities.push(result.impact_velocity);
3306 impact_positions.push(deviation);
3307 }
3308 Err(_) => {
3309 continue;
3311 }
3312 }
3313 }
3314
3315 if ranges.is_empty() {
3316 return Err("No successful simulations".into());
3317 }
3318
3319 Ok(MonteCarloResults {
3320 ranges,
3321 impact_velocities,
3322 impact_positions,
3323 })
3324}
3325
3326pub fn calculate_zero_angle(
3328 inputs: BallisticInputs,
3329 target_distance: f64,
3330 target_height: f64,
3331) -> Result<f64, BallisticsError> {
3332 calculate_zero_angle_with_conditions(
3333 inputs,
3334 target_distance,
3335 target_height,
3336 WindConditions::default(),
3337 AtmosphericConditions::default(),
3338 )
3339}
3340
3341pub fn calculate_zero_angle_with_conditions(
3342 inputs: BallisticInputs,
3343 target_distance: f64,
3344 target_height: f64,
3345 wind: WindConditions,
3346 atmosphere: AtmosphericConditions,
3347) -> Result<f64, BallisticsError> {
3348 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
3349 solver.calculate_and_set_zero_angle(target_distance, target_height)
3350}
3351
3352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3354pub enum BcFitMode {
3355 Drop,
3357 Velocity,
3360}
3361
3362#[derive(Debug, Clone, Copy)]
3364pub struct BcEstimate {
3365 pub bc: f64,
3367 pub rms_error: f64,
3369 pub drag_model: DragModel,
3371 pub mode: BcFitMode,
3373 pub at_bound: bool,
3377}
3378
3379fn fit_value_at(
3387 points: &[TrajectoryPoint],
3388 target_dist: f64,
3389 mode: BcFitMode,
3390 drop_offset: f64,
3391) -> Option<f64> {
3392 let val = |p: &TrajectoryPoint| match mode {
3393 BcFitMode::Drop => drop_offset - p.position.y,
3394 BcFitMode::Velocity => p.velocity_magnitude,
3395 };
3396 for i in 0..points.len() {
3397 if points[i].position.x >= target_dist {
3398 if i == 0 {
3399 return Some(val(&points[0]));
3400 }
3401 let p1 = &points[i - 1];
3402 let p2 = &points[i];
3403 let dx = p2.position.x - p1.position.x;
3404 if dx.abs() < 1e-9 {
3405 return Some(val(p2));
3406 }
3407 let t = (target_dist - p1.position.x) / dx;
3408 return Some(val(p1) + t * (val(p2) - val(p1)));
3409 }
3410 }
3411 None
3412}
3413
3414fn fit_residual_sse(
3415 trajectory: &[TrajectoryPoint],
3416 observations: &[(f64, f64)],
3417 mode: BcFitMode,
3418 drop_offset: f64,
3419) -> Option<f64> {
3420 if observations.is_empty() {
3421 return None;
3422 }
3423 let mut total = 0.0;
3424 for (target_dist, target_val) in observations {
3425 let value = fit_value_at(trajectory, *target_dist, mode, drop_offset)?;
3428 let error = value - target_val;
3429 total += error * error;
3430 }
3431 Some(total)
3432}
3433
3434#[allow(clippy::too_many_arguments)] pub fn estimate_bc_fit(
3452 velocity: f64,
3453 mass: f64,
3454 diameter: f64,
3455 points: &[(f64, f64)],
3456 drag_model: DragModel,
3457 mode: BcFitMode,
3458 atmosphere: AtmosphericConditions,
3459 zero_range: Option<f64>,
3460 sight_height: f64,
3461) -> Result<BcEstimate, BallisticsError> {
3462 if points.is_empty() {
3463 return Err(BallisticsError::from(
3464 "No data points provided for BC estimation.".to_string(),
3465 ));
3466 }
3467 let max_dist = points.iter().map(|(d, _)| *d).fold(0.0_f64, f64::max);
3468 let drop_offset = if zero_range.is_some() { sight_height } else { 0.0 };
3471
3472 let sse = |bc_value: f64| -> Option<f64> {
3474 let mut inputs = BallisticInputs {
3475 muzzle_velocity: velocity,
3476 bc_value,
3477 bc_type: drag_model,
3478 bullet_mass: mass,
3479 bullet_diameter: diameter,
3480 sight_height,
3481 ..Default::default()
3482 };
3483 if let Some(zr) = zero_range {
3486 let za = calculate_zero_angle_with_conditions(
3492 inputs.clone(),
3493 zr,
3494 sight_height,
3495 WindConditions::default(),
3496 atmosphere.clone(),
3497 )
3498 .ok()?;
3499 inputs.muzzle_angle = za;
3500 }
3501 let mut solver =
3502 TrajectorySolver::new(inputs, WindConditions::default(), atmosphere.clone());
3503 solver.set_max_range(max_dist * 1.5);
3504 let result = solver.solve().ok()?;
3505 fit_residual_sse(&result.points, points, mode, drop_offset)
3506 };
3507
3508 let (bc_min, bc_max) = match drag_model {
3512 DragModel::G7 => (0.05, 0.70),
3513 _ => (0.10, 1.20),
3514 };
3515
3516 let mut best_bc = f64::NAN;
3518 let mut best_sse = f64::MAX;
3519 let mut bc = bc_min;
3520 while bc <= bc_max + 1e-9 {
3521 if let Some(s) = sse(bc) {
3522 if s < best_sse {
3523 best_sse = s;
3524 best_bc = bc;
3525 }
3526 }
3527 bc += 0.01;
3528 }
3529 if !best_bc.is_finite() {
3530 return Err(BallisticsError::from(
3531 "Unable to estimate BC from provided data. Check that the values and units are correct."
3532 .to_string(),
3533 ));
3534 }
3535
3536 let lo = (best_bc - 0.01).max(bc_min);
3538 let hi = (best_bc + 0.01).min(bc_max);
3539 let mut bc = lo;
3540 while bc <= hi + 1e-9 {
3541 if let Some(s) = sse(bc) {
3542 if s < best_sse {
3543 best_sse = s;
3544 best_bc = bc;
3545 }
3546 }
3547 bc += 0.001;
3548 }
3549
3550 let at_bound = best_bc <= bc_min + 0.011 || best_bc >= bc_max - 0.011;
3553 let rms_error = (best_sse / points.len() as f64).sqrt();
3556 Ok(BcEstimate {
3557 bc: best_bc,
3558 rms_error,
3559 drag_model,
3560 mode,
3561 at_bound,
3562 })
3563}
3564
3565pub fn estimate_bc_from_trajectory(
3568 velocity: f64,
3569 mass: f64,
3570 diameter: f64,
3571 points: &[(f64, f64)], ) -> Result<f64, BallisticsError> {
3573 estimate_bc_fit(
3574 velocity,
3575 mass,
3576 diameter,
3577 points,
3578 DragModel::G1,
3579 BcFitMode::Drop,
3580 AtmosphericConditions::default(),
3581 None,
3582 0.05,
3583 )
3584 .map(|e| e.bc)
3585}
3586
3587use rand;
3589use rand_distr;
3590
3591#[cfg(test)]
3592mod mba1302_solver_seam_tests {
3593 use super::*;
3594 use crate::wind::WindSegment;
3595
3596 #[test]
3597 fn authoritative_station_atmosphere_preserves_explicit_standard_values_at_altitude() {
3598 let atmosphere = AtmosphericConditions {
3599 temperature: 15.0,
3600 pressure: 1013.25,
3601 humidity: 50.0,
3602 altitude: 2_000.0,
3603 };
3604 let legacy = TrajectorySolver::new(
3605 BallisticInputs::default(),
3606 WindConditions::default(),
3607 atmosphere.clone(),
3608 );
3609 let authoritative = TrajectorySolver::new_with_resolved_station_atmosphere(
3610 BallisticInputs::default(),
3611 WindConditions::default(),
3612 atmosphere,
3613 );
3614
3615 let (legacy_density, _, legacy_temp_c, legacy_pressure_hpa) = legacy.resolved_atmosphere();
3616 let (authoritative_density, _, authoritative_temp_c, authoritative_pressure_hpa) =
3617 authoritative.resolved_atmosphere();
3618 let (icao_temp_k, icao_pressure_pa) =
3619 crate::atmosphere::calculate_icao_standard_atmosphere(2_000.0);
3620 let (expected_authoritative_density, _) =
3621 crate::atmosphere::calculate_atmosphere(2_000.0, Some(15.0), Some(1013.25), 50.0);
3622
3623 assert!((legacy_temp_c - (icao_temp_k - 273.15)).abs() < 1e-12);
3624 assert!((legacy_pressure_hpa - icao_pressure_pa / 100.0).abs() < 1e-12);
3625 assert_eq!(authoritative_temp_c.to_bits(), 15.0_f64.to_bits());
3626 assert_eq!(authoritative_pressure_hpa.to_bits(), 1013.25_f64.to_bits());
3627 assert_eq!(
3628 authoritative_density.to_bits(),
3629 expected_authoritative_density.to_bits()
3630 );
3631 assert!(
3632 (authoritative_density - legacy_density).abs() > 0.1,
3633 "explicit standard values at altitude must differ from ICAO-at-altitude: explicit={authoritative_density}, ICAO={legacy_density}"
3634 );
3635 }
3636
3637 fn configured_euler_zero(vertical_wind_mps: f64, time_step_s: f64) -> TrajectorySolver {
3638 let inputs = BallisticInputs {
3639 muzzle_velocity: 800.0,
3640 bc_value: 0.5,
3641 bc_type: DragModel::G7,
3642 bullet_mass: 0.0109,
3643 bullet_diameter: 0.00782,
3644 bullet_length: 0.0309,
3645 sight_height: 0.05,
3646 ground_threshold: -100.0,
3647 use_rk4: false,
3648 use_adaptive_rk45: false,
3649 ..BallisticInputs::default()
3650 };
3651 let mut solver = TrajectorySolver::new_with_resolved_station_atmosphere(
3652 inputs,
3653 WindConditions::default(),
3654 AtmosphericConditions::default(),
3655 );
3656 solver.set_max_range(300.0);
3657 solver.set_time_step(time_step_s);
3658 if vertical_wind_mps != 0.0 {
3659 solver.set_wind_segments(vec![WindSegment {
3660 speed_kmh: 0.0,
3661 angle_deg: 0.0,
3662 until_m: 400.0,
3663 vertical_mps: vertical_wind_mps,
3664 }]);
3665 }
3666 solver
3667 }
3668
3669 #[test]
3670 fn configured_zero_keeps_segments_method_and_time_step_then_sets_base_angle() {
3671 const TARGET_DISTANCE_M: f64 = 150.0;
3672 const TARGET_HEIGHT_M: f64 = 0.05;
3673
3674 let mut segmented = configured_euler_zero(-10.0, 0.02);
3677 let coarse_height = segmented
3678 .zero_trial_height_at(0.0, TARGET_DISTANCE_M)
3679 .expect("coarse configured trial")
3680 .expect("coarse trial reaches target");
3681 let mut fine = segmented.clone();
3682 fine.set_time_step(0.001);
3683 let fine_height = fine
3684 .zero_trial_height_at(0.0, TARGET_DISTANCE_M)
3685 .expect("fine configured trial")
3686 .expect("fine trial reaches target");
3687 assert!(
3688 (coarse_height - fine_height).abs() > 1e-5,
3689 "configured Euler step must affect zero trials: coarse={coarse_height}, fine={fine_height}"
3690 );
3691
3692 let segmented_angle = segmented
3693 .calculate_and_set_zero_angle(TARGET_DISTANCE_M, TARGET_HEIGHT_M)
3694 .expect("segmented zero");
3695 assert_eq!(
3696 segmented.inputs.muzzle_angle.to_bits(),
3697 segmented_angle.to_bits(),
3698 "successful zero must install its angle on the configured solver"
3699 );
3700 assert_eq!(segmented.time_step.to_bits(), 0.02_f64.to_bits());
3701 assert_eq!(segmented.max_range.to_bits(), 300.0_f64.to_bits());
3702 assert!(segmented.wind_sock.is_some());
3703 assert_eq!(
3704 segmented.station_atmosphere_resolution,
3705 StationAtmosphereResolution::Authoritative
3706 );
3707 let zero_height = segmented
3708 .zero_trial_height_at(segmented_angle, TARGET_DISTANCE_M)
3709 .expect("verify segmented zero")
3710 .expect("zeroed trial reaches target");
3711 assert!(
3712 (zero_height - TARGET_HEIGHT_M).abs() < 0.0001,
3713 "configured zero missed target: height={zero_height}"
3714 );
3715
3716 let mut calm = configured_euler_zero(0.0, 0.02);
3717 let calm_angle = calm
3718 .calculate_and_set_zero_angle(TARGET_DISTANCE_M, TARGET_HEIGHT_M)
3719 .expect("calm zero");
3720 assert!(
3721 (segmented_angle - calm_angle).abs() > 1e-5,
3722 "segmented vertical wind must participate in zero trials: segmented={segmented_angle}, calm={calm_angle}"
3723 );
3724 }
3725}
3726
3727#[cfg(test)]
3728mod result_sanity_tests {
3729 use super::*;
3730
3731 fn default_solver() -> TrajectorySolver {
3732 TrajectorySolver::new(
3733 BallisticInputs::default(),
3734 WindConditions::default(),
3735 AtmosphericConditions::default(),
3736 )
3737 }
3738
3739 fn minimal_result() -> TrajectoryResult {
3740 TrajectoryResult {
3741 max_range: 100.0,
3742 max_height: 1.0,
3743 time_of_flight: 0.5,
3744 impact_velocity: 700.0,
3745 impact_energy: 2450.0,
3746 projectile_mass_kg: 0.01,
3747 line_of_sight_height_m: 1.5,
3748 station_speed_of_sound_mps: 340.0,
3749 termination: TrajectoryTermination::MaxRange,
3750 points: vec![],
3751 sampled_points: None,
3752 min_pitch_damping: None,
3753 transonic_mach: None,
3754 angular_state: None,
3755 max_yaw_angle: None,
3756 max_precession_angle: None,
3757 aerodynamic_jump: None,
3758 }
3759 }
3760
3761 #[test]
3762 fn mba1293_negative_scalars_fail_the_result_postcondition() {
3763 let solver = default_solver();
3764 solver
3765 .validate_result_sanity(&minimal_result())
3766 .expect("a sane result must pass");
3767
3768 for (name, mutate) in [
3769 ("max_range", (|r| r.max_range = -50.588) as fn(&mut TrajectoryResult)),
3770 ("time_of_flight", |r| r.time_of_flight = -1.0),
3771 ("impact_velocity", |r| r.impact_velocity = -700.0),
3772 ("impact_energy", |r| r.impact_energy = -1.0),
3773 ] {
3774 let mut result = minimal_result();
3775 mutate(&mut result);
3776 let error = solver
3777 .validate_result_sanity(&result)
3778 .expect_err("negative scalar must fail");
3779 assert!(
3780 error.to_string().contains(name),
3781 "error for {name} did not name the field: {error}"
3782 );
3783 }
3784 }
3785
3786 #[test]
3787 fn mba1293_speed_budget_bounds_legitimate_states_and_rejects_divergence() {
3788 let solver = default_solver();
3789 let mv = solver.inputs.muzzle_velocity;
3790
3791 let position = Vector3::new(10.0, 0.0, 0.0);
3793 solver
3794 .validate_integration_state(&position, &Vector3::new(mv, 0.0, 0.0), 0.01)
3795 .expect("muzzle-speed state must pass");
3796
3797 let error = solver
3799 .validate_integration_state(&position, &Vector3::new(-13.0 * mv, 0.0, 0.0), 0.01)
3800 .expect_err("13x muzzle speed must fail the budget");
3801 assert!(error.to_string().contains("diverged"), "{error}");
3802
3803 let after_fall = mv + crate::constants::G_ACCEL_MPS2 * 60.0;
3805 solver
3806 .validate_integration_state(&position, &Vector3::new(0.0, -after_fall, 0.0), 60.0)
3807 .expect("gravity-accelerated speed within g*t must pass");
3808 }
3809}
3810
3811#[cfg(test)]
3812mod trajectory_point_budget_tests {
3813 use super::*;
3814 use crate::MAX_TRAJECTORY_SAMPLES;
3815
3816 fn solver_with_budget(
3817 use_rk4: bool,
3818 use_adaptive_rk45: bool,
3819 point_budget: usize,
3820 max_range: f64,
3821 ) -> TrajectorySolver {
3822 let inputs = BallisticInputs {
3823 use_rk4,
3824 use_adaptive_rk45,
3825 ground_threshold: f64::NEG_INFINITY,
3826 ..BallisticInputs::default()
3827 };
3828 let mut solver = TrajectorySolver::new(
3829 inputs,
3830 WindConditions::default(),
3831 AtmosphericConditions::default(),
3832 );
3833 solver.max_trajectory_points = point_budget;
3834 solver.set_max_range(max_range);
3835 solver.set_time_step(0.001);
3836 solver
3837 }
3838
3839 #[test]
3840 fn mba1283_every_solver_errors_instead_of_exceeding_point_budget() {
3841 for (mode, use_rk4, use_adaptive_rk45) in [
3842 ("Euler", false, false),
3843 ("RK4", true, false),
3844 ("RK45", true, true),
3845 ] {
3846 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 3, 10.0)
3847 .solve()
3848 .expect_err("a solve requiring more than three points must fail");
3849 assert!(
3850 error.to_string().contains("point limit of 3"),
3851 "unexpected {mode} point-budget error: {error}"
3852 );
3853 }
3854 }
3855
3856 #[test]
3857 fn mba1283_interpolated_endpoint_counts_toward_point_budget() {
3858 for (mode, use_rk4, use_adaptive_rk45) in [
3859 ("Euler", false, false),
3860 ("RK4", true, false),
3861 ("RK45", true, true),
3862 ] {
3863 let result = solver_with_budget(use_rk4, use_adaptive_rk45, 2, 0.1)
3864 .solve()
3865 .expect("the initial point plus exact endpoint fit a two-point budget");
3866 assert_eq!(result.points.len(), 2, "unexpected {mode} point count");
3867
3868 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 1, 0.1)
3869 .solve()
3870 .expect_err("the exact endpoint must not exceed a one-point budget");
3871 assert!(
3872 error.to_string().contains("point limit of 1"),
3873 "unexpected {mode} endpoint-budget error: {error}"
3874 );
3875 }
3876 }
3877
3878 #[test]
3879 fn mba1299_every_solver_preflights_the_sample_budget() {
3880 for (mode, use_rk4, use_adaptive_rk45) in [
3881 ("Euler", false, false),
3882 ("RK4", true, false),
3883 ("RK45", true, true),
3884 ] {
3885 let inputs = BallisticInputs {
3886 use_rk4,
3887 use_adaptive_rk45,
3888 enable_trajectory_sampling: true,
3889 sample_interval: 1.0,
3890 ground_threshold: f64::NEG_INFINITY,
3891 ..BallisticInputs::default()
3892 };
3893 let mut solver = TrajectorySolver::new(
3894 inputs,
3895 WindConditions::default(),
3896 AtmosphericConditions::default(),
3897 );
3898 solver.set_max_range(MAX_TRAJECTORY_SAMPLES as f64);
3899 solver.max_trajectory_points = 0;
3902
3903 let error = solver
3904 .solve()
3905 .expect_err("an over-limit sample grid must fail before integration");
3906 assert!(
3907 error
3908 .to_string()
3909 .contains("trajectory sample limit of 250000 exceeded"),
3910 "unexpected {mode} sample-budget error: {error}"
3911 );
3912 }
3913 }
3914
3915 #[test]
3916 fn mba1299_normal_sampling_does_not_change_solver_results() {
3917 for (mode, use_rk4, use_adaptive_rk45) in [
3918 ("Euler", false, false),
3919 ("RK4", true, false),
3920 ("RK45", true, true),
3921 ] {
3922 let solve = |enable_trajectory_sampling| {
3923 let inputs = BallisticInputs {
3924 use_rk4,
3925 use_adaptive_rk45,
3926 enable_trajectory_sampling,
3927 sample_interval: 0.5,
3928 ground_threshold: f64::NEG_INFINITY,
3929 ..BallisticInputs::default()
3930 };
3931 let mut solver = TrajectorySolver::new(
3932 inputs,
3933 WindConditions::default(),
3934 AtmosphericConditions::default(),
3935 );
3936 solver.set_max_range(2.0);
3937 solver.solve().expect("normal short-range solve")
3938 };
3939
3940 let baseline = solve(false);
3941 let sampled = solve(true);
3942 for (field, left, right) in [
3943 ("max_range", baseline.max_range, sampled.max_range),
3944 ("max_height", baseline.max_height, sampled.max_height),
3945 (
3946 "time_of_flight",
3947 baseline.time_of_flight,
3948 sampled.time_of_flight,
3949 ),
3950 (
3951 "impact_velocity",
3952 baseline.impact_velocity,
3953 sampled.impact_velocity,
3954 ),
3955 (
3956 "impact_energy",
3957 baseline.impact_energy,
3958 sampled.impact_energy,
3959 ),
3960 ] {
3961 assert_eq!(
3962 left.to_bits(),
3963 right.to_bits(),
3964 "{mode} sampling changed {field}"
3965 );
3966 }
3967 assert_eq!(baseline.points.len(), sampled.points.len());
3968 for (index, (left, right)) in baseline
3969 .points
3970 .iter()
3971 .zip(&sampled.points)
3972 .enumerate()
3973 {
3974 assert_eq!(left.time.to_bits(), right.time.to_bits(), "{mode} point {index}");
3975 assert_eq!(
3976 left.position.map(f64::to_bits),
3977 right.position.map(f64::to_bits),
3978 "{mode} point {index} position"
3979 );
3980 assert_eq!(
3981 left.velocity_magnitude.to_bits(),
3982 right.velocity_magnitude.to_bits(),
3983 "{mode} point {index} velocity"
3984 );
3985 assert_eq!(
3986 left.kinetic_energy.to_bits(),
3987 right.kinetic_energy.to_bits(),
3988 "{mode} point {index} energy"
3989 );
3990 }
3991 assert!(baseline.sampled_points.is_none());
3992 let samples = sampled
3993 .sampled_points
3994 .expect("sampling-enabled solve should return observations");
3995 assert_eq!(
3996 samples
3997 .iter()
3998 .map(|sample| sample.distance_m)
3999 .collect::<Vec<_>>(),
4000 vec![0.0, 0.5, 1.0, 1.5, 2.0],
4001 "{mode} normal sampling grid changed"
4002 );
4003 }
4004 }
4005}
4006
4007#[cfg(test)]
4008mod monte_carlo_result_tests {
4009 use super::*;
4010
4011 fn make_results(impact_positions: Vec<Vector3<f64>>) -> MonteCarloResults {
4012 let count = impact_positions.len();
4013 MonteCarloResults {
4014 ranges: vec![500.0; count],
4015 impact_velocities: vec![300.0; count],
4016 impact_positions,
4017 }
4018 }
4019
4020 #[test]
4021 fn target_plane_cep_excludes_shortfall_markers() {
4022 let mut positions: Vec<Vector3<f64>> = (1..=5)
4023 .map(|radius| Vector3::new(0.0, radius as f64, 0.0))
4024 .collect();
4025 positions.extend(
4026 (0..5).map(|_| Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)),
4027 );
4028 let results = make_results(positions);
4029
4030 assert_eq!(results.target_arrival_count(), 5);
4031 assert_eq!(results.target_shortfall_fraction(), 0.5);
4032 assert_eq!(results.target_plane_cep(), Some(3.0));
4033
4034 let one_shortfall = make_results(vec![
4035 Vector3::new(0.0, 1.0, 0.0),
4036 Vector3::new(0.0, 2.0, 0.0),
4037 Vector3::new(0.0, 3.0, 0.0),
4038 Vector3::new(0.0, 4.0, 0.0),
4039 Vector3::new(0.0, 5.0, 0.0),
4040 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4041 ]);
4042 assert_eq!(one_shortfall.target_plane_cep(), Some(3.0));
4043 }
4044
4045 #[test]
4046 fn all_shortfalls_have_no_cep_but_still_count_as_misses() {
4047 let all_shortfalls = make_results(vec![
4048 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4049 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4050 ]);
4051 assert_eq!(all_shortfalls.target_arrival_count(), 0);
4052 assert_eq!(all_shortfalls.target_shortfall_fraction(), 1.0);
4053 assert_eq!(all_shortfalls.target_plane_cep(), None);
4054 assert_eq!(all_shortfalls.hit_probability(0.3), 0.0);
4055
4056 let one_hit_one_shortfall = make_results(vec![
4057 Vector3::new(0.0, 0.1, 0.0),
4058 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4059 ]);
4060 assert_eq!(one_hit_one_shortfall.hit_probability(0.3), 0.5);
4061 }
4062
4063 #[test]
4065 fn rect_hit_probability_checks_independent_axis_halves() {
4066 let results = make_results(vec![
4067 Vector3::new(0.0, 0.1, 0.1),
4069 Vector3::new(0.0, 0.0, 0.2),
4071 Vector3::new(0.0, 0.0, 0.201),
4073 Vector3::new(0.0, 0.301, 0.0),
4075 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
4077 ]);
4078 assert!((results.rect_hit_probability(0.4, 0.6) - 0.4).abs() < 1e-12);
4080 }
4081
4082 #[test]
4083 fn rect_hit_probability_matches_circular_hit_probability_for_a_centered_hit() {
4084 let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
4085 assert_eq!(results.rect_hit_probability(0.5, 0.5), 1.0);
4086 assert_eq!(results.hit_probability(0.3), 1.0);
4087 }
4088
4089 #[test]
4090 fn rect_hit_probability_is_zero_for_empty_or_nonpositive_dimensions() {
4091 let empty = make_results(vec![]);
4092 assert_eq!(empty.rect_hit_probability(1.0, 1.0), 0.0);
4093
4094 let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
4095 assert_eq!(results.rect_hit_probability(0.0, 1.0), 0.0);
4096 assert_eq!(results.rect_hit_probability(1.0, 0.0), 0.0);
4097 assert_eq!(results.rect_hit_probability(-1.0, 1.0), 0.0);
4098 }
4099}
4100
4101#[cfg(test)]
4102mod monte_carlo_seeded_tests {
4103 use super::*;
4104
4105 #[test]
4106 fn seeded_runs_are_deterministic_and_match_the_using_rng_path() {
4107 let inputs = BallisticInputs {
4108 muzzle_velocity: 800.0,
4109 ..BallisticInputs::default()
4110 };
4111 let params = MonteCarloParams {
4112 num_simulations: 64,
4113 target_distance: Some(200.0),
4114 ..MonteCarloParams::default()
4115 };
4116
4117 let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4118 inputs.clone(),
4119 WindConditions::default(),
4120 params.clone(),
4121 0.01,
4122 42,
4123 )
4124 .expect("seeded run a");
4125 let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4126 inputs,
4127 WindConditions::default(),
4128 params,
4129 0.01,
4130 42,
4131 )
4132 .expect("seeded run b");
4133
4134 assert_eq!(a.ranges.len(), b.ranges.len());
4135 for (ra, rb) in a.ranges.iter().zip(b.ranges.iter()) {
4136 assert_eq!(ra.to_bits(), rb.to_bits());
4137 }
4138 for (pa, pb) in a.impact_positions.iter().zip(b.impact_positions.iter()) {
4139 assert_eq!(pa.x.to_bits(), pb.x.to_bits());
4140 assert_eq!(pa.y.to_bits(), pb.y.to_bits());
4141 assert_eq!(pa.z.to_bits(), pb.z.to_bits());
4142 }
4143 }
4144
4145 #[test]
4146 fn different_seeds_generally_produce_different_draws() {
4147 let inputs = BallisticInputs {
4148 muzzle_velocity: 800.0,
4149 ..BallisticInputs::default()
4150 };
4151 let params = MonteCarloParams {
4152 num_simulations: 32,
4153 velocity_std_dev: 5.0,
4154 target_distance: Some(200.0),
4155 ..MonteCarloParams::default()
4156 };
4157
4158 let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4159 inputs.clone(),
4160 WindConditions::default(),
4161 params.clone(),
4162 0.0,
4163 1,
4164 )
4165 .expect("seeded run a");
4166 let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4167 inputs,
4168 WindConditions::default(),
4169 params,
4170 0.0,
4171 2,
4172 )
4173 .expect("seeded run b");
4174
4175 assert_ne!(a.impact_velocities, b.impact_velocities);
4176 }
4177}
4178
4179#[cfg(test)]
4180mod monte_carlo_powder_curve_tests {
4181 use super::*;
4182 use rand::{rngs::StdRng, SeedableRng};
4183
4184 #[test]
4185 fn powder_curve_preserves_sampled_muzzle_velocity_dispersion() {
4186 let inputs = BallisticInputs {
4187 muzzle_velocity: 700.0,
4188 powder_temp_curve: Some(vec![(15.0, 800.0)]),
4189 powder_curve_temp_c: Some(15.0),
4190 ..BallisticInputs::default()
4191 };
4192 let params = MonteCarloParams {
4193 num_simulations: 16,
4194 velocity_std_dev: 20.0,
4195 angle_std_dev: 1e-12,
4196 bc_std_dev: 1e-12,
4197 wind_speed_std_dev: 1e-12,
4198 target_distance: Some(100.0),
4199 azimuth_std_dev: 1e-12,
4200 ..MonteCarloParams::default()
4201 };
4202
4203 let mut rng = StdRng::seed_from_u64(0x5EED_1176);
4204 let results = run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
4205 inputs,
4206 WindConditions::default(),
4207 params,
4208 0.0,
4209 &mut rng,
4210 )
4211 .expect("Monte Carlo solve");
4212 let min_velocity = results
4213 .impact_velocities
4214 .iter()
4215 .copied()
4216 .fold(f64::INFINITY, f64::min);
4217 let max_velocity = results
4218 .impact_velocities
4219 .iter()
4220 .copied()
4221 .fold(f64::NEG_INFINITY, f64::max);
4222
4223 assert!(
4224 max_velocity - min_velocity > 1.0,
4225 "20 m/s muzzle spread collapsed after curve resolution: impact-velocity span={} m/s",
4226 max_velocity - min_velocity
4227 );
4228 }
4229}
4230
4231#[cfg(test)]
4232mod monte_carlo_wind_sampling_tests {
4233 use super::*;
4234 use rand::{rngs::StdRng, SeedableRng};
4235
4236 #[test]
4237 fn wind_speed_sigma_does_not_change_seeded_direction_draws() {
4238 let base_wind = WindConditions {
4239 speed: 100.0,
4240 direction: 0.37,
4241 vertical_speed: 0.0,
4242 };
4243 let narrow_speed = MonteCarloWindSampler::new(&base_wind, 0.5, 0.2).unwrap();
4244 let wide_speed = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
4245 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
4246 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
4247 let mut speed_changed = false;
4248
4249 for _ in 0..32 {
4250 let narrow = narrow_speed.sample(&mut narrow_rng);
4251 let wide = wide_speed.sample(&mut wide_rng);
4252 assert!(narrow.speed > 0.0 && wide.speed > 0.0);
4253 assert_eq!(narrow.direction.to_bits(), wide.direction.to_bits());
4254 speed_changed |= narrow.speed.to_bits() != wide.speed.to_bits();
4255 }
4256 assert!(
4257 speed_changed,
4258 "different speed sigmas must still vary speed draws"
4259 );
4260 }
4261
4262 #[test]
4263 fn zero_direction_sigma_has_no_angular_jitter() {
4264 let base_wind = WindConditions {
4265 speed: 100.0,
4266 direction: 0.37,
4267 vertical_speed: 0.0,
4268 };
4269 let sampler = MonteCarloWindSampler::new(&base_wind, 4.0, 0.0).unwrap();
4270 let mut rng = StdRng::seed_from_u64(0x5EED_1223);
4271 let mut speed_changed = false;
4272
4273 for _ in 0..32 {
4274 let wind = sampler.sample(&mut rng);
4275 speed_changed |= wind.speed.to_bits() != base_wind.speed.to_bits();
4276 assert_eq!(wind.direction.to_bits(), base_wind.direction.to_bits());
4277 }
4278 assert!(speed_changed, "speed uncertainty should remain active");
4279 }
4280
4281 #[test]
4282 fn direction_sigma_controls_seeded_angular_spread_in_radians() {
4283 let base_wind = WindConditions {
4284 speed: 100.0,
4285 direction: 0.37,
4286 vertical_speed: 0.0,
4287 };
4288 let narrow = MonteCarloWindSampler::new(&base_wind, 4.0, 0.1).unwrap();
4289 let wide = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
4290 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
4291 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
4292 let mut nonzero_direction_draw = false;
4293
4294 for _ in 0..32 {
4295 let narrow_wind = narrow.sample(&mut narrow_rng);
4296 let wide_wind = wide.sample(&mut wide_rng);
4297 assert_eq!(narrow_wind.speed.to_bits(), wide_wind.speed.to_bits());
4298
4299 let narrow_delta = narrow_wind.direction - base_wind.direction;
4300 let wide_delta = wide_wind.direction - base_wind.direction;
4301 assert!((wide_delta - 2.0 * narrow_delta).abs() < 1e-12);
4302 nonzero_direction_draw |= narrow_delta.abs() > 1e-6;
4303 }
4304 assert!(
4305 nonzero_direction_draw,
4306 "positive radians sigma must vary direction"
4307 );
4308 }
4309
4310 #[test]
4311 fn direction_sigma_rejects_negative_or_nonfinite_values() {
4312 let base_wind = WindConditions::default();
4313 for sigma in [-0.1, f64::NAN, f64::INFINITY] {
4314 assert!(MonteCarloWindSampler::new(&base_wind, 1.0, sigma).is_err());
4315 }
4316 }
4317
4318 #[test]
4319 fn base_vertical_wind_rides_into_every_mc_sample() {
4320 use rand::SeedableRng;
4324 let base_wind = WindConditions { vertical_speed: 4.2, ..Default::default() };
4325 let sampler = MonteCarloWindSampler::new(&base_wind, 1.0, 0.2).unwrap();
4326 let mut rng = rand::rngs::StdRng::seed_from_u64(7);
4327 for _ in 0..32 {
4328 let w = sampler.sample(&mut rng);
4329 assert_eq!(w.vertical_speed, 4.2);
4330 }
4331 }
4332
4333 #[test]
4334 fn negative_speed_sample_reverses_wind_direction() {
4335 let direction = 0.25;
4336 let signed_speed = -2.5;
4337 let wind = wind_from_signed_speed_sample(signed_speed, direction, 0.0);
4338 let positive_wind = wind_from_signed_speed_sample(2.5, direction, 0.0);
4339
4340 assert_eq!(wind.speed, 2.5);
4341 assert!(
4342 (wind.direction - (direction + std::f64::consts::PI)).abs() < f64::EPSILON,
4343 "negative speed must reverse direction by pi: got {}",
4344 wind.direction
4345 );
4346 assert_eq!(positive_wind.speed, 2.5);
4347 assert_eq!(positive_wind.direction, direction);
4348
4349 let normalized_x = -wind.speed * wind.direction.cos();
4350 let normalized_z = -wind.speed * wind.direction.sin();
4351 let signed_x = -signed_speed * direction.cos();
4352 let signed_z = -signed_speed * direction.sin();
4353 assert!((normalized_x - signed_x).abs() < 1e-12);
4354 assert!((normalized_z - signed_z).abs() < 1e-12);
4355 }
4356}
4357
4358#[cfg(test)]
4359mod bc_fit_objective_tests {
4360 use super::*;
4361
4362 fn velocity_point(range_m: f64, velocity_mps: f64) -> TrajectoryPoint {
4363 TrajectoryPoint {
4364 time: 0.0,
4365 position: Vector3::new(range_m, 0.0, 0.0),
4366 velocity_magnitude: velocity_mps,
4367 kinetic_energy: 0.0,
4368 }
4369 }
4370
4371 #[test]
4372 fn candidate_that_misses_an_observation_has_no_score() {
4373 let trajectory = vec![velocity_point(0.0, 800.0), velocity_point(100.0, 700.0)];
4374 let observations = vec![(50.0, 750.0), (150.0, 600.0)];
4375
4376 assert!(
4377 fit_residual_sse(&trajectory, &observations, BcFitMode::Velocity, 0.0).is_none(),
4378 "a candidate that reaches only one of two observations must not compete on partial SSE"
4379 );
4380
4381 let complete_observations = vec![(50.0, 740.0), (100.0, 680.0)];
4382 assert_eq!(
4383 fit_residual_sse(
4384 &trajectory,
4385 &complete_observations,
4386 BcFitMode::Velocity,
4387 0.0,
4388 ),
4389 Some(500.0)
4390 );
4391 }
4392}
4393
4394#[cfg(test)]
4395mod cluster_bc_reference_space_tests {
4396 use super::*;
4397
4398 fn acceleration_at_1100_fps(inputs: BallisticInputs) -> Vector3<f64> {
4399 let solver = TrajectorySolver::new(
4400 inputs,
4401 WindConditions::default(),
4402 AtmosphericConditions::default(),
4403 );
4404 let position = Vector3::zeros();
4405 let velocity = Vector3::new(1100.0 / 3.28084, 0.0, 0.0);
4406 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4407 solver.calculate_acceleration(
4408 &position,
4409 &velocity,
4410 &Vector3::zeros(),
4411 (temp_c, pressure_hpa, density / 1.225),
4412 )
4413 }
4414
4415 #[test]
4416 fn solver_passes_g7_reference_model_to_cluster_classifier() {
4417 let inputs = BallisticInputs {
4418 bc_value: 0.190,
4419 bc_type: DragModel::G7,
4420 bullet_mass: 77.0 * crate::constants::GRAINS_TO_KG,
4421 bullet_diameter: 0.224 * 0.0254,
4422 use_cluster_bc: true,
4423 ..BallisticInputs::default()
4424 };
4425
4426 let solver = TrajectorySolver::new(
4427 inputs,
4428 WindConditions::default(),
4429 AtmosphericConditions::default(),
4430 );
4431 let corrected = solver.apply_cluster_bc_correction(0.190, 2800.0);
4432
4433 assert!(
4434 (corrected / 0.190 - 1.004).abs() < 1e-12,
4435 "solver selected the wrong G7 cluster multiplier: {}",
4436 corrected / 0.190
4437 );
4438 }
4439
4440 #[test]
4441 fn velocity_bc_segments_are_not_cluster_corrected_twice() {
4442 let segmented_clustered = BallisticInputs {
4443 bc_value: 0.5,
4444 bc_type: DragModel::G7,
4445 use_bc_segments: true,
4446 bc_segments_data: Some(vec![
4447 crate::BCSegmentData {
4448 velocity_min: 0.0,
4449 velocity_max: 1_600.0,
4450 bc_value: 0.4,
4451 },
4452 crate::BCSegmentData {
4453 velocity_min: 1_600.0,
4454 velocity_max: 5_000.0,
4455 bc_value: 0.45,
4456 },
4457 ]),
4458 use_cluster_bc: true,
4459 ..BallisticInputs::default()
4460 };
4461 let mut segmented_only = segmented_clustered.clone();
4462 segmented_only.use_cluster_bc = false;
4463 let mut constant_clustered = segmented_clustered.clone();
4464 constant_clustered.bc_value = 0.4;
4465 constant_clustered.bc_segments_data = None;
4466
4467 let stacked = acceleration_at_1100_fps(segmented_clustered);
4468 let segment_only = acceleration_at_1100_fps(segmented_only);
4469 let cluster_only = acceleration_at_1100_fps(constant_clustered);
4470
4471 assert!(
4472 (stacked.x - segment_only.x).abs() < 1e-12,
4473 "segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
4474 stacked.x,
4475 segment_only.x
4476 );
4477 assert!(
4478 (cluster_only.x - segment_only.x).abs() > 1e-6,
4479 "cluster correction must remain active for a constant BC"
4480 );
4481 }
4482
4483 #[test]
4484 fn mach_bc_segments_are_not_cluster_corrected_twice() {
4485 let mach_segmented_clustered = BallisticInputs {
4486 bc_value: 0.5,
4487 bc_type: DragModel::G7,
4488 use_bc_segments: false,
4489 bc_segments: Some(vec![(0.5, 0.3), (1.5, 0.5)]),
4490 use_cluster_bc: true,
4491 ..BallisticInputs::default()
4492 };
4493 let mut mach_segmented_only = mach_segmented_clustered.clone();
4494 mach_segmented_only.use_cluster_bc = false;
4495
4496 let stacked = acceleration_at_1100_fps(mach_segmented_clustered);
4497 let segment_only = acceleration_at_1100_fps(mach_segmented_only);
4498
4499 assert!(
4500 (stacked.x - segment_only.x).abs() < 1e-12,
4501 "Mach segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
4502 stacked.x,
4503 segment_only.x
4504 );
4505 }
4506}
4507
4508#[cfg(test)]
4509mod velocity_bc_flag_tests {
4510 use super::*;
4511
4512 fn acceleration_at_600_mps(inputs: BallisticInputs) -> Vector3<f64> {
4513 let solver = TrajectorySolver::new(
4514 inputs,
4515 WindConditions::default(),
4516 AtmosphericConditions::default(),
4517 );
4518 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4519 solver.calculate_acceleration(
4520 &Vector3::zeros(),
4521 &Vector3::new(600.0, 0.0, 0.0),
4522 &Vector3::zeros(),
4523 (temp_c, pressure_hpa, density / 1.225),
4524 )
4525 }
4526
4527 #[test]
4528 fn velocity_bc_data_requires_opt_in_in_trajectory_solver() {
4529 let scalar_inputs = BallisticInputs {
4530 bc_value: 0.5,
4531 bc_type: DragModel::G7,
4532 ..BallisticInputs::default()
4533 };
4534 let mut disabled_inputs = scalar_inputs.clone();
4535 disabled_inputs.bc_segments_data = Some(vec![crate::BCSegmentData {
4536 velocity_min: 0.0,
4537 velocity_max: 4_000.0,
4538 bc_value: 0.46,
4539 }]);
4540 disabled_inputs.use_bc_segments = false;
4541 let mut enabled_inputs = disabled_inputs.clone();
4542 enabled_inputs.use_bc_segments = true;
4543 let mut mach_only_inputs = scalar_inputs.clone();
4544 mach_only_inputs.bc_segments = Some(vec![(0.0, 0.4), (3.0, 0.4)]);
4545 let mut disabled_with_both = mach_only_inputs.clone();
4546 disabled_with_both.bc_segments_data = disabled_inputs.bc_segments_data.clone();
4547
4548 let scalar = acceleration_at_600_mps(scalar_inputs);
4549 let disabled = acceleration_at_600_mps(disabled_inputs);
4550 let enabled = acceleration_at_600_mps(enabled_inputs);
4551 let mach_only = acceleration_at_600_mps(mach_only_inputs);
4552 let disabled_with_both = acceleration_at_600_mps(disabled_with_both);
4553
4554 assert_eq!(
4555 disabled.x.to_bits(),
4556 scalar.x.to_bits(),
4557 "a populated velocity table must not change drag while use_bc_segments is false"
4558 );
4559 assert!(
4560 enabled.x < disabled.x - 1.0,
4561 "enabling the lower BC table must increase drag: disabled ax={} enabled ax={}",
4562 disabled.x,
4563 enabled.x
4564 );
4565 assert_eq!(
4566 disabled_with_both.x.to_bits(),
4567 mach_only.x.to_bits(),
4568 "disabling velocity data must fall through to an explicit Mach table"
4569 );
4570 }
4571}
4572
4573#[cfg(test)]
4574mod mach_bc_segment_tests {
4575 use super::*;
4576
4577 #[test]
4578 fn trajectory_solver_interpolates_explicit_mach_bc_segments() {
4579 let segmented_inputs = BallisticInputs {
4580 bc_value: 0.8,
4581 use_bc_segments: false,
4582 bc_segments: Some(vec![(1.0, 0.2), (2.0, 0.4)]),
4583 bc_segments_data: None,
4584 ..BallisticInputs::default()
4585 };
4586
4587 let mut expected_inputs = segmented_inputs.clone();
4588 expected_inputs.bc_value = 0.3;
4589 expected_inputs.bc_segments = None;
4590
4591 let atmosphere = AtmosphericConditions::default();
4592 let segmented_solver = TrajectorySolver::new(
4593 segmented_inputs,
4594 WindConditions::default(),
4595 atmosphere.clone(),
4596 );
4597 let expected_solver = TrajectorySolver::new(
4598 expected_inputs,
4599 WindConditions::default(),
4600 atmosphere,
4601 );
4602 let position = Vector3::zeros();
4603 let (density, _, temp_c, pressure_hpa) = segmented_solver.resolved_atmosphere();
4604 let (_, local_speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
4605 segmented_solver.atmosphere.altitude,
4606 segmented_solver.atmosphere.altitude,
4607 temp_c,
4608 pressure_hpa,
4609 density / 1.225,
4610 segmented_solver.atmosphere.humidity,
4611 );
4612 let velocity = Vector3::new(1.5 * local_speed_of_sound, 0.0, 0.0);
4613 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
4614
4615 let segmented_acceleration = segmented_solver.calculate_acceleration(
4616 &position,
4617 &velocity,
4618 &Vector3::zeros(),
4619 resolved_atmo,
4620 );
4621 let expected_acceleration = expected_solver.calculate_acceleration(
4622 &position,
4623 &velocity,
4624 &Vector3::zeros(),
4625 resolved_atmo,
4626 );
4627
4628 assert!(
4629 (segmented_acceleration.x - expected_acceleration.x).abs() < 1e-12,
4630 "Mach 1.5 must interpolate BC 0.3: segmented ax={} expected ax={}",
4631 segmented_acceleration.x,
4632 expected_acceleration.x
4633 );
4634 }
4635}
4636
4637#[cfg(test)]
4638mod custom_drag_table_validation_tests {
4639 use super::*;
4640
4641 #[test]
4642 fn solve_accepts_zero_bc_when_custom_table_present() {
4643 let inputs = BallisticInputs {
4644 bc_value: 0.0, bullet_mass: 0.0106,
4646 bullet_diameter: 0.00782,
4647 muzzle_velocity: 850.0,
4648 custom_drag_table: Some(crate::drag::DragTable::new(
4649 vec![0.5, 1.0, 2.0, 3.0],
4650 vec![0.23, 0.40, 0.30, 0.26],
4651 )),
4652 ..BallisticInputs::default()
4653 };
4654 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
4655 assert!(solver.solve().is_ok());
4657 }
4658
4659 #[test]
4660 fn solve_still_requires_bc_without_table() {
4661 let inputs = BallisticInputs {
4662 bc_value: 0.0,
4663 bullet_mass: 0.0106,
4664 bullet_diameter: 0.00782,
4665 muzzle_velocity: 850.0,
4666 ..BallisticInputs::default()
4667 };
4668 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
4669 assert!(solver.solve().is_err());
4670 }
4671}
4672
4673#[cfg(test)]
4674mod humid_local_mach_tests {
4675 use super::*;
4676
4677 fn solver_with_station_humidity(humidity_percent: f64) -> TrajectorySolver {
4678 let inputs = BallisticInputs {
4679 custom_drag_table: Some(crate::drag::DragTable::new(vec![0.5, 1.5], vec![0.1, 1.1])),
4680 ..BallisticInputs::default()
4681 };
4682 TrajectorySolver::new(
4683 inputs,
4684 WindConditions::default(),
4685 AtmosphericConditions {
4686 temperature: 30.0,
4687 pressure: 1013.25,
4688 humidity: humidity_percent,
4689 altitude: 0.0,
4690 },
4691 )
4692 }
4693
4694 fn acceleration(solver: &TrajectorySolver, base_ratio: f64) -> Vector3<f64> {
4695 solver.calculate_acceleration(
4696 &Vector3::zeros(),
4697 &Vector3::new(350.0, 0.0, 0.0),
4698 &Vector3::zeros(),
4699 (30.0, 1013.25, base_ratio),
4700 )
4701 }
4702
4703 #[test]
4704 fn local_mach_uses_station_humidity_when_density_is_held_constant() {
4705 let dry = acceleration(&solver_with_station_humidity(0.0), 1.0);
4706 let humid = acceleration(&solver_with_station_humidity(100.0), 1.0);
4707
4708 assert!(
4709 humid.x > dry.x,
4710 "humid sound speed should lower Mach and drag on the rising test curve: dry ax={} humid ax={}",
4711 dry.x,
4712 humid.x
4713 );
4714 }
4715
4716 #[test]
4717 fn active_atmosphere_zone_uses_zone_humidity_instead_of_station_humidity() {
4718 let zone_humidity = 80.0;
4719 let zone_ratio =
4720 crate::atmosphere::calculate_air_density_cimp(30.0, 1013.25, zone_humidity) / 1.225;
4721 let station_solver = solver_with_station_humidity(zone_humidity);
4722 let mut zoned_solver = solver_with_station_humidity(0.0);
4723 zoned_solver.set_atmo_segments(vec![(30.0, 1013.25, zone_humidity, 1_000.0)]);
4724
4725 let station = acceleration(&station_solver, zone_ratio);
4726 let zoned = acceleration(&zoned_solver, zone_ratio);
4727
4728 assert!(
4729 (zoned - station).norm() < 1e-12,
4730 "active zone T/P/RH should override the station atmosphere: station={station:?} zoned={zoned:?}"
4731 );
4732 }
4733}
4734
4735#[cfg(test)]
4736mod inclined_atmosphere_frame_tests {
4737 use super::*;
4738
4739 fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
4740 let (sin_angle, cos_angle) = angle.sin_cos();
4741 Vector3::new(
4742 level.x * cos_angle + level.y * sin_angle,
4743 -level.x * sin_angle + level.y * cos_angle,
4744 level.z,
4745 )
4746 }
4747
4748 #[test]
4749 fn inclined_positions_at_same_world_altitude_have_same_solver_acceleration() {
4750 let angle = std::f64::consts::FRAC_PI_6;
4751 let inputs = BallisticInputs {
4752 shooting_angle: angle,
4753 ..BallisticInputs::default()
4754 };
4755 let atmosphere = AtmosphericConditions {
4756 altitude: 100.0,
4757 ..AtmosphericConditions::default()
4758 };
4759 let solver = TrajectorySolver::new(inputs, WindConditions::default(), atmosphere);
4760 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4761 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
4762 let velocity = Vector3::new(600.0, 0.0, 0.0);
4763 let along_slant = Vector3::new(1_000.0, 0.0, 0.0);
4764 let across_slant = Vector3::new(0.0, 500.0 / angle.cos(), 0.0);
4765
4766 let a = solver.calculate_acceleration(
4767 &along_slant,
4768 &velocity,
4769 &Vector3::zeros(),
4770 resolved_atmo,
4771 );
4772 let b = solver.calculate_acceleration(
4773 &across_slant,
4774 &velocity,
4775 &Vector3::zeros(),
4776 resolved_atmo,
4777 );
4778
4779 assert!(
4780 (a - b).norm() < 1e-10,
4781 "solver acceleration differs at equal world altitude: {a:?} vs {b:?}"
4782 );
4783 }
4784
4785 #[test]
4786 fn inclined_headwind_is_rotated_into_solver_frame() {
4787 let angle = std::f64::consts::FRAC_PI_6;
4788 let inputs = BallisticInputs {
4789 shooting_angle: angle,
4790 ..BallisticInputs::default()
4791 };
4792 let solver = TrajectorySolver::new(
4793 inputs,
4794 WindConditions::default(),
4795 AtmosphericConditions::default(),
4796 );
4797 let level_headwind = Vector3::new(-100.0, 0.0, 0.0);
4798 let velocity = expected_shot_frame_vector(level_headwind, angle);
4799 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4800 let actual = solver.calculate_acceleration(
4801 &Vector3::zeros(),
4802 &velocity,
4803 &level_headwind,
4804 (temp_c, pressure_hpa, density / 1.225),
4805 );
4806
4807 assert!(
4808 (actual - solver.gravity_acceleration()).norm() < 1e-12,
4809 "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
4810 );
4811 }
4812
4813 #[test]
4814 fn inclined_coriolis_is_rotated_into_solver_frame() {
4815 let angle = std::f64::consts::FRAC_PI_6;
4816 let latitude_deg = 45.0_f64;
4817 let shot_azimuth = 0.4_f64;
4818 let velocity = Vector3::new(600.0, 20.0, 5.0);
4819 let base_inputs = BallisticInputs {
4820 shooting_angle: angle,
4821 latitude: Some(latitude_deg),
4822 shot_azimuth,
4823 ..BallisticInputs::default()
4824 };
4825 let acceleration = |enable_coriolis| {
4826 let mut inputs = base_inputs.clone();
4827 inputs.enable_coriolis = enable_coriolis;
4828 let solver = TrajectorySolver::new(
4829 inputs,
4830 WindConditions::default(),
4831 AtmosphericConditions::default(),
4832 );
4833 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4834 solver.calculate_acceleration(
4835 &Vector3::zeros(),
4836 &velocity,
4837 &Vector3::zeros(),
4838 (temp_c, pressure_hpa, density / 1.225),
4839 )
4840 };
4841
4842 let omega_earth = 7.2921159e-5_f64;
4843 let latitude = latitude_deg.to_radians();
4844 let level_omega = Vector3::new(
4845 omega_earth * latitude.cos() * shot_azimuth.cos(),
4846 omega_earth * latitude.sin(),
4847 -omega_earth * latitude.cos() * shot_azimuth.sin(),
4848 );
4849 let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
4850 let actual = acceleration(true) - acceleration(false);
4851
4852 assert!(
4853 (actual - expected).norm() < 1e-12,
4854 "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
4855 );
4856 }
4857}
4858
4859#[cfg(test)]
4860mod terminal_range_interpolation_tests {
4861 use super::*;
4862
4863 #[test]
4864 fn terminal_finalizer_selects_the_earliest_crossed_boundary() {
4865 let inputs = BallisticInputs {
4866 ground_threshold: 0.0,
4867 ..BallisticInputs::default()
4868 };
4869 let mut solver = TrajectorySolver::new(
4870 inputs,
4871 WindConditions::default(),
4872 AtmosphericConditions::default(),
4873 );
4874 solver.set_max_range(120.0);
4875
4876 let previous_speed = 700.0;
4877 let mut points = vec![TrajectoryPoint {
4878 time: 99.0,
4879 position: Vector3::new(90.0, 1.0, -1.0),
4880 velocity_magnitude: previous_speed,
4881 kinetic_energy: 0.5 * solver.inputs.bullet_mass * previous_speed.powi(2),
4882 }];
4883 let mut max_height = 1.0;
4884 let termination = solver
4885 .append_terminal_endpoint(
4886 &mut points,
4887 Vector3::new(130.0, -3.0, 3.0),
4888 Vector3::new(600.0, 0.0, 0.0),
4889 101.0,
4890 &mut max_height,
4891 )
4892 .expect("the final step brackets supported boundaries");
4893
4894 assert_eq!(termination, TrajectoryTermination::GroundThreshold);
4895 assert_eq!(points.len(), 2);
4896 let terminal = points.last().expect("terminal point");
4897 assert_eq!(terminal.time, 99.5);
4898 assert_eq!(terminal.position, Vector3::new(100.0, 0.0, 0.0));
4899 assert_eq!(terminal.velocity_magnitude, 675.0);
4900 assert_eq!(
4901 terminal.kinetic_energy,
4902 0.5 * solver.inputs.bullet_mass * 675.0_f64.powi(2)
4903 );
4904
4905 solver.set_max_range(100.0);
4907 let mut tied_points = vec![points[0].clone()];
4908 assert_eq!(
4909 solver
4910 .append_terminal_endpoint(
4911 &mut tied_points,
4912 Vector3::new(130.0, -3.0, 3.0),
4913 Vector3::new(600.0, 0.0, 0.0),
4914 101.0,
4915 &mut max_height,
4916 )
4917 .expect("tied boundaries remain a valid terminal"),
4918 TrajectoryTermination::GroundThreshold
4919 );
4920 }
4921
4922 #[test]
4923 fn sub_ulp_terminal_crossing_replaces_instead_of_duplicating_range() {
4924 let ground_threshold = f64::from_bits(1.0_f64.to_bits() - 1);
4925 let inputs = BallisticInputs {
4926 ground_threshold,
4927 ..BallisticInputs::default()
4928 };
4929 let mut solver = TrajectorySolver::new(
4930 inputs,
4931 WindConditions::default(),
4932 AtmosphericConditions::default(),
4933 );
4934 solver.set_max_range(1_000.0);
4935
4936 let speed = 700.0;
4937 let mut points = vec![TrajectoryPoint {
4938 time: 0.0,
4939 position: Vector3::new(100.0, 1.0, 0.0),
4940 velocity_magnitude: speed,
4941 kinetic_energy: 0.5 * solver.inputs.bullet_mass * speed.powi(2),
4942 }];
4943 let mut max_height = 1.0;
4944 let termination = solver
4945 .append_terminal_endpoint(
4946 &mut points,
4947 Vector3::new(101.0, 0.0, 0.0),
4948 Vector3::new(699.0, 0.0, 0.0),
4949 1.0,
4950 &mut max_height,
4951 )
4952 .expect("sub-ULP ground crossing remains representable as one terminal state");
4953
4954 assert_eq!(termination, TrajectoryTermination::GroundThreshold);
4955 assert_eq!(points.len(), 1);
4956 assert_eq!(points[0].position.x, 100.0);
4957 assert_eq!(points[0].position.y.to_bits(), ground_threshold.to_bits());
4958 assert!(points[0].time > 0.0);
4959 }
4960
4961 #[test]
4962 fn every_solver_appends_an_exact_max_range_endpoint() {
4963 let target_range = 0.1;
4964 let modes = [
4965 ("Euler", false, false),
4966 ("RK4", true, false),
4967 ("RK45", true, true),
4968 ];
4969
4970 for (name, use_rk4, use_adaptive_rk45) in modes {
4971 let inputs = BallisticInputs {
4972 use_rk4,
4973 use_adaptive_rk45,
4974 ground_threshold: f64::NEG_INFINITY,
4975 enable_trajectory_sampling: true,
4976 sample_interval: target_range,
4977 ..BallisticInputs::default()
4978 };
4979 let mut solver = TrajectorySolver::new(
4980 inputs,
4981 WindConditions::default(),
4982 AtmosphericConditions::default(),
4983 );
4984 solver.set_max_range(target_range);
4985
4986 let result = solver.solve().expect("short-range solve should succeed");
4987 let terminal = result.points.last().expect("terminal point is missing");
4988 let muzzle = result.points.first().expect("muzzle point is missing");
4989
4990 assert_eq!(result.termination, TrajectoryTermination::MaxRange);
4991 assert_eq!(
4992 terminal.position.x.to_bits(),
4993 target_range.to_bits(),
4994 "{name} did not terminate exactly at max_range"
4995 );
4996 assert_eq!(result.max_range.to_bits(), target_range.to_bits());
4997 assert!(
4998 result.time_of_flight > 0.0 && result.time_of_flight < solver.time_step,
4999 "{name} terminal time was not interpolated within the crossing step: {}",
5000 result.time_of_flight
5001 );
5002 assert_eq!(result.time_of_flight.to_bits(), terminal.time.to_bits());
5003 assert_eq!(
5004 result.impact_velocity.to_bits(),
5005 terminal.velocity_magnitude.to_bits()
5006 );
5007 assert_eq!(
5008 result.impact_energy.to_bits(),
5009 terminal.kinetic_energy.to_bits()
5010 );
5011 let expected_energy = 0.5 * solver.inputs.bullet_mass * result.impact_velocity.powi(2);
5012 assert!((result.impact_energy - expected_energy).abs() < 1e-12);
5013 assert!(terminal.velocity_magnitude < muzzle.velocity_magnitude);
5014 assert!(terminal.kinetic_energy < muzzle.kinetic_energy);
5015
5016 let terminal_sample = result
5017 .sampled_points
5018 .as_ref()
5019 .and_then(|samples| samples.last())
5020 .expect("terminal trajectory sample is missing");
5021 assert_eq!(
5022 terminal_sample.distance_m.to_bits(),
5023 target_range.to_bits(),
5024 "{name} sampling did not include max_range"
5025 );
5026 assert_eq!(
5027 terminal_sample.time_s.to_bits(),
5028 result.time_of_flight.to_bits()
5029 );
5030 assert_eq!(
5031 terminal_sample.velocity_mps.to_bits(),
5032 result.impact_velocity.to_bits()
5033 );
5034 assert!((terminal_sample.energy_j - result.impact_energy).abs() < 1e-12);
5035 }
5036 }
5037}
5038
5039#[cfg(test)]
5040mod precession_inertia_wiring_tests {
5041 use super::*;
5042
5043 #[test]
5044 fn solver_uses_projectile_specific_moments_of_inertia() {
5045 let mass_kg = 55.0 * crate::constants::GRAINS_TO_KG;
5046 let caliber_m = 0.224 * 0.0254;
5047 let length_m = 0.75 * 0.0254;
5048 let inputs = BallisticInputs {
5049 bullet_mass: mass_kg,
5050 bullet_diameter: caliber_m,
5051 bullet_length: length_m,
5052 muzzle_velocity: 800.0,
5053 twist_rate: 7.0,
5054 enable_precession_nutation: true,
5055 use_rk4: false,
5056 use_adaptive_rk45: false,
5057 ..BallisticInputs::default()
5058 };
5059 let mut solver = TrajectorySolver::new(
5060 inputs,
5061 WindConditions::default(),
5062 AtmosphericConditions::default(),
5063 );
5064 solver.set_max_range(0.1);
5065
5066 let (air_density, speed_of_sound, _, _) = solver.resolved_atmosphere();
5067 let velocity_mps = solver.inputs.muzzle_velocity;
5068 let velocity_fps = velocity_mps * 3.28084;
5069 let twist_rate_ft = solver.inputs.twist_rate / 12.0;
5070 let spin_rate_rad_s = (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI;
5071 let initial_state = AngularState {
5072 pitch_angle: 0.001,
5073 yaw_angle: 0.001,
5074 pitch_rate: 0.0,
5075 yaw_rate: 0.0,
5076 precession_angle: 0.0,
5077 nutation_phase: 0.0,
5078 };
5079 let params = PrecessionNutationParams {
5080 mass_kg,
5081 caliber_m,
5082 length_m,
5083 spin_rate_rad_s,
5084 spin_inertia: crate::spin_decay::calculate_moment_of_inertia(
5085 mass_kg, caliber_m, length_m, "ogive",
5086 ),
5087 transverse_inertia: crate::pitch_damping::calculate_transverse_moment_of_inertia(
5088 mass_kg, caliber_m, length_m, "ogive",
5089 ),
5090 velocity_mps,
5091 air_density_kg_m3: air_density,
5092 mach: velocity_mps / speed_of_sound,
5093 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
5094 nutation_damping_factor: 0.05,
5095 };
5096 let expected = calculate_combined_angular_motion(
5097 ¶ms,
5098 &initial_state,
5099 0.0,
5100 solver.time_step,
5101 0.001,
5102 );
5103 let actual = solver
5104 .solve()
5105 .expect("one-step solve should succeed")
5106 .angular_state
5107 .expect("precession/nutation was enabled");
5108
5109 assert!(
5110 (actual.precession_angle - expected.precession_angle).abs() < 1e-15,
5111 "precession phase used the wrong inertia: actual={}, expected={}",
5112 actual.precession_angle,
5113 expected.precession_angle
5114 );
5115 assert!(
5116 (actual.nutation_phase - expected.nutation_phase).abs() < 1e-15,
5117 "nutation phase used the wrong inertia: actual={}, expected={}",
5118 actual.nutation_phase,
5119 expected.nutation_phase
5120 );
5121 }
5122}
5123
5124#[cfg(test)]
5125mod form_factor_drag_tests {
5126 use super::*;
5127
5128 fn acceleration_with_form_factor_flag(enabled: bool) -> Vector3<f64> {
5129 let inputs = BallisticInputs {
5130 bc_value: 0.462,
5131 bc_type: DragModel::G1,
5132 bullet_model: Some("168gr SMK Match".to_string()),
5133 use_form_factor: enabled,
5134 ..BallisticInputs::default()
5135 };
5136 let solver = TrajectorySolver::new(
5137 inputs,
5138 WindConditions::default(),
5139 AtmosphericConditions::default(),
5140 );
5141 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5142 solver.calculate_acceleration(
5143 &Vector3::zeros(),
5144 &Vector3::new(600.0, 0.0, 0.0),
5145 &Vector3::zeros(),
5146 (temp_c, pressure_hpa, density / 1.225),
5147 )
5148 }
5149
5150 #[test]
5151 fn measured_bc_drag_does_not_apply_name_based_form_factor_again() {
5152 let baseline = acceleration_with_form_factor_flag(false);
5153 let flagged = acceleration_with_form_factor_flag(true);
5154
5155 assert!(
5156 (flagged - baseline).norm() < 1e-12,
5157 "published BC already encodes form factor: baseline={baseline:?} flagged={flagged:?}"
5158 );
5159 }
5160}
5161
5162#[cfg(test)]
5163mod rk45_adaptivity_tests {
5164 use super::*;
5165
5166 #[test]
5167 fn cli_rk45_error_norm_scales_components_independently() {
5168 let position = Vector3::new(1.0e9, 0.0, 0.0);
5169 let velocity = Vector3::new(800.0, 0.0, 0.0);
5170 let fifth_position = position;
5171 let fifth_velocity = velocity;
5172 let fourth_position = position;
5173 let fourth_velocity = Vector3::new(800.0, 1.0e-3, 0.0);
5174
5175 let error = cli_rk45_error_norm(
5176 &position,
5177 &velocity,
5178 &fifth_position,
5179 &fifth_velocity,
5180 &fourth_position,
5181 &fourth_velocity,
5182 );
5183 let expected = 1.0e-3 / 6.0_f64.sqrt();
5184
5185 assert!(
5186 (error - expected).abs() <= 1e-15,
5187 "large downrange position masked a velocity-component error: {error}"
5188 );
5189 }
5190
5191 fn discontinuous_wind_solver() -> TrajectorySolver {
5192 let inputs = BallisticInputs::default();
5193 let mut solver = TrajectorySolver::new(
5194 inputs,
5195 WindConditions::default(),
5196 AtmosphericConditions::default(),
5197 );
5198 solver.set_wind_segments(vec![
5199 crate::wind::WindSegment::new(0.0, 90.0, 4.0),
5200 crate::wind::WindSegment::new(1_000.0, 90.0, 10_000.0),
5201 ]);
5202 solver
5203 }
5204
5205 #[test]
5206 fn rk45_retries_discontinuous_trial_before_advancing() {
5207 let solver = discontinuous_wind_solver();
5208 let position = Vector3::new(0.0, solver.inputs.muzzle_height, 0.0);
5209 let velocity = Vector3::new(solver.inputs.muzzle_velocity, 0.0, 0.0);
5210 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5211 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
5212 let dt = 0.01;
5213
5214 let rejected_trial = solver.rk45_step(
5215 &position,
5216 &velocity,
5217 dt,
5218 &Vector3::zeros(),
5219 RK45_TOLERANCE,
5220 resolved_atmo,
5221 );
5222 assert!(
5223 rejected_trial.error > RK45_TOLERANCE,
5224 "discontinuous full step must exceed tolerance, got {}",
5225 rejected_trial.error
5226 );
5227
5228 let accepted = solver.adaptive_rk45_step(
5229 &position,
5230 &velocity,
5231 dt,
5232 &Vector3::zeros(),
5233 resolved_atmo,
5234 );
5235 assert!(accepted.used_dt < dt, "oversized trial was not retried");
5236 assert!(
5237 accepted.error <= RK45_TOLERANCE || accepted.used_dt <= RK45_MIN_DT,
5238 "accepted error {} exceeds tolerance at dt {}",
5239 accepted.error,
5240 accepted.used_dt
5241 );
5242
5243 let accepted_trial = solver.rk45_step(
5244 &position,
5245 &velocity,
5246 accepted.used_dt,
5247 &Vector3::zeros(),
5248 RK45_TOLERANCE,
5249 resolved_atmo,
5250 );
5251 assert_eq!(accepted.position, accepted_trial.position);
5252 assert_eq!(accepted.velocity, accepted_trial.velocity);
5253 assert!((RK45_MIN_DT..=RK45_MAX_DT).contains(&accepted.next_dt));
5254 }
5255}
5256
5257#[cfg(test)]
5258mod ground_termination_tests {
5259 use super::*;
5260 use crate::trajectory_observation::TrajectoryObservationFlag;
5261
5262 #[test]
5263 fn every_solver_reports_one_exact_early_ground_endpoint() {
5264 for (name, use_rk4, use_adaptive_rk45) in [
5265 ("Euler", false, false),
5266 ("RK4", true, false),
5267 ("RK45", true, true),
5268 ] {
5269 let inputs = BallisticInputs {
5270 muzzle_height: 1.0,
5271 muzzle_angle: -0.2,
5272 ground_threshold: 0.0,
5273 use_rk4,
5274 use_adaptive_rk45,
5275 ..BallisticInputs::default()
5276 };
5277 let mut solver = TrajectorySolver::new(
5278 inputs,
5279 WindConditions::default(),
5280 AtmosphericConditions::default(),
5281 );
5282 solver.set_max_range(1_000.0);
5283
5284 let result = solver.solve().expect("early-ground solve should succeed");
5285 let terminal = result.points.last().expect("terminal point is missing");
5286
5287 assert_eq!(result.termination, TrajectoryTermination::GroundThreshold);
5288 assert_eq!(terminal.position.y.to_bits(), 0.0_f64.to_bits());
5289 assert!(
5290 terminal.position.x < 1_000.0,
5291 "{name} incorrectly reached max range"
5292 );
5293 assert_eq!(result.max_range.to_bits(), terminal.position.x.to_bits());
5294 assert_eq!(
5295 result
5296 .points
5297 .iter()
5298 .filter(|point| point.position.y == 0.0)
5299 .count(),
5300 1,
5301 "{name} did not retain exactly one ground endpoint"
5302 );
5303
5304 let observations = result
5305 .sample_observations(1.0, 100)
5306 .expect("checked early-ground sampling should succeed");
5307 assert!(observations[..observations.len() - 1]
5308 .iter()
5309 .all(|observation| observation.distance_m < terminal.position.x));
5310 let terminal_observation = observations.last().expect("terminal observation");
5311 assert_eq!(
5312 terminal_observation.distance_m.to_bits(),
5313 terminal.position.x.to_bits()
5314 );
5315 assert!(terminal_observation
5316 .flags
5317 .contains(&TrajectoryObservationFlag::Terminal));
5318 assert!(terminal_observation
5319 .flags
5320 .contains(&TrajectoryObservationFlag::GroundThreshold));
5321 assert_eq!(
5322 observations
5323 .iter()
5324 .filter(|observation| observation
5325 .flags
5326 .contains(&TrajectoryObservationFlag::Terminal))
5327 .count(),
5328 1,
5329 "{name} repeated the terminal observation"
5330 );
5331 }
5332 }
5333
5334 #[test]
5339 fn rk4_and_rk45_descend_to_ground_threshold() {
5340 for adaptive in [false, true] {
5341 let inputs = BallisticInputs {
5342 muzzle_angle: 0.1, use_rk4: true,
5344 use_adaptive_rk45: adaptive,
5345 ..BallisticInputs::default()
5346 };
5347 assert_eq!(
5348 inputs.ground_threshold, -100.0,
5349 "default ground_threshold is -100 m"
5350 );
5351
5352 let mut solver = TrajectorySolver::new(
5353 inputs,
5354 WindConditions::default(),
5355 AtmosphericConditions::default(),
5356 );
5357 solver.set_max_range(1.0e7);
5359
5360 let result = solver.solve().expect("solve should succeed");
5361 let final_y = result
5362 .points
5363 .last()
5364 .expect("trajectory has points")
5365 .position
5366 .y;
5367 assert!(
5368 final_y < -1.0,
5369 "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
5370 past launch level toward the ground_threshold floor, not stop at y = 0"
5371 );
5372 }
5373 }
5374}
5375
5376#[cfg(test)]
5377mod magnus_stability_tests {
5378 use super::*;
5379
5380 #[test]
5381 fn yaw_of_repose_magnus_force_is_vertical_and_twist_signed() {
5382 let acceleration = |enable_magnus, is_twist_right| {
5383 let inputs = BallisticInputs {
5384 muzzle_velocity: 822.96,
5385 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
5386 bullet_diameter: 0.308 * 0.0254,
5387 bullet_length: 1.215 * 0.0254,
5388 twist_rate: 10.0,
5389 is_twist_right,
5390 enable_magnus,
5391 ..BallisticInputs::default()
5392 };
5393 let solver = TrajectorySolver::new(
5394 inputs,
5395 WindConditions::default(),
5396 AtmosphericConditions::default(),
5397 );
5398 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5399 solver.calculate_acceleration(
5400 &Vector3::zeros(),
5401 &Vector3::new(822.96, 0.0, 0.0),
5402 &Vector3::zeros(),
5403 (temp_c, pressure_hpa, density / 1.225),
5404 )
5405 };
5406
5407 let baseline = acceleration(false, true);
5408 let right_twist = acceleration(true, true) - baseline;
5409 let left_twist = acceleration(true, false) - baseline;
5410
5411 assert!(
5412 right_twist.y < 0.0,
5413 "right-hand Magnus must point down, got {right_twist:?}"
5414 );
5415 assert!(
5416 left_twist.y > 0.0,
5417 "left-hand Magnus must point up, got {left_twist:?}"
5418 );
5419 assert!((right_twist.y + left_twist.y).abs() < 1e-12);
5420 assert!(right_twist.x.abs() < 1e-12 && right_twist.z.abs() < 1e-12);
5421 assert!(left_twist.x.abs() < 1e-12 && left_twist.z.abs() < 1e-12);
5422 }
5423
5424 #[test]
5425 fn magnus_uses_velocity_corrected_muzzle_stability_gate() {
5426 let muzzle_velocity = 1_400.0 / 3.28084;
5427 let inputs = BallisticInputs {
5428 muzzle_velocity,
5429 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
5430 bullet_diameter: 0.308 * 0.0254,
5431 bullet_length: 1.215 * 0.0254,
5432 twist_rate: 15.0,
5433 enable_magnus: true,
5434 ..BallisticInputs::default()
5435 };
5436 let solver = TrajectorySolver::new(
5437 inputs.clone(),
5438 WindConditions::default(),
5439 AtmosphericConditions::default(),
5440 );
5441
5442 let bare_sg = crate::spin_drift::miller_stability(0.308, 168.0, 15.0, 1.215);
5443 let canonical_sg = solver.effective_spin_drift_sg();
5444 assert!(bare_sg > 1.0, "test requires bare Sg above the Magnus gate");
5445 assert!(
5446 canonical_sg < 1.0,
5447 "velocity-corrected Sg must be below the gate, got {canonical_sg}"
5448 );
5449
5450 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5451 let acceleration = solver.calculate_acceleration(
5452 &Vector3::zeros(),
5453 &Vector3::new(muzzle_velocity, 0.0, 0.0),
5454 &Vector3::zeros(),
5455 (temp_c, pressure_hpa, density / 1.225),
5456 );
5457 let mut baseline_inputs = inputs;
5458 baseline_inputs.enable_magnus = false;
5459 let baseline_solver = TrajectorySolver::new(
5460 baseline_inputs,
5461 WindConditions::default(),
5462 AtmosphericConditions::default(),
5463 );
5464 let baseline = baseline_solver.calculate_acceleration(
5465 &Vector3::zeros(),
5466 &Vector3::new(muzzle_velocity, 0.0, 0.0),
5467 &Vector3::zeros(),
5468 (temp_c, pressure_hpa, density / 1.225),
5469 );
5470
5471 assert_eq!(
5472 acceleration, baseline,
5473 "canonical Sg below 1 must suppress every Magnus acceleration component"
5474 );
5475 }
5476
5477 #[test]
5478 fn magnus_force_grows_as_fixed_spin_projectile_slows() {
5479 let inputs = BallisticInputs {
5480 muzzle_velocity: 800.0,
5481 bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
5482 bullet_diameter: 0.308 * 0.0254,
5483 bullet_length: 1.215 * 0.0254,
5484 twist_rate: 12.0,
5485 enable_magnus: true,
5486 ..BallisticInputs::default()
5487 };
5488
5489 let magnus_acceleration = |speed_mps| {
5490 let evaluate = |enable_magnus| {
5491 let mut run_inputs = inputs.clone();
5492 run_inputs.enable_magnus = enable_magnus;
5493 let solver = TrajectorySolver::new(
5494 run_inputs,
5495 WindConditions::default(),
5496 AtmosphericConditions::default(),
5497 );
5498 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
5499 solver
5500 .calculate_acceleration(
5501 &Vector3::zeros(),
5502 &Vector3::new(speed_mps, 0.0, 0.0),
5503 &Vector3::zeros(),
5504 (temp_c, pressure_hpa, density / 1.225),
5505 )
5506 .y
5507 };
5508 (evaluate(true) - evaluate(false)).abs()
5509 };
5510
5511 let fast = magnus_acceleration(200.0);
5512 let slow = magnus_acceleration(100.0);
5513 let ratio = slow / fast;
5514 let expected_ratio = 2.0_f64.powf(5.0 / 3.0);
5515
5516 assert!(fast > 0.0 && slow > 0.0, "fast={fast}, slow={slow}");
5517 assert!(
5518 (ratio - expected_ratio).abs() < 1e-3,
5519 "fixed-spin Magnus acceleration must grow downrange; slow/fast={ratio}, \
5520 expected={expected_ratio}"
5521 );
5522 }
5523}
5524
5525#[cfg(test)]
5526mod coriolis_direction_tests {
5527 use super::*;
5528 use std::f64::consts::FRAC_PI_2;
5529
5530 #[test]
5531 fn supersonic_crossing_flags_a_positive_range_sample() {
5532 use crate::trajectory_sampling::TrajectoryFlag;
5536
5537 for (solver_name, use_rk4, use_adaptive_rk45) in [
5538 ("Euler", false, false),
5539 ("RK4", true, false),
5540 ("RK45", true, true),
5541 ] {
5542 let inputs = BallisticInputs {
5543 muzzle_velocity: 850.0,
5544 bc_value: 0.2,
5545 bc_type: DragModel::G7,
5546 muzzle_angle: 0.03,
5547 enable_trajectory_sampling: true,
5548 sample_interval: 50.0,
5549 use_rk4,
5550 use_adaptive_rk45,
5551 ..BallisticInputs::default()
5552 };
5553 let mut solver = TrajectorySolver::new(
5554 inputs,
5555 WindConditions::default(),
5556 AtmosphericConditions::default(),
5557 );
5558 solver.set_max_range(2000.0);
5559 let samples = solver
5560 .solve()
5561 .expect("supersonic solve should succeed")
5562 .sampled_points
5563 .expect("sampling was enabled");
5564 let flagged_distances: Vec<_> = samples
5565 .iter()
5566 .filter(|sample| sample.flags.contains(&TrajectoryFlag::MachTransition))
5567 .map(|sample| sample.distance_m)
5568 .collect();
5569
5570 assert!(
5571 !flagged_distances.is_empty()
5572 && flagged_distances.iter().all(|distance| *distance > 0.0),
5573 "{solver_name} must flag genuine crossings only at positive range: {flagged_distances:?}"
5574 );
5575 }
5576 }
5577
5578 #[test]
5579 fn subsonic_launch_does_not_flag_a_muzzle_transition() {
5580 use crate::trajectory_sampling::TrajectoryFlag;
5581
5582 for (solver_name, use_rk4, use_adaptive_rk45) in [
5583 ("Euler", false, false),
5584 ("RK4", true, false),
5585 ("RK45", true, true),
5586 ] {
5587 let inputs = BallisticInputs {
5588 muzzle_velocity: 250.0,
5589 muzzle_angle: 0.02,
5590 enable_trajectory_sampling: true,
5591 sample_interval: 25.0,
5592 use_rk4,
5593 use_adaptive_rk45,
5594 ..BallisticInputs::default()
5595 };
5596 let mut solver = TrajectorySolver::new(
5597 inputs,
5598 WindConditions::default(),
5599 AtmosphericConditions::default(),
5600 );
5601 solver.set_max_range(300.0);
5602 let samples = solver
5603 .solve()
5604 .expect("subsonic solve should succeed")
5605 .sampled_points
5606 .expect("sampling was enabled");
5607
5608 assert!(
5609 samples
5610 .iter()
5611 .all(|sample| !sample.flags.contains(&TrajectoryFlag::MachTransition)),
5612 "{solver_name} marked a Mach transition for a launch already below Mach 1"
5613 );
5614 }
5615 }
5616
5617 #[test]
5618 fn mach_transition_tracker_requires_a_downward_crossing() {
5619 fn record(mach_values: &[f64]) -> Vec<f64> {
5620 let mut tracker = MachTransitionTracker::default();
5621 let mut distances = Vec::new();
5622 for (index, mach) in mach_values.iter().copied().enumerate() {
5623 tracker.record_downward_crossings(mach, index as f64 * 10.0, &mut distances);
5624 }
5625 distances
5626 }
5627
5628 assert!(record(&[0.9, 0.8, 0.7]).is_empty());
5629 assert_eq!(record(&[1.1, 1.05, 0.99]), vec![20.0]);
5630 assert_eq!(record(&[1.2, 1.19, 1.0, 0.99]), vec![10.0, 30.0]);
5631 assert_eq!(record(&[0.9, 1.3, 1.1, 0.9, 1.3, 0.8]), vec![20.0, 30.0]);
5632 assert!(record(&[1.3, f64::NAN, 1.1]).is_empty());
5633 }
5634
5635 #[test]
5636 fn humidity_percent_converts_and_clamps() {
5637 let mut i = BallisticInputs {
5639 humidity: 0.5,
5640 ..BallisticInputs::default()
5641 };
5642 assert!((i.humidity_percent() - 50.0).abs() < 1e-9, "0.5 -> 50%");
5643 i.humidity = 0.0;
5644 assert_eq!(i.humidity_percent(), 0.0);
5645 i.humidity = 1.0;
5646 assert_eq!(i.humidity_percent(), 100.0);
5647 i.humidity = 1.5; assert_eq!(i.humidity_percent(), 100.0);
5649 }
5650
5651 fn vertical_at(shot_azimuth: f64, range_m: f64) -> f64 {
5654 let inputs = BallisticInputs {
5655 muzzle_velocity: 800.0,
5656 bc_value: 0.5,
5657 bc_type: DragModel::G7,
5658 muzzle_angle: 0.02, enable_coriolis: true,
5660 latitude: Some(45.0),
5661 shot_azimuth,
5662 ground_threshold: f64::NEG_INFINITY, ..BallisticInputs::default()
5664 };
5665 let mut solver = TrajectorySolver::new(
5666 inputs,
5667 WindConditions::default(),
5668 AtmosphericConditions::default(),
5669 );
5670 solver.set_max_range(range_m + 50.0);
5671 let r = solver.solve().expect("solve");
5672 let pts = &r.points;
5673 for i in 1..pts.len() {
5674 if pts[i].position.x >= range_m {
5675 let p1 = &pts[i - 1];
5676 let p2 = &pts[i];
5677 let t = (range_m - p1.position.x) / (p2.position.x - p1.position.x);
5678 return p1.position.y + t * (p2.position.y - p1.position.y);
5679 }
5680 }
5681 panic!("range {range_m} not reached");
5682 }
5683
5684 #[test]
5689 fn eotvos_east_higher_than_west() {
5690 let range = 600.0;
5691 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!(
5695 east > west,
5696 "east ({east:.5}) must be higher than west ({west:.5}) at {range} m (Eötvös)"
5697 );
5698 assert!(
5699 east > north && north > west,
5700 "north ({north:.5}) must lie between east ({east:.5}) and west ({west:.5})"
5701 );
5702 assert!(
5703 (east - west) > 1e-3,
5704 "E-W vertical separation ({:.6} m) should be physically meaningful, not FP noise",
5705 east - west
5706 );
5707 }
5708}
5709
5710#[cfg(test)]
5711mod cant_tests {
5712 use super::*;
5713
5714 fn base_inputs() -> BallisticInputs {
5715 BallisticInputs {
5716 muzzle_velocity: 800.0,
5717 bc_value: 0.5,
5718 bc_type: DragModel::G7,
5719 bullet_mass: 0.0109,
5720 bullet_diameter: 0.00782,
5721 bullet_length: 0.0309,
5722 sight_height: 0.05,
5723 twist_rate: 10.0,
5724 use_rk4: true,
5725 ..BallisticInputs::default()
5726 }
5727 }
5728
5729 fn solve_with(inputs: BallisticInputs, max_range: f64) -> TrajectoryResult {
5730 let mut s = TrajectorySolver::new(
5731 inputs,
5732 WindConditions::default(),
5733 AtmosphericConditions::default(),
5734 );
5735 s.set_max_range(max_range);
5736 s.solve().expect("solve")
5737 }
5738
5739 fn yz_at(result: &TrajectoryResult, x: f64) -> (f64, f64) {
5741 let pts = &result.points;
5742 for i in 1..pts.len() {
5743 if pts[i].position.x >= x {
5744 let (p1, p2) = (&pts[i - 1], &pts[i]);
5745 let dx = p2.position.x - p1.position.x;
5746 let t = if dx.abs() < 1e-12 { 0.0 } else { (x - p1.position.x) / dx };
5747 return (
5748 p1.position.y + t * (p2.position.y - p1.position.y),
5749 p1.position.z + t * (p2.position.z - p1.position.z),
5750 );
5751 }
5752 }
5753 panic!("trajectory never reached {x} m");
5754 }
5755
5756 #[test]
5757 fn cant_sign_clockwise_up_offset_goes_right_and_low() {
5758 let mut level = base_inputs();
5760 level.muzzle_angle = 0.003; let mut canted = level.clone();
5762 canted.cant_angle = 10f64.to_radians();
5763
5764 let (y0, z0) = yz_at(&solve_with(level, 400.0), 300.0);
5765 let (y1, z1) = yz_at(&solve_with(canted, 400.0), 300.0);
5766 assert!(z1 > z0 + 0.01, "clockwise cant must move POI right: z0={z0} z1={z1}");
5767 assert!(y1 < y0 - 0.001, "clockwise cant must move POI low: y0={y0} y1={y1}");
5768 }
5769
5770 #[test]
5771 fn pure_cant_shows_bore_offset_near_range() {
5772 let mut i = base_inputs();
5775 i.muzzle_angle = 0.0;
5776 i.cant_angle = 10f64.to_radians();
5777 let sh = i.sight_height;
5778 let r = solve_with(i, 60.0);
5779 let first = &r.points[1]; let expected = -sh * 10f64.to_radians().sin();
5781 assert!(
5782 (first.position.z - expected).abs() < 0.005,
5783 "near-muzzle lateral {} should be ~bore offset {expected}",
5784 first.position.z
5785 );
5786 }
5787
5788 #[test]
5789 fn zero_angle_is_independent_of_cant() {
5790 let a = base_inputs();
5791 let mut b = base_inputs();
5792 b.cant_angle = 15f64.to_radians();
5793 let za = calculate_zero_angle(a.clone(), 100.0, 0.0).expect("zero a");
5794 let zb = calculate_zero_angle(b.clone(), 100.0, 0.0).expect("zero b");
5795 assert_eq!(za.to_bits(), zb.to_bits(), "zeroing must ignore cant: {za} vs {zb}");
5796 let _ = (a.cant_angle, b.cant_angle);
5798 }
5799
5800 #[test]
5801 fn nonfinite_cant_is_rejected() {
5802 let mut i = base_inputs();
5803 i.cant_angle = f64::NAN;
5804 let s = TrajectorySolver::new(i, WindConditions::default(), AtmosphericConditions::default());
5805 assert!(s.solve().is_err());
5806 }
5807
5808 #[test]
5809 fn incline_and_cant_compose_without_breaking() {
5810 let mut flat = base_inputs();
5812 flat.muzzle_angle = 0.003;
5813 flat.shooting_angle = 15f64.to_radians();
5814 let mut canted = flat.clone();
5815 canted.cant_angle = 10f64.to_radians();
5816 let (_, z_flat) = yz_at(&solve_with(flat, 400.0), 300.0);
5817 let (_, z_cant) = yz_at(&solve_with(canted, 400.0), 300.0);
5818 assert!(z_cant > z_flat, "cant must still deflect right on an incline");
5819 }
5820}
5821
5822#[cfg(test)]
5823mod vertical_wind_tests {
5824 use super::*;
5825
5826 fn base_inputs() -> BallisticInputs {
5827 BallisticInputs {
5828 muzzle_velocity: 800.0,
5829 bc_value: 0.5,
5830 bc_type: DragModel::G7,
5831 bullet_mass: 0.0109,
5832 bullet_diameter: 0.00782,
5833 bullet_length: 0.0309,
5834 sight_height: 0.05,
5835 twist_rate: 10.0,
5836 use_rk4: true,
5837 ..BallisticInputs::default()
5838 }
5839 }
5840
5841 fn y_at(result: &TrajectoryResult, x: f64) -> f64 {
5843 let pts = &result.points;
5844 for i in 1..pts.len() {
5845 if pts[i].position.x >= x {
5846 let (p1, p2) = (&pts[i - 1], &pts[i]);
5847 let dx = p2.position.x - p1.position.x;
5848 let t = if dx.abs() < 1e-12 { 0.0 } else { (x - p1.position.x) / dx };
5849 return p1.position.y + t * (p2.position.y - p1.position.y);
5850 }
5851 }
5852 panic!("trajectory never reached {x} m");
5853 }
5854
5855 fn solve_with(inputs: BallisticInputs, wind: WindConditions, max_range: f64) -> TrajectoryResult {
5856 let mut s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
5857 s.set_max_range(max_range);
5858 s.solve().expect("solve")
5859 }
5860
5861 #[test]
5862 fn updraft_raises_poi_downrange() {
5863 let calm_inputs = base_inputs();
5866 let calm_wind = WindConditions::default();
5867 let updraft = WindConditions {
5868 vertical_speed: 5.0,
5869 ..Default::default()
5870 };
5871
5872 let calm = solve_with(calm_inputs.clone(), calm_wind, 500.0);
5873 let updraft_result = solve_with(calm_inputs, updraft, 500.0);
5874
5875 let y_calm = y_at(&calm, 400.0);
5876 let y_updraft = y_at(&updraft_result, 400.0);
5877 assert!(
5878 y_updraft > y_calm,
5879 "5 m/s updraft must raise POI at 400m: calm={y_calm}, updraft={y_updraft}"
5880 );
5881 }
5882
5883 #[test]
5884 fn zero_vertical_is_default_and_finite_required() {
5885 assert_eq!(WindConditions::default().vertical_speed, 0.0);
5886
5887 let inputs = base_inputs();
5888 let wind = WindConditions {
5889 vertical_speed: f64::NAN,
5890 ..Default::default()
5891 };
5892 let s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
5893 assert!(
5894 s.solve().is_err(),
5895 "NaN wind.vertical_speed must be rejected by validate_for_solve"
5896 );
5897 }
5898}