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 sample_trajectory, TrajectoryData, TrajectoryOutputs, TrajectorySample,
10};
11use crate::wind_shear::WindShearModel;
12use crate::DragModel;
13use nalgebra::{Vector3, Vector6};
14use std::error::Error;
15use std::fmt;
16
17#[derive(Debug, Clone, Copy, PartialEq)]
19pub enum UnitSystem {
20 Imperial,
21 Metric,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq)]
26pub enum OutputFormat {
27 Table,
28 Json,
29 Csv,
30}
31
32#[derive(Debug)]
34pub struct BallisticsError {
35 message: String,
36}
37
38impl fmt::Display for BallisticsError {
39 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
40 write!(f, "{}", self.message)
41 }
42}
43
44impl Error for BallisticsError {}
45
46impl From<String> for BallisticsError {
47 fn from(msg: String) -> Self {
48 BallisticsError { message: msg }
49 }
50}
51
52impl From<&str> for BallisticsError {
53 fn from(msg: &str) -> Self {
54 BallisticsError {
55 message: msg.to_string(),
56 }
57 }
58}
59
60#[derive(Debug, Clone)]
64pub struct BallisticInputs {
65 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,
82 pub shooting_angle: f64, pub cant_angle: f64,
92 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,
106 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,
128 pub enable_magnus: bool, pub enable_coriolis: bool, pub use_powder_sensitivity: bool,
131 pub powder_temp_sensitivity: f64, pub powder_temp: f64, pub powder_temp_curve: Option<Vec<(f64, f64)>>,
140 pub powder_curve_temp_c: Option<f64>,
144 pub tipoff_yaw: f64, pub tipoff_decay_distance: f64, pub use_bc_segments: bool,
149 pub bc_segments: Option<Vec<(f64, f64)>>, pub bc_segments_data: Option<Vec<crate::BCSegmentData>>, pub use_enhanced_spin_drift: bool,
152 pub use_form_factor: bool,
155 pub enable_wind_shear: bool,
156 pub wind_shear_model: String,
157 pub enable_trajectory_sampling: bool,
158 pub sample_interval: f64, pub enable_pitch_damping: bool,
160 pub enable_precession_nutation: bool,
161 pub enable_aerodynamic_jump: bool,
164 pub use_cluster_bc: bool, pub custom_drag_table: Option<crate::drag::DragTable>,
168
169 pub bc_type_str: Option<String>,
171}
172
173impl BallisticInputs {
174 pub fn humidity_percent(&self) -> f64 {
179 (self.humidity * 100.0).clamp(0.0, 100.0)
180 }
181
182 pub fn sectional_density_lb_in2(&self) -> Option<f64> {
188 let weight_gr = if self.weight_grains > 0.0 {
189 self.weight_grains
190 } else {
191 self.bullet_mass / 0.00006479891 };
193 let diameter_in = if self.caliber_inches > 0.0 {
194 self.caliber_inches
195 } else {
196 self.bullet_diameter / 0.0254 };
198 if weight_gr > 0.0 && diameter_in > 0.0 {
199 Some(weight_gr / 7000.0 / (diameter_in * diameter_in))
200 } else {
201 None
202 }
203 }
204
205 pub fn custom_drag_denominator(&self, fallback_bc: f64) -> f64 {
217 match self.sectional_density_lb_in2() {
218 Some(sd) => sd,
219 None => {
220 static WARN_ONCE: std::sync::Once = std::sync::Once::new();
221 WARN_ONCE.call_once(|| {
222 eprintln!(
223 "Warning: custom drag table active but bullet mass/diameter are \
224 unavailable; falling back to bc_value for the retardation denominator"
225 );
226 });
227 fallback_bc
228 }
229 }
230 }
231}
232
233impl Default for BallisticInputs {
234 fn default() -> Self {
235 let mass_kg = 0.01;
236 let diameter_m = 0.00762;
237 let bc = 0.5;
238 let muzzle_angle_rad = 0.0;
239 let bc_type = DragModel::G1;
240
241 Self {
242 bc_value: bc,
244 bc_type,
245 bullet_mass: mass_kg,
246 muzzle_velocity: 800.0,
247 bullet_diameter: diameter_m,
248 bullet_length: crate::stability::estimate_bullet_length_m(diameter_m, mass_kg),
252
253 muzzle_angle: muzzle_angle_rad,
255 target_distance: 100.0,
256 azimuth_angle: 0.0,
257 shot_azimuth: 0.0,
258 shooting_angle: 0.0,
259 cant_angle: 0.0,
260 sight_height: 0.05,
261 muzzle_height: 0.0, target_height: 0.0, ground_threshold: -100.0, altitude: 0.0,
267 temperature: 15.0,
268 pressure: 1013.25, humidity: 0.5, latitude: None,
271
272 wind_speed: 0.0,
274 wind_angle: 0.0,
275
276 twist_rate: 12.0, is_twist_right: true,
279 caliber_inches: diameter_m / 0.0254, weight_grains: mass_kg / 0.00006479891, manufacturer: None,
282 bullet_model: None,
283 bullet_id: None,
284 bullet_cluster: None,
285
286 use_rk4: true, use_adaptive_rk45: true, enable_advanced_effects: false,
292 enable_magnus: false,
293 enable_coriolis: false,
294 use_powder_sensitivity: false,
295 powder_temp_sensitivity: 0.0,
296 powder_temp: 15.0,
297 powder_temp_curve: None,
298 powder_curve_temp_c: None,
299 tipoff_yaw: 0.0,
300 tipoff_decay_distance: 50.0,
301 use_bc_segments: false,
302 bc_segments: None,
303 bc_segments_data: None,
304 use_enhanced_spin_drift: false,
305 use_form_factor: false,
306 enable_wind_shear: false,
307 wind_shear_model: "none".to_string(),
308 enable_trajectory_sampling: false,
309 sample_interval: 10.0, enable_pitch_damping: false,
311 enable_precession_nutation: false,
312 enable_aerodynamic_jump: false,
313 use_cluster_bc: false, custom_drag_table: None,
317
318 bc_type_str: None,
320 }
321 }
322}
323
324pub fn interpolate_powder_temp_curve(curve: &[(f64, f64)], temp_c: f64) -> f64 {
330 debug_assert!(!curve.is_empty());
331 if curve.is_empty() {
332 return 0.0;
333 }
334 let mut sorted;
337 let pts: &[(f64, f64)] = if curve.windows(2).all(|w| w[0].0 <= w[1].0) {
338 curve
339 } else {
340 sorted = curve.to_vec();
341 sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
342 &sorted
343 };
344 let n = pts.len();
345 if temp_c <= pts[0].0 {
346 return pts[0].1; }
348 if temp_c >= pts[n - 1].0 {
349 return pts[n - 1].1; }
351 for i in 1..n {
352 let (t0, v0) = pts[i - 1];
353 let (t1, v1) = pts[i];
354 if temp_c <= t1 {
355 let span = t1 - t0;
356 if span.abs() < f64::EPSILON {
357 return v1; }
359 let f = (temp_c - t0) / span;
360 return v0 + f * (v1 - v0);
361 }
362 }
363 pts[n - 1].1
364}
365
366#[derive(Debug, Clone)]
368pub struct WindConditions {
369 pub speed: f64, pub direction: f64,
373}
374
375impl Default for WindConditions {
376 fn default() -> Self {
377 Self {
378 speed: 0.0,
379 direction: 0.0,
380 }
381 }
382}
383
384#[derive(Debug, Clone)]
386pub struct AtmosphericConditions {
387 pub temperature: f64, pub pressure: f64, pub humidity: f64,
393 pub altitude: f64, }
395
396impl Default for AtmosphericConditions {
397 fn default() -> Self {
398 Self {
399 temperature: 15.0,
400 pressure: 1013.25,
401 humidity: 50.0,
402 altitude: 0.0,
403 }
404 }
405}
406
407#[derive(Debug, Clone)]
409pub struct TrajectoryPoint {
410 pub time: f64,
411 pub position: Vector3<f64>,
412 pub velocity_magnitude: f64,
413 pub kinetic_energy: f64,
414}
415
416#[derive(Debug, Clone)]
418pub struct TrajectoryResult {
419 pub max_range: f64,
420 pub max_height: f64,
421 pub time_of_flight: f64,
422 pub impact_velocity: f64,
423 pub impact_energy: f64,
424 pub points: Vec<TrajectoryPoint>,
425 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>,
434}
435
436const RK45_TOLERANCE: f64 = 1e-6;
437const RK45_SAFETY_FACTOR: f64 = 0.9;
438const RK45_MAX_DT: f64 = 0.01;
439const RK45_MIN_DT: f64 = 1e-6;
440
441pub const MAX_TRAJECTORY_POINTS: usize = 250_000;
447
448fn cli_rk45_error_norm(
450 position: &Vector3<f64>,
451 velocity: &Vector3<f64>,
452 fifth_position: &Vector3<f64>,
453 fifth_velocity: &Vector3<f64>,
454 fourth_position: &Vector3<f64>,
455 fourth_velocity: &Vector3<f64>,
456) -> f64 {
457 let pack_state = |position: &Vector3<f64>, velocity: &Vector3<f64>| {
458 Vector6::new(
459 position.x, position.y, position.z, velocity.x, velocity.y, velocity.z,
460 )
461 };
462 let state = pack_state(position, velocity);
463 let fifth_order = pack_state(fifth_position, fifth_velocity);
464 let fourth_order = pack_state(fourth_position, fourth_velocity);
465
466 crate::trajectory_integration::rk45_error_norm(&state, &fifth_order, &fourth_order)
467}
468
469struct Rk45Trial {
470 position: Vector3<f64>,
471 velocity: Vector3<f64>,
472 suggested_dt: f64,
473 error: f64,
474}
475
476struct Rk45AcceptedStep {
477 position: Vector3<f64>,
478 velocity: Vector3<f64>,
479 used_dt: f64,
480 next_dt: f64,
481 error: f64,
482}
483
484#[derive(Default)]
485struct MachTransitionTracker {
486 previous_mach: Option<f64>,
487 crossed_transonic: bool,
488 crossed_subsonic: bool,
489}
490
491impl MachTransitionTracker {
492 fn record_downward_crossings(&mut self, mach: f64, downrange_m: f64, distances: &mut Vec<f64>) {
493 if !mach.is_finite() {
494 self.previous_mach = None;
495 return;
496 }
497
498 if let Some(previous_mach) = self.previous_mach {
499 if !self.crossed_transonic && previous_mach >= 1.2 && mach < 1.2 {
500 self.crossed_transonic = true;
501 distances.push(downrange_m);
502 }
503 if !self.crossed_subsonic && previous_mach >= 1.0 && mach < 1.0 {
504 self.crossed_subsonic = true;
505 distances.push(downrange_m);
506 }
507 }
508 self.previous_mach = Some(mach);
509 }
510}
511
512impl TrajectoryResult {
513 pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
517 if self.points.is_empty() {
518 return None;
519 }
520
521 for i in 0..self.points.len() - 1 {
523 let p1 = &self.points[i];
524 let p2 = &self.points[i + 1];
525
526 if p1.position.x <= target_range && p2.position.x >= target_range {
528 let dx = p2.position.x - p1.position.x;
530 if dx.abs() < 1e-10 {
531 return Some(p1.position);
532 }
533 let t = (target_range - p1.position.x) / dx;
534
535 return Some(Vector3::new(
537 target_range,
538 p1.position.y + t * (p2.position.y - p1.position.y),
539 p1.position.z + t * (p2.position.z - p1.position.z),
540 ));
541 }
542 }
543
544 self.points.last().map(|p| p.position)
546 }
547}
548
549pub struct TrajectorySolver {
551 inputs: BallisticInputs,
552 wind: WindConditions,
553 atmosphere: AtmosphericConditions,
554 max_range: f64,
555 time_step: f64,
556 max_trajectory_points: usize,
557 cluster_bc: Option<ClusterBCDegradation>,
558 precession_nutation_inertias: (f64, f64),
560 wind_sock: Option<crate::wind::WindSock>,
565 atmo_sock: Option<crate::atmosphere::AtmoSock>,
572}
573
574impl TrajectorySolver {
575 pub fn new(
576 mut inputs: BallisticInputs,
577 wind: WindConditions,
578 atmosphere: AtmosphericConditions,
579 ) -> Self {
580 inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
582 inputs.weight_grains = inputs.bullet_mass / 0.00006479891;
583
584 if let Some(curve) = inputs.powder_temp_curve.as_ref() {
593 if !curve.is_empty() {
594 let lookup_c = inputs.powder_curve_temp_c.unwrap_or(inputs.temperature);
599 inputs.muzzle_velocity = interpolate_powder_temp_curve(curve, lookup_c);
600 }
601 } else if inputs.use_powder_sensitivity {
602 let temp_delta_c = inputs.temperature - inputs.powder_temp;
603 inputs.muzzle_velocity += inputs.powder_temp_sensitivity * temp_delta_c;
604 }
605
606 let cluster_bc = if inputs.use_cluster_bc {
608 Some(ClusterBCDegradation::new())
609 } else {
610 None
611 };
612 let precession_nutation_inertias = projectile_moments_of_inertia(
613 inputs.bullet_mass,
614 inputs.bullet_diameter,
615 inputs.bullet_length,
616 );
617
618 Self {
619 inputs,
620 wind,
621 atmosphere,
622 max_range: 1000.0,
623 time_step: 0.001,
624 max_trajectory_points: MAX_TRAJECTORY_POINTS,
625 cluster_bc,
626 precession_nutation_inertias,
627 wind_sock: None,
628 atmo_sock: None,
629 }
630 }
631
632 pub fn set_max_range(&mut self, range: f64) {
633 self.max_range = range;
634 }
635
636 pub fn set_time_step(&mut self, step: f64) {
637 self.time_step = step;
638 }
639
640 fn validate_for_solve(&self) -> Result<(), BallisticsError> {
646 let require_finite = |name: &str, value: f64| {
647 if value.is_finite() {
648 Ok(())
649 } else {
650 Err(BallisticsError::from(format!("{name} must be finite")))
651 }
652 };
653 let require_positive = |name: &str, value: f64| {
654 if value.is_finite() && value > 0.0 {
655 Ok(())
656 } else {
657 Err(BallisticsError::from(format!(
658 "{name} must be finite and greater than zero"
659 )))
660 }
661 };
662
663 if self.inputs.custom_drag_table.is_none() {
669 require_positive("bc_value", self.inputs.bc_value)?;
670 }
671 require_positive("bullet_mass", self.inputs.bullet_mass)?;
672 require_positive("bullet_diameter", self.inputs.bullet_diameter)?;
673 require_positive("muzzle_velocity", self.inputs.muzzle_velocity)?;
674
675 require_finite("muzzle_angle", self.inputs.muzzle_angle)?;
676 require_finite("azimuth_angle", self.inputs.azimuth_angle)?;
677 require_finite("shooting_angle", self.inputs.shooting_angle)?;
678 require_finite("cant_angle", self.inputs.cant_angle)?;
679 require_finite("muzzle_height", self.inputs.muzzle_height)?;
680
681 if !(self.inputs.ground_threshold.is_finite()
684 || self.inputs.ground_threshold == f64::NEG_INFINITY)
685 {
686 return Err(BallisticsError::from(
687 "ground_threshold must be finite or negative infinity",
688 ));
689 }
690
691 if self.wind_sock.is_none() {
692 require_finite("wind.speed", self.wind.speed)?;
693 require_finite("wind.direction", self.wind.direction)?;
694 }
695
696 require_finite("atmosphere.temperature", self.atmosphere.temperature)?;
697 require_finite("atmosphere.pressure", self.atmosphere.pressure)?;
698 require_finite("atmosphere.humidity", self.atmosphere.humidity)?;
699 require_finite("atmosphere.altitude", self.atmosphere.altitude)?;
700
701 require_positive("max_range", self.max_range)?;
702 if !self.inputs.use_rk4 || !self.inputs.use_adaptive_rk45 {
705 require_positive("time_step", self.time_step)?;
706 }
707
708 if self.inputs.enable_trajectory_sampling {
709 require_finite("sight_height", self.inputs.sight_height)?;
710 require_positive("sample_interval", self.inputs.sample_interval)?;
711 }
712
713 if self.inputs.enable_coriolis {
714 require_finite("shot_azimuth", self.inputs.shot_azimuth)?;
715 if let Some(latitude) = self.inputs.latitude {
716 require_finite("latitude", latitude)?;
717 }
718 }
719
720 Ok(())
721 }
722
723 fn validate_result_finiteness(&self, result: &TrajectoryResult) -> Result<(), BallisticsError> {
727 let require_finite = |name: &str, value: f64| {
728 if value.is_finite() {
729 Ok(())
730 } else {
731 Err(BallisticsError::from(format!(
732 "trajectory result contains non-finite {name}"
733 )))
734 }
735 };
736 let require_indexed_finite = |collection: &str, index: usize, field: &str, value: f64| {
737 if value.is_finite() {
738 Ok(())
739 } else {
740 Err(BallisticsError::from(format!(
741 "trajectory result contains non-finite {collection}[{index}].{field}"
742 )))
743 }
744 };
745
746 require_finite("max_range", result.max_range)?;
747 require_finite("max_height", result.max_height)?;
748 require_finite("time_of_flight", result.time_of_flight)?;
749 require_finite("impact_velocity", result.impact_velocity)?;
750 require_finite("impact_energy", result.impact_energy)?;
751
752 for (index, point) in result.points.iter().enumerate() {
753 require_indexed_finite("points", index, "time", point.time)?;
754 require_indexed_finite("points", index, "position.x", point.position.x)?;
755 require_indexed_finite("points", index, "position.y", point.position.y)?;
756 require_indexed_finite("points", index, "position.z", point.position.z)?;
757 require_indexed_finite(
758 "points",
759 index,
760 "velocity_magnitude",
761 point.velocity_magnitude,
762 )?;
763 require_indexed_finite("points", index, "kinetic_energy", point.kinetic_energy)?;
764 }
765
766 if let Some(samples) = &result.sampled_points {
767 for (index, sample) in samples.iter().enumerate() {
768 require_indexed_finite("sampled_points", index, "distance_m", sample.distance_m)?;
769 require_indexed_finite("sampled_points", index, "drop_m", sample.drop_m)?;
770 require_indexed_finite(
771 "sampled_points",
772 index,
773 "wind_drift_m",
774 sample.wind_drift_m,
775 )?;
776 require_indexed_finite(
777 "sampled_points",
778 index,
779 "velocity_mps",
780 sample.velocity_mps,
781 )?;
782 require_indexed_finite("sampled_points", index, "energy_j", sample.energy_j)?;
783 require_indexed_finite("sampled_points", index, "time_s", sample.time_s)?;
784 }
785 }
786
787 for (name, value) in [
788 ("min_pitch_damping", result.min_pitch_damping),
789 ("transonic_mach", result.transonic_mach),
790 ("max_yaw_angle", result.max_yaw_angle),
791 ("max_precession_angle", result.max_precession_angle),
792 ] {
793 if let Some(value) = value {
794 require_finite(name, value)?;
795 }
796 }
797
798 if let Some(state) = result.angular_state {
799 for (name, value) in [
800 ("angular_state.pitch_angle", state.pitch_angle),
801 ("angular_state.yaw_angle", state.yaw_angle),
802 ("angular_state.pitch_rate", state.pitch_rate),
803 ("angular_state.yaw_rate", state.yaw_rate),
804 ("angular_state.precession_angle", state.precession_angle),
805 ("angular_state.nutation_phase", state.nutation_phase),
806 ] {
807 require_finite(name, value)?;
808 }
809 }
810
811 if let Some(jump) = result.aerodynamic_jump {
812 for (name, value) in [
813 ("aerodynamic_jump.vertical_jump_moa", jump.vertical_jump_moa),
814 (
815 "aerodynamic_jump.horizontal_jump_moa",
816 jump.horizontal_jump_moa,
817 ),
818 ("aerodynamic_jump.jump_angle_rad", jump.jump_angle_rad),
819 (
820 "aerodynamic_jump.magnus_component_moa",
821 jump.magnus_component_moa,
822 ),
823 ("aerodynamic_jump.yaw_component_moa", jump.yaw_component_moa),
824 (
825 "aerodynamic_jump.stabilization_factor",
826 jump.stabilization_factor,
827 ),
828 ] {
829 require_finite(name, value)?;
830 }
831 }
832
833 Ok(())
834 }
835
836 fn validate_integration_state(
840 position: &Vector3<f64>,
841 velocity: &Vector3<f64>,
842 time: f64,
843 ) -> Result<(), BallisticsError> {
844 if position.iter().all(|value| value.is_finite())
845 && velocity.iter().all(|value| value.is_finite())
846 && time.is_finite()
847 {
848 Ok(())
849 } else {
850 Err(BallisticsError::from(
851 "trajectory integration produced a non-finite state",
852 ))
853 }
854 }
855
856 fn push_trajectory_point(
858 &self,
859 points: &mut Vec<TrajectoryPoint>,
860 point: TrajectoryPoint,
861 ) -> Result<(), BallisticsError> {
862 if points.len() >= self.max_trajectory_points {
863 return Err(BallisticsError::from(format!(
864 "trajectory point limit of {} exceeded",
865 self.max_trajectory_points
866 )));
867 }
868 points.push(point);
869 Ok(())
870 }
871
872 pub fn set_wind_segments(&mut self, segments: Vec<crate::wind::WindSegment>) {
879 self.wind_sock = if segments.is_empty() {
880 None
881 } else {
882 Some(crate::wind::WindSock::new(segments))
883 };
884 }
885
886 pub fn set_atmo_segments(&mut self, segments: Vec<crate::atmosphere::AtmoSegment>) {
895 self.atmo_sock = if segments.is_empty() {
896 None
897 } else {
898 Some(crate::atmosphere::AtmoSock::new(segments))
899 };
900 }
901
902 fn launch_angles_from(
911 &self,
912 aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
913 ) -> (f64, f64) {
914 let (mut elev, mut azim) = (self.inputs.muzzle_angle, self.inputs.azimuth_angle);
915 if self.inputs.cant_angle != 0.0 {
922 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
923 let (e0, a0) = (elev, azim);
924 elev = e0 * cos_c - a0 * sin_c;
925 azim = a0 * cos_c + e0 * sin_c;
926 }
927 match aj {
928 Some(c) => {
929 const MOA_PER_RAD: f64 = 3437.7467707849;
931 (
932 elev + c.vertical_jump_moa / MOA_PER_RAD,
933 azim + c.horizontal_jump_moa / MOA_PER_RAD,
934 )
935 }
936 None => (elev, azim),
937 }
938 }
939
940 fn aerodynamic_jump_components(
948 &self,
949 ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
950 if !self.inputs.enable_aerodynamic_jump {
951 return None;
952 }
953 let diameter_m = self.inputs.bullet_diameter;
957 if !(self.inputs.twist_rate.is_finite() && self.inputs.twist_rate != 0.0)
958 || !(diameter_m.is_finite() && diameter_m > 0.0)
959 || !(self.inputs.bullet_length.is_finite() && self.inputs.bullet_length > 0.0)
960 || !self.inputs.muzzle_velocity.is_finite()
961 {
962 return None;
963 }
964
965 let (_, _, temp_c, pressure_hpa) = self.resolved_atmosphere();
967 let sg = crate::stability::compute_stability_coefficient(
968 &self.inputs,
969 (self.atmosphere.altitude, temp_c, pressure_hpa, 0.0),
970 );
971 if !(sg.is_finite() && sg > 0.0) {
972 return None;
973 }
974 let length_calibers = self.inputs.bullet_length / diameter_m;
975
976 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
982 let crosswind_from_right_mps = if let Some(sock) = &self.wind_sock {
983 -sock.vector_for_range_stateless(0.0)[2]
984 } else {
985 self.wind.speed * self.wind.direction.sin()
986 };
987 let crosswind_from_right_mph = crosswind_from_right_mps * MS_TO_MPH;
988
989 let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
990 sg,
991 length_calibers,
992 crosswind_from_right_mph,
993 self.inputs.is_twist_right,
994 );
995 if !vertical_jump_moa.is_finite() {
996 return None;
997 }
998
999 const MOA_PER_RAD: f64 = 3437.7467707849;
1000 Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
1001 vertical_jump_moa,
1002 horizontal_jump_moa: 0.0,
1004 jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
1005 magnus_component_moa: 0.0,
1006 yaw_component_moa: 0.0,
1007 stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
1008 })
1009 }
1010
1011 fn resolved_atmosphere(&self) -> (f64, f64, f64, f64) {
1012 let (temp_c, pressure_hpa) = crate::atmosphere::resolve_station_conditions(
1013 self.atmosphere.temperature,
1014 self.atmosphere.pressure,
1015 self.atmosphere.altitude,
1016 );
1017 let (density, speed_of_sound) = crate::atmosphere::calculate_atmosphere(
1018 self.atmosphere.altitude,
1019 Some(temp_c),
1020 Some(pressure_hpa),
1021 self.atmosphere.humidity,
1022 );
1023 (density, speed_of_sound, temp_c, pressure_hpa)
1024 }
1025
1026 fn precession_nutation_params(
1027 &self,
1028 velocity_mps: f64,
1029 air_density_kg_m3: f64,
1030 speed_of_sound_mps: f64,
1031 ) -> PrecessionNutationParams {
1032 let (spin_inertia, transverse_inertia) = self.precession_nutation_inertias;
1033 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
1034 let velocity_fps = velocity_mps * 3.28084;
1035 let twist_rate_ft = self.inputs.twist_rate / 12.0;
1036 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
1037 } else {
1038 0.0
1039 };
1040
1041 PrecessionNutationParams {
1042 mass_kg: self.inputs.bullet_mass,
1043 caliber_m: self.inputs.bullet_diameter,
1044 length_m: self.inputs.bullet_length,
1045 spin_rate_rad_s,
1046 spin_inertia,
1047 transverse_inertia,
1048 velocity_mps,
1049 air_density_kg_m3,
1050 mach: velocity_mps / speed_of_sound_mps,
1051 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
1052 nutation_damping_factor: 0.05,
1053 }
1054 }
1055
1056 fn append_max_range_endpoint(
1062 &self,
1063 points: &mut Vec<TrajectoryPoint>,
1064 post_position: Vector3<f64>,
1065 post_velocity: Vector3<f64>,
1066 post_time: f64,
1067 max_height: &mut f64,
1068 ) -> Result<(), BallisticsError> {
1069 let Some(previous) = points.last().cloned() else {
1070 return Ok(());
1071 };
1072 if previous.position.x >= self.max_range || post_position.x < self.max_range {
1073 return Ok(());
1074 }
1075
1076 let span = post_position.x - previous.position.x;
1077 if !span.is_finite() || span <= 1e-9 {
1078 return Ok(());
1079 }
1080
1081 let fraction = (self.max_range - previous.position.x) / span;
1082 let mut position = previous.position + (post_position - previous.position) * fraction;
1083 position.x = self.max_range;
1084 let velocity_magnitude = previous.velocity_magnitude
1085 + (post_velocity.magnitude() - previous.velocity_magnitude) * fraction;
1086 let time = previous.time + (post_time - previous.time) * fraction;
1087 let kinetic_energy =
1088 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1089
1090 if position.y > *max_height {
1091 *max_height = position.y;
1092 }
1093 self.push_trajectory_point(
1094 points,
1095 TrajectoryPoint {
1096 time,
1097 position,
1098 velocity_magnitude,
1099 kinetic_energy,
1100 },
1101 )
1102 }
1103
1104 fn gravity_acceleration(&self) -> Vector3<f64> {
1105 let theta = self.inputs.shooting_angle;
1106 Vector3::new(
1107 -crate::constants::G_ACCEL_MPS2 * theta.sin(),
1108 -crate::constants::G_ACCEL_MPS2 * theta.cos(),
1109 0.0,
1110 )
1111 }
1112
1113 fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
1114 let model = match self.inputs.wind_shear_model.as_str() {
1129 "logarithmic" => WindShearModel::Logarithmic,
1130 "power_law" | "powerlaw" | "exponential" => WindShearModel::PowerLaw,
1131 "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
1132 "custom_layers" | "custom" => WindShearModel::CustomLayers,
1133 _ => WindShearModel::PowerLaw,
1134 };
1135 let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
1136
1137 Vector3::new(
1140 -self.wind.speed * self.wind.direction.cos() * speed_ratio, 0.0,
1142 -self.wind.speed * self.wind.direction.sin() * speed_ratio, )
1144 }
1145
1146 pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
1147 self.validate_for_solve()?;
1148 let mut result = if self.inputs.use_rk4 {
1149 if self.inputs.use_adaptive_rk45 {
1150 self.solve_rk45()?
1151 } else {
1152 self.solve_rk4()?
1153 }
1154 } else {
1155 self.solve_euler()?
1156 };
1157 self.apply_spin_drift(&mut result);
1158 self.validate_result_finiteness(&result)?;
1159 Ok(result)
1160 }
1161
1162 fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
1168 if !self.inputs.use_enhanced_spin_drift {
1169 return;
1170 }
1171 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 {
1175 return;
1176 }
1177
1178 let sg = self.effective_spin_drift_sg();
1185
1186 for p in result.points.iter_mut() {
1187 if p.time <= 0.0 {
1188 continue;
1189 }
1190 p.position.z +=
1192 crate::spin_drift::litz_drift_meters(sg, p.time, self.inputs.is_twist_right);
1193 }
1194
1195 if let Some(samples) = result.sampled_points.as_mut() {
1199 for s in samples.iter_mut() {
1200 if s.time_s <= 0.0 {
1201 continue;
1202 }
1203 s.wind_drift_m +=
1204 crate::spin_drift::litz_drift_meters(sg, s.time_s, self.inputs.is_twist_right);
1205 }
1206 }
1207 }
1208
1209 fn effective_spin_drift_sg(&self) -> f64 {
1214 let (_, _, temp_c, press_hpa) = self.resolved_atmosphere();
1215 crate::spin_drift::effective_sg_from_inputs(&self.inputs, temp_c, press_hpa)
1216 }
1217
1218 fn initial_position(&self) -> Vector3<f64> {
1224 if self.inputs.cant_angle == 0.0 {
1225 return Vector3::new(0.0, self.inputs.muzzle_height, 0.0);
1226 }
1227 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
1228 let sh = self.inputs.sight_height;
1229 Vector3::new(
1230 0.0,
1231 self.inputs.muzzle_height + sh * (1.0 - cos_c),
1232 -sh * sin_c,
1233 )
1234 }
1235
1236 fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
1237 let mut time = 0.0;
1239 let mut position = self.initial_position();
1243 let aj_components = self.aerodynamic_jump_components();
1249 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1250 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1251 let mut velocity = Vector3::new(
1252 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1256
1257 let mut points = Vec::new();
1258 let mut max_height = position.y;
1259 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
1265 let mut mach_transitions = MachTransitionTracker::default();
1266
1267 let mut angular_state = if self.inputs.enable_precession_nutation {
1269 Some(AngularState {
1270 pitch_angle: 0.001, yaw_angle: 0.001,
1272 pitch_rate: 0.0,
1273 yaw_rate: 0.0,
1274 precession_angle: 0.0,
1275 nutation_phase: 0.0,
1276 })
1277 } else {
1278 None
1279 };
1280 let mut max_yaw_angle = 0.0;
1281 let mut max_precession_angle = 0.0;
1282
1283 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
1285 self.resolved_atmosphere();
1286 let base_ratio = air_density / 1.225;
1291
1292 let wind_vector = Vector3::new(
1296 -self.wind.speed * self.wind.direction.cos(), 0.0,
1298 -self.wind.speed * self.wind.direction.sin(), );
1300
1301 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1304 self.inputs.bullet_model.as_deref().unwrap_or("default"),
1305 );
1306
1307 while position.x < self.max_range
1309 && position.y > self.inputs.ground_threshold
1310 && time < 100.0
1311 {
1312 let velocity_magnitude = velocity.magnitude();
1314 let kinetic_energy =
1315 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1316
1317 self.push_trajectory_point(
1318 &mut points,
1319 TrajectoryPoint {
1320 time,
1321 position,
1322 velocity_magnitude,
1323 kinetic_energy,
1324 },
1325 )?;
1326
1327 {
1330 let mach_here = if speed_of_sound > 0.0 {
1331 velocity_magnitude / speed_of_sound
1332 } else {
1333 0.0
1334 };
1335 mach_transitions.record_downward_crossings(
1336 mach_here,
1337 position.x,
1338 &mut transonic_distances,
1339 );
1340 }
1341
1342 #[cfg(debug_assertions)]
1346 if points.len() == 1 || points.len() % 100 == 0 {
1347 eprintln!("Trajectory point {}: time={:.3}s, downrange={:.2}m, vertical={:.2}m, lateral={:.2}m, vel={:.1}m/s",
1348 points.len(), time, position.x, position.y, position.z, velocity_magnitude);
1349 }
1350
1351 if position.y > max_height {
1353 max_height = position.y;
1354 }
1355
1356 if self.inputs.enable_pitch_damping {
1358 let mach = velocity_magnitude / speed_of_sound;
1359
1360 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1362 transonic_mach = Some(mach);
1363 }
1364
1365 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1367
1368 if pitch_damping < min_pitch_damping {
1370 min_pitch_damping = pitch_damping;
1371 }
1372 }
1373
1374 if self.inputs.enable_precession_nutation {
1376 if let Some(ref mut state) = angular_state {
1377 let velocity_magnitude = velocity.magnitude();
1378 let params = self.precession_nutation_params(
1379 velocity_magnitude,
1380 air_density,
1381 speed_of_sound,
1382 );
1383
1384 *state = calculate_combined_angular_motion(
1386 ¶ms,
1387 state,
1388 time,
1389 self.time_step,
1390 0.001, );
1392
1393 if state.yaw_angle.abs() > max_yaw_angle {
1395 max_yaw_angle = state.yaw_angle.abs();
1396 }
1397 if state.precession_angle.abs() > max_precession_angle {
1398 max_precession_angle = state.precession_angle.abs();
1399 }
1400 }
1401 }
1402
1403 let acceleration = self.calculate_acceleration(
1410 &position,
1411 &velocity,
1412 &wind_vector,
1413 (resolved_temp_c, resolved_press_hpa, base_ratio),
1414 );
1415
1416 velocity += acceleration * self.time_step;
1418 position += velocity * self.time_step;
1419 time += self.time_step;
1420 Self::validate_integration_state(&position, &velocity, time)?;
1421 }
1422
1423 self.append_max_range_endpoint(&mut points, position, velocity, time, &mut max_height)?;
1424
1425 let last_point = points.last().ok_or("No trajectory points generated")?;
1427
1428 let sampled_points = if self.inputs.enable_trajectory_sampling {
1430 let trajectory_data = TrajectoryData {
1431 times: points.iter().map(|p| p.time).collect(),
1432 positions: points.iter().map(|p| p.position).collect(),
1433 velocities: points
1434 .iter()
1435 .map(|p| {
1436 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1438 })
1439 .collect(),
1440 transonic_distances, };
1442
1443 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1448 let outputs = TrajectoryOutputs {
1449 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
1451 time_of_flight_s: last_point.time,
1452 max_ord_dist_horiz_m: max_height,
1453 sight_height_m: sight_position_m,
1454 };
1455
1456 let samples = sample_trajectory(
1458 &trajectory_data,
1459 &outputs,
1460 self.inputs.sample_interval,
1461 self.inputs.bullet_mass,
1462 );
1463 Some(samples)
1464 } else {
1465 None
1466 };
1467
1468 Ok(TrajectoryResult {
1469 max_range: last_point.position.x, max_height,
1471 time_of_flight: last_point.time,
1472 impact_velocity: last_point.velocity_magnitude,
1473 impact_energy: last_point.kinetic_energy,
1474 points,
1475 sampled_points,
1476 min_pitch_damping: if self.inputs.enable_pitch_damping {
1477 Some(min_pitch_damping)
1478 } else {
1479 None
1480 },
1481 transonic_mach,
1482 angular_state,
1483 max_yaw_angle: if self.inputs.enable_precession_nutation {
1484 Some(max_yaw_angle)
1485 } else {
1486 None
1487 },
1488 max_precession_angle: if self.inputs.enable_precession_nutation {
1489 Some(max_precession_angle)
1490 } else {
1491 None
1492 },
1493 aerodynamic_jump: aj_components,
1494 })
1495 }
1496
1497 fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
1498 let mut time = 0.0;
1500 let mut position = self.initial_position();
1505
1506 let aj_components = self.aerodynamic_jump_components();
1512 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1513 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1514 let mut velocity = Vector3::new(
1515 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1519
1520 let mut points = Vec::new();
1521 let mut max_height = position.y;
1522 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
1528 let mut mach_transitions = MachTransitionTracker::default();
1529
1530 let mut angular_state = if self.inputs.enable_precession_nutation {
1532 Some(AngularState {
1533 pitch_angle: 0.001, yaw_angle: 0.001,
1535 pitch_rate: 0.0,
1536 yaw_rate: 0.0,
1537 precession_angle: 0.0,
1538 nutation_phase: 0.0,
1539 })
1540 } else {
1541 None
1542 };
1543 let mut max_yaw_angle = 0.0;
1544 let mut max_precession_angle = 0.0;
1545
1546 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
1548 self.resolved_atmosphere();
1549 let base_ratio = air_density / 1.225;
1554
1555 let wind_vector = Vector3::new(
1559 -self.wind.speed * self.wind.direction.cos(), 0.0,
1561 -self.wind.speed * self.wind.direction.sin(), );
1563
1564 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1567 self.inputs.bullet_model.as_deref().unwrap_or("default"),
1568 );
1569
1570 while position.x < self.max_range
1572 && position.y > self.inputs.ground_threshold
1573 && time < 100.0
1574 {
1575 let velocity_magnitude = velocity.magnitude();
1577 let kinetic_energy =
1578 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1579
1580 self.push_trajectory_point(
1581 &mut points,
1582 TrajectoryPoint {
1583 time,
1584 position,
1585 velocity_magnitude,
1586 kinetic_energy,
1587 },
1588 )?;
1589
1590 {
1593 let mach_here = if speed_of_sound > 0.0 {
1594 velocity_magnitude / speed_of_sound
1595 } else {
1596 0.0
1597 };
1598 mach_transitions.record_downward_crossings(
1599 mach_here,
1600 position.x,
1601 &mut transonic_distances,
1602 );
1603 }
1604
1605 if position.y > max_height {
1606 max_height = position.y;
1607 }
1608
1609 if self.inputs.enable_pitch_damping {
1611 let mach = velocity_magnitude / speed_of_sound;
1612
1613 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1615 transonic_mach = Some(mach);
1616 }
1617
1618 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1620
1621 if pitch_damping < min_pitch_damping {
1623 min_pitch_damping = pitch_damping;
1624 }
1625 }
1626
1627 if self.inputs.enable_precession_nutation {
1629 if let Some(ref mut state) = angular_state {
1630 let velocity_magnitude = velocity.magnitude();
1631 let params = self.precession_nutation_params(
1632 velocity_magnitude,
1633 air_density,
1634 speed_of_sound,
1635 );
1636
1637 *state = calculate_combined_angular_motion(
1639 ¶ms,
1640 state,
1641 time,
1642 self.time_step,
1643 0.001, );
1645
1646 if state.yaw_angle.abs() > max_yaw_angle {
1648 max_yaw_angle = state.yaw_angle.abs();
1649 }
1650 if state.precession_angle.abs() > max_precession_angle {
1651 max_precession_angle = state.precession_angle.abs();
1652 }
1653 }
1654 }
1655
1656 let dt = self.time_step;
1658
1659 let acc1 = self.calculate_acceleration(
1661 &position,
1662 &velocity,
1663 &wind_vector,
1664 (resolved_temp_c, resolved_press_hpa, base_ratio),
1665 );
1666
1667 let pos2 = position + velocity * (dt * 0.5);
1669 let vel2 = velocity + acc1 * (dt * 0.5);
1670 let acc2 = self.calculate_acceleration(
1671 &pos2,
1672 &vel2,
1673 &wind_vector,
1674 (resolved_temp_c, resolved_press_hpa, base_ratio),
1675 );
1676
1677 let pos3 = position + vel2 * (dt * 0.5);
1679 let vel3 = velocity + acc2 * (dt * 0.5);
1680 let acc3 = self.calculate_acceleration(
1681 &pos3,
1682 &vel3,
1683 &wind_vector,
1684 (resolved_temp_c, resolved_press_hpa, base_ratio),
1685 );
1686
1687 let pos4 = position + vel3 * dt;
1689 let vel4 = velocity + acc3 * dt;
1690 let acc4 = self.calculate_acceleration(
1691 &pos4,
1692 &vel4,
1693 &wind_vector,
1694 (resolved_temp_c, resolved_press_hpa, base_ratio),
1695 );
1696
1697 position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
1699 velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
1700 time += dt;
1701 Self::validate_integration_state(&position, &velocity, time)?;
1702 }
1703
1704 self.append_max_range_endpoint(&mut points, position, velocity, time, &mut max_height)?;
1705
1706 let last_point = points.last().ok_or("No trajectory points generated")?;
1708
1709 let sampled_points = if self.inputs.enable_trajectory_sampling {
1711 let trajectory_data = TrajectoryData {
1712 times: points.iter().map(|p| p.time).collect(),
1713 positions: points.iter().map(|p| p.position).collect(),
1714 velocities: points
1715 .iter()
1716 .map(|p| {
1717 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1719 })
1720 .collect(),
1721 transonic_distances, };
1723
1724 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1729 let outputs = TrajectoryOutputs {
1730 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
1732 time_of_flight_s: last_point.time,
1733 max_ord_dist_horiz_m: max_height,
1734 sight_height_m: sight_position_m,
1735 };
1736
1737 let samples = sample_trajectory(
1739 &trajectory_data,
1740 &outputs,
1741 self.inputs.sample_interval,
1742 self.inputs.bullet_mass,
1743 );
1744 Some(samples)
1745 } else {
1746 None
1747 };
1748
1749 Ok(TrajectoryResult {
1750 max_range: last_point.position.x, max_height,
1752 time_of_flight: last_point.time,
1753 impact_velocity: last_point.velocity_magnitude,
1754 impact_energy: last_point.kinetic_energy,
1755 points,
1756 sampled_points,
1757 min_pitch_damping: if self.inputs.enable_pitch_damping {
1758 Some(min_pitch_damping)
1759 } else {
1760 None
1761 },
1762 transonic_mach,
1763 angular_state,
1764 max_yaw_angle: if self.inputs.enable_precession_nutation {
1765 Some(max_yaw_angle)
1766 } else {
1767 None
1768 },
1769 max_precession_angle: if self.inputs.enable_precession_nutation {
1770 Some(max_precession_angle)
1771 } else {
1772 None
1773 },
1774 aerodynamic_jump: aj_components,
1775 })
1776 }
1777
1778 fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
1779 let mut time = 0.0;
1781 let mut position = self.initial_position();
1785
1786 let aj_components = self.aerodynamic_jump_components();
1792 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1793 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1794 let mut velocity = Vector3::new(
1795 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1799
1800 let mut points = Vec::new();
1801 let mut max_height = position.y;
1802 let mut dt = 0.001; let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
1807 self.resolved_atmosphere();
1808 let base_ratio = air_density / 1.225;
1813 let wind_vector = Vector3::new(
1816 -self.wind.speed * self.wind.direction.cos(), 0.0,
1818 -self.wind.speed * self.wind.direction.sin(), );
1820
1821 let mut transonic_distances: Vec<f64> = Vec::new();
1823 let mut mach_transitions = MachTransitionTracker::default();
1824
1825 let mut min_pitch_damping = f64::INFINITY;
1830 let mut transonic_mach: Option<f64> = None;
1831 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1832 self.inputs.bullet_model.as_deref().unwrap_or("default"),
1833 );
1834 let mut angular_state = if self.inputs.enable_precession_nutation {
1835 Some(AngularState {
1836 pitch_angle: 0.001,
1837 yaw_angle: 0.001,
1838 pitch_rate: 0.0,
1839 yaw_rate: 0.0,
1840 precession_angle: 0.0,
1841 nutation_phase: 0.0,
1842 })
1843 } else {
1844 None
1845 };
1846 let mut max_yaw_angle = 0.0;
1847 let mut max_precession_angle = 0.0;
1848
1849 while position.x < self.max_range
1850 && position.y > self.inputs.ground_threshold
1851 && time < 100.0
1852 {
1853 let velocity_magnitude = velocity.magnitude();
1855 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
1856
1857 self.push_trajectory_point(
1858 &mut points,
1859 TrajectoryPoint {
1860 time,
1861 position,
1862 velocity_magnitude,
1863 kinetic_energy,
1864 },
1865 )?;
1866
1867 {
1870 let mach_here = if speed_of_sound > 0.0 {
1871 velocity_magnitude / speed_of_sound
1872 } else {
1873 0.0
1874 };
1875 mach_transitions.record_downward_crossings(
1876 mach_here,
1877 position.x,
1878 &mut transonic_distances,
1879 );
1880 }
1881
1882 if position.y > max_height {
1883 max_height = position.y;
1884 }
1885
1886 if self.inputs.enable_pitch_damping {
1889 let mach = velocity_magnitude / speed_of_sound;
1890 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1891 transonic_mach = Some(mach);
1892 }
1893 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1894 if pitch_damping < min_pitch_damping {
1895 min_pitch_damping = pitch_damping;
1896 }
1897 }
1898
1899 let accepted_step = self.adaptive_rk45_step(
1902 &position,
1903 &velocity,
1904 dt,
1905 &wind_vector,
1906 (resolved_temp_c, resolved_press_hpa, base_ratio),
1907 );
1908 debug_assert!(
1909 accepted_step.error <= RK45_TOLERANCE || accepted_step.used_dt <= RK45_MIN_DT
1910 );
1911
1912 if self.inputs.enable_precession_nutation {
1916 if let Some(ref mut state) = angular_state {
1917 let params = self.precession_nutation_params(
1918 velocity_magnitude,
1919 air_density,
1920 speed_of_sound,
1921 );
1922
1923 *state = calculate_combined_angular_motion(
1924 ¶ms,
1925 state,
1926 time,
1927 accepted_step.used_dt,
1928 0.001,
1929 );
1930
1931 if state.yaw_angle.abs() > max_yaw_angle {
1932 max_yaw_angle = state.yaw_angle.abs();
1933 }
1934 if state.precession_angle.abs() > max_precession_angle {
1935 max_precession_angle = state.precession_angle.abs();
1936 }
1937 }
1938 }
1939
1940 position = accepted_step.position;
1941 velocity = accepted_step.velocity;
1942 time += accepted_step.used_dt;
1943 Self::validate_integration_state(&position, &velocity, time)?;
1944
1945 dt = accepted_step.next_dt;
1947 }
1948
1949 if points.is_empty() {
1951 return Err(BallisticsError::from("No trajectory points calculated"));
1952 }
1953
1954 self.append_max_range_endpoint(&mut points, position, velocity, time, &mut max_height)?;
1956
1957 let last_point = points.last().unwrap();
1958
1959 let sampled_points = if self.inputs.enable_trajectory_sampling {
1961 let trajectory_data = TrajectoryData {
1963 times: points.iter().map(|p| p.time).collect(),
1964 positions: points.iter().map(|p| p.position).collect(),
1965 velocities: points
1966 .iter()
1967 .map(|p| {
1968 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1970 })
1971 .collect(),
1972 transonic_distances, };
1974
1975 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1980 let outputs = TrajectoryOutputs {
1981 target_distance_horiz_m: last_point.position.x,
1982 target_vertical_height_m: sight_position_m,
1983 time_of_flight_s: last_point.time,
1984 max_ord_dist_horiz_m: max_height,
1985 sight_height_m: sight_position_m,
1986 };
1987
1988 let samples = sample_trajectory(
1989 &trajectory_data,
1990 &outputs,
1991 self.inputs.sample_interval,
1992 self.inputs.bullet_mass,
1993 );
1994 Some(samples)
1995 } else {
1996 None
1997 };
1998
1999 Ok(TrajectoryResult {
2000 max_range: last_point.position.x, max_height,
2002 time_of_flight: last_point.time,
2003 impact_velocity: last_point.velocity_magnitude,
2004 impact_energy: last_point.kinetic_energy,
2005 points,
2006 sampled_points,
2007 min_pitch_damping: if self.inputs.enable_pitch_damping {
2008 Some(min_pitch_damping)
2009 } else {
2010 None
2011 },
2012 transonic_mach,
2013 angular_state,
2014 max_yaw_angle: if self.inputs.enable_precession_nutation {
2015 Some(max_yaw_angle)
2016 } else {
2017 None
2018 },
2019 max_precession_angle: if self.inputs.enable_precession_nutation {
2020 Some(max_precession_angle)
2021 } else {
2022 None
2023 },
2024 aerodynamic_jump: aj_components,
2025 })
2026 }
2027
2028 fn adaptive_rk45_step(
2029 &self,
2030 position: &Vector3<f64>,
2031 velocity: &Vector3<f64>,
2032 initial_dt: f64,
2033 wind_vector: &Vector3<f64>,
2034 resolved_atmo: (f64, f64, f64),
2035 ) -> Rk45AcceptedStep {
2036 let mut trial_dt = initial_dt;
2037
2038 loop {
2039 let trial = self.rk45_step(
2040 position,
2041 velocity,
2042 trial_dt,
2043 wind_vector,
2044 RK45_TOLERANCE,
2045 resolved_atmo,
2046 );
2047 let next_dt = if trial.suggested_dt.is_finite() {
2052 (RK45_SAFETY_FACTOR * trial.suggested_dt).clamp(RK45_MIN_DT, RK45_MAX_DT)
2053 } else {
2054 RK45_MIN_DT
2055 };
2056
2057 if trial.error <= RK45_TOLERANCE || trial_dt <= RK45_MIN_DT {
2058 return Rk45AcceptedStep {
2059 position: trial.position,
2060 velocity: trial.velocity,
2061 used_dt: trial_dt,
2062 next_dt,
2063 error: trial.error,
2064 };
2065 }
2066
2067 trial_dt = next_dt;
2068 }
2069 }
2070
2071 fn rk45_step(
2072 &self,
2073 position: &Vector3<f64>,
2074 velocity: &Vector3<f64>,
2075 dt: f64,
2076 wind_vector: &Vector3<f64>,
2077 tolerance: f64,
2078 resolved_atmo: (f64, f64, f64), ) -> Rk45Trial {
2080 const A21: f64 = 1.0 / 5.0;
2082 const A31: f64 = 3.0 / 40.0;
2083 const A32: f64 = 9.0 / 40.0;
2084 const A41: f64 = 44.0 / 45.0;
2085 const A42: f64 = -56.0 / 15.0;
2086 const A43: f64 = 32.0 / 9.0;
2087 const A51: f64 = 19372.0 / 6561.0;
2088 const A52: f64 = -25360.0 / 2187.0;
2089 const A53: f64 = 64448.0 / 6561.0;
2090 const A54: f64 = -212.0 / 729.0;
2091 const A61: f64 = 9017.0 / 3168.0;
2092 const A62: f64 = -355.0 / 33.0;
2093 const A63: f64 = 46732.0 / 5247.0;
2094 const A64: f64 = 49.0 / 176.0;
2095 const A65: f64 = -5103.0 / 18656.0;
2096 const A71: f64 = 35.0 / 384.0;
2097 const A73: f64 = 500.0 / 1113.0;
2098 const A74: f64 = 125.0 / 192.0;
2099 const A75: f64 = -2187.0 / 6784.0;
2100 const A76: f64 = 11.0 / 84.0;
2101
2102 const B1: f64 = 35.0 / 384.0;
2104 const B3: f64 = 500.0 / 1113.0;
2105 const B4: f64 = 125.0 / 192.0;
2106 const B5: f64 = -2187.0 / 6784.0;
2107 const B6: f64 = 11.0 / 84.0;
2108
2109 const B1_ERR: f64 = 5179.0 / 57600.0;
2111 const B3_ERR: f64 = 7571.0 / 16695.0;
2112 const B4_ERR: f64 = 393.0 / 640.0;
2113 const B5_ERR: f64 = -92097.0 / 339200.0;
2114 const B6_ERR: f64 = 187.0 / 2100.0;
2115 const B7_ERR: f64 = 1.0 / 40.0;
2116
2117 let k1_v = self.calculate_acceleration(position, velocity, wind_vector, resolved_atmo);
2119 let k1_p = *velocity;
2120
2121 let p2 = position + dt * A21 * k1_p;
2122 let v2 = velocity + dt * A21 * k1_v;
2123 let k2_v = self.calculate_acceleration(&p2, &v2, wind_vector, resolved_atmo);
2124 let k2_p = v2;
2125
2126 let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
2127 let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
2128 let k3_v = self.calculate_acceleration(&p3, &v3, wind_vector, resolved_atmo);
2129 let k3_p = v3;
2130
2131 let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
2132 let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
2133 let k4_v = self.calculate_acceleration(&p4, &v4, wind_vector, resolved_atmo);
2134 let k4_p = v4;
2135
2136 let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
2137 let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
2138 let k5_v = self.calculate_acceleration(&p5, &v5, wind_vector, resolved_atmo);
2139 let k5_p = v5;
2140
2141 let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
2142 let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
2143 let k6_v = self.calculate_acceleration(&p6, &v6, wind_vector, resolved_atmo);
2144 let k6_p = v6;
2145
2146 let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
2147 let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
2148 let k7_v = self.calculate_acceleration(&p7, &v7, wind_vector, resolved_atmo);
2149 let k7_p = v7;
2150
2151 let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
2153 let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
2154
2155 let pos_err = position
2157 + dt * (B1_ERR * k1_p
2158 + B3_ERR * k3_p
2159 + B4_ERR * k4_p
2160 + B5_ERR * k5_p
2161 + B6_ERR * k6_p
2162 + B7_ERR * k7_p);
2163 let vel_err = velocity
2164 + dt * (B1_ERR * k1_v
2165 + B3_ERR * k3_v
2166 + B4_ERR * k4_v
2167 + B5_ERR * k5_v
2168 + B6_ERR * k6_v
2169 + B7_ERR * k7_v);
2170
2171 let error = cli_rk45_error_norm(position, velocity, &new_pos, &new_vel, &pos_err, &vel_err);
2173
2174 let dt_new = if error < tolerance {
2176 dt * (tolerance / error).powf(0.2).min(2.0)
2177 } else {
2178 dt * (tolerance / error).powf(0.25).max(0.1)
2179 };
2180
2181 Rk45Trial {
2182 position: new_pos,
2183 velocity: new_vel,
2184 suggested_dt: dt_new,
2185 error,
2186 }
2187 }
2188
2189 fn apply_cluster_bc_correction(&self, base_bc: f64, velocity_fps: f64) -> f64 {
2190 if let Some(ref cluster_bc) = self.cluster_bc {
2191 cluster_bc.apply_correction_for_drag_model(
2192 base_bc,
2193 self.inputs.caliber_inches,
2194 self.inputs.weight_grains,
2195 velocity_fps,
2196 self.inputs.bc_type,
2197 )
2198 } else {
2199 base_bc
2200 }
2201 }
2202
2203 fn calculate_acceleration(
2204 &self,
2205 position: &Vector3<f64>,
2206 velocity: &Vector3<f64>,
2207 wind_vector: &Vector3<f64>,
2208 resolved_atmo: (f64, f64, f64), ) -> Vector3<f64> {
2210 let actual_wind = if let Some(ref sock) = self.wind_sock {
2216 sock.vector_for_range_stateless(position.x)
2217 } else if self.inputs.enable_wind_shear {
2218 self.get_wind_at_altitude(position.y)
2219 } else {
2220 *wind_vector
2221 };
2222 let actual_wind =
2223 crate::derivatives::level_vector_to_shot_frame(actual_wind, self.inputs.shooting_angle);
2224
2225 let relative_velocity = velocity - actual_wind;
2226 let velocity_magnitude = relative_velocity.magnitude();
2227
2228 if velocity_magnitude < 0.001 {
2229 return self.gravity_acceleration();
2230 }
2231
2232 let (base_temp_c, base_press_hpa, station_ratio) = resolved_atmo;
2243
2244 let (drag_base_temp_c, drag_base_press_hpa, drag_base_ratio, drag_humidity_percent) =
2252 if let Some(ref sock) = self.atmo_sock {
2253 let (zone_temp_c, zone_press_hpa, zone_humidity) = sock.atmo_for_range(position.x);
2254 let zone_base_ratio = crate::atmosphere::calculate_air_density_cimp(
2255 zone_temp_c,
2256 zone_press_hpa,
2257 zone_humidity,
2258 ) / 1.225;
2259 (zone_temp_c, zone_press_hpa, zone_base_ratio, zone_humidity)
2260 } else {
2261 (
2262 base_temp_c,
2263 base_press_hpa,
2264 station_ratio,
2265 self.atmosphere.humidity,
2266 )
2267 };
2268 let local_alt = crate::atmosphere::shot_frame_altitude(
2269 self.atmosphere.altitude,
2270 position.x,
2271 position.y,
2272 self.inputs.shooting_angle,
2273 );
2274 let (air_density, speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
2275 local_alt,
2276 self.atmosphere.altitude,
2277 drag_base_temp_c,
2278 drag_base_press_hpa,
2279 drag_base_ratio,
2280 drag_humidity_percent,
2281 );
2282
2283 let cd = self.calculate_drag_coefficient(velocity_magnitude, speed_of_sound);
2285
2286 let velocity_fps = velocity_magnitude * 3.28084;
2288
2289 let (base_bc, bc_from_segments) = if let Some(segments) = self
2294 .inputs
2295 .bc_segments_data
2296 .as_ref()
2297 .filter(|segments| self.inputs.use_bc_segments && !segments.is_empty())
2298 {
2299 (
2301 crate::bc_estimation::velocity_segment_bc(
2302 velocity_fps,
2303 segments,
2304 self.inputs.bc_value,
2305 ),
2306 true,
2307 )
2308 } else if let Some(segments) = self
2309 .inputs
2310 .bc_segments
2311 .as_ref()
2312 .filter(|segments| !segments.is_empty())
2313 {
2314 (
2315 crate::derivatives::interpolated_bc(
2316 velocity_magnitude / speed_of_sound,
2317 segments,
2318 Some(&self.inputs),
2319 ),
2320 true,
2321 )
2322 } else {
2323 (self.inputs.bc_value, false)
2324 };
2325
2326 let effective_bc = if bc_from_segments {
2331 base_bc
2332 } else {
2333 self.apply_cluster_bc_correction(base_bc, velocity_fps)
2334 };
2335 let effective_bc = effective_bc.max(1e-6);
2338
2339 let retard_denom = if self.inputs.custom_drag_table.is_some() {
2344 self.inputs.custom_drag_denominator(effective_bc)
2345 } else {
2346 effective_bc
2347 };
2348
2349 let cd_to_retard = crate::constants::CD_TO_RETARD;
2354 let standard_factor = cd * cd_to_retard;
2355 let density_scale = air_density / 1.225; let a_drag_ft_s2 =
2359 (velocity_fps * velocity_fps) * standard_factor * density_scale / retard_denom;
2360 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
2364
2365 let mut accel = drag_acceleration + self.gravity_acceleration();
2368
2369 if self.inputs.enable_coriolis {
2372 if let Some(lat_deg) = self.inputs.latitude {
2373 let omega_earth = 7.2921159e-5_f64; let lat = lat_deg.to_radians();
2375 let az = self.inputs.shot_azimuth; let omega = Vector3::new(
2382 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), );
2386 let omega = crate::derivatives::level_vector_to_shot_frame(
2387 omega,
2388 self.inputs.shooting_angle,
2389 );
2390 accel += -2.0 * omega.cross(velocity);
2395 }
2396 }
2397
2398 if self.inputs.enable_magnus
2405 && !self.inputs.use_enhanced_spin_drift
2406 && self.inputs.bullet_diameter > 0.0
2407 && self.inputs.twist_rate > 0.0
2408 {
2409 let diameter_m = self.inputs.bullet_diameter;
2410 let (spin_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
2411 self.inputs.muzzle_velocity,
2412 velocity_magnitude,
2413 self.inputs.twist_rate,
2414 diameter_m,
2415 );
2416 let mach = velocity_magnitude / speed_of_sound;
2418
2419 let d_in = self.inputs.bullet_diameter / 0.0254;
2421 let m_gr = self.inputs.bullet_mass / 0.00006479891;
2422 let l_in = if self.inputs.bullet_length > 0.0 {
2423 self.inputs.bullet_length / 0.0254
2424 } else {
2425 let est_m = crate::stability::estimate_bullet_length_m(
2427 self.inputs.bullet_diameter,
2428 self.inputs.bullet_mass,
2429 );
2430 if est_m > 0.0 {
2431 est_m / 0.0254
2432 } else {
2433 4.5 * d_in
2434 }
2435 };
2436 let sg = crate::spin_drift::calculate_dynamic_stability(
2440 m_gr,
2441 velocity_magnitude,
2442 spin_rad_s,
2443 d_in,
2444 l_in,
2445 air_density,
2446 );
2447
2448 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
2450 sg,
2451 velocity_magnitude,
2452 spin_rad_s,
2453 0.0, 0.0, air_density,
2456 d_in,
2457 l_in,
2458 m_gr,
2459 mach,
2460 "match",
2461 false,
2462 );
2463
2464 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
2466 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
2467 let magnus_force = 0.5
2468 * air_density
2469 * velocity_magnitude.powi(2)
2470 * area
2471 * c_np
2472 * spin_param
2473 * yaw_rad.sin();
2474
2475 if magnus_force.abs() > 1e-12 {
2479 if let Some(dir) = crate::derivatives::yaw_of_repose_magnus_direction(
2480 relative_velocity,
2481 self.gravity_acceleration(),
2482 self.inputs.is_twist_right,
2483 ) {
2484 accel += (magnus_force / self.inputs.bullet_mass) * dir;
2485 }
2486 }
2487 }
2488
2489 accel
2490 }
2491
2492 fn calculate_drag_coefficient(&self, velocity: f64, speed_of_sound: f64) -> f64 {
2493 let mach = velocity / speed_of_sound;
2494
2495 if let Some(ref table) = self.inputs.custom_drag_table {
2499 return table.interpolate(mach);
2500 }
2501
2502 crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type)
2505 }
2506}
2507
2508#[derive(Debug, Clone)]
2510pub struct MonteCarloParams {
2511 pub num_simulations: usize,
2512 pub velocity_std_dev: f64,
2513 pub angle_std_dev: f64,
2514 pub bc_std_dev: f64,
2515 pub wind_speed_std_dev: f64,
2516 pub target_distance: Option<f64>,
2517 pub base_wind_speed: f64,
2518 pub base_wind_direction: f64,
2519 pub azimuth_std_dev: f64, }
2521
2522impl Default for MonteCarloParams {
2523 fn default() -> Self {
2524 Self {
2525 num_simulations: 1000,
2526 velocity_std_dev: 1.0,
2527 angle_std_dev: 0.001,
2528 bc_std_dev: 0.01,
2529 wind_speed_std_dev: 1.0,
2530 target_distance: None,
2531 base_wind_speed: 0.0,
2532 base_wind_direction: 0.0,
2533 azimuth_std_dev: 0.001, }
2535 }
2536}
2537
2538#[derive(Debug, Clone)]
2540pub struct MonteCarloResults {
2541 pub ranges: Vec<f64>,
2542 pub impact_velocities: Vec<f64>,
2543 pub impact_positions: Vec<Vector3<f64>>,
2549}
2550
2551pub const DEFAULT_HIT_RADIUS_M: f64 = 0.3;
2554
2555pub const TARGET_NOT_REACHED_SENTINEL_M: f64 = -1.0e9;
2561
2562impl MonteCarloResults {
2563 pub fn position_reached_target(position: &Vector3<f64>) -> bool {
2565 position.iter().all(|component| component.is_finite())
2566 && position.y != TARGET_NOT_REACHED_SENTINEL_M
2567 }
2568
2569 pub fn target_arrival_count(&self) -> usize {
2571 self.impact_positions
2572 .iter()
2573 .filter(|position| Self::position_reached_target(position))
2574 .count()
2575 }
2576
2577 pub fn target_shortfall_fraction(&self) -> f64 {
2580 if self.impact_positions.is_empty() {
2581 return 0.0;
2582 }
2583 (self.impact_positions.len() - self.target_arrival_count()) as f64
2584 / self.impact_positions.len() as f64
2585 }
2586
2587 pub fn target_plane_cep(&self) -> Option<f64> {
2593 let mut radial_misses: Vec<f64> = self
2594 .impact_positions
2595 .iter()
2596 .filter(|position| Self::position_reached_target(position))
2597 .map(Vector3::norm)
2598 .filter(|miss| miss.is_finite())
2599 .collect();
2600 radial_misses.sort_by(f64::total_cmp);
2601 if radial_misses.is_empty() {
2602 None
2603 } else {
2604 Some(radial_misses[radial_misses.len() / 2])
2605 }
2606 }
2607
2608 pub fn hit_probability(&self, hit_radius_m: f64) -> f64 {
2617 if self.impact_positions.is_empty() {
2618 return 0.0;
2619 }
2620 let hits = self
2621 .impact_positions
2622 .iter()
2623 .filter(|position| {
2624 Self::position_reached_target(position) && position.norm() < hit_radius_m
2625 })
2626 .count();
2627 hits as f64 / self.impact_positions.len() as f64
2628 }
2629}
2630
2631fn wind_from_signed_speed_sample(signed_speed: f64, sampled_direction: f64) -> WindConditions {
2632 if signed_speed < 0.0 {
2633 WindConditions {
2634 speed: -signed_speed,
2635 direction: sampled_direction + std::f64::consts::PI,
2636 }
2637 } else {
2638 WindConditions {
2639 speed: signed_speed,
2640 direction: sampled_direction,
2641 }
2642 }
2643}
2644
2645struct MonteCarloWindSampler {
2646 speed: rand_distr::Normal<f64>,
2647 direction: rand_distr::Normal<f64>,
2648}
2649
2650impl MonteCarloWindSampler {
2651 fn new(
2652 base_wind: &WindConditions,
2653 wind_speed_std_dev: f64,
2654 wind_direction_std_dev: f64,
2655 ) -> Result<Self, BallisticsError> {
2656 use rand_distr::Normal;
2657
2658 if !wind_direction_std_dev.is_finite() || wind_direction_std_dev < 0.0 {
2659 return Err("Wind direction standard deviation must be finite and non-negative".into());
2660 }
2661
2662 let speed = Normal::new(base_wind.speed, wind_speed_std_dev)
2663 .map_err(|e| format!("Invalid wind speed distribution: {e}"))?;
2664 let direction = Normal::new(base_wind.direction, wind_direction_std_dev)
2665 .map_err(|e| format!("Invalid wind direction distribution: {e}"))?;
2666 Ok(Self { speed, direction })
2667 }
2668
2669 fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> WindConditions {
2670 use rand_distr::Distribution;
2671
2672 wind_from_signed_speed_sample(self.speed.sample(rng), self.direction.sample(rng))
2673 }
2674}
2675
2676pub fn run_monte_carlo(
2678 base_inputs: BallisticInputs,
2679 params: MonteCarloParams,
2680) -> Result<MonteCarloResults, BallisticsError> {
2681 run_monte_carlo_with_direction_std_dev(base_inputs, params, 0.0)
2682}
2683
2684pub fn run_monte_carlo_with_direction_std_dev(
2689 base_inputs: BallisticInputs,
2690 params: MonteCarloParams,
2691 wind_direction_std_dev: f64,
2692) -> Result<MonteCarloResults, BallisticsError> {
2693 let base_wind = WindConditions {
2694 speed: params.base_wind_speed,
2695 direction: params.base_wind_direction,
2696 };
2697 run_monte_carlo_with_wind_and_direction_std_dev(
2698 base_inputs,
2699 base_wind,
2700 params,
2701 wind_direction_std_dev,
2702 )
2703}
2704
2705pub fn run_monte_carlo_with_wind(
2707 base_inputs: BallisticInputs,
2708 base_wind: WindConditions,
2709 params: MonteCarloParams,
2710) -> Result<MonteCarloResults, BallisticsError> {
2711 run_monte_carlo_with_wind_and_direction_std_dev(base_inputs, base_wind, params, 0.0)
2712}
2713
2714pub fn run_monte_carlo_with_wind_and_direction_std_dev(
2719 base_inputs: BallisticInputs,
2720 base_wind: WindConditions,
2721 params: MonteCarloParams,
2722 wind_direction_std_dev: f64,
2723) -> Result<MonteCarloResults, BallisticsError> {
2724 use rand_distr::{Distribution, Normal};
2725
2726 let mut rng = rand::rng();
2727 let mut ranges = Vec::new();
2728 let mut impact_velocities = Vec::new();
2729 let mut impact_positions = Vec::new();
2730
2731 let atmosphere = AtmosphericConditions {
2732 temperature: base_inputs.temperature,
2733 pressure: base_inputs.pressure,
2734 humidity: base_inputs.humidity_percent(),
2735 altitude: base_inputs.altitude,
2736 };
2737 let target_hint = params
2738 .target_distance
2739 .unwrap_or(base_inputs.target_distance);
2740 let solver_max_range = target_hint.max(1000.0) * 2.0;
2741
2742 let mut baseline_solver =
2744 TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), atmosphere.clone());
2745 baseline_solver.set_max_range(solver_max_range);
2746 let baseline_result = baseline_solver.solve()?;
2747
2748 let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
2750
2751 let baseline_at_target = baseline_result
2753 .position_at_range(target_distance)
2754 .ok_or("Could not interpolate baseline at target distance")?;
2755
2756 let velocity_delta_dist = Normal::new(0.0, params.velocity_std_dev)
2761 .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
2762 let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
2763 .map_err(|e| format!("Invalid angle distribution: {}", e))?;
2764 let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
2765 .map_err(|e| format!("Invalid BC distribution: {}", e))?;
2766 let wind_sampler = MonteCarloWindSampler::new(
2769 &base_wind,
2770 params.wind_speed_std_dev,
2771 wind_direction_std_dev,
2772 )?;
2773 let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
2774 .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
2775
2776 for _ in 0..params.num_simulations {
2777 let mut inputs = base_inputs.clone();
2779 let muzzle_velocity_delta = velocity_delta_dist.sample(&mut rng);
2780 inputs.muzzle_angle = angle_dist.sample(&mut rng);
2781 inputs.bc_value = bc_dist.sample(&mut rng).max(0.01);
2782 inputs.azimuth_angle = azimuth_dist.sample(&mut rng); let wind = wind_sampler.sample(&mut rng);
2786
2787 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere.clone());
2789 solver.inputs.muzzle_velocity =
2790 (solver.inputs.muzzle_velocity + muzzle_velocity_delta).max(0.0);
2791 solver.set_max_range(solver_max_range);
2792 match solver.solve() {
2793 Ok(result) => {
2794 let deviation = if result.max_range < target_distance {
2800 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)
2803 } else {
2804 let pos_at_target = match result.position_at_range(target_distance) {
2805 Some(p) => p,
2806 None => continue, };
2808 Vector3::new(
2813 0.0,
2814 pos_at_target.y - baseline_at_target.y,
2815 pos_at_target.z - baseline_at_target.z,
2816 )
2817 };
2818
2819 ranges.push(result.max_range);
2820 impact_velocities.push(result.impact_velocity);
2821 impact_positions.push(deviation);
2822 }
2823 Err(_) => {
2824 continue;
2826 }
2827 }
2828 }
2829
2830 if ranges.is_empty() {
2831 return Err("No successful simulations".into());
2832 }
2833
2834 Ok(MonteCarloResults {
2835 ranges,
2836 impact_velocities,
2837 impact_positions,
2838 })
2839}
2840
2841pub fn calculate_zero_angle(
2843 inputs: BallisticInputs,
2844 target_distance: f64,
2845 target_height: f64,
2846) -> Result<f64, BallisticsError> {
2847 calculate_zero_angle_with_conditions(
2848 inputs,
2849 target_distance,
2850 target_height,
2851 WindConditions::default(),
2852 AtmosphericConditions::default(),
2853 )
2854}
2855
2856pub fn calculate_zero_angle_with_conditions(
2857 inputs: BallisticInputs,
2858 target_distance: f64,
2859 target_height: f64,
2860 wind: WindConditions,
2861 atmosphere: AtmosphericConditions,
2862) -> Result<f64, BallisticsError> {
2863 let get_height_at_angle = |angle: f64| -> Result<Option<f64>, BallisticsError> {
2865 let mut test_inputs = inputs.clone();
2866 test_inputs.muzzle_angle = angle;
2867 test_inputs.enable_aerodynamic_jump = false;
2872 test_inputs.cant_angle = 0.0;
2876
2877 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
2878 solver.set_max_range(target_distance * 2.0);
2879 solver.set_time_step(0.001);
2880 let result = solver.solve()?;
2881
2882 for i in 0..result.points.len() {
2884 if result.points[i].position.x >= target_distance {
2885 if i > 0 {
2886 let p1 = &result.points[i - 1];
2887 let p2 = &result.points[i];
2888 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
2889 return Ok(Some(p1.position.y + t * (p2.position.y - p1.position.y)));
2890 } else {
2891 return Ok(Some(result.points[i].position.y));
2892 }
2893 }
2894 }
2895 Ok(None)
2896 };
2897
2898 let mut low_angle = 0.0; let mut high_angle = 0.2; let tolerance = 1e-7; let max_iterations = 60;
2904
2905 let low_height = get_height_at_angle(low_angle)?;
2908 let high_height = get_height_at_angle(high_angle)?;
2909
2910 match (low_height, high_height) {
2911 (Some(lh), Some(hh)) => {
2912 let low_error = lh - target_height;
2913 let high_error = hh - target_height;
2914
2915 if low_error > 0.0 && high_error > 0.0 {
2918 } else if low_error < 0.0 && high_error < 0.0 {
2923 let mut expanded = false;
2926 for multiplier in [2.0, 3.0, 4.0] {
2927 let new_high = (high_angle * multiplier).min(0.785);
2928 if let Ok(Some(h)) = get_height_at_angle(new_high) {
2929 if h - target_height > 0.0 {
2930 high_angle = new_high;
2931 expanded = true;
2932 break;
2933 }
2934 }
2935 if new_high >= 0.785 {
2936 break;
2937 }
2938 }
2939 if !expanded {
2940 return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
2941 }
2942 }
2943 }
2945 (None, Some(_hh)) => {
2946 }
2949 (Some(_lh), None) => {
2950 return Err(
2952 "Cannot find zero angle: high angle trajectory doesn't reach target distance"
2953 .into(),
2954 );
2955 }
2956 (None, None) => {
2957 return Err(
2959 "Cannot find zero angle: trajectory cannot reach target distance at any angle"
2960 .into(),
2961 );
2962 }
2963 }
2964
2965 for _iteration in 0..max_iterations {
2966 let mid_angle = (low_angle + high_angle) / 2.0;
2967
2968 let mut test_inputs = inputs.clone();
2969 test_inputs.muzzle_angle = mid_angle;
2970 test_inputs.enable_aerodynamic_jump = false;
2972 test_inputs.cant_angle = 0.0;
2976
2977 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
2978 solver.set_max_range(target_distance * 2.0);
2980 solver.set_time_step(0.001);
2981 let result = solver.solve()?;
2982
2983 let mut height_at_target = None;
2985 for i in 0..result.points.len() {
2986 if result.points[i].position.x >= target_distance {
2987 if i > 0 {
2988 let p1 = &result.points[i - 1];
2990 let p2 = &result.points[i];
2991 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
2992 height_at_target = Some(p1.position.y + t * (p2.position.y - p1.position.y));
2993 } else {
2994 height_at_target = Some(result.points[i].position.y);
2995 }
2996 break;
2997 }
2998 }
2999
3000 match height_at_target {
3001 Some(height) => {
3002 let error = height - target_height;
3003 if error.abs() < 0.0001 {
3009 return Ok(mid_angle);
3010 }
3011
3012 if (high_angle - low_angle).abs() < tolerance {
3016 if error.abs() < 0.01 {
3017 return Ok(mid_angle);
3019 }
3020 return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
3025 }
3026
3027 if error > 0.0 {
3028 high_angle = mid_angle;
3029 } else {
3030 low_angle = mid_angle;
3031 }
3032 }
3033 None => {
3034 low_angle = mid_angle;
3036
3037 if (high_angle - low_angle).abs() < tolerance {
3039 return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
3040 }
3041 }
3042 }
3043 }
3044
3045 Err("Failed to find zero angle".into())
3046}
3047
3048#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3050pub enum BcFitMode {
3051 Drop,
3053 Velocity,
3056}
3057
3058#[derive(Debug, Clone, Copy)]
3060pub struct BcEstimate {
3061 pub bc: f64,
3063 pub rms_error: f64,
3065 pub drag_model: DragModel,
3067 pub mode: BcFitMode,
3069 pub at_bound: bool,
3073}
3074
3075fn fit_value_at(
3083 points: &[TrajectoryPoint],
3084 target_dist: f64,
3085 mode: BcFitMode,
3086 drop_offset: f64,
3087) -> Option<f64> {
3088 let val = |p: &TrajectoryPoint| match mode {
3089 BcFitMode::Drop => drop_offset - p.position.y,
3090 BcFitMode::Velocity => p.velocity_magnitude,
3091 };
3092 for i in 0..points.len() {
3093 if points[i].position.x >= target_dist {
3094 if i == 0 {
3095 return Some(val(&points[0]));
3096 }
3097 let p1 = &points[i - 1];
3098 let p2 = &points[i];
3099 let dx = p2.position.x - p1.position.x;
3100 if dx.abs() < 1e-9 {
3101 return Some(val(p2));
3102 }
3103 let t = (target_dist - p1.position.x) / dx;
3104 return Some(val(p1) + t * (val(p2) - val(p1)));
3105 }
3106 }
3107 None
3108}
3109
3110fn fit_residual_sse(
3111 trajectory: &[TrajectoryPoint],
3112 observations: &[(f64, f64)],
3113 mode: BcFitMode,
3114 drop_offset: f64,
3115) -> Option<f64> {
3116 if observations.is_empty() {
3117 return None;
3118 }
3119 let mut total = 0.0;
3120 for (target_dist, target_val) in observations {
3121 let value = fit_value_at(trajectory, *target_dist, mode, drop_offset)?;
3124 let error = value - target_val;
3125 total += error * error;
3126 }
3127 Some(total)
3128}
3129
3130pub fn estimate_bc_fit(
3147 velocity: f64,
3148 mass: f64,
3149 diameter: f64,
3150 points: &[(f64, f64)],
3151 drag_model: DragModel,
3152 mode: BcFitMode,
3153 atmosphere: AtmosphericConditions,
3154 zero_range: Option<f64>,
3155 sight_height: f64,
3156) -> Result<BcEstimate, BallisticsError> {
3157 if points.is_empty() {
3158 return Err(BallisticsError::from(
3159 "No data points provided for BC estimation.".to_string(),
3160 ));
3161 }
3162 let max_dist = points.iter().map(|(d, _)| *d).fold(0.0_f64, f64::max);
3163 let drop_offset = if zero_range.is_some() { sight_height } else { 0.0 };
3166
3167 let sse = |bc_value: f64| -> Option<f64> {
3169 let mut inputs = BallisticInputs {
3170 muzzle_velocity: velocity,
3171 bc_value,
3172 bc_type: drag_model,
3173 bullet_mass: mass,
3174 bullet_diameter: diameter,
3175 sight_height,
3176 ..Default::default()
3177 };
3178 if let Some(zr) = zero_range {
3181 let za = calculate_zero_angle_with_conditions(
3187 inputs.clone(),
3188 zr,
3189 sight_height,
3190 WindConditions::default(),
3191 atmosphere.clone(),
3192 )
3193 .ok()?;
3194 inputs.muzzle_angle = za;
3195 }
3196 let mut solver =
3197 TrajectorySolver::new(inputs, WindConditions::default(), atmosphere.clone());
3198 solver.set_max_range(max_dist * 1.5);
3199 let result = solver.solve().ok()?;
3200 fit_residual_sse(&result.points, points, mode, drop_offset)
3201 };
3202
3203 let (bc_min, bc_max) = match drag_model {
3207 DragModel::G7 => (0.05, 0.70),
3208 _ => (0.10, 1.20),
3209 };
3210
3211 let mut best_bc = f64::NAN;
3213 let mut best_sse = f64::MAX;
3214 let mut bc = bc_min;
3215 while bc <= bc_max + 1e-9 {
3216 if let Some(s) = sse(bc) {
3217 if s < best_sse {
3218 best_sse = s;
3219 best_bc = bc;
3220 }
3221 }
3222 bc += 0.01;
3223 }
3224 if !best_bc.is_finite() {
3225 return Err(BallisticsError::from(
3226 "Unable to estimate BC from provided data. Check that the values and units are correct."
3227 .to_string(),
3228 ));
3229 }
3230
3231 let lo = (best_bc - 0.01).max(bc_min);
3233 let hi = (best_bc + 0.01).min(bc_max);
3234 let mut bc = lo;
3235 while bc <= hi + 1e-9 {
3236 if let Some(s) = sse(bc) {
3237 if s < best_sse {
3238 best_sse = s;
3239 best_bc = bc;
3240 }
3241 }
3242 bc += 0.001;
3243 }
3244
3245 let at_bound = best_bc <= bc_min + 0.011 || best_bc >= bc_max - 0.011;
3248 let rms_error = (best_sse / points.len() as f64).sqrt();
3251 Ok(BcEstimate {
3252 bc: best_bc,
3253 rms_error,
3254 drag_model,
3255 mode,
3256 at_bound,
3257 })
3258}
3259
3260pub fn estimate_bc_from_trajectory(
3263 velocity: f64,
3264 mass: f64,
3265 diameter: f64,
3266 points: &[(f64, f64)], ) -> Result<f64, BallisticsError> {
3268 estimate_bc_fit(
3269 velocity,
3270 mass,
3271 diameter,
3272 points,
3273 DragModel::G1,
3274 BcFitMode::Drop,
3275 AtmosphericConditions::default(),
3276 None,
3277 0.05,
3278 )
3279 .map(|e| e.bc)
3280}
3281
3282use rand;
3284use rand_distr;
3285
3286#[cfg(test)]
3287mod trajectory_point_budget_tests {
3288 use super::*;
3289
3290 fn solver_with_budget(
3291 use_rk4: bool,
3292 use_adaptive_rk45: bool,
3293 point_budget: usize,
3294 max_range: f64,
3295 ) -> TrajectorySolver {
3296 let inputs = BallisticInputs {
3297 use_rk4,
3298 use_adaptive_rk45,
3299 ground_threshold: f64::NEG_INFINITY,
3300 ..BallisticInputs::default()
3301 };
3302 let mut solver = TrajectorySolver::new(
3303 inputs,
3304 WindConditions::default(),
3305 AtmosphericConditions::default(),
3306 );
3307 solver.max_trajectory_points = point_budget;
3308 solver.set_max_range(max_range);
3309 solver.set_time_step(0.001);
3310 solver
3311 }
3312
3313 #[test]
3314 fn mba1283_every_solver_errors_instead_of_exceeding_point_budget() {
3315 for (mode, use_rk4, use_adaptive_rk45) in [
3316 ("Euler", false, false),
3317 ("RK4", true, false),
3318 ("RK45", true, true),
3319 ] {
3320 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 3, 10.0)
3321 .solve()
3322 .expect_err("a solve requiring more than three points must fail");
3323 assert!(
3324 error.to_string().contains("point limit of 3"),
3325 "unexpected {mode} point-budget error: {error}"
3326 );
3327 }
3328 }
3329
3330 #[test]
3331 fn mba1283_interpolated_endpoint_counts_toward_point_budget() {
3332 for (mode, use_rk4, use_adaptive_rk45) in [
3333 ("Euler", false, false),
3334 ("RK4", true, false),
3335 ("RK45", true, true),
3336 ] {
3337 let result = solver_with_budget(use_rk4, use_adaptive_rk45, 2, 0.1)
3338 .solve()
3339 .expect("the initial point plus exact endpoint fit a two-point budget");
3340 assert_eq!(result.points.len(), 2, "unexpected {mode} point count");
3341
3342 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 1, 0.1)
3343 .solve()
3344 .expect_err("the exact endpoint must not exceed a one-point budget");
3345 assert!(
3346 error.to_string().contains("point limit of 1"),
3347 "unexpected {mode} endpoint-budget error: {error}"
3348 );
3349 }
3350 }
3351}
3352
3353#[cfg(test)]
3354mod monte_carlo_result_tests {
3355 use super::*;
3356
3357 fn make_results(impact_positions: Vec<Vector3<f64>>) -> MonteCarloResults {
3358 let count = impact_positions.len();
3359 MonteCarloResults {
3360 ranges: vec![500.0; count],
3361 impact_velocities: vec![300.0; count],
3362 impact_positions,
3363 }
3364 }
3365
3366 #[test]
3367 fn target_plane_cep_excludes_shortfall_markers() {
3368 let mut positions: Vec<Vector3<f64>> = (1..=5)
3369 .map(|radius| Vector3::new(0.0, radius as f64, 0.0))
3370 .collect();
3371 positions.extend(
3372 (0..5).map(|_| Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)),
3373 );
3374 let results = make_results(positions);
3375
3376 assert_eq!(results.target_arrival_count(), 5);
3377 assert_eq!(results.target_shortfall_fraction(), 0.5);
3378 assert_eq!(results.target_plane_cep(), Some(3.0));
3379
3380 let one_shortfall = make_results(vec![
3381 Vector3::new(0.0, 1.0, 0.0),
3382 Vector3::new(0.0, 2.0, 0.0),
3383 Vector3::new(0.0, 3.0, 0.0),
3384 Vector3::new(0.0, 4.0, 0.0),
3385 Vector3::new(0.0, 5.0, 0.0),
3386 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
3387 ]);
3388 assert_eq!(one_shortfall.target_plane_cep(), Some(3.0));
3389 }
3390
3391 #[test]
3392 fn all_shortfalls_have_no_cep_but_still_count_as_misses() {
3393 let all_shortfalls = make_results(vec![
3394 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
3395 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
3396 ]);
3397 assert_eq!(all_shortfalls.target_arrival_count(), 0);
3398 assert_eq!(all_shortfalls.target_shortfall_fraction(), 1.0);
3399 assert_eq!(all_shortfalls.target_plane_cep(), None);
3400 assert_eq!(all_shortfalls.hit_probability(0.3), 0.0);
3401
3402 let one_hit_one_shortfall = make_results(vec![
3403 Vector3::new(0.0, 0.1, 0.0),
3404 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
3405 ]);
3406 assert_eq!(one_hit_one_shortfall.hit_probability(0.3), 0.5);
3407 }
3408}
3409
3410#[cfg(test)]
3411mod monte_carlo_powder_curve_tests {
3412 use super::*;
3413
3414 #[test]
3415 fn powder_curve_preserves_sampled_muzzle_velocity_dispersion() {
3416 let inputs = BallisticInputs {
3417 muzzle_velocity: 700.0,
3418 powder_temp_curve: Some(vec![(15.0, 800.0)]),
3419 powder_curve_temp_c: Some(15.0),
3420 ..BallisticInputs::default()
3421 };
3422 let params = MonteCarloParams {
3423 num_simulations: 16,
3424 velocity_std_dev: 20.0,
3425 angle_std_dev: 1e-12,
3426 bc_std_dev: 1e-12,
3427 wind_speed_std_dev: 1e-12,
3428 target_distance: Some(100.0),
3429 azimuth_std_dev: 1e-12,
3430 ..MonteCarloParams::default()
3431 };
3432
3433 let results = run_monte_carlo(inputs, params).expect("Monte Carlo solve");
3434 let min_velocity = results
3435 .impact_velocities
3436 .iter()
3437 .copied()
3438 .fold(f64::INFINITY, f64::min);
3439 let max_velocity = results
3440 .impact_velocities
3441 .iter()
3442 .copied()
3443 .fold(f64::NEG_INFINITY, f64::max);
3444
3445 assert!(
3446 max_velocity - min_velocity > 1.0,
3447 "20 m/s muzzle spread collapsed after curve resolution: impact-velocity span={} m/s",
3448 max_velocity - min_velocity
3449 );
3450 }
3451}
3452
3453#[cfg(test)]
3454mod monte_carlo_wind_sampling_tests {
3455 use super::*;
3456 use rand::{rngs::StdRng, SeedableRng};
3457
3458 #[test]
3459 fn wind_speed_sigma_does_not_change_seeded_direction_draws() {
3460 let base_wind = WindConditions {
3461 speed: 100.0,
3462 direction: 0.37,
3463 };
3464 let narrow_speed = MonteCarloWindSampler::new(&base_wind, 0.5, 0.2).unwrap();
3465 let wide_speed = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
3466 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
3467 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
3468 let mut speed_changed = false;
3469
3470 for _ in 0..32 {
3471 let narrow = narrow_speed.sample(&mut narrow_rng);
3472 let wide = wide_speed.sample(&mut wide_rng);
3473 assert!(narrow.speed > 0.0 && wide.speed > 0.0);
3474 assert_eq!(narrow.direction.to_bits(), wide.direction.to_bits());
3475 speed_changed |= narrow.speed.to_bits() != wide.speed.to_bits();
3476 }
3477 assert!(
3478 speed_changed,
3479 "different speed sigmas must still vary speed draws"
3480 );
3481 }
3482
3483 #[test]
3484 fn zero_direction_sigma_has_no_angular_jitter() {
3485 let base_wind = WindConditions {
3486 speed: 100.0,
3487 direction: 0.37,
3488 };
3489 let sampler = MonteCarloWindSampler::new(&base_wind, 4.0, 0.0).unwrap();
3490 let mut rng = StdRng::seed_from_u64(0x5EED_1223);
3491 let mut speed_changed = false;
3492
3493 for _ in 0..32 {
3494 let wind = sampler.sample(&mut rng);
3495 speed_changed |= wind.speed.to_bits() != base_wind.speed.to_bits();
3496 assert_eq!(wind.direction.to_bits(), base_wind.direction.to_bits());
3497 }
3498 assert!(speed_changed, "speed uncertainty should remain active");
3499 }
3500
3501 #[test]
3502 fn direction_sigma_controls_seeded_angular_spread_in_radians() {
3503 let base_wind = WindConditions {
3504 speed: 100.0,
3505 direction: 0.37,
3506 };
3507 let narrow = MonteCarloWindSampler::new(&base_wind, 4.0, 0.1).unwrap();
3508 let wide = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
3509 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
3510 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
3511 let mut nonzero_direction_draw = false;
3512
3513 for _ in 0..32 {
3514 let narrow_wind = narrow.sample(&mut narrow_rng);
3515 let wide_wind = wide.sample(&mut wide_rng);
3516 assert_eq!(narrow_wind.speed.to_bits(), wide_wind.speed.to_bits());
3517
3518 let narrow_delta = narrow_wind.direction - base_wind.direction;
3519 let wide_delta = wide_wind.direction - base_wind.direction;
3520 assert!((wide_delta - 2.0 * narrow_delta).abs() < 1e-12);
3521 nonzero_direction_draw |= narrow_delta.abs() > 1e-6;
3522 }
3523 assert!(
3524 nonzero_direction_draw,
3525 "positive radians sigma must vary direction"
3526 );
3527 }
3528
3529 #[test]
3530 fn direction_sigma_rejects_negative_or_nonfinite_values() {
3531 let base_wind = WindConditions::default();
3532 for sigma in [-0.1, f64::NAN, f64::INFINITY] {
3533 assert!(MonteCarloWindSampler::new(&base_wind, 1.0, sigma).is_err());
3534 }
3535 }
3536
3537 #[test]
3538 fn negative_speed_sample_reverses_wind_direction() {
3539 let direction = 0.25;
3540 let signed_speed = -2.5;
3541 let wind = wind_from_signed_speed_sample(signed_speed, direction);
3542 let positive_wind = wind_from_signed_speed_sample(2.5, direction);
3543
3544 assert_eq!(wind.speed, 2.5);
3545 assert!(
3546 (wind.direction - (direction + std::f64::consts::PI)).abs() < f64::EPSILON,
3547 "negative speed must reverse direction by pi: got {}",
3548 wind.direction
3549 );
3550 assert_eq!(positive_wind.speed, 2.5);
3551 assert_eq!(positive_wind.direction, direction);
3552
3553 let normalized_x = -wind.speed * wind.direction.cos();
3554 let normalized_z = -wind.speed * wind.direction.sin();
3555 let signed_x = -signed_speed * direction.cos();
3556 let signed_z = -signed_speed * direction.sin();
3557 assert!((normalized_x - signed_x).abs() < 1e-12);
3558 assert!((normalized_z - signed_z).abs() < 1e-12);
3559 }
3560}
3561
3562#[cfg(test)]
3563mod bc_fit_objective_tests {
3564 use super::*;
3565
3566 fn velocity_point(range_m: f64, velocity_mps: f64) -> TrajectoryPoint {
3567 TrajectoryPoint {
3568 time: 0.0,
3569 position: Vector3::new(range_m, 0.0, 0.0),
3570 velocity_magnitude: velocity_mps,
3571 kinetic_energy: 0.0,
3572 }
3573 }
3574
3575 #[test]
3576 fn candidate_that_misses_an_observation_has_no_score() {
3577 let trajectory = vec![velocity_point(0.0, 800.0), velocity_point(100.0, 700.0)];
3578 let observations = vec![(50.0, 750.0), (150.0, 600.0)];
3579
3580 assert!(
3581 fit_residual_sse(&trajectory, &observations, BcFitMode::Velocity, 0.0).is_none(),
3582 "a candidate that reaches only one of two observations must not compete on partial SSE"
3583 );
3584
3585 let complete_observations = vec![(50.0, 740.0), (100.0, 680.0)];
3586 assert_eq!(
3587 fit_residual_sse(
3588 &trajectory,
3589 &complete_observations,
3590 BcFitMode::Velocity,
3591 0.0,
3592 ),
3593 Some(500.0)
3594 );
3595 }
3596}
3597
3598#[cfg(test)]
3599mod cluster_bc_reference_space_tests {
3600 use super::*;
3601
3602 fn acceleration_at_1100_fps(inputs: BallisticInputs) -> Vector3<f64> {
3603 let solver = TrajectorySolver::new(
3604 inputs,
3605 WindConditions::default(),
3606 AtmosphericConditions::default(),
3607 );
3608 let position = Vector3::zeros();
3609 let velocity = Vector3::new(1100.0 / 3.28084, 0.0, 0.0);
3610 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
3611 solver.calculate_acceleration(
3612 &position,
3613 &velocity,
3614 &Vector3::zeros(),
3615 (temp_c, pressure_hpa, density / 1.225),
3616 )
3617 }
3618
3619 #[test]
3620 fn solver_passes_g7_reference_model_to_cluster_classifier() {
3621 let mut inputs = BallisticInputs::default();
3622 inputs.bc_value = 0.190;
3623 inputs.bc_type = DragModel::G7;
3624 inputs.bullet_mass = 77.0 * 0.00006479891;
3625 inputs.bullet_diameter = 0.224 * 0.0254;
3626 inputs.use_cluster_bc = true;
3627
3628 let solver = TrajectorySolver::new(
3629 inputs,
3630 WindConditions::default(),
3631 AtmosphericConditions::default(),
3632 );
3633 let corrected = solver.apply_cluster_bc_correction(0.190, 2800.0);
3634
3635 assert!(
3636 (corrected / 0.190 - 1.004).abs() < 1e-12,
3637 "solver selected the wrong G7 cluster multiplier: {}",
3638 corrected / 0.190
3639 );
3640 }
3641
3642 #[test]
3643 fn velocity_bc_segments_are_not_cluster_corrected_twice() {
3644 let segmented_clustered = BallisticInputs {
3645 bc_value: 0.5,
3646 bc_type: DragModel::G7,
3647 use_bc_segments: true,
3648 bc_segments_data: Some(vec![
3649 crate::BCSegmentData {
3650 velocity_min: 0.0,
3651 velocity_max: 1_600.0,
3652 bc_value: 0.4,
3653 },
3654 crate::BCSegmentData {
3655 velocity_min: 1_600.0,
3656 velocity_max: 5_000.0,
3657 bc_value: 0.45,
3658 },
3659 ]),
3660 use_cluster_bc: true,
3661 ..BallisticInputs::default()
3662 };
3663 let mut segmented_only = segmented_clustered.clone();
3664 segmented_only.use_cluster_bc = false;
3665 let mut constant_clustered = segmented_clustered.clone();
3666 constant_clustered.bc_value = 0.4;
3667 constant_clustered.bc_segments_data = None;
3668
3669 let stacked = acceleration_at_1100_fps(segmented_clustered);
3670 let segment_only = acceleration_at_1100_fps(segmented_only);
3671 let cluster_only = acceleration_at_1100_fps(constant_clustered);
3672
3673 assert!(
3674 (stacked.x - segment_only.x).abs() < 1e-12,
3675 "segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
3676 stacked.x,
3677 segment_only.x
3678 );
3679 assert!(
3680 (cluster_only.x - segment_only.x).abs() > 1e-6,
3681 "cluster correction must remain active for a constant BC"
3682 );
3683 }
3684
3685 #[test]
3686 fn mach_bc_segments_are_not_cluster_corrected_twice() {
3687 let mach_segmented_clustered = BallisticInputs {
3688 bc_value: 0.5,
3689 bc_type: DragModel::G7,
3690 use_bc_segments: false,
3691 bc_segments: Some(vec![(0.5, 0.3), (1.5, 0.5)]),
3692 use_cluster_bc: true,
3693 ..BallisticInputs::default()
3694 };
3695 let mut mach_segmented_only = mach_segmented_clustered.clone();
3696 mach_segmented_only.use_cluster_bc = false;
3697
3698 let stacked = acceleration_at_1100_fps(mach_segmented_clustered);
3699 let segment_only = acceleration_at_1100_fps(mach_segmented_only);
3700
3701 assert!(
3702 (stacked.x - segment_only.x).abs() < 1e-12,
3703 "Mach segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
3704 stacked.x,
3705 segment_only.x
3706 );
3707 }
3708}
3709
3710#[cfg(test)]
3711mod velocity_bc_flag_tests {
3712 use super::*;
3713
3714 fn acceleration_at_600_mps(inputs: BallisticInputs) -> Vector3<f64> {
3715 let solver = TrajectorySolver::new(
3716 inputs,
3717 WindConditions::default(),
3718 AtmosphericConditions::default(),
3719 );
3720 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
3721 solver.calculate_acceleration(
3722 &Vector3::zeros(),
3723 &Vector3::new(600.0, 0.0, 0.0),
3724 &Vector3::zeros(),
3725 (temp_c, pressure_hpa, density / 1.225),
3726 )
3727 }
3728
3729 #[test]
3730 fn velocity_bc_data_requires_opt_in_in_trajectory_solver() {
3731 let scalar_inputs = BallisticInputs {
3732 bc_value: 0.5,
3733 bc_type: DragModel::G7,
3734 ..BallisticInputs::default()
3735 };
3736 let mut disabled_inputs = scalar_inputs.clone();
3737 disabled_inputs.bc_segments_data = Some(vec![crate::BCSegmentData {
3738 velocity_min: 0.0,
3739 velocity_max: 4_000.0,
3740 bc_value: 0.46,
3741 }]);
3742 disabled_inputs.use_bc_segments = false;
3743 let mut enabled_inputs = disabled_inputs.clone();
3744 enabled_inputs.use_bc_segments = true;
3745 let mut mach_only_inputs = scalar_inputs.clone();
3746 mach_only_inputs.bc_segments = Some(vec![(0.0, 0.4), (3.0, 0.4)]);
3747 let mut disabled_with_both = mach_only_inputs.clone();
3748 disabled_with_both.bc_segments_data = disabled_inputs.bc_segments_data.clone();
3749
3750 let scalar = acceleration_at_600_mps(scalar_inputs);
3751 let disabled = acceleration_at_600_mps(disabled_inputs);
3752 let enabled = acceleration_at_600_mps(enabled_inputs);
3753 let mach_only = acceleration_at_600_mps(mach_only_inputs);
3754 let disabled_with_both = acceleration_at_600_mps(disabled_with_both);
3755
3756 assert_eq!(
3757 disabled.x.to_bits(),
3758 scalar.x.to_bits(),
3759 "a populated velocity table must not change drag while use_bc_segments is false"
3760 );
3761 assert!(
3762 enabled.x < disabled.x - 1.0,
3763 "enabling the lower BC table must increase drag: disabled ax={} enabled ax={}",
3764 disabled.x,
3765 enabled.x
3766 );
3767 assert_eq!(
3768 disabled_with_both.x.to_bits(),
3769 mach_only.x.to_bits(),
3770 "disabling velocity data must fall through to an explicit Mach table"
3771 );
3772 }
3773}
3774
3775#[cfg(test)]
3776mod mach_bc_segment_tests {
3777 use super::*;
3778
3779 #[test]
3780 fn trajectory_solver_interpolates_explicit_mach_bc_segments() {
3781 let segmented_inputs = BallisticInputs {
3782 bc_value: 0.8,
3783 use_bc_segments: false,
3784 bc_segments: Some(vec![(1.0, 0.2), (2.0, 0.4)]),
3785 bc_segments_data: None,
3786 ..BallisticInputs::default()
3787 };
3788
3789 let mut expected_inputs = segmented_inputs.clone();
3790 expected_inputs.bc_value = 0.3;
3791 expected_inputs.bc_segments = None;
3792
3793 let atmosphere = AtmosphericConditions::default();
3794 let segmented_solver = TrajectorySolver::new(
3795 segmented_inputs,
3796 WindConditions::default(),
3797 atmosphere.clone(),
3798 );
3799 let expected_solver = TrajectorySolver::new(
3800 expected_inputs,
3801 WindConditions::default(),
3802 atmosphere,
3803 );
3804 let position = Vector3::zeros();
3805 let (density, _, temp_c, pressure_hpa) = segmented_solver.resolved_atmosphere();
3806 let (_, local_speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
3807 segmented_solver.atmosphere.altitude,
3808 segmented_solver.atmosphere.altitude,
3809 temp_c,
3810 pressure_hpa,
3811 density / 1.225,
3812 segmented_solver.atmosphere.humidity,
3813 );
3814 let velocity = Vector3::new(1.5 * local_speed_of_sound, 0.0, 0.0);
3815 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
3816
3817 let segmented_acceleration = segmented_solver.calculate_acceleration(
3818 &position,
3819 &velocity,
3820 &Vector3::zeros(),
3821 resolved_atmo,
3822 );
3823 let expected_acceleration = expected_solver.calculate_acceleration(
3824 &position,
3825 &velocity,
3826 &Vector3::zeros(),
3827 resolved_atmo,
3828 );
3829
3830 assert!(
3831 (segmented_acceleration.x - expected_acceleration.x).abs() < 1e-12,
3832 "Mach 1.5 must interpolate BC 0.3: segmented ax={} expected ax={}",
3833 segmented_acceleration.x,
3834 expected_acceleration.x
3835 );
3836 }
3837}
3838
3839#[cfg(test)]
3840mod custom_drag_table_validation_tests {
3841 use super::*;
3842
3843 #[test]
3844 fn solve_accepts_zero_bc_when_custom_table_present() {
3845 let mut inputs = BallisticInputs::default();
3846 inputs.bc_value = 0.0; inputs.bullet_mass = 0.0106;
3848 inputs.bullet_diameter = 0.00782;
3849 inputs.muzzle_velocity = 850.0;
3850 inputs.custom_drag_table = Some(crate::drag::DragTable::new(
3851 vec![0.5, 1.0, 2.0, 3.0],
3852 vec![0.23, 0.40, 0.30, 0.26],
3853 ));
3854 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
3855 assert!(solver.solve().is_ok());
3857 }
3858
3859 #[test]
3860 fn solve_still_requires_bc_without_table() {
3861 let mut inputs = BallisticInputs::default();
3862 inputs.bc_value = 0.0;
3863 inputs.bullet_mass = 0.0106;
3864 inputs.bullet_diameter = 0.00782;
3865 inputs.muzzle_velocity = 850.0;
3866 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
3867 assert!(solver.solve().is_err());
3868 }
3869}
3870
3871#[cfg(test)]
3872mod humid_local_mach_tests {
3873 use super::*;
3874
3875 fn solver_with_station_humidity(humidity_percent: f64) -> TrajectorySolver {
3876 let inputs = BallisticInputs {
3877 custom_drag_table: Some(crate::drag::DragTable::new(vec![0.5, 1.5], vec![0.1, 1.1])),
3878 ..BallisticInputs::default()
3879 };
3880 TrajectorySolver::new(
3881 inputs,
3882 WindConditions::default(),
3883 AtmosphericConditions {
3884 temperature: 30.0,
3885 pressure: 1013.25,
3886 humidity: humidity_percent,
3887 altitude: 0.0,
3888 },
3889 )
3890 }
3891
3892 fn acceleration(solver: &TrajectorySolver, base_ratio: f64) -> Vector3<f64> {
3893 solver.calculate_acceleration(
3894 &Vector3::zeros(),
3895 &Vector3::new(350.0, 0.0, 0.0),
3896 &Vector3::zeros(),
3897 (30.0, 1013.25, base_ratio),
3898 )
3899 }
3900
3901 #[test]
3902 fn local_mach_uses_station_humidity_when_density_is_held_constant() {
3903 let dry = acceleration(&solver_with_station_humidity(0.0), 1.0);
3904 let humid = acceleration(&solver_with_station_humidity(100.0), 1.0);
3905
3906 assert!(
3907 humid.x > dry.x,
3908 "humid sound speed should lower Mach and drag on the rising test curve: dry ax={} humid ax={}",
3909 dry.x,
3910 humid.x
3911 );
3912 }
3913
3914 #[test]
3915 fn active_atmosphere_zone_uses_zone_humidity_instead_of_station_humidity() {
3916 let zone_humidity = 80.0;
3917 let zone_ratio =
3918 crate::atmosphere::calculate_air_density_cimp(30.0, 1013.25, zone_humidity) / 1.225;
3919 let station_solver = solver_with_station_humidity(zone_humidity);
3920 let mut zoned_solver = solver_with_station_humidity(0.0);
3921 zoned_solver.set_atmo_segments(vec![(30.0, 1013.25, zone_humidity, 1_000.0)]);
3922
3923 let station = acceleration(&station_solver, zone_ratio);
3924 let zoned = acceleration(&zoned_solver, zone_ratio);
3925
3926 assert!(
3927 (zoned - station).norm() < 1e-12,
3928 "active zone T/P/RH should override the station atmosphere: station={station:?} zoned={zoned:?}"
3929 );
3930 }
3931}
3932
3933#[cfg(test)]
3934mod inclined_atmosphere_frame_tests {
3935 use super::*;
3936
3937 fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
3938 let (sin_angle, cos_angle) = angle.sin_cos();
3939 Vector3::new(
3940 level.x * cos_angle + level.y * sin_angle,
3941 -level.x * sin_angle + level.y * cos_angle,
3942 level.z,
3943 )
3944 }
3945
3946 #[test]
3947 fn inclined_positions_at_same_world_altitude_have_same_solver_acceleration() {
3948 let angle = std::f64::consts::FRAC_PI_6;
3949 let inputs = BallisticInputs {
3950 shooting_angle: angle,
3951 ..BallisticInputs::default()
3952 };
3953 let atmosphere = AtmosphericConditions {
3954 altitude: 100.0,
3955 ..AtmosphericConditions::default()
3956 };
3957 let solver = TrajectorySolver::new(inputs, WindConditions::default(), atmosphere);
3958 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
3959 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
3960 let velocity = Vector3::new(600.0, 0.0, 0.0);
3961 let along_slant = Vector3::new(1_000.0, 0.0, 0.0);
3962 let across_slant = Vector3::new(0.0, 500.0 / angle.cos(), 0.0);
3963
3964 let a = solver.calculate_acceleration(
3965 &along_slant,
3966 &velocity,
3967 &Vector3::zeros(),
3968 resolved_atmo,
3969 );
3970 let b = solver.calculate_acceleration(
3971 &across_slant,
3972 &velocity,
3973 &Vector3::zeros(),
3974 resolved_atmo,
3975 );
3976
3977 assert!(
3978 (a - b).norm() < 1e-10,
3979 "solver acceleration differs at equal world altitude: {a:?} vs {b:?}"
3980 );
3981 }
3982
3983 #[test]
3984 fn inclined_headwind_is_rotated_into_solver_frame() {
3985 let angle = std::f64::consts::FRAC_PI_6;
3986 let inputs = BallisticInputs {
3987 shooting_angle: angle,
3988 ..BallisticInputs::default()
3989 };
3990 let solver = TrajectorySolver::new(
3991 inputs,
3992 WindConditions::default(),
3993 AtmosphericConditions::default(),
3994 );
3995 let level_headwind = Vector3::new(-100.0, 0.0, 0.0);
3996 let velocity = expected_shot_frame_vector(level_headwind, angle);
3997 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
3998 let actual = solver.calculate_acceleration(
3999 &Vector3::zeros(),
4000 &velocity,
4001 &level_headwind,
4002 (temp_c, pressure_hpa, density / 1.225),
4003 );
4004
4005 assert!(
4006 (actual - solver.gravity_acceleration()).norm() < 1e-12,
4007 "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
4008 );
4009 }
4010
4011 #[test]
4012 fn inclined_coriolis_is_rotated_into_solver_frame() {
4013 let angle = std::f64::consts::FRAC_PI_6;
4014 let latitude_deg = 45.0_f64;
4015 let shot_azimuth = 0.4_f64;
4016 let velocity = Vector3::new(600.0, 20.0, 5.0);
4017 let base_inputs = BallisticInputs {
4018 shooting_angle: angle,
4019 latitude: Some(latitude_deg),
4020 shot_azimuth,
4021 ..BallisticInputs::default()
4022 };
4023 let acceleration = |enable_coriolis| {
4024 let mut inputs = base_inputs.clone();
4025 inputs.enable_coriolis = enable_coriolis;
4026 let solver = TrajectorySolver::new(
4027 inputs,
4028 WindConditions::default(),
4029 AtmosphericConditions::default(),
4030 );
4031 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4032 solver.calculate_acceleration(
4033 &Vector3::zeros(),
4034 &velocity,
4035 &Vector3::zeros(),
4036 (temp_c, pressure_hpa, density / 1.225),
4037 )
4038 };
4039
4040 let omega_earth = 7.2921159e-5_f64;
4041 let latitude = latitude_deg.to_radians();
4042 let level_omega = Vector3::new(
4043 omega_earth * latitude.cos() * shot_azimuth.cos(),
4044 omega_earth * latitude.sin(),
4045 -omega_earth * latitude.cos() * shot_azimuth.sin(),
4046 );
4047 let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
4048 let actual = acceleration(true) - acceleration(false);
4049
4050 assert!(
4051 (actual - expected).norm() < 1e-12,
4052 "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
4053 );
4054 }
4055}
4056
4057#[cfg(test)]
4058mod terminal_range_interpolation_tests {
4059 use super::*;
4060
4061 #[test]
4062 fn every_solver_appends_an_exact_max_range_endpoint() {
4063 let target_range = 0.1;
4064 let modes = [
4065 ("Euler", false, false),
4066 ("RK4", true, false),
4067 ("RK45", true, true),
4068 ];
4069
4070 for (name, use_rk4, use_adaptive_rk45) in modes {
4071 let inputs = BallisticInputs {
4072 use_rk4,
4073 use_adaptive_rk45,
4074 ground_threshold: f64::NEG_INFINITY,
4075 enable_trajectory_sampling: true,
4076 sample_interval: target_range,
4077 ..BallisticInputs::default()
4078 };
4079 let mut solver = TrajectorySolver::new(
4080 inputs,
4081 WindConditions::default(),
4082 AtmosphericConditions::default(),
4083 );
4084 solver.set_max_range(target_range);
4085
4086 let result = solver.solve().expect("short-range solve should succeed");
4087 let terminal = result.points.last().expect("terminal point is missing");
4088 let muzzle = result.points.first().expect("muzzle point is missing");
4089
4090 assert_eq!(
4091 terminal.position.x.to_bits(),
4092 target_range.to_bits(),
4093 "{name} did not terminate exactly at max_range"
4094 );
4095 assert_eq!(result.max_range.to_bits(), target_range.to_bits());
4096 assert!(
4097 result.time_of_flight > 0.0 && result.time_of_flight < solver.time_step,
4098 "{name} terminal time was not interpolated within the crossing step: {}",
4099 result.time_of_flight
4100 );
4101 assert_eq!(result.time_of_flight.to_bits(), terminal.time.to_bits());
4102 assert_eq!(
4103 result.impact_velocity.to_bits(),
4104 terminal.velocity_magnitude.to_bits()
4105 );
4106 assert_eq!(
4107 result.impact_energy.to_bits(),
4108 terminal.kinetic_energy.to_bits()
4109 );
4110 let expected_energy = 0.5 * solver.inputs.bullet_mass * result.impact_velocity.powi(2);
4111 assert!((result.impact_energy - expected_energy).abs() < 1e-12);
4112 assert!(terminal.velocity_magnitude < muzzle.velocity_magnitude);
4113 assert!(terminal.kinetic_energy < muzzle.kinetic_energy);
4114
4115 let terminal_sample = result
4116 .sampled_points
4117 .as_ref()
4118 .and_then(|samples| samples.last())
4119 .expect("terminal trajectory sample is missing");
4120 assert_eq!(
4121 terminal_sample.distance_m.to_bits(),
4122 target_range.to_bits(),
4123 "{name} sampling did not include max_range"
4124 );
4125 assert_eq!(
4126 terminal_sample.time_s.to_bits(),
4127 result.time_of_flight.to_bits()
4128 );
4129 assert_eq!(
4130 terminal_sample.velocity_mps.to_bits(),
4131 result.impact_velocity.to_bits()
4132 );
4133 assert!((terminal_sample.energy_j - result.impact_energy).abs() < 1e-12);
4134 }
4135 }
4136}
4137
4138#[cfg(test)]
4139mod precession_inertia_wiring_tests {
4140 use super::*;
4141
4142 #[test]
4143 fn solver_uses_projectile_specific_moments_of_inertia() {
4144 let mass_kg = 55.0 * 0.00006479891;
4145 let caliber_m = 0.224 * 0.0254;
4146 let length_m = 0.75 * 0.0254;
4147 let inputs = BallisticInputs {
4148 bullet_mass: mass_kg,
4149 bullet_diameter: caliber_m,
4150 bullet_length: length_m,
4151 muzzle_velocity: 800.0,
4152 twist_rate: 7.0,
4153 enable_precession_nutation: true,
4154 use_rk4: false,
4155 use_adaptive_rk45: false,
4156 ..BallisticInputs::default()
4157 };
4158 let mut solver = TrajectorySolver::new(
4159 inputs,
4160 WindConditions::default(),
4161 AtmosphericConditions::default(),
4162 );
4163 solver.set_max_range(0.1);
4164
4165 let (air_density, speed_of_sound, _, _) = solver.resolved_atmosphere();
4166 let velocity_mps = solver.inputs.muzzle_velocity;
4167 let velocity_fps = velocity_mps * 3.28084;
4168 let twist_rate_ft = solver.inputs.twist_rate / 12.0;
4169 let spin_rate_rad_s = (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI;
4170 let initial_state = AngularState {
4171 pitch_angle: 0.001,
4172 yaw_angle: 0.001,
4173 pitch_rate: 0.0,
4174 yaw_rate: 0.0,
4175 precession_angle: 0.0,
4176 nutation_phase: 0.0,
4177 };
4178 let params = PrecessionNutationParams {
4179 mass_kg,
4180 caliber_m,
4181 length_m,
4182 spin_rate_rad_s,
4183 spin_inertia: crate::spin_decay::calculate_moment_of_inertia(
4184 mass_kg, caliber_m, length_m, "ogive",
4185 ),
4186 transverse_inertia: crate::pitch_damping::calculate_transverse_moment_of_inertia(
4187 mass_kg, caliber_m, length_m, "ogive",
4188 ),
4189 velocity_mps,
4190 air_density_kg_m3: air_density,
4191 mach: velocity_mps / speed_of_sound,
4192 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
4193 nutation_damping_factor: 0.05,
4194 };
4195 let expected = calculate_combined_angular_motion(
4196 ¶ms,
4197 &initial_state,
4198 0.0,
4199 solver.time_step,
4200 0.001,
4201 );
4202 let actual = solver
4203 .solve()
4204 .expect("one-step solve should succeed")
4205 .angular_state
4206 .expect("precession/nutation was enabled");
4207
4208 assert!(
4209 (actual.precession_angle - expected.precession_angle).abs() < 1e-15,
4210 "precession phase used the wrong inertia: actual={}, expected={}",
4211 actual.precession_angle,
4212 expected.precession_angle
4213 );
4214 assert!(
4215 (actual.nutation_phase - expected.nutation_phase).abs() < 1e-15,
4216 "nutation phase used the wrong inertia: actual={}, expected={}",
4217 actual.nutation_phase,
4218 expected.nutation_phase
4219 );
4220 }
4221}
4222
4223#[cfg(test)]
4224mod form_factor_drag_tests {
4225 use super::*;
4226
4227 fn acceleration_with_form_factor_flag(enabled: bool) -> Vector3<f64> {
4228 let inputs = BallisticInputs {
4229 bc_value: 0.462,
4230 bc_type: DragModel::G1,
4231 bullet_model: Some("168gr SMK Match".to_string()),
4232 use_form_factor: enabled,
4233 ..BallisticInputs::default()
4234 };
4235 let solver = TrajectorySolver::new(
4236 inputs,
4237 WindConditions::default(),
4238 AtmosphericConditions::default(),
4239 );
4240 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4241 solver.calculate_acceleration(
4242 &Vector3::zeros(),
4243 &Vector3::new(600.0, 0.0, 0.0),
4244 &Vector3::zeros(),
4245 (temp_c, pressure_hpa, density / 1.225),
4246 )
4247 }
4248
4249 #[test]
4250 fn measured_bc_drag_does_not_apply_name_based_form_factor_again() {
4251 let baseline = acceleration_with_form_factor_flag(false);
4252 let flagged = acceleration_with_form_factor_flag(true);
4253
4254 assert!(
4255 (flagged - baseline).norm() < 1e-12,
4256 "published BC already encodes form factor: baseline={baseline:?} flagged={flagged:?}"
4257 );
4258 }
4259}
4260
4261#[cfg(test)]
4262mod rk45_adaptivity_tests {
4263 use super::*;
4264
4265 #[test]
4266 fn cli_rk45_error_norm_scales_components_independently() {
4267 let position = Vector3::new(1.0e9, 0.0, 0.0);
4268 let velocity = Vector3::new(800.0, 0.0, 0.0);
4269 let fifth_position = position;
4270 let fifth_velocity = velocity;
4271 let fourth_position = position;
4272 let fourth_velocity = Vector3::new(800.0, 1.0e-3, 0.0);
4273
4274 let error = cli_rk45_error_norm(
4275 &position,
4276 &velocity,
4277 &fifth_position,
4278 &fifth_velocity,
4279 &fourth_position,
4280 &fourth_velocity,
4281 );
4282 let expected = 1.0e-3 / 6.0_f64.sqrt();
4283
4284 assert!(
4285 (error - expected).abs() <= 1e-15,
4286 "large downrange position masked a velocity-component error: {error}"
4287 );
4288 }
4289
4290 fn discontinuous_wind_solver() -> TrajectorySolver {
4291 let inputs = BallisticInputs::default();
4292 let mut solver = TrajectorySolver::new(
4293 inputs,
4294 WindConditions::default(),
4295 AtmosphericConditions::default(),
4296 );
4297 solver.set_wind_segments(vec![
4298 (0.0, 90.0, 4.0),
4299 (1_000.0, 90.0, 10_000.0),
4300 ]);
4301 solver
4302 }
4303
4304 #[test]
4305 fn rk45_retries_discontinuous_trial_before_advancing() {
4306 let solver = discontinuous_wind_solver();
4307 let position = Vector3::new(0.0, solver.inputs.muzzle_height, 0.0);
4308 let velocity = Vector3::new(solver.inputs.muzzle_velocity, 0.0, 0.0);
4309 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4310 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
4311 let dt = 0.01;
4312
4313 let rejected_trial = solver.rk45_step(
4314 &position,
4315 &velocity,
4316 dt,
4317 &Vector3::zeros(),
4318 RK45_TOLERANCE,
4319 resolved_atmo,
4320 );
4321 assert!(
4322 rejected_trial.error > RK45_TOLERANCE,
4323 "discontinuous full step must exceed tolerance, got {}",
4324 rejected_trial.error
4325 );
4326
4327 let accepted = solver.adaptive_rk45_step(
4328 &position,
4329 &velocity,
4330 dt,
4331 &Vector3::zeros(),
4332 resolved_atmo,
4333 );
4334 assert!(accepted.used_dt < dt, "oversized trial was not retried");
4335 assert!(
4336 accepted.error <= RK45_TOLERANCE || accepted.used_dt <= RK45_MIN_DT,
4337 "accepted error {} exceeds tolerance at dt {}",
4338 accepted.error,
4339 accepted.used_dt
4340 );
4341
4342 let accepted_trial = solver.rk45_step(
4343 &position,
4344 &velocity,
4345 accepted.used_dt,
4346 &Vector3::zeros(),
4347 RK45_TOLERANCE,
4348 resolved_atmo,
4349 );
4350 assert_eq!(accepted.position, accepted_trial.position);
4351 assert_eq!(accepted.velocity, accepted_trial.velocity);
4352 assert!((RK45_MIN_DT..=RK45_MAX_DT).contains(&accepted.next_dt));
4353 }
4354}
4355
4356#[cfg(test)]
4357mod ground_termination_tests {
4358 use super::*;
4359
4360 #[test]
4365 fn rk4_and_rk45_descend_to_ground_threshold() {
4366 for adaptive in [false, true] {
4367 let mut inputs = BallisticInputs::default();
4368 inputs.muzzle_angle = 0.1; inputs.use_rk4 = true;
4370 inputs.use_adaptive_rk45 = adaptive;
4371 assert_eq!(
4372 inputs.ground_threshold, -100.0,
4373 "default ground_threshold is -100 m"
4374 );
4375
4376 let mut solver = TrajectorySolver::new(
4377 inputs,
4378 WindConditions::default(),
4379 AtmosphericConditions::default(),
4380 );
4381 solver.set_max_range(1.0e7);
4383
4384 let result = solver.solve().expect("solve should succeed");
4385 let final_y = result
4386 .points
4387 .last()
4388 .expect("trajectory has points")
4389 .position
4390 .y;
4391 assert!(
4392 final_y < -1.0,
4393 "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
4394 past launch level toward the ground_threshold floor, not stop at y = 0"
4395 );
4396 }
4397 }
4398}
4399
4400#[cfg(test)]
4401mod magnus_stability_tests {
4402 use super::*;
4403
4404 #[test]
4405 fn yaw_of_repose_magnus_force_is_vertical_and_twist_signed() {
4406 let acceleration = |enable_magnus, is_twist_right| {
4407 let inputs = BallisticInputs {
4408 muzzle_velocity: 822.96,
4409 bullet_mass: 168.0 * 0.00006479891,
4410 bullet_diameter: 0.308 * 0.0254,
4411 bullet_length: 1.215 * 0.0254,
4412 twist_rate: 10.0,
4413 is_twist_right,
4414 enable_magnus,
4415 ..BallisticInputs::default()
4416 };
4417 let solver = TrajectorySolver::new(
4418 inputs,
4419 WindConditions::default(),
4420 AtmosphericConditions::default(),
4421 );
4422 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4423 solver.calculate_acceleration(
4424 &Vector3::zeros(),
4425 &Vector3::new(822.96, 0.0, 0.0),
4426 &Vector3::zeros(),
4427 (temp_c, pressure_hpa, density / 1.225),
4428 )
4429 };
4430
4431 let baseline = acceleration(false, true);
4432 let right_twist = acceleration(true, true) - baseline;
4433 let left_twist = acceleration(true, false) - baseline;
4434
4435 assert!(
4436 right_twist.y < 0.0,
4437 "right-hand Magnus must point down, got {right_twist:?}"
4438 );
4439 assert!(
4440 left_twist.y > 0.0,
4441 "left-hand Magnus must point up, got {left_twist:?}"
4442 );
4443 assert!((right_twist.y + left_twist.y).abs() < 1e-12);
4444 assert!(right_twist.x.abs() < 1e-12 && right_twist.z.abs() < 1e-12);
4445 assert!(left_twist.x.abs() < 1e-12 && left_twist.z.abs() < 1e-12);
4446 }
4447
4448 #[test]
4449 fn magnus_uses_velocity_corrected_muzzle_stability_gate() {
4450 let muzzle_velocity = 1_400.0 / 3.28084;
4451 let inputs = BallisticInputs {
4452 muzzle_velocity,
4453 bullet_mass: 168.0 * 0.00006479891,
4454 bullet_diameter: 0.308 * 0.0254,
4455 bullet_length: 1.215 * 0.0254,
4456 twist_rate: 15.0,
4457 enable_magnus: true,
4458 ..BallisticInputs::default()
4459 };
4460 let solver = TrajectorySolver::new(
4461 inputs.clone(),
4462 WindConditions::default(),
4463 AtmosphericConditions::default(),
4464 );
4465
4466 let bare_sg = crate::spin_drift::miller_stability(0.308, 168.0, 15.0, 1.215);
4467 let canonical_sg = solver.effective_spin_drift_sg();
4468 assert!(bare_sg > 1.0, "test requires bare Sg above the Magnus gate");
4469 assert!(
4470 canonical_sg < 1.0,
4471 "velocity-corrected Sg must be below the gate, got {canonical_sg}"
4472 );
4473
4474 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4475 let acceleration = solver.calculate_acceleration(
4476 &Vector3::zeros(),
4477 &Vector3::new(muzzle_velocity, 0.0, 0.0),
4478 &Vector3::zeros(),
4479 (temp_c, pressure_hpa, density / 1.225),
4480 );
4481 let mut baseline_inputs = inputs;
4482 baseline_inputs.enable_magnus = false;
4483 let baseline_solver = TrajectorySolver::new(
4484 baseline_inputs,
4485 WindConditions::default(),
4486 AtmosphericConditions::default(),
4487 );
4488 let baseline = baseline_solver.calculate_acceleration(
4489 &Vector3::zeros(),
4490 &Vector3::new(muzzle_velocity, 0.0, 0.0),
4491 &Vector3::zeros(),
4492 (temp_c, pressure_hpa, density / 1.225),
4493 );
4494
4495 assert_eq!(
4496 acceleration, baseline,
4497 "canonical Sg below 1 must suppress every Magnus acceleration component"
4498 );
4499 }
4500
4501 #[test]
4502 fn magnus_force_grows_as_fixed_spin_projectile_slows() {
4503 let inputs = BallisticInputs {
4504 muzzle_velocity: 800.0,
4505 bullet_mass: 168.0 * 0.00006479891,
4506 bullet_diameter: 0.308 * 0.0254,
4507 bullet_length: 1.215 * 0.0254,
4508 twist_rate: 12.0,
4509 enable_magnus: true,
4510 ..BallisticInputs::default()
4511 };
4512
4513 let magnus_acceleration = |speed_mps| {
4514 let evaluate = |enable_magnus| {
4515 let mut run_inputs = inputs.clone();
4516 run_inputs.enable_magnus = enable_magnus;
4517 let solver = TrajectorySolver::new(
4518 run_inputs,
4519 WindConditions::default(),
4520 AtmosphericConditions::default(),
4521 );
4522 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4523 solver
4524 .calculate_acceleration(
4525 &Vector3::zeros(),
4526 &Vector3::new(speed_mps, 0.0, 0.0),
4527 &Vector3::zeros(),
4528 (temp_c, pressure_hpa, density / 1.225),
4529 )
4530 .y
4531 };
4532 (evaluate(true) - evaluate(false)).abs()
4533 };
4534
4535 let fast = magnus_acceleration(200.0);
4536 let slow = magnus_acceleration(100.0);
4537 let ratio = slow / fast;
4538 let expected_ratio = 2.0_f64.powf(5.0 / 3.0);
4539
4540 assert!(fast > 0.0 && slow > 0.0, "fast={fast}, slow={slow}");
4541 assert!(
4542 (ratio - expected_ratio).abs() < 1e-3,
4543 "fixed-spin Magnus acceleration must grow downrange; slow/fast={ratio}, \
4544 expected={expected_ratio}"
4545 );
4546 }
4547}
4548
4549#[cfg(test)]
4550mod coriolis_direction_tests {
4551 use super::*;
4552 use std::f64::consts::FRAC_PI_2;
4553
4554 #[test]
4555 fn supersonic_crossing_flags_a_positive_range_sample() {
4556 use crate::trajectory_sampling::TrajectoryFlag;
4560
4561 for (solver_name, use_rk4, use_adaptive_rk45) in [
4562 ("Euler", false, false),
4563 ("RK4", true, false),
4564 ("RK45", true, true),
4565 ] {
4566 let inputs = BallisticInputs {
4567 muzzle_velocity: 850.0,
4568 bc_value: 0.2,
4569 bc_type: DragModel::G7,
4570 muzzle_angle: 0.03,
4571 enable_trajectory_sampling: true,
4572 sample_interval: 50.0,
4573 use_rk4,
4574 use_adaptive_rk45,
4575 ..BallisticInputs::default()
4576 };
4577 let mut solver = TrajectorySolver::new(
4578 inputs,
4579 WindConditions::default(),
4580 AtmosphericConditions::default(),
4581 );
4582 solver.set_max_range(2000.0);
4583 let samples = solver
4584 .solve()
4585 .expect("supersonic solve should succeed")
4586 .sampled_points
4587 .expect("sampling was enabled");
4588 let flagged_distances: Vec<_> = samples
4589 .iter()
4590 .filter(|sample| sample.flags.contains(&TrajectoryFlag::MachTransition))
4591 .map(|sample| sample.distance_m)
4592 .collect();
4593
4594 assert!(
4595 !flagged_distances.is_empty()
4596 && flagged_distances.iter().all(|distance| *distance > 0.0),
4597 "{solver_name} must flag genuine crossings only at positive range: {flagged_distances:?}"
4598 );
4599 }
4600 }
4601
4602 #[test]
4603 fn subsonic_launch_does_not_flag_a_muzzle_transition() {
4604 use crate::trajectory_sampling::TrajectoryFlag;
4605
4606 for (solver_name, use_rk4, use_adaptive_rk45) in [
4607 ("Euler", false, false),
4608 ("RK4", true, false),
4609 ("RK45", true, true),
4610 ] {
4611 let inputs = BallisticInputs {
4612 muzzle_velocity: 250.0,
4613 muzzle_angle: 0.02,
4614 enable_trajectory_sampling: true,
4615 sample_interval: 25.0,
4616 use_rk4,
4617 use_adaptive_rk45,
4618 ..BallisticInputs::default()
4619 };
4620 let mut solver = TrajectorySolver::new(
4621 inputs,
4622 WindConditions::default(),
4623 AtmosphericConditions::default(),
4624 );
4625 solver.set_max_range(300.0);
4626 let samples = solver
4627 .solve()
4628 .expect("subsonic solve should succeed")
4629 .sampled_points
4630 .expect("sampling was enabled");
4631
4632 assert!(
4633 samples
4634 .iter()
4635 .all(|sample| !sample.flags.contains(&TrajectoryFlag::MachTransition)),
4636 "{solver_name} marked a Mach transition for a launch already below Mach 1"
4637 );
4638 }
4639 }
4640
4641 #[test]
4642 fn mach_transition_tracker_requires_a_downward_crossing() {
4643 fn record(mach_values: &[f64]) -> Vec<f64> {
4644 let mut tracker = MachTransitionTracker::default();
4645 let mut distances = Vec::new();
4646 for (index, mach) in mach_values.iter().copied().enumerate() {
4647 tracker.record_downward_crossings(mach, index as f64 * 10.0, &mut distances);
4648 }
4649 distances
4650 }
4651
4652 assert!(record(&[0.9, 0.8, 0.7]).is_empty());
4653 assert_eq!(record(&[1.1, 1.05, 0.99]), vec![20.0]);
4654 assert_eq!(record(&[1.2, 1.19, 1.0, 0.99]), vec![10.0, 30.0]);
4655 assert_eq!(record(&[0.9, 1.3, 1.1, 0.9, 1.3, 0.8]), vec![20.0, 30.0]);
4656 assert!(record(&[1.3, f64::NAN, 1.1]).is_empty());
4657 }
4658
4659 #[test]
4660 fn humidity_percent_converts_and_clamps() {
4661 let mut i = BallisticInputs::default();
4663 i.humidity = 0.5;
4664 assert!((i.humidity_percent() - 50.0).abs() < 1e-9, "0.5 -> 50%");
4665 i.humidity = 0.0;
4666 assert_eq!(i.humidity_percent(), 0.0);
4667 i.humidity = 1.0;
4668 assert_eq!(i.humidity_percent(), 100.0);
4669 i.humidity = 1.5; assert_eq!(i.humidity_percent(), 100.0);
4671 }
4672
4673 fn vertical_at(shot_azimuth: f64, range_m: f64) -> f64 {
4676 let mut inputs = BallisticInputs::default();
4677 inputs.muzzle_velocity = 800.0;
4678 inputs.bc_value = 0.5;
4679 inputs.bc_type = DragModel::G7;
4680 inputs.muzzle_angle = 0.02; inputs.enable_coriolis = true;
4682 inputs.latitude = Some(45.0);
4683 inputs.shot_azimuth = shot_azimuth;
4684 inputs.ground_threshold = f64::NEG_INFINITY; let mut solver = TrajectorySolver::new(
4686 inputs,
4687 WindConditions::default(),
4688 AtmosphericConditions::default(),
4689 );
4690 solver.set_max_range(range_m + 50.0);
4691 let r = solver.solve().expect("solve");
4692 let pts = &r.points;
4693 for i in 1..pts.len() {
4694 if pts[i].position.x >= range_m {
4695 let p1 = &pts[i - 1];
4696 let p2 = &pts[i];
4697 let t = (range_m - p1.position.x) / (p2.position.x - p1.position.x);
4698 return p1.position.y + t * (p2.position.y - p1.position.y);
4699 }
4700 }
4701 panic!("range {range_m} not reached");
4702 }
4703
4704 #[test]
4709 fn eotvos_east_higher_than_west() {
4710 let range = 600.0;
4711 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!(
4715 east > west,
4716 "east ({east:.5}) must be higher than west ({west:.5}) at {range} m (Eötvös)"
4717 );
4718 assert!(
4719 east > north && north > west,
4720 "north ({north:.5}) must lie between east ({east:.5}) and west ({west:.5})"
4721 );
4722 assert!(
4723 (east - west) > 1e-3,
4724 "E-W vertical separation ({:.6} m) should be physically meaningful, not FP noise",
4725 east - west
4726 );
4727 }
4728}
4729
4730#[cfg(test)]
4731mod cant_tests {
4732 use super::*;
4733
4734 fn base_inputs() -> BallisticInputs {
4735 let mut i = BallisticInputs::default();
4736 i.muzzle_velocity = 800.0;
4737 i.bc_value = 0.5;
4738 i.bc_type = DragModel::G7;
4739 i.bullet_mass = 0.0109;
4740 i.bullet_diameter = 0.00782;
4741 i.bullet_length = 0.0309;
4742 i.sight_height = 0.05;
4743 i.twist_rate = 10.0;
4744 i.use_rk4 = true;
4745 i
4746 }
4747
4748 fn solve_with(inputs: BallisticInputs, max_range: f64) -> TrajectoryResult {
4749 let mut s = TrajectorySolver::new(
4750 inputs,
4751 WindConditions::default(),
4752 AtmosphericConditions::default(),
4753 );
4754 s.set_max_range(max_range);
4755 s.solve().expect("solve")
4756 }
4757
4758 fn yz_at(result: &TrajectoryResult, x: f64) -> (f64, f64) {
4760 let pts = &result.points;
4761 for i in 1..pts.len() {
4762 if pts[i].position.x >= x {
4763 let (p1, p2) = (&pts[i - 1], &pts[i]);
4764 let dx = p2.position.x - p1.position.x;
4765 let t = if dx.abs() < 1e-12 { 0.0 } else { (x - p1.position.x) / dx };
4766 return (
4767 p1.position.y + t * (p2.position.y - p1.position.y),
4768 p1.position.z + t * (p2.position.z - p1.position.z),
4769 );
4770 }
4771 }
4772 panic!("trajectory never reached {x} m");
4773 }
4774
4775 #[test]
4776 fn cant_sign_clockwise_up_offset_goes_right_and_low() {
4777 let mut level = base_inputs();
4779 level.muzzle_angle = 0.003; let mut canted = level.clone();
4781 canted.cant_angle = 10f64.to_radians();
4782
4783 let (y0, z0) = yz_at(&solve_with(level, 400.0), 300.0);
4784 let (y1, z1) = yz_at(&solve_with(canted, 400.0), 300.0);
4785 assert!(z1 > z0 + 0.01, "clockwise cant must move POI right: z0={z0} z1={z1}");
4786 assert!(y1 < y0 - 0.001, "clockwise cant must move POI low: y0={y0} y1={y1}");
4787 }
4788
4789 #[test]
4790 fn pure_cant_shows_bore_offset_near_range() {
4791 let mut i = base_inputs();
4794 i.muzzle_angle = 0.0;
4795 i.cant_angle = 10f64.to_radians();
4796 let sh = i.sight_height;
4797 let r = solve_with(i, 60.0);
4798 let first = &r.points[1]; let expected = -sh * 10f64.to_radians().sin();
4800 assert!(
4801 (first.position.z - expected).abs() < 0.005,
4802 "near-muzzle lateral {} should be ~bore offset {expected}",
4803 first.position.z
4804 );
4805 }
4806
4807 #[test]
4808 fn zero_angle_is_independent_of_cant() {
4809 let a = base_inputs();
4810 let mut b = base_inputs();
4811 b.cant_angle = 15f64.to_radians();
4812 let za = calculate_zero_angle(a.clone(), 100.0, 0.0).expect("zero a");
4813 let zb = calculate_zero_angle(b.clone(), 100.0, 0.0).expect("zero b");
4814 assert_eq!(za.to_bits(), zb.to_bits(), "zeroing must ignore cant: {za} vs {zb}");
4815 let _ = (a.cant_angle, b.cant_angle);
4817 }
4818
4819 #[test]
4820 fn nonfinite_cant_is_rejected() {
4821 let mut i = base_inputs();
4822 i.cant_angle = f64::NAN;
4823 let s = TrajectorySolver::new(i, WindConditions::default(), AtmosphericConditions::default());
4824 assert!(s.solve().is_err());
4825 }
4826
4827 #[test]
4828 fn incline_and_cant_compose_without_breaking() {
4829 let mut flat = base_inputs();
4831 flat.muzzle_angle = 0.003;
4832 flat.shooting_angle = 15f64.to_radians();
4833 let mut canted = flat.clone();
4834 canted.cant_angle = 10f64.to_radians();
4835 let (_, z_flat) = yz_at(&solve_with(flat, 400.0), 300.0);
4836 let (_, z_cant) = yz_at(&solve_with(canted, 400.0), 300.0);
4837 assert!(z_cant > z_flat, "cant must still deflect right on an incline");
4838 }
4839}