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