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 pub vertical_speed: f64,
381}
382
383impl Default for WindConditions {
384 fn default() -> Self {
385 Self {
386 speed: 0.0,
387 direction: 0.0,
388 vertical_speed: 0.0,
389 }
390 }
391}
392
393#[derive(Debug, Clone)]
395pub struct AtmosphericConditions {
396 pub temperature: f64, pub pressure: f64, pub humidity: f64,
402 pub altitude: f64, }
404
405impl Default for AtmosphericConditions {
406 fn default() -> Self {
407 Self {
408 temperature: 15.0,
409 pressure: 1013.25,
410 humidity: 50.0,
411 altitude: 0.0,
412 }
413 }
414}
415
416#[derive(Debug, Clone)]
418pub struct TrajectoryPoint {
419 pub time: f64,
420 pub position: Vector3<f64>,
421 pub velocity_magnitude: f64,
422 pub kinetic_energy: f64,
423}
424
425#[derive(Debug, Clone)]
427pub struct TrajectoryResult {
428 pub max_range: f64,
429 pub max_height: f64,
430 pub time_of_flight: f64,
431 pub impact_velocity: f64,
432 pub impact_energy: f64,
433 pub points: Vec<TrajectoryPoint>,
434 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>,
443}
444
445const RK45_TOLERANCE: f64 = 1e-6;
446const RK45_SAFETY_FACTOR: f64 = 0.9;
447const RK45_MAX_DT: f64 = 0.01;
448const RK45_MIN_DT: f64 = 1e-6;
449
450pub const MAX_TRAJECTORY_POINTS: usize = 250_000;
456
457fn cli_rk45_error_norm(
459 position: &Vector3<f64>,
460 velocity: &Vector3<f64>,
461 fifth_position: &Vector3<f64>,
462 fifth_velocity: &Vector3<f64>,
463 fourth_position: &Vector3<f64>,
464 fourth_velocity: &Vector3<f64>,
465) -> f64 {
466 let pack_state = |position: &Vector3<f64>, velocity: &Vector3<f64>| {
467 Vector6::new(
468 position.x, position.y, position.z, velocity.x, velocity.y, velocity.z,
469 )
470 };
471 let state = pack_state(position, velocity);
472 let fifth_order = pack_state(fifth_position, fifth_velocity);
473 let fourth_order = pack_state(fourth_position, fourth_velocity);
474
475 crate::trajectory_integration::rk45_error_norm(&state, &fifth_order, &fourth_order)
476}
477
478struct Rk45Trial {
479 position: Vector3<f64>,
480 velocity: Vector3<f64>,
481 suggested_dt: f64,
482 error: f64,
483}
484
485struct Rk45AcceptedStep {
486 position: Vector3<f64>,
487 velocity: Vector3<f64>,
488 used_dt: f64,
489 next_dt: f64,
490 error: f64,
491}
492
493#[derive(Default)]
494struct MachTransitionTracker {
495 previous_mach: Option<f64>,
496 crossed_transonic: bool,
497 crossed_subsonic: bool,
498}
499
500impl MachTransitionTracker {
501 fn record_downward_crossings(&mut self, mach: f64, downrange_m: f64, distances: &mut Vec<f64>) {
502 if !mach.is_finite() {
503 self.previous_mach = None;
504 return;
505 }
506
507 if let Some(previous_mach) = self.previous_mach {
508 if !self.crossed_transonic && previous_mach >= 1.2 && mach < 1.2 {
509 self.crossed_transonic = true;
510 distances.push(downrange_m);
511 }
512 if !self.crossed_subsonic && previous_mach >= 1.0 && mach < 1.0 {
513 self.crossed_subsonic = true;
514 distances.push(downrange_m);
515 }
516 }
517 self.previous_mach = Some(mach);
518 }
519}
520
521impl TrajectoryResult {
522 pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
526 if self.points.is_empty() {
527 return None;
528 }
529
530 for i in 0..self.points.len() - 1 {
532 let p1 = &self.points[i];
533 let p2 = &self.points[i + 1];
534
535 if p1.position.x <= target_range && p2.position.x >= target_range {
537 let dx = p2.position.x - p1.position.x;
539 if dx.abs() < 1e-10 {
540 return Some(p1.position);
541 }
542 let t = (target_range - p1.position.x) / dx;
543
544 return Some(Vector3::new(
546 target_range,
547 p1.position.y + t * (p2.position.y - p1.position.y),
548 p1.position.z + t * (p2.position.z - p1.position.z),
549 ));
550 }
551 }
552
553 self.points.last().map(|p| p.position)
555 }
556}
557
558pub struct TrajectorySolver {
560 inputs: BallisticInputs,
561 wind: WindConditions,
562 atmosphere: AtmosphericConditions,
563 max_range: f64,
564 time_step: f64,
565 max_trajectory_points: usize,
566 cluster_bc: Option<ClusterBCDegradation>,
567 precession_nutation_inertias: (f64, f64),
569 wind_sock: Option<crate::wind::WindSock>,
574 atmo_sock: Option<crate::atmosphere::AtmoSock>,
581}
582
583impl TrajectorySolver {
584 pub fn new(
585 mut inputs: BallisticInputs,
586 wind: WindConditions,
587 atmosphere: AtmosphericConditions,
588 ) -> Self {
589 inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
591 inputs.weight_grains = inputs.bullet_mass / 0.00006479891;
592
593 if let Some(curve) = inputs.powder_temp_curve.as_ref() {
602 if !curve.is_empty() {
603 let lookup_c = inputs.powder_curve_temp_c.unwrap_or(inputs.temperature);
608 inputs.muzzle_velocity = interpolate_powder_temp_curve(curve, lookup_c);
609 }
610 } else if inputs.use_powder_sensitivity {
611 let temp_delta_c = inputs.temperature - inputs.powder_temp;
612 inputs.muzzle_velocity += inputs.powder_temp_sensitivity * temp_delta_c;
613 }
614
615 let cluster_bc = if inputs.use_cluster_bc {
617 Some(ClusterBCDegradation::new())
618 } else {
619 None
620 };
621 let precession_nutation_inertias = projectile_moments_of_inertia(
622 inputs.bullet_mass,
623 inputs.bullet_diameter,
624 inputs.bullet_length,
625 );
626
627 Self {
628 inputs,
629 wind,
630 atmosphere,
631 max_range: 1000.0,
632 time_step: 0.001,
633 max_trajectory_points: MAX_TRAJECTORY_POINTS,
634 cluster_bc,
635 precession_nutation_inertias,
636 wind_sock: None,
637 atmo_sock: None,
638 }
639 }
640
641 pub fn set_max_range(&mut self, range: f64) {
642 self.max_range = range;
643 }
644
645 pub fn set_time_step(&mut self, step: f64) {
646 self.time_step = step;
647 }
648
649 fn validate_for_solve(&self) -> Result<(), BallisticsError> {
655 let require_finite = |name: &str, value: f64| {
656 if value.is_finite() {
657 Ok(())
658 } else {
659 Err(BallisticsError::from(format!("{name} must be finite")))
660 }
661 };
662 let require_positive = |name: &str, value: f64| {
663 if value.is_finite() && value > 0.0 {
664 Ok(())
665 } else {
666 Err(BallisticsError::from(format!(
667 "{name} must be finite and greater than zero"
668 )))
669 }
670 };
671
672 if self.inputs.custom_drag_table.is_none() {
678 require_positive("bc_value", self.inputs.bc_value)?;
679 }
680 require_positive("bullet_mass", self.inputs.bullet_mass)?;
681 require_positive("bullet_diameter", self.inputs.bullet_diameter)?;
682 require_positive("muzzle_velocity", self.inputs.muzzle_velocity)?;
683
684 require_finite("muzzle_angle", self.inputs.muzzle_angle)?;
685 require_finite("azimuth_angle", self.inputs.azimuth_angle)?;
686 require_finite("shooting_angle", self.inputs.shooting_angle)?;
687 require_finite("cant_angle", self.inputs.cant_angle)?;
688 require_finite("muzzle_height", self.inputs.muzzle_height)?;
689
690 if !(self.inputs.ground_threshold.is_finite()
693 || self.inputs.ground_threshold == f64::NEG_INFINITY)
694 {
695 return Err(BallisticsError::from(
696 "ground_threshold must be finite or negative infinity",
697 ));
698 }
699
700 if self.wind_sock.is_none() {
701 require_finite("wind.speed", self.wind.speed)?;
702 require_finite("wind.direction", self.wind.direction)?;
703 require_finite("wind.vertical_speed", self.wind.vertical_speed)?;
704 }
705
706 require_finite("atmosphere.temperature", self.atmosphere.temperature)?;
707 require_finite("atmosphere.pressure", self.atmosphere.pressure)?;
708 require_finite("atmosphere.humidity", self.atmosphere.humidity)?;
709 require_finite("atmosphere.altitude", self.atmosphere.altitude)?;
710
711 require_positive("max_range", self.max_range)?;
712 if !self.inputs.use_rk4 || !self.inputs.use_adaptive_rk45 {
715 require_positive("time_step", self.time_step)?;
716 }
717
718 if self.inputs.enable_trajectory_sampling {
719 require_finite("sight_height", self.inputs.sight_height)?;
720 require_positive("sample_interval", self.inputs.sample_interval)?;
721 }
722
723 if self.inputs.enable_coriolis {
724 require_finite("shot_azimuth", self.inputs.shot_azimuth)?;
725 if let Some(latitude) = self.inputs.latitude {
726 require_finite("latitude", latitude)?;
727 }
728 }
729
730 Ok(())
731 }
732
733 fn validate_result_finiteness(&self, result: &TrajectoryResult) -> Result<(), BallisticsError> {
737 let require_finite = |name: &str, value: f64| {
738 if value.is_finite() {
739 Ok(())
740 } else {
741 Err(BallisticsError::from(format!(
742 "trajectory result contains non-finite {name}"
743 )))
744 }
745 };
746 let require_indexed_finite = |collection: &str, index: usize, field: &str, value: f64| {
747 if value.is_finite() {
748 Ok(())
749 } else {
750 Err(BallisticsError::from(format!(
751 "trajectory result contains non-finite {collection}[{index}].{field}"
752 )))
753 }
754 };
755
756 require_finite("max_range", result.max_range)?;
757 require_finite("max_height", result.max_height)?;
758 require_finite("time_of_flight", result.time_of_flight)?;
759 require_finite("impact_velocity", result.impact_velocity)?;
760 require_finite("impact_energy", result.impact_energy)?;
761
762 for (index, point) in result.points.iter().enumerate() {
763 require_indexed_finite("points", index, "time", point.time)?;
764 require_indexed_finite("points", index, "position.x", point.position.x)?;
765 require_indexed_finite("points", index, "position.y", point.position.y)?;
766 require_indexed_finite("points", index, "position.z", point.position.z)?;
767 require_indexed_finite(
768 "points",
769 index,
770 "velocity_magnitude",
771 point.velocity_magnitude,
772 )?;
773 require_indexed_finite("points", index, "kinetic_energy", point.kinetic_energy)?;
774 }
775
776 if let Some(samples) = &result.sampled_points {
777 for (index, sample) in samples.iter().enumerate() {
778 require_indexed_finite("sampled_points", index, "distance_m", sample.distance_m)?;
779 require_indexed_finite("sampled_points", index, "drop_m", sample.drop_m)?;
780 require_indexed_finite(
781 "sampled_points",
782 index,
783 "wind_drift_m",
784 sample.wind_drift_m,
785 )?;
786 require_indexed_finite(
787 "sampled_points",
788 index,
789 "velocity_mps",
790 sample.velocity_mps,
791 )?;
792 require_indexed_finite("sampled_points", index, "energy_j", sample.energy_j)?;
793 require_indexed_finite("sampled_points", index, "time_s", sample.time_s)?;
794 }
795 }
796
797 for (name, value) in [
798 ("min_pitch_damping", result.min_pitch_damping),
799 ("transonic_mach", result.transonic_mach),
800 ("max_yaw_angle", result.max_yaw_angle),
801 ("max_precession_angle", result.max_precession_angle),
802 ] {
803 if let Some(value) = value {
804 require_finite(name, value)?;
805 }
806 }
807
808 if let Some(state) = result.angular_state {
809 for (name, value) in [
810 ("angular_state.pitch_angle", state.pitch_angle),
811 ("angular_state.yaw_angle", state.yaw_angle),
812 ("angular_state.pitch_rate", state.pitch_rate),
813 ("angular_state.yaw_rate", state.yaw_rate),
814 ("angular_state.precession_angle", state.precession_angle),
815 ("angular_state.nutation_phase", state.nutation_phase),
816 ] {
817 require_finite(name, value)?;
818 }
819 }
820
821 if let Some(jump) = result.aerodynamic_jump {
822 for (name, value) in [
823 ("aerodynamic_jump.vertical_jump_moa", jump.vertical_jump_moa),
824 (
825 "aerodynamic_jump.horizontal_jump_moa",
826 jump.horizontal_jump_moa,
827 ),
828 ("aerodynamic_jump.jump_angle_rad", jump.jump_angle_rad),
829 (
830 "aerodynamic_jump.magnus_component_moa",
831 jump.magnus_component_moa,
832 ),
833 ("aerodynamic_jump.yaw_component_moa", jump.yaw_component_moa),
834 (
835 "aerodynamic_jump.stabilization_factor",
836 jump.stabilization_factor,
837 ),
838 ] {
839 require_finite(name, value)?;
840 }
841 }
842
843 Ok(())
844 }
845
846 fn validate_integration_state(
850 position: &Vector3<f64>,
851 velocity: &Vector3<f64>,
852 time: f64,
853 ) -> Result<(), BallisticsError> {
854 if position.iter().all(|value| value.is_finite())
855 && velocity.iter().all(|value| value.is_finite())
856 && time.is_finite()
857 {
858 Ok(())
859 } else {
860 Err(BallisticsError::from(
861 "trajectory integration produced a non-finite state",
862 ))
863 }
864 }
865
866 fn push_trajectory_point(
868 &self,
869 points: &mut Vec<TrajectoryPoint>,
870 point: TrajectoryPoint,
871 ) -> Result<(), BallisticsError> {
872 if points.len() >= self.max_trajectory_points {
873 return Err(BallisticsError::from(format!(
874 "trajectory point limit of {} exceeded",
875 self.max_trajectory_points
876 )));
877 }
878 points.push(point);
879 Ok(())
880 }
881
882 pub fn set_wind_segments(&mut self, segments: Vec<crate::wind::WindSegment>) {
889 self.wind_sock = if segments.is_empty() {
890 None
891 } else {
892 Some(crate::wind::WindSock::new(segments))
893 };
894 }
895
896 pub fn set_atmo_segments(&mut self, segments: Vec<crate::atmosphere::AtmoSegment>) {
905 self.atmo_sock = if segments.is_empty() {
906 None
907 } else {
908 Some(crate::atmosphere::AtmoSock::new(segments))
909 };
910 }
911
912 fn launch_angles_from(
921 &self,
922 aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
923 ) -> (f64, f64) {
924 let (mut elev, mut azim) = (self.inputs.muzzle_angle, self.inputs.azimuth_angle);
925 if self.inputs.cant_angle != 0.0 {
932 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
933 let (e0, a0) = (elev, azim);
934 elev = e0 * cos_c - a0 * sin_c;
935 azim = a0 * cos_c + e0 * sin_c;
936 }
937 match aj {
938 Some(c) => {
939 const MOA_PER_RAD: f64 = 3437.7467707849;
941 (
942 elev + c.vertical_jump_moa / MOA_PER_RAD,
943 azim + c.horizontal_jump_moa / MOA_PER_RAD,
944 )
945 }
946 None => (elev, azim),
947 }
948 }
949
950 fn aerodynamic_jump_components(
958 &self,
959 ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
960 if !self.inputs.enable_aerodynamic_jump {
961 return None;
962 }
963 let diameter_m = self.inputs.bullet_diameter;
967 if !(self.inputs.twist_rate.is_finite() && self.inputs.twist_rate != 0.0)
968 || !(diameter_m.is_finite() && diameter_m > 0.0)
969 || !(self.inputs.bullet_length.is_finite() && self.inputs.bullet_length > 0.0)
970 || !self.inputs.muzzle_velocity.is_finite()
971 {
972 return None;
973 }
974
975 let (_, _, temp_c, pressure_hpa) = self.resolved_atmosphere();
977 let sg = crate::stability::compute_stability_coefficient(
978 &self.inputs,
979 (self.atmosphere.altitude, temp_c, pressure_hpa, 0.0),
980 );
981 if !(sg.is_finite() && sg > 0.0) {
982 return None;
983 }
984 let length_calibers = self.inputs.bullet_length / diameter_m;
985
986 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
992 let crosswind_from_right_mps = if let Some(sock) = &self.wind_sock {
993 -sock.vector_for_range_stateless(0.0)[2]
994 } else {
995 self.wind.speed * self.wind.direction.sin()
996 };
997 let crosswind_from_right_mph = crosswind_from_right_mps * MS_TO_MPH;
998
999 let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
1000 sg,
1001 length_calibers,
1002 crosswind_from_right_mph,
1003 self.inputs.is_twist_right,
1004 );
1005 if !vertical_jump_moa.is_finite() {
1006 return None;
1007 }
1008
1009 const MOA_PER_RAD: f64 = 3437.7467707849;
1010 Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
1011 vertical_jump_moa,
1012 horizontal_jump_moa: 0.0,
1014 jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
1015 magnus_component_moa: 0.0,
1016 yaw_component_moa: 0.0,
1017 stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
1018 })
1019 }
1020
1021 fn resolved_atmosphere(&self) -> (f64, f64, f64, f64) {
1022 let (temp_c, pressure_hpa) = crate::atmosphere::resolve_station_conditions(
1023 self.atmosphere.temperature,
1024 self.atmosphere.pressure,
1025 self.atmosphere.altitude,
1026 );
1027 let (density, speed_of_sound) = crate::atmosphere::calculate_atmosphere(
1028 self.atmosphere.altitude,
1029 Some(temp_c),
1030 Some(pressure_hpa),
1031 self.atmosphere.humidity,
1032 );
1033 (density, speed_of_sound, temp_c, pressure_hpa)
1034 }
1035
1036 fn precession_nutation_params(
1037 &self,
1038 velocity_mps: f64,
1039 air_density_kg_m3: f64,
1040 speed_of_sound_mps: f64,
1041 ) -> PrecessionNutationParams {
1042 let (spin_inertia, transverse_inertia) = self.precession_nutation_inertias;
1043 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
1044 let velocity_fps = velocity_mps * 3.28084;
1045 let twist_rate_ft = self.inputs.twist_rate / 12.0;
1046 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
1047 } else {
1048 0.0
1049 };
1050
1051 PrecessionNutationParams {
1052 mass_kg: self.inputs.bullet_mass,
1053 caliber_m: self.inputs.bullet_diameter,
1054 length_m: self.inputs.bullet_length,
1055 spin_rate_rad_s,
1056 spin_inertia,
1057 transverse_inertia,
1058 velocity_mps,
1059 air_density_kg_m3,
1060 mach: velocity_mps / speed_of_sound_mps,
1061 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
1062 nutation_damping_factor: 0.05,
1063 }
1064 }
1065
1066 fn append_max_range_endpoint(
1072 &self,
1073 points: &mut Vec<TrajectoryPoint>,
1074 post_position: Vector3<f64>,
1075 post_velocity: Vector3<f64>,
1076 post_time: f64,
1077 max_height: &mut f64,
1078 ) -> Result<(), BallisticsError> {
1079 let Some(previous) = points.last().cloned() else {
1080 return Ok(());
1081 };
1082 if previous.position.x >= self.max_range || post_position.x < self.max_range {
1083 return Ok(());
1084 }
1085
1086 let span = post_position.x - previous.position.x;
1087 if !span.is_finite() || span <= 1e-9 {
1088 return Ok(());
1089 }
1090
1091 let fraction = (self.max_range - previous.position.x) / span;
1092 let mut position = previous.position + (post_position - previous.position) * fraction;
1093 position.x = self.max_range;
1094 let velocity_magnitude = previous.velocity_magnitude
1095 + (post_velocity.magnitude() - previous.velocity_magnitude) * fraction;
1096 let time = previous.time + (post_time - previous.time) * fraction;
1097 let kinetic_energy =
1098 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1099
1100 if position.y > *max_height {
1101 *max_height = position.y;
1102 }
1103 self.push_trajectory_point(
1104 points,
1105 TrajectoryPoint {
1106 time,
1107 position,
1108 velocity_magnitude,
1109 kinetic_energy,
1110 },
1111 )
1112 }
1113
1114 fn gravity_acceleration(&self) -> Vector3<f64> {
1115 let theta = self.inputs.shooting_angle;
1116 Vector3::new(
1117 -crate::constants::G_ACCEL_MPS2 * theta.sin(),
1118 -crate::constants::G_ACCEL_MPS2 * theta.cos(),
1119 0.0,
1120 )
1121 }
1122
1123 fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
1124 let model = match self.inputs.wind_shear_model.as_str() {
1139 "logarithmic" => WindShearModel::Logarithmic,
1140 "power_law" | "powerlaw" | "exponential" => WindShearModel::PowerLaw,
1141 "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
1142 "custom_layers" | "custom" => WindShearModel::CustomLayers,
1143 _ => WindShearModel::PowerLaw,
1144 };
1145 let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
1146
1147 crate::wind::wind_vector(self.wind.speed, self.wind.direction, 0.0) * speed_ratio
1154 + Vector3::new(0.0, self.wind.vertical_speed, 0.0)
1155 }
1156
1157 pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
1158 self.validate_for_solve()?;
1159 let mut result = if self.inputs.use_rk4 {
1160 if self.inputs.use_adaptive_rk45 {
1161 self.solve_rk45()?
1162 } else {
1163 self.solve_rk4()?
1164 }
1165 } else {
1166 self.solve_euler()?
1167 };
1168 self.apply_spin_drift(&mut result);
1169 self.validate_result_finiteness(&result)?;
1170 Ok(result)
1171 }
1172
1173 fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
1179 if !self.inputs.use_enhanced_spin_drift {
1180 return;
1181 }
1182 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 {
1186 return;
1187 }
1188
1189 let sg = self.effective_spin_drift_sg();
1196
1197 for p in result.points.iter_mut() {
1198 if p.time <= 0.0 {
1199 continue;
1200 }
1201 p.position.z +=
1203 crate::spin_drift::litz_drift_meters(sg, p.time, self.inputs.is_twist_right);
1204 }
1205
1206 if let Some(samples) = result.sampled_points.as_mut() {
1210 for s in samples.iter_mut() {
1211 if s.time_s <= 0.0 {
1212 continue;
1213 }
1214 s.wind_drift_m +=
1215 crate::spin_drift::litz_drift_meters(sg, s.time_s, self.inputs.is_twist_right);
1216 }
1217 }
1218 }
1219
1220 fn effective_spin_drift_sg(&self) -> f64 {
1225 let (_, _, temp_c, press_hpa) = self.resolved_atmosphere();
1226 crate::spin_drift::effective_sg_from_inputs(&self.inputs, temp_c, press_hpa)
1227 }
1228
1229 fn initial_position(&self) -> Vector3<f64> {
1235 if self.inputs.cant_angle == 0.0 {
1236 return Vector3::new(0.0, self.inputs.muzzle_height, 0.0);
1237 }
1238 let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
1239 let sh = self.inputs.sight_height;
1240 Vector3::new(
1241 0.0,
1242 self.inputs.muzzle_height + sh * (1.0 - cos_c),
1243 -sh * sin_c,
1244 )
1245 }
1246
1247 fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
1248 let mut time = 0.0;
1250 let mut position = self.initial_position();
1254 let aj_components = self.aerodynamic_jump_components();
1260 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1261 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1262 let mut velocity = Vector3::new(
1263 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1267
1268 let mut points = Vec::new();
1269 let mut max_height = position.y;
1270 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
1276 let mut mach_transitions = MachTransitionTracker::default();
1277
1278 let mut angular_state = if self.inputs.enable_precession_nutation {
1280 Some(AngularState {
1281 pitch_angle: 0.001, yaw_angle: 0.001,
1283 pitch_rate: 0.0,
1284 yaw_rate: 0.0,
1285 precession_angle: 0.0,
1286 nutation_phase: 0.0,
1287 })
1288 } else {
1289 None
1290 };
1291 let mut max_yaw_angle = 0.0;
1292 let mut max_precession_angle = 0.0;
1293
1294 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
1296 self.resolved_atmosphere();
1297 let base_ratio = air_density / 1.225;
1302
1303 let wind_vector =
1309 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
1310
1311 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1314 self.inputs.bullet_model.as_deref().unwrap_or("default"),
1315 );
1316
1317 while position.x < self.max_range
1319 && position.y > self.inputs.ground_threshold
1320 && time < 100.0
1321 {
1322 let velocity_magnitude = velocity.magnitude();
1324 let kinetic_energy =
1325 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1326
1327 self.push_trajectory_point(
1328 &mut points,
1329 TrajectoryPoint {
1330 time,
1331 position,
1332 velocity_magnitude,
1333 kinetic_energy,
1334 },
1335 )?;
1336
1337 {
1340 let mach_here = if speed_of_sound > 0.0 {
1341 velocity_magnitude / speed_of_sound
1342 } else {
1343 0.0
1344 };
1345 mach_transitions.record_downward_crossings(
1346 mach_here,
1347 position.x,
1348 &mut transonic_distances,
1349 );
1350 }
1351
1352 #[cfg(debug_assertions)]
1356 if points.len() == 1 || points.len() % 100 == 0 {
1357 eprintln!("Trajectory point {}: time={:.3}s, downrange={:.2}m, vertical={:.2}m, lateral={:.2}m, vel={:.1}m/s",
1358 points.len(), time, position.x, position.y, position.z, velocity_magnitude);
1359 }
1360
1361 if position.y > max_height {
1363 max_height = position.y;
1364 }
1365
1366 if self.inputs.enable_pitch_damping {
1368 let mach = velocity_magnitude / speed_of_sound;
1369
1370 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1372 transonic_mach = Some(mach);
1373 }
1374
1375 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1377
1378 if pitch_damping < min_pitch_damping {
1380 min_pitch_damping = pitch_damping;
1381 }
1382 }
1383
1384 if self.inputs.enable_precession_nutation {
1386 if let Some(ref mut state) = angular_state {
1387 let velocity_magnitude = velocity.magnitude();
1388 let params = self.precession_nutation_params(
1389 velocity_magnitude,
1390 air_density,
1391 speed_of_sound,
1392 );
1393
1394 *state = calculate_combined_angular_motion(
1396 ¶ms,
1397 state,
1398 time,
1399 self.time_step,
1400 0.001, );
1402
1403 if state.yaw_angle.abs() > max_yaw_angle {
1405 max_yaw_angle = state.yaw_angle.abs();
1406 }
1407 if state.precession_angle.abs() > max_precession_angle {
1408 max_precession_angle = state.precession_angle.abs();
1409 }
1410 }
1411 }
1412
1413 let acceleration = self.calculate_acceleration(
1420 &position,
1421 &velocity,
1422 &wind_vector,
1423 (resolved_temp_c, resolved_press_hpa, base_ratio),
1424 );
1425
1426 velocity += acceleration * self.time_step;
1428 position += velocity * self.time_step;
1429 time += self.time_step;
1430 Self::validate_integration_state(&position, &velocity, time)?;
1431 }
1432
1433 self.append_max_range_endpoint(&mut points, position, velocity, time, &mut max_height)?;
1434
1435 let last_point = points.last().ok_or("No trajectory points generated")?;
1437
1438 let sampled_points = if self.inputs.enable_trajectory_sampling {
1440 let trajectory_data = TrajectoryData {
1441 times: points.iter().map(|p| p.time).collect(),
1442 positions: points.iter().map(|p| p.position).collect(),
1443 velocities: points
1444 .iter()
1445 .map(|p| {
1446 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1448 })
1449 .collect(),
1450 transonic_distances, };
1452
1453 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1458 let outputs = TrajectoryOutputs {
1459 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
1461 time_of_flight_s: last_point.time,
1462 max_ord_dist_horiz_m: max_height,
1463 sight_height_m: sight_position_m,
1464 };
1465
1466 let samples = sample_trajectory(
1468 &trajectory_data,
1469 &outputs,
1470 self.inputs.sample_interval,
1471 self.inputs.bullet_mass,
1472 );
1473 Some(samples)
1474 } else {
1475 None
1476 };
1477
1478 Ok(TrajectoryResult {
1479 max_range: last_point.position.x, max_height,
1481 time_of_flight: last_point.time,
1482 impact_velocity: last_point.velocity_magnitude,
1483 impact_energy: last_point.kinetic_energy,
1484 points,
1485 sampled_points,
1486 min_pitch_damping: if self.inputs.enable_pitch_damping {
1487 Some(min_pitch_damping)
1488 } else {
1489 None
1490 },
1491 transonic_mach,
1492 angular_state,
1493 max_yaw_angle: if self.inputs.enable_precession_nutation {
1494 Some(max_yaw_angle)
1495 } else {
1496 None
1497 },
1498 max_precession_angle: if self.inputs.enable_precession_nutation {
1499 Some(max_precession_angle)
1500 } else {
1501 None
1502 },
1503 aerodynamic_jump: aj_components,
1504 })
1505 }
1506
1507 fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
1508 let mut time = 0.0;
1510 let mut position = self.initial_position();
1515
1516 let aj_components = self.aerodynamic_jump_components();
1522 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1523 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1524 let mut velocity = Vector3::new(
1525 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1529
1530 let mut points = Vec::new();
1531 let mut max_height = position.y;
1532 let mut min_pitch_damping = f64::INFINITY; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
1538 let mut mach_transitions = MachTransitionTracker::default();
1539
1540 let mut angular_state = if self.inputs.enable_precession_nutation {
1542 Some(AngularState {
1543 pitch_angle: 0.001, yaw_angle: 0.001,
1545 pitch_rate: 0.0,
1546 yaw_rate: 0.0,
1547 precession_angle: 0.0,
1548 nutation_phase: 0.0,
1549 })
1550 } else {
1551 None
1552 };
1553 let mut max_yaw_angle = 0.0;
1554 let mut max_precession_angle = 0.0;
1555
1556 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
1558 self.resolved_atmosphere();
1559 let base_ratio = air_density / 1.225;
1564
1565 let wind_vector =
1571 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
1572
1573 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1576 self.inputs.bullet_model.as_deref().unwrap_or("default"),
1577 );
1578
1579 while position.x < self.max_range
1581 && position.y > self.inputs.ground_threshold
1582 && time < 100.0
1583 {
1584 let velocity_magnitude = velocity.magnitude();
1586 let kinetic_energy =
1587 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1588
1589 self.push_trajectory_point(
1590 &mut points,
1591 TrajectoryPoint {
1592 time,
1593 position,
1594 velocity_magnitude,
1595 kinetic_energy,
1596 },
1597 )?;
1598
1599 {
1602 let mach_here = if speed_of_sound > 0.0 {
1603 velocity_magnitude / speed_of_sound
1604 } else {
1605 0.0
1606 };
1607 mach_transitions.record_downward_crossings(
1608 mach_here,
1609 position.x,
1610 &mut transonic_distances,
1611 );
1612 }
1613
1614 if position.y > max_height {
1615 max_height = position.y;
1616 }
1617
1618 if self.inputs.enable_pitch_damping {
1620 let mach = velocity_magnitude / speed_of_sound;
1621
1622 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1624 transonic_mach = Some(mach);
1625 }
1626
1627 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1629
1630 if pitch_damping < min_pitch_damping {
1632 min_pitch_damping = pitch_damping;
1633 }
1634 }
1635
1636 if self.inputs.enable_precession_nutation {
1638 if let Some(ref mut state) = angular_state {
1639 let velocity_magnitude = velocity.magnitude();
1640 let params = self.precession_nutation_params(
1641 velocity_magnitude,
1642 air_density,
1643 speed_of_sound,
1644 );
1645
1646 *state = calculate_combined_angular_motion(
1648 ¶ms,
1649 state,
1650 time,
1651 self.time_step,
1652 0.001, );
1654
1655 if state.yaw_angle.abs() > max_yaw_angle {
1657 max_yaw_angle = state.yaw_angle.abs();
1658 }
1659 if state.precession_angle.abs() > max_precession_angle {
1660 max_precession_angle = state.precession_angle.abs();
1661 }
1662 }
1663 }
1664
1665 let dt = self.time_step;
1667
1668 let acc1 = self.calculate_acceleration(
1670 &position,
1671 &velocity,
1672 &wind_vector,
1673 (resolved_temp_c, resolved_press_hpa, base_ratio),
1674 );
1675
1676 let pos2 = position + velocity * (dt * 0.5);
1678 let vel2 = velocity + acc1 * (dt * 0.5);
1679 let acc2 = self.calculate_acceleration(
1680 &pos2,
1681 &vel2,
1682 &wind_vector,
1683 (resolved_temp_c, resolved_press_hpa, base_ratio),
1684 );
1685
1686 let pos3 = position + vel2 * (dt * 0.5);
1688 let vel3 = velocity + acc2 * (dt * 0.5);
1689 let acc3 = self.calculate_acceleration(
1690 &pos3,
1691 &vel3,
1692 &wind_vector,
1693 (resolved_temp_c, resolved_press_hpa, base_ratio),
1694 );
1695
1696 let pos4 = position + vel3 * dt;
1698 let vel4 = velocity + acc3 * dt;
1699 let acc4 = self.calculate_acceleration(
1700 &pos4,
1701 &vel4,
1702 &wind_vector,
1703 (resolved_temp_c, resolved_press_hpa, base_ratio),
1704 );
1705
1706 position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
1708 velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
1709 time += dt;
1710 Self::validate_integration_state(&position, &velocity, time)?;
1711 }
1712
1713 self.append_max_range_endpoint(&mut points, position, velocity, time, &mut max_height)?;
1714
1715 let last_point = points.last().ok_or("No trajectory points generated")?;
1717
1718 let sampled_points = if self.inputs.enable_trajectory_sampling {
1720 let trajectory_data = TrajectoryData {
1721 times: points.iter().map(|p| p.time).collect(),
1722 positions: points.iter().map(|p| p.position).collect(),
1723 velocities: points
1724 .iter()
1725 .map(|p| {
1726 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1728 })
1729 .collect(),
1730 transonic_distances, };
1732
1733 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1738 let outputs = TrajectoryOutputs {
1739 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
1741 time_of_flight_s: last_point.time,
1742 max_ord_dist_horiz_m: max_height,
1743 sight_height_m: sight_position_m,
1744 };
1745
1746 let samples = sample_trajectory(
1748 &trajectory_data,
1749 &outputs,
1750 self.inputs.sample_interval,
1751 self.inputs.bullet_mass,
1752 );
1753 Some(samples)
1754 } else {
1755 None
1756 };
1757
1758 Ok(TrajectoryResult {
1759 max_range: last_point.position.x, max_height,
1761 time_of_flight: last_point.time,
1762 impact_velocity: last_point.velocity_magnitude,
1763 impact_energy: last_point.kinetic_energy,
1764 points,
1765 sampled_points,
1766 min_pitch_damping: if self.inputs.enable_pitch_damping {
1767 Some(min_pitch_damping)
1768 } else {
1769 None
1770 },
1771 transonic_mach,
1772 angular_state,
1773 max_yaw_angle: if self.inputs.enable_precession_nutation {
1774 Some(max_yaw_angle)
1775 } else {
1776 None
1777 },
1778 max_precession_angle: if self.inputs.enable_precession_nutation {
1779 Some(max_precession_angle)
1780 } else {
1781 None
1782 },
1783 aerodynamic_jump: aj_components,
1784 })
1785 }
1786
1787 fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
1788 let mut time = 0.0;
1790 let mut position = self.initial_position();
1794
1795 let aj_components = self.aerodynamic_jump_components();
1801 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1802 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1803 let mut velocity = Vector3::new(
1804 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1808
1809 let mut points = Vec::new();
1810 let mut max_height = position.y;
1811 let mut dt = 0.001; let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
1816 self.resolved_atmosphere();
1817 let base_ratio = air_density / 1.225;
1822 let wind_vector =
1827 crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
1828
1829 let mut transonic_distances: Vec<f64> = Vec::new();
1831 let mut mach_transitions = MachTransitionTracker::default();
1832
1833 let mut min_pitch_damping = f64::INFINITY;
1838 let mut transonic_mach: Option<f64> = None;
1839 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1840 self.inputs.bullet_model.as_deref().unwrap_or("default"),
1841 );
1842 let mut angular_state = if self.inputs.enable_precession_nutation {
1843 Some(AngularState {
1844 pitch_angle: 0.001,
1845 yaw_angle: 0.001,
1846 pitch_rate: 0.0,
1847 yaw_rate: 0.0,
1848 precession_angle: 0.0,
1849 nutation_phase: 0.0,
1850 })
1851 } else {
1852 None
1853 };
1854 let mut max_yaw_angle = 0.0;
1855 let mut max_precession_angle = 0.0;
1856
1857 while position.x < self.max_range
1858 && position.y > self.inputs.ground_threshold
1859 && time < 100.0
1860 {
1861 let velocity_magnitude = velocity.magnitude();
1863 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
1864
1865 self.push_trajectory_point(
1866 &mut points,
1867 TrajectoryPoint {
1868 time,
1869 position,
1870 velocity_magnitude,
1871 kinetic_energy,
1872 },
1873 )?;
1874
1875 {
1878 let mach_here = if speed_of_sound > 0.0 {
1879 velocity_magnitude / speed_of_sound
1880 } else {
1881 0.0
1882 };
1883 mach_transitions.record_downward_crossings(
1884 mach_here,
1885 position.x,
1886 &mut transonic_distances,
1887 );
1888 }
1889
1890 if position.y > max_height {
1891 max_height = position.y;
1892 }
1893
1894 if self.inputs.enable_pitch_damping {
1897 let mach = velocity_magnitude / speed_of_sound;
1898 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1899 transonic_mach = Some(mach);
1900 }
1901 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1902 if pitch_damping < min_pitch_damping {
1903 min_pitch_damping = pitch_damping;
1904 }
1905 }
1906
1907 let accepted_step = self.adaptive_rk45_step(
1910 &position,
1911 &velocity,
1912 dt,
1913 &wind_vector,
1914 (resolved_temp_c, resolved_press_hpa, base_ratio),
1915 );
1916 debug_assert!(
1917 accepted_step.error <= RK45_TOLERANCE || accepted_step.used_dt <= RK45_MIN_DT
1918 );
1919
1920 if self.inputs.enable_precession_nutation {
1924 if let Some(ref mut state) = angular_state {
1925 let params = self.precession_nutation_params(
1926 velocity_magnitude,
1927 air_density,
1928 speed_of_sound,
1929 );
1930
1931 *state = calculate_combined_angular_motion(
1932 ¶ms,
1933 state,
1934 time,
1935 accepted_step.used_dt,
1936 0.001,
1937 );
1938
1939 if state.yaw_angle.abs() > max_yaw_angle {
1940 max_yaw_angle = state.yaw_angle.abs();
1941 }
1942 if state.precession_angle.abs() > max_precession_angle {
1943 max_precession_angle = state.precession_angle.abs();
1944 }
1945 }
1946 }
1947
1948 position = accepted_step.position;
1949 velocity = accepted_step.velocity;
1950 time += accepted_step.used_dt;
1951 Self::validate_integration_state(&position, &velocity, time)?;
1952
1953 dt = accepted_step.next_dt;
1955 }
1956
1957 if points.is_empty() {
1959 return Err(BallisticsError::from("No trajectory points calculated"));
1960 }
1961
1962 self.append_max_range_endpoint(&mut points, position, velocity, time, &mut max_height)?;
1964
1965 let last_point = points.last().unwrap();
1966
1967 let sampled_points = if self.inputs.enable_trajectory_sampling {
1969 let trajectory_data = TrajectoryData {
1971 times: points.iter().map(|p| p.time).collect(),
1972 positions: points.iter().map(|p| p.position).collect(),
1973 velocities: points
1974 .iter()
1975 .map(|p| {
1976 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1978 })
1979 .collect(),
1980 transonic_distances, };
1982
1983 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1988 let outputs = TrajectoryOutputs {
1989 target_distance_horiz_m: last_point.position.x,
1990 target_vertical_height_m: sight_position_m,
1991 time_of_flight_s: last_point.time,
1992 max_ord_dist_horiz_m: max_height,
1993 sight_height_m: sight_position_m,
1994 };
1995
1996 let samples = sample_trajectory(
1997 &trajectory_data,
1998 &outputs,
1999 self.inputs.sample_interval,
2000 self.inputs.bullet_mass,
2001 );
2002 Some(samples)
2003 } else {
2004 None
2005 };
2006
2007 Ok(TrajectoryResult {
2008 max_range: last_point.position.x, max_height,
2010 time_of_flight: last_point.time,
2011 impact_velocity: last_point.velocity_magnitude,
2012 impact_energy: last_point.kinetic_energy,
2013 points,
2014 sampled_points,
2015 min_pitch_damping: if self.inputs.enable_pitch_damping {
2016 Some(min_pitch_damping)
2017 } else {
2018 None
2019 },
2020 transonic_mach,
2021 angular_state,
2022 max_yaw_angle: if self.inputs.enable_precession_nutation {
2023 Some(max_yaw_angle)
2024 } else {
2025 None
2026 },
2027 max_precession_angle: if self.inputs.enable_precession_nutation {
2028 Some(max_precession_angle)
2029 } else {
2030 None
2031 },
2032 aerodynamic_jump: aj_components,
2033 })
2034 }
2035
2036 fn adaptive_rk45_step(
2037 &self,
2038 position: &Vector3<f64>,
2039 velocity: &Vector3<f64>,
2040 initial_dt: f64,
2041 wind_vector: &Vector3<f64>,
2042 resolved_atmo: (f64, f64, f64),
2043 ) -> Rk45AcceptedStep {
2044 let mut trial_dt = initial_dt;
2045
2046 loop {
2047 let trial = self.rk45_step(
2048 position,
2049 velocity,
2050 trial_dt,
2051 wind_vector,
2052 RK45_TOLERANCE,
2053 resolved_atmo,
2054 );
2055 let next_dt = if trial.suggested_dt.is_finite() {
2060 (RK45_SAFETY_FACTOR * trial.suggested_dt).clamp(RK45_MIN_DT, RK45_MAX_DT)
2061 } else {
2062 RK45_MIN_DT
2063 };
2064
2065 if trial.error <= RK45_TOLERANCE || trial_dt <= RK45_MIN_DT {
2066 return Rk45AcceptedStep {
2067 position: trial.position,
2068 velocity: trial.velocity,
2069 used_dt: trial_dt,
2070 next_dt,
2071 error: trial.error,
2072 };
2073 }
2074
2075 trial_dt = next_dt;
2076 }
2077 }
2078
2079 fn rk45_step(
2080 &self,
2081 position: &Vector3<f64>,
2082 velocity: &Vector3<f64>,
2083 dt: f64,
2084 wind_vector: &Vector3<f64>,
2085 tolerance: f64,
2086 resolved_atmo: (f64, f64, f64), ) -> Rk45Trial {
2088 const A21: f64 = 1.0 / 5.0;
2090 const A31: f64 = 3.0 / 40.0;
2091 const A32: f64 = 9.0 / 40.0;
2092 const A41: f64 = 44.0 / 45.0;
2093 const A42: f64 = -56.0 / 15.0;
2094 const A43: f64 = 32.0 / 9.0;
2095 const A51: f64 = 19372.0 / 6561.0;
2096 const A52: f64 = -25360.0 / 2187.0;
2097 const A53: f64 = 64448.0 / 6561.0;
2098 const A54: f64 = -212.0 / 729.0;
2099 const A61: f64 = 9017.0 / 3168.0;
2100 const A62: f64 = -355.0 / 33.0;
2101 const A63: f64 = 46732.0 / 5247.0;
2102 const A64: f64 = 49.0 / 176.0;
2103 const A65: f64 = -5103.0 / 18656.0;
2104 const A71: f64 = 35.0 / 384.0;
2105 const A73: f64 = 500.0 / 1113.0;
2106 const A74: f64 = 125.0 / 192.0;
2107 const A75: f64 = -2187.0 / 6784.0;
2108 const A76: f64 = 11.0 / 84.0;
2109
2110 const B1: f64 = 35.0 / 384.0;
2112 const B3: f64 = 500.0 / 1113.0;
2113 const B4: f64 = 125.0 / 192.0;
2114 const B5: f64 = -2187.0 / 6784.0;
2115 const B6: f64 = 11.0 / 84.0;
2116
2117 const B1_ERR: f64 = 5179.0 / 57600.0;
2119 const B3_ERR: f64 = 7571.0 / 16695.0;
2120 const B4_ERR: f64 = 393.0 / 640.0;
2121 const B5_ERR: f64 = -92097.0 / 339200.0;
2122 const B6_ERR: f64 = 187.0 / 2100.0;
2123 const B7_ERR: f64 = 1.0 / 40.0;
2124
2125 let k1_v = self.calculate_acceleration(position, velocity, wind_vector, resolved_atmo);
2127 let k1_p = *velocity;
2128
2129 let p2 = position + dt * A21 * k1_p;
2130 let v2 = velocity + dt * A21 * k1_v;
2131 let k2_v = self.calculate_acceleration(&p2, &v2, wind_vector, resolved_atmo);
2132 let k2_p = v2;
2133
2134 let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
2135 let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
2136 let k3_v = self.calculate_acceleration(&p3, &v3, wind_vector, resolved_atmo);
2137 let k3_p = v3;
2138
2139 let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
2140 let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
2141 let k4_v = self.calculate_acceleration(&p4, &v4, wind_vector, resolved_atmo);
2142 let k4_p = v4;
2143
2144 let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
2145 let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
2146 let k5_v = self.calculate_acceleration(&p5, &v5, wind_vector, resolved_atmo);
2147 let k5_p = v5;
2148
2149 let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
2150 let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
2151 let k6_v = self.calculate_acceleration(&p6, &v6, wind_vector, resolved_atmo);
2152 let k6_p = v6;
2153
2154 let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
2155 let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
2156 let k7_v = self.calculate_acceleration(&p7, &v7, wind_vector, resolved_atmo);
2157 let k7_p = v7;
2158
2159 let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
2161 let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
2162
2163 let pos_err = position
2165 + dt * (B1_ERR * k1_p
2166 + B3_ERR * k3_p
2167 + B4_ERR * k4_p
2168 + B5_ERR * k5_p
2169 + B6_ERR * k6_p
2170 + B7_ERR * k7_p);
2171 let vel_err = velocity
2172 + dt * (B1_ERR * k1_v
2173 + B3_ERR * k3_v
2174 + B4_ERR * k4_v
2175 + B5_ERR * k5_v
2176 + B6_ERR * k6_v
2177 + B7_ERR * k7_v);
2178
2179 let error = cli_rk45_error_norm(position, velocity, &new_pos, &new_vel, &pos_err, &vel_err);
2181
2182 let dt_new = if error < tolerance {
2184 dt * (tolerance / error).powf(0.2).min(2.0)
2185 } else {
2186 dt * (tolerance / error).powf(0.25).max(0.1)
2187 };
2188
2189 Rk45Trial {
2190 position: new_pos,
2191 velocity: new_vel,
2192 suggested_dt: dt_new,
2193 error,
2194 }
2195 }
2196
2197 fn apply_cluster_bc_correction(&self, base_bc: f64, velocity_fps: f64) -> f64 {
2198 if let Some(ref cluster_bc) = self.cluster_bc {
2199 cluster_bc.apply_correction_for_drag_model(
2200 base_bc,
2201 self.inputs.caliber_inches,
2202 self.inputs.weight_grains,
2203 velocity_fps,
2204 self.inputs.bc_type,
2205 )
2206 } else {
2207 base_bc
2208 }
2209 }
2210
2211 fn calculate_acceleration(
2212 &self,
2213 position: &Vector3<f64>,
2214 velocity: &Vector3<f64>,
2215 wind_vector: &Vector3<f64>,
2216 resolved_atmo: (f64, f64, f64), ) -> Vector3<f64> {
2218 let actual_wind = if let Some(ref sock) = self.wind_sock {
2224 sock.vector_for_range_stateless(position.x)
2225 } else if self.inputs.enable_wind_shear {
2226 self.get_wind_at_altitude(position.y)
2227 } else {
2228 *wind_vector
2229 };
2230 let actual_wind =
2231 crate::derivatives::level_vector_to_shot_frame(actual_wind, self.inputs.shooting_angle);
2232
2233 let relative_velocity = velocity - actual_wind;
2234 let velocity_magnitude = relative_velocity.magnitude();
2235
2236 if velocity_magnitude < 0.001 {
2237 return self.gravity_acceleration();
2238 }
2239
2240 let (base_temp_c, base_press_hpa, station_ratio) = resolved_atmo;
2251
2252 let (drag_base_temp_c, drag_base_press_hpa, drag_base_ratio, drag_humidity_percent) =
2260 if let Some(ref sock) = self.atmo_sock {
2261 let (zone_temp_c, zone_press_hpa, zone_humidity) = sock.atmo_for_range(position.x);
2262 let zone_base_ratio = crate::atmosphere::calculate_air_density_cimp(
2263 zone_temp_c,
2264 zone_press_hpa,
2265 zone_humidity,
2266 ) / 1.225;
2267 (zone_temp_c, zone_press_hpa, zone_base_ratio, zone_humidity)
2268 } else {
2269 (
2270 base_temp_c,
2271 base_press_hpa,
2272 station_ratio,
2273 self.atmosphere.humidity,
2274 )
2275 };
2276 let local_alt = crate::atmosphere::shot_frame_altitude(
2277 self.atmosphere.altitude,
2278 position.x,
2279 position.y,
2280 self.inputs.shooting_angle,
2281 );
2282 let (air_density, speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
2283 local_alt,
2284 self.atmosphere.altitude,
2285 drag_base_temp_c,
2286 drag_base_press_hpa,
2287 drag_base_ratio,
2288 drag_humidity_percent,
2289 );
2290
2291 let cd = self.calculate_drag_coefficient(velocity_magnitude, speed_of_sound);
2293
2294 let velocity_fps = velocity_magnitude * 3.28084;
2296
2297 let (base_bc, bc_from_segments) = if let Some(segments) = self
2302 .inputs
2303 .bc_segments_data
2304 .as_ref()
2305 .filter(|segments| self.inputs.use_bc_segments && !segments.is_empty())
2306 {
2307 (
2309 crate::bc_estimation::velocity_segment_bc(
2310 velocity_fps,
2311 segments,
2312 self.inputs.bc_value,
2313 ),
2314 true,
2315 )
2316 } else if let Some(segments) = self
2317 .inputs
2318 .bc_segments
2319 .as_ref()
2320 .filter(|segments| !segments.is_empty())
2321 {
2322 (
2323 crate::derivatives::interpolated_bc(
2324 velocity_magnitude / speed_of_sound,
2325 segments,
2326 Some(&self.inputs),
2327 ),
2328 true,
2329 )
2330 } else {
2331 (self.inputs.bc_value, false)
2332 };
2333
2334 let effective_bc = if bc_from_segments {
2339 base_bc
2340 } else {
2341 self.apply_cluster_bc_correction(base_bc, velocity_fps)
2342 };
2343 let effective_bc = effective_bc.max(1e-6);
2346
2347 let retard_denom = if self.inputs.custom_drag_table.is_some() {
2352 self.inputs.custom_drag_denominator(effective_bc)
2353 } else {
2354 effective_bc
2355 };
2356
2357 let cd_to_retard = crate::constants::CD_TO_RETARD;
2362 let standard_factor = cd * cd_to_retard;
2363 let density_scale = air_density / 1.225; let a_drag_ft_s2 =
2367 (velocity_fps * velocity_fps) * standard_factor * density_scale / retard_denom;
2368 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
2372
2373 let mut accel = drag_acceleration + self.gravity_acceleration();
2376
2377 if self.inputs.enable_coriolis {
2380 if let Some(lat_deg) = self.inputs.latitude {
2381 let omega_earth = 7.2921159e-5_f64; let lat = lat_deg.to_radians();
2383 let az = self.inputs.shot_azimuth; let omega = Vector3::new(
2390 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), );
2394 let omega = crate::derivatives::level_vector_to_shot_frame(
2395 omega,
2396 self.inputs.shooting_angle,
2397 );
2398 accel += -2.0 * omega.cross(velocity);
2403 }
2404 }
2405
2406 if self.inputs.enable_magnus
2413 && !self.inputs.use_enhanced_spin_drift
2414 && self.inputs.bullet_diameter > 0.0
2415 && self.inputs.twist_rate > 0.0
2416 {
2417 let diameter_m = self.inputs.bullet_diameter;
2418 let (spin_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
2419 self.inputs.muzzle_velocity,
2420 velocity_magnitude,
2421 self.inputs.twist_rate,
2422 diameter_m,
2423 );
2424 let mach = velocity_magnitude / speed_of_sound;
2426
2427 let d_in = self.inputs.bullet_diameter / 0.0254;
2429 let m_gr = self.inputs.bullet_mass / 0.00006479891;
2430 let l_in = if self.inputs.bullet_length > 0.0 {
2431 self.inputs.bullet_length / 0.0254
2432 } else {
2433 let est_m = crate::stability::estimate_bullet_length_m(
2435 self.inputs.bullet_diameter,
2436 self.inputs.bullet_mass,
2437 );
2438 if est_m > 0.0 {
2439 est_m / 0.0254
2440 } else {
2441 4.5 * d_in
2442 }
2443 };
2444 let sg = crate::spin_drift::calculate_dynamic_stability(
2448 m_gr,
2449 velocity_magnitude,
2450 spin_rad_s,
2451 d_in,
2452 l_in,
2453 air_density,
2454 );
2455
2456 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
2458 sg,
2459 velocity_magnitude,
2460 spin_rad_s,
2461 0.0, 0.0, air_density,
2464 d_in,
2465 l_in,
2466 m_gr,
2467 mach,
2468 "match",
2469 false,
2470 );
2471
2472 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
2474 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
2475 let magnus_force = 0.5
2476 * air_density
2477 * velocity_magnitude.powi(2)
2478 * area
2479 * c_np
2480 * spin_param
2481 * yaw_rad.sin();
2482
2483 if magnus_force.abs() > 1e-12 {
2487 if let Some(dir) = crate::derivatives::yaw_of_repose_magnus_direction(
2488 relative_velocity,
2489 self.gravity_acceleration(),
2490 self.inputs.is_twist_right,
2491 ) {
2492 accel += (magnus_force / self.inputs.bullet_mass) * dir;
2493 }
2494 }
2495 }
2496
2497 accel
2498 }
2499
2500 fn calculate_drag_coefficient(&self, velocity: f64, speed_of_sound: f64) -> f64 {
2501 let mach = velocity / speed_of_sound;
2502
2503 if let Some(ref table) = self.inputs.custom_drag_table {
2507 return table.interpolate(mach);
2508 }
2509
2510 crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type)
2513 }
2514}
2515
2516#[derive(Debug, Clone)]
2518pub struct MonteCarloParams {
2519 pub num_simulations: usize,
2520 pub velocity_std_dev: f64,
2521 pub angle_std_dev: f64,
2522 pub bc_std_dev: f64,
2523 pub wind_speed_std_dev: f64,
2524 pub target_distance: Option<f64>,
2525 pub base_wind_speed: f64,
2526 pub base_wind_direction: f64,
2527 pub azimuth_std_dev: f64, }
2529
2530impl Default for MonteCarloParams {
2531 fn default() -> Self {
2532 Self {
2533 num_simulations: 1000,
2534 velocity_std_dev: 1.0,
2535 angle_std_dev: 0.001,
2536 bc_std_dev: 0.01,
2537 wind_speed_std_dev: 1.0,
2538 target_distance: None,
2539 base_wind_speed: 0.0,
2540 base_wind_direction: 0.0,
2541 azimuth_std_dev: 0.001, }
2543 }
2544}
2545
2546#[derive(Debug, Clone)]
2548pub struct MonteCarloResults {
2549 pub ranges: Vec<f64>,
2550 pub impact_velocities: Vec<f64>,
2551 pub impact_positions: Vec<Vector3<f64>>,
2557}
2558
2559pub const DEFAULT_HIT_RADIUS_M: f64 = 0.3;
2562
2563pub const TARGET_NOT_REACHED_SENTINEL_M: f64 = -1.0e9;
2569
2570impl MonteCarloResults {
2571 pub fn position_reached_target(position: &Vector3<f64>) -> bool {
2573 position.iter().all(|component| component.is_finite())
2574 && position.y != TARGET_NOT_REACHED_SENTINEL_M
2575 }
2576
2577 pub fn target_arrival_count(&self) -> usize {
2579 self.impact_positions
2580 .iter()
2581 .filter(|position| Self::position_reached_target(position))
2582 .count()
2583 }
2584
2585 pub fn target_shortfall_fraction(&self) -> f64 {
2588 if self.impact_positions.is_empty() {
2589 return 0.0;
2590 }
2591 (self.impact_positions.len() - self.target_arrival_count()) as f64
2592 / self.impact_positions.len() as f64
2593 }
2594
2595 pub fn target_plane_cep(&self) -> Option<f64> {
2601 let mut radial_misses: Vec<f64> = self
2602 .impact_positions
2603 .iter()
2604 .filter(|position| Self::position_reached_target(position))
2605 .map(Vector3::norm)
2606 .filter(|miss| miss.is_finite())
2607 .collect();
2608 radial_misses.sort_by(f64::total_cmp);
2609 if radial_misses.is_empty() {
2610 None
2611 } else {
2612 Some(radial_misses[radial_misses.len() / 2])
2613 }
2614 }
2615
2616 pub fn hit_probability(&self, hit_radius_m: f64) -> f64 {
2625 if self.impact_positions.is_empty() {
2626 return 0.0;
2627 }
2628 let hits = self
2629 .impact_positions
2630 .iter()
2631 .filter(|position| {
2632 Self::position_reached_target(position) && position.norm() < hit_radius_m
2633 })
2634 .count();
2635 hits as f64 / self.impact_positions.len() as f64
2636 }
2637}
2638
2639fn wind_from_signed_speed_sample(
2640 signed_speed: f64,
2641 sampled_direction: f64,
2642 vertical_speed: f64,
2643) -> WindConditions {
2644 if signed_speed < 0.0 {
2649 WindConditions {
2650 speed: -signed_speed,
2651 direction: sampled_direction + std::f64::consts::PI,
2652 vertical_speed,
2653 }
2654 } else {
2655 WindConditions {
2656 speed: signed_speed,
2657 direction: sampled_direction,
2658 vertical_speed,
2659 }
2660 }
2661}
2662
2663struct MonteCarloWindSampler {
2664 speed: rand_distr::Normal<f64>,
2665 direction: rand_distr::Normal<f64>,
2666 vertical_speed: f64,
2668}
2669
2670impl MonteCarloWindSampler {
2671 fn new(
2672 base_wind: &WindConditions,
2673 wind_speed_std_dev: f64,
2674 wind_direction_std_dev: f64,
2675 ) -> Result<Self, BallisticsError> {
2676 use rand_distr::Normal;
2677
2678 if !wind_direction_std_dev.is_finite() || wind_direction_std_dev < 0.0 {
2679 return Err("Wind direction standard deviation must be finite and non-negative".into());
2680 }
2681
2682 let speed = Normal::new(base_wind.speed, wind_speed_std_dev)
2683 .map_err(|e| format!("Invalid wind speed distribution: {e}"))?;
2684 let direction = Normal::new(base_wind.direction, wind_direction_std_dev)
2685 .map_err(|e| format!("Invalid wind direction distribution: {e}"))?;
2686 Ok(Self { speed, direction, vertical_speed: base_wind.vertical_speed })
2687 }
2688
2689 fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> WindConditions {
2690 use rand_distr::Distribution;
2691
2692 wind_from_signed_speed_sample(
2693 self.speed.sample(rng),
2694 self.direction.sample(rng),
2695 self.vertical_speed,
2696 )
2697 }
2698}
2699
2700pub fn run_monte_carlo(
2702 base_inputs: BallisticInputs,
2703 params: MonteCarloParams,
2704) -> Result<MonteCarloResults, BallisticsError> {
2705 run_monte_carlo_with_direction_std_dev(base_inputs, params, 0.0)
2706}
2707
2708pub fn run_monte_carlo_with_direction_std_dev(
2713 base_inputs: BallisticInputs,
2714 params: MonteCarloParams,
2715 wind_direction_std_dev: f64,
2716) -> Result<MonteCarloResults, BallisticsError> {
2717 let base_wind = WindConditions {
2718 speed: params.base_wind_speed,
2719 direction: params.base_wind_direction,
2720 vertical_speed: 0.0,
2721 };
2722 run_monte_carlo_with_wind_and_direction_std_dev(
2723 base_inputs,
2724 base_wind,
2725 params,
2726 wind_direction_std_dev,
2727 )
2728}
2729
2730pub fn run_monte_carlo_with_wind(
2732 base_inputs: BallisticInputs,
2733 base_wind: WindConditions,
2734 params: MonteCarloParams,
2735) -> Result<MonteCarloResults, BallisticsError> {
2736 run_monte_carlo_with_wind_and_direction_std_dev(base_inputs, base_wind, params, 0.0)
2737}
2738
2739pub fn run_monte_carlo_with_wind_and_direction_std_dev(
2744 base_inputs: BallisticInputs,
2745 base_wind: WindConditions,
2746 params: MonteCarloParams,
2747 wind_direction_std_dev: f64,
2748) -> Result<MonteCarloResults, BallisticsError> {
2749 use rand_distr::{Distribution, Normal};
2750
2751 let mut rng = rand::rng();
2752 let mut ranges = Vec::new();
2753 let mut impact_velocities = Vec::new();
2754 let mut impact_positions = Vec::new();
2755
2756 let atmosphere = AtmosphericConditions {
2757 temperature: base_inputs.temperature,
2758 pressure: base_inputs.pressure,
2759 humidity: base_inputs.humidity_percent(),
2760 altitude: base_inputs.altitude,
2761 };
2762 let target_hint = params
2763 .target_distance
2764 .unwrap_or(base_inputs.target_distance);
2765 let solver_max_range = target_hint.max(1000.0) * 2.0;
2766
2767 let mut baseline_solver =
2769 TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), atmosphere.clone());
2770 baseline_solver.set_max_range(solver_max_range);
2771 let baseline_result = baseline_solver.solve()?;
2772
2773 let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
2775
2776 let baseline_at_target = baseline_result
2778 .position_at_range(target_distance)
2779 .ok_or("Could not interpolate baseline at target distance")?;
2780
2781 let velocity_delta_dist = Normal::new(0.0, params.velocity_std_dev)
2786 .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
2787 let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
2788 .map_err(|e| format!("Invalid angle distribution: {}", e))?;
2789 let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
2790 .map_err(|e| format!("Invalid BC distribution: {}", e))?;
2791 let wind_sampler = MonteCarloWindSampler::new(
2794 &base_wind,
2795 params.wind_speed_std_dev,
2796 wind_direction_std_dev,
2797 )?;
2798 let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
2799 .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
2800
2801 for _ in 0..params.num_simulations {
2802 let mut inputs = base_inputs.clone();
2804 let muzzle_velocity_delta = velocity_delta_dist.sample(&mut rng);
2805 inputs.muzzle_angle = angle_dist.sample(&mut rng);
2806 inputs.bc_value = bc_dist.sample(&mut rng).max(0.01);
2807 inputs.azimuth_angle = azimuth_dist.sample(&mut rng); let wind = wind_sampler.sample(&mut rng);
2811
2812 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere.clone());
2814 solver.inputs.muzzle_velocity =
2815 (solver.inputs.muzzle_velocity + muzzle_velocity_delta).max(0.0);
2816 solver.set_max_range(solver_max_range);
2817 match solver.solve() {
2818 Ok(result) => {
2819 let deviation = if result.max_range < target_distance {
2825 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)
2828 } else {
2829 let pos_at_target = match result.position_at_range(target_distance) {
2830 Some(p) => p,
2831 None => continue, };
2833 Vector3::new(
2838 0.0,
2839 pos_at_target.y - baseline_at_target.y,
2840 pos_at_target.z - baseline_at_target.z,
2841 )
2842 };
2843
2844 ranges.push(result.max_range);
2845 impact_velocities.push(result.impact_velocity);
2846 impact_positions.push(deviation);
2847 }
2848 Err(_) => {
2849 continue;
2851 }
2852 }
2853 }
2854
2855 if ranges.is_empty() {
2856 return Err("No successful simulations".into());
2857 }
2858
2859 Ok(MonteCarloResults {
2860 ranges,
2861 impact_velocities,
2862 impact_positions,
2863 })
2864}
2865
2866pub fn calculate_zero_angle(
2868 inputs: BallisticInputs,
2869 target_distance: f64,
2870 target_height: f64,
2871) -> Result<f64, BallisticsError> {
2872 calculate_zero_angle_with_conditions(
2873 inputs,
2874 target_distance,
2875 target_height,
2876 WindConditions::default(),
2877 AtmosphericConditions::default(),
2878 )
2879}
2880
2881pub fn calculate_zero_angle_with_conditions(
2882 inputs: BallisticInputs,
2883 target_distance: f64,
2884 target_height: f64,
2885 wind: WindConditions,
2886 atmosphere: AtmosphericConditions,
2887) -> Result<f64, BallisticsError> {
2888 let get_height_at_angle = |angle: f64| -> Result<Option<f64>, BallisticsError> {
2890 let mut test_inputs = inputs.clone();
2891 test_inputs.muzzle_angle = angle;
2892 test_inputs.enable_aerodynamic_jump = false;
2897 test_inputs.cant_angle = 0.0;
2901
2902 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
2903 solver.set_max_range(target_distance * 2.0);
2904 solver.set_time_step(0.001);
2905 let result = solver.solve()?;
2906
2907 for i in 0..result.points.len() {
2909 if result.points[i].position.x >= target_distance {
2910 if i > 0 {
2911 let p1 = &result.points[i - 1];
2912 let p2 = &result.points[i];
2913 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
2914 return Ok(Some(p1.position.y + t * (p2.position.y - p1.position.y)));
2915 } else {
2916 return Ok(Some(result.points[i].position.y));
2917 }
2918 }
2919 }
2920 Ok(None)
2921 };
2922
2923 let mut low_angle = 0.0; let mut high_angle = 0.2; let tolerance = 1e-7; let max_iterations = 60;
2929
2930 let low_height = get_height_at_angle(low_angle)?;
2933 let high_height = get_height_at_angle(high_angle)?;
2934
2935 match (low_height, high_height) {
2936 (Some(lh), Some(hh)) => {
2937 let low_error = lh - target_height;
2938 let high_error = hh - target_height;
2939
2940 if low_error > 0.0 && high_error > 0.0 {
2943 } else if low_error < 0.0 && high_error < 0.0 {
2948 let mut expanded = false;
2951 for multiplier in [2.0, 3.0, 4.0] {
2952 let new_high = (high_angle * multiplier).min(0.785);
2953 if let Ok(Some(h)) = get_height_at_angle(new_high) {
2954 if h - target_height > 0.0 {
2955 high_angle = new_high;
2956 expanded = true;
2957 break;
2958 }
2959 }
2960 if new_high >= 0.785 {
2961 break;
2962 }
2963 }
2964 if !expanded {
2965 return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
2966 }
2967 }
2968 }
2970 (None, Some(_hh)) => {
2971 }
2974 (Some(_lh), None) => {
2975 return Err(
2977 "Cannot find zero angle: high angle trajectory doesn't reach target distance"
2978 .into(),
2979 );
2980 }
2981 (None, None) => {
2982 return Err(
2984 "Cannot find zero angle: trajectory cannot reach target distance at any angle"
2985 .into(),
2986 );
2987 }
2988 }
2989
2990 for _iteration in 0..max_iterations {
2991 let mid_angle = (low_angle + high_angle) / 2.0;
2992
2993 let mut test_inputs = inputs.clone();
2994 test_inputs.muzzle_angle = mid_angle;
2995 test_inputs.enable_aerodynamic_jump = false;
2997 test_inputs.cant_angle = 0.0;
3001
3002 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
3003 solver.set_max_range(target_distance * 2.0);
3005 solver.set_time_step(0.001);
3006 let result = solver.solve()?;
3007
3008 let mut height_at_target = None;
3010 for i in 0..result.points.len() {
3011 if result.points[i].position.x >= target_distance {
3012 if i > 0 {
3013 let p1 = &result.points[i - 1];
3015 let p2 = &result.points[i];
3016 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
3017 height_at_target = Some(p1.position.y + t * (p2.position.y - p1.position.y));
3018 } else {
3019 height_at_target = Some(result.points[i].position.y);
3020 }
3021 break;
3022 }
3023 }
3024
3025 match height_at_target {
3026 Some(height) => {
3027 let error = height - target_height;
3028 if error.abs() < 0.0001 {
3034 return Ok(mid_angle);
3035 }
3036
3037 if (high_angle - low_angle).abs() < tolerance {
3041 if error.abs() < 0.01 {
3042 return Ok(mid_angle);
3044 }
3045 return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
3050 }
3051
3052 if error > 0.0 {
3053 high_angle = mid_angle;
3054 } else {
3055 low_angle = mid_angle;
3056 }
3057 }
3058 None => {
3059 low_angle = mid_angle;
3061
3062 if (high_angle - low_angle).abs() < tolerance {
3064 return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
3065 }
3066 }
3067 }
3068 }
3069
3070 Err("Failed to find zero angle".into())
3071}
3072
3073#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3075pub enum BcFitMode {
3076 Drop,
3078 Velocity,
3081}
3082
3083#[derive(Debug, Clone, Copy)]
3085pub struct BcEstimate {
3086 pub bc: f64,
3088 pub rms_error: f64,
3090 pub drag_model: DragModel,
3092 pub mode: BcFitMode,
3094 pub at_bound: bool,
3098}
3099
3100fn fit_value_at(
3108 points: &[TrajectoryPoint],
3109 target_dist: f64,
3110 mode: BcFitMode,
3111 drop_offset: f64,
3112) -> Option<f64> {
3113 let val = |p: &TrajectoryPoint| match mode {
3114 BcFitMode::Drop => drop_offset - p.position.y,
3115 BcFitMode::Velocity => p.velocity_magnitude,
3116 };
3117 for i in 0..points.len() {
3118 if points[i].position.x >= target_dist {
3119 if i == 0 {
3120 return Some(val(&points[0]));
3121 }
3122 let p1 = &points[i - 1];
3123 let p2 = &points[i];
3124 let dx = p2.position.x - p1.position.x;
3125 if dx.abs() < 1e-9 {
3126 return Some(val(p2));
3127 }
3128 let t = (target_dist - p1.position.x) / dx;
3129 return Some(val(p1) + t * (val(p2) - val(p1)));
3130 }
3131 }
3132 None
3133}
3134
3135fn fit_residual_sse(
3136 trajectory: &[TrajectoryPoint],
3137 observations: &[(f64, f64)],
3138 mode: BcFitMode,
3139 drop_offset: f64,
3140) -> Option<f64> {
3141 if observations.is_empty() {
3142 return None;
3143 }
3144 let mut total = 0.0;
3145 for (target_dist, target_val) in observations {
3146 let value = fit_value_at(trajectory, *target_dist, mode, drop_offset)?;
3149 let error = value - target_val;
3150 total += error * error;
3151 }
3152 Some(total)
3153}
3154
3155pub fn estimate_bc_fit(
3172 velocity: f64,
3173 mass: f64,
3174 diameter: f64,
3175 points: &[(f64, f64)],
3176 drag_model: DragModel,
3177 mode: BcFitMode,
3178 atmosphere: AtmosphericConditions,
3179 zero_range: Option<f64>,
3180 sight_height: f64,
3181) -> Result<BcEstimate, BallisticsError> {
3182 if points.is_empty() {
3183 return Err(BallisticsError::from(
3184 "No data points provided for BC estimation.".to_string(),
3185 ));
3186 }
3187 let max_dist = points.iter().map(|(d, _)| *d).fold(0.0_f64, f64::max);
3188 let drop_offset = if zero_range.is_some() { sight_height } else { 0.0 };
3191
3192 let sse = |bc_value: f64| -> Option<f64> {
3194 let mut inputs = BallisticInputs {
3195 muzzle_velocity: velocity,
3196 bc_value,
3197 bc_type: drag_model,
3198 bullet_mass: mass,
3199 bullet_diameter: diameter,
3200 sight_height,
3201 ..Default::default()
3202 };
3203 if let Some(zr) = zero_range {
3206 let za = calculate_zero_angle_with_conditions(
3212 inputs.clone(),
3213 zr,
3214 sight_height,
3215 WindConditions::default(),
3216 atmosphere.clone(),
3217 )
3218 .ok()?;
3219 inputs.muzzle_angle = za;
3220 }
3221 let mut solver =
3222 TrajectorySolver::new(inputs, WindConditions::default(), atmosphere.clone());
3223 solver.set_max_range(max_dist * 1.5);
3224 let result = solver.solve().ok()?;
3225 fit_residual_sse(&result.points, points, mode, drop_offset)
3226 };
3227
3228 let (bc_min, bc_max) = match drag_model {
3232 DragModel::G7 => (0.05, 0.70),
3233 _ => (0.10, 1.20),
3234 };
3235
3236 let mut best_bc = f64::NAN;
3238 let mut best_sse = f64::MAX;
3239 let mut bc = bc_min;
3240 while bc <= bc_max + 1e-9 {
3241 if let Some(s) = sse(bc) {
3242 if s < best_sse {
3243 best_sse = s;
3244 best_bc = bc;
3245 }
3246 }
3247 bc += 0.01;
3248 }
3249 if !best_bc.is_finite() {
3250 return Err(BallisticsError::from(
3251 "Unable to estimate BC from provided data. Check that the values and units are correct."
3252 .to_string(),
3253 ));
3254 }
3255
3256 let lo = (best_bc - 0.01).max(bc_min);
3258 let hi = (best_bc + 0.01).min(bc_max);
3259 let mut bc = lo;
3260 while bc <= hi + 1e-9 {
3261 if let Some(s) = sse(bc) {
3262 if s < best_sse {
3263 best_sse = s;
3264 best_bc = bc;
3265 }
3266 }
3267 bc += 0.001;
3268 }
3269
3270 let at_bound = best_bc <= bc_min + 0.011 || best_bc >= bc_max - 0.011;
3273 let rms_error = (best_sse / points.len() as f64).sqrt();
3276 Ok(BcEstimate {
3277 bc: best_bc,
3278 rms_error,
3279 drag_model,
3280 mode,
3281 at_bound,
3282 })
3283}
3284
3285pub fn estimate_bc_from_trajectory(
3288 velocity: f64,
3289 mass: f64,
3290 diameter: f64,
3291 points: &[(f64, f64)], ) -> Result<f64, BallisticsError> {
3293 estimate_bc_fit(
3294 velocity,
3295 mass,
3296 diameter,
3297 points,
3298 DragModel::G1,
3299 BcFitMode::Drop,
3300 AtmosphericConditions::default(),
3301 None,
3302 0.05,
3303 )
3304 .map(|e| e.bc)
3305}
3306
3307use rand;
3309use rand_distr;
3310
3311#[cfg(test)]
3312mod trajectory_point_budget_tests {
3313 use super::*;
3314
3315 fn solver_with_budget(
3316 use_rk4: bool,
3317 use_adaptive_rk45: bool,
3318 point_budget: usize,
3319 max_range: f64,
3320 ) -> TrajectorySolver {
3321 let inputs = BallisticInputs {
3322 use_rk4,
3323 use_adaptive_rk45,
3324 ground_threshold: f64::NEG_INFINITY,
3325 ..BallisticInputs::default()
3326 };
3327 let mut solver = TrajectorySolver::new(
3328 inputs,
3329 WindConditions::default(),
3330 AtmosphericConditions::default(),
3331 );
3332 solver.max_trajectory_points = point_budget;
3333 solver.set_max_range(max_range);
3334 solver.set_time_step(0.001);
3335 solver
3336 }
3337
3338 #[test]
3339 fn mba1283_every_solver_errors_instead_of_exceeding_point_budget() {
3340 for (mode, use_rk4, use_adaptive_rk45) in [
3341 ("Euler", false, false),
3342 ("RK4", true, false),
3343 ("RK45", true, true),
3344 ] {
3345 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 3, 10.0)
3346 .solve()
3347 .expect_err("a solve requiring more than three points must fail");
3348 assert!(
3349 error.to_string().contains("point limit of 3"),
3350 "unexpected {mode} point-budget error: {error}"
3351 );
3352 }
3353 }
3354
3355 #[test]
3356 fn mba1283_interpolated_endpoint_counts_toward_point_budget() {
3357 for (mode, use_rk4, use_adaptive_rk45) in [
3358 ("Euler", false, false),
3359 ("RK4", true, false),
3360 ("RK45", true, true),
3361 ] {
3362 let result = solver_with_budget(use_rk4, use_adaptive_rk45, 2, 0.1)
3363 .solve()
3364 .expect("the initial point plus exact endpoint fit a two-point budget");
3365 assert_eq!(result.points.len(), 2, "unexpected {mode} point count");
3366
3367 let error = solver_with_budget(use_rk4, use_adaptive_rk45, 1, 0.1)
3368 .solve()
3369 .expect_err("the exact endpoint must not exceed a one-point budget");
3370 assert!(
3371 error.to_string().contains("point limit of 1"),
3372 "unexpected {mode} endpoint-budget error: {error}"
3373 );
3374 }
3375 }
3376}
3377
3378#[cfg(test)]
3379mod monte_carlo_result_tests {
3380 use super::*;
3381
3382 fn make_results(impact_positions: Vec<Vector3<f64>>) -> MonteCarloResults {
3383 let count = impact_positions.len();
3384 MonteCarloResults {
3385 ranges: vec![500.0; count],
3386 impact_velocities: vec![300.0; count],
3387 impact_positions,
3388 }
3389 }
3390
3391 #[test]
3392 fn target_plane_cep_excludes_shortfall_markers() {
3393 let mut positions: Vec<Vector3<f64>> = (1..=5)
3394 .map(|radius| Vector3::new(0.0, radius as f64, 0.0))
3395 .collect();
3396 positions.extend(
3397 (0..5).map(|_| Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)),
3398 );
3399 let results = make_results(positions);
3400
3401 assert_eq!(results.target_arrival_count(), 5);
3402 assert_eq!(results.target_shortfall_fraction(), 0.5);
3403 assert_eq!(results.target_plane_cep(), Some(3.0));
3404
3405 let one_shortfall = make_results(vec![
3406 Vector3::new(0.0, 1.0, 0.0),
3407 Vector3::new(0.0, 2.0, 0.0),
3408 Vector3::new(0.0, 3.0, 0.0),
3409 Vector3::new(0.0, 4.0, 0.0),
3410 Vector3::new(0.0, 5.0, 0.0),
3411 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
3412 ]);
3413 assert_eq!(one_shortfall.target_plane_cep(), Some(3.0));
3414 }
3415
3416 #[test]
3417 fn all_shortfalls_have_no_cep_but_still_count_as_misses() {
3418 let all_shortfalls = make_results(vec![
3419 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
3420 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
3421 ]);
3422 assert_eq!(all_shortfalls.target_arrival_count(), 0);
3423 assert_eq!(all_shortfalls.target_shortfall_fraction(), 1.0);
3424 assert_eq!(all_shortfalls.target_plane_cep(), None);
3425 assert_eq!(all_shortfalls.hit_probability(0.3), 0.0);
3426
3427 let one_hit_one_shortfall = make_results(vec![
3428 Vector3::new(0.0, 0.1, 0.0),
3429 Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
3430 ]);
3431 assert_eq!(one_hit_one_shortfall.hit_probability(0.3), 0.5);
3432 }
3433}
3434
3435#[cfg(test)]
3436mod monte_carlo_powder_curve_tests {
3437 use super::*;
3438
3439 #[test]
3440 fn powder_curve_preserves_sampled_muzzle_velocity_dispersion() {
3441 let inputs = BallisticInputs {
3442 muzzle_velocity: 700.0,
3443 powder_temp_curve: Some(vec![(15.0, 800.0)]),
3444 powder_curve_temp_c: Some(15.0),
3445 ..BallisticInputs::default()
3446 };
3447 let params = MonteCarloParams {
3448 num_simulations: 16,
3449 velocity_std_dev: 20.0,
3450 angle_std_dev: 1e-12,
3451 bc_std_dev: 1e-12,
3452 wind_speed_std_dev: 1e-12,
3453 target_distance: Some(100.0),
3454 azimuth_std_dev: 1e-12,
3455 ..MonteCarloParams::default()
3456 };
3457
3458 let results = run_monte_carlo(inputs, params).expect("Monte Carlo solve");
3459 let min_velocity = results
3460 .impact_velocities
3461 .iter()
3462 .copied()
3463 .fold(f64::INFINITY, f64::min);
3464 let max_velocity = results
3465 .impact_velocities
3466 .iter()
3467 .copied()
3468 .fold(f64::NEG_INFINITY, f64::max);
3469
3470 assert!(
3471 max_velocity - min_velocity > 1.0,
3472 "20 m/s muzzle spread collapsed after curve resolution: impact-velocity span={} m/s",
3473 max_velocity - min_velocity
3474 );
3475 }
3476}
3477
3478#[cfg(test)]
3479mod monte_carlo_wind_sampling_tests {
3480 use super::*;
3481 use rand::{rngs::StdRng, SeedableRng};
3482
3483 #[test]
3484 fn wind_speed_sigma_does_not_change_seeded_direction_draws() {
3485 let base_wind = WindConditions {
3486 speed: 100.0,
3487 direction: 0.37,
3488 vertical_speed: 0.0,
3489 };
3490 let narrow_speed = MonteCarloWindSampler::new(&base_wind, 0.5, 0.2).unwrap();
3491 let wide_speed = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
3492 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
3493 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
3494 let mut speed_changed = false;
3495
3496 for _ in 0..32 {
3497 let narrow = narrow_speed.sample(&mut narrow_rng);
3498 let wide = wide_speed.sample(&mut wide_rng);
3499 assert!(narrow.speed > 0.0 && wide.speed > 0.0);
3500 assert_eq!(narrow.direction.to_bits(), wide.direction.to_bits());
3501 speed_changed |= narrow.speed.to_bits() != wide.speed.to_bits();
3502 }
3503 assert!(
3504 speed_changed,
3505 "different speed sigmas must still vary speed draws"
3506 );
3507 }
3508
3509 #[test]
3510 fn zero_direction_sigma_has_no_angular_jitter() {
3511 let base_wind = WindConditions {
3512 speed: 100.0,
3513 direction: 0.37,
3514 vertical_speed: 0.0,
3515 };
3516 let sampler = MonteCarloWindSampler::new(&base_wind, 4.0, 0.0).unwrap();
3517 let mut rng = StdRng::seed_from_u64(0x5EED_1223);
3518 let mut speed_changed = false;
3519
3520 for _ in 0..32 {
3521 let wind = sampler.sample(&mut rng);
3522 speed_changed |= wind.speed.to_bits() != base_wind.speed.to_bits();
3523 assert_eq!(wind.direction.to_bits(), base_wind.direction.to_bits());
3524 }
3525 assert!(speed_changed, "speed uncertainty should remain active");
3526 }
3527
3528 #[test]
3529 fn direction_sigma_controls_seeded_angular_spread_in_radians() {
3530 let base_wind = WindConditions {
3531 speed: 100.0,
3532 direction: 0.37,
3533 vertical_speed: 0.0,
3534 };
3535 let narrow = MonteCarloWindSampler::new(&base_wind, 4.0, 0.1).unwrap();
3536 let wide = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
3537 let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
3538 let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
3539 let mut nonzero_direction_draw = false;
3540
3541 for _ in 0..32 {
3542 let narrow_wind = narrow.sample(&mut narrow_rng);
3543 let wide_wind = wide.sample(&mut wide_rng);
3544 assert_eq!(narrow_wind.speed.to_bits(), wide_wind.speed.to_bits());
3545
3546 let narrow_delta = narrow_wind.direction - base_wind.direction;
3547 let wide_delta = wide_wind.direction - base_wind.direction;
3548 assert!((wide_delta - 2.0 * narrow_delta).abs() < 1e-12);
3549 nonzero_direction_draw |= narrow_delta.abs() > 1e-6;
3550 }
3551 assert!(
3552 nonzero_direction_draw,
3553 "positive radians sigma must vary direction"
3554 );
3555 }
3556
3557 #[test]
3558 fn direction_sigma_rejects_negative_or_nonfinite_values() {
3559 let base_wind = WindConditions::default();
3560 for sigma in [-0.1, f64::NAN, f64::INFINITY] {
3561 assert!(MonteCarloWindSampler::new(&base_wind, 1.0, sigma).is_err());
3562 }
3563 }
3564
3565 #[test]
3566 fn base_vertical_wind_rides_into_every_mc_sample() {
3567 use rand::SeedableRng;
3571 let base_wind = WindConditions { vertical_speed: 4.2, ..Default::default() };
3572 let sampler = MonteCarloWindSampler::new(&base_wind, 1.0, 0.2).unwrap();
3573 let mut rng = rand::rngs::StdRng::seed_from_u64(7);
3574 for _ in 0..32 {
3575 let w = sampler.sample(&mut rng);
3576 assert_eq!(w.vertical_speed, 4.2);
3577 }
3578 }
3579
3580 #[test]
3581 fn negative_speed_sample_reverses_wind_direction() {
3582 let direction = 0.25;
3583 let signed_speed = -2.5;
3584 let wind = wind_from_signed_speed_sample(signed_speed, direction, 0.0);
3585 let positive_wind = wind_from_signed_speed_sample(2.5, direction, 0.0);
3586
3587 assert_eq!(wind.speed, 2.5);
3588 assert!(
3589 (wind.direction - (direction + std::f64::consts::PI)).abs() < f64::EPSILON,
3590 "negative speed must reverse direction by pi: got {}",
3591 wind.direction
3592 );
3593 assert_eq!(positive_wind.speed, 2.5);
3594 assert_eq!(positive_wind.direction, direction);
3595
3596 let normalized_x = -wind.speed * wind.direction.cos();
3597 let normalized_z = -wind.speed * wind.direction.sin();
3598 let signed_x = -signed_speed * direction.cos();
3599 let signed_z = -signed_speed * direction.sin();
3600 assert!((normalized_x - signed_x).abs() < 1e-12);
3601 assert!((normalized_z - signed_z).abs() < 1e-12);
3602 }
3603}
3604
3605#[cfg(test)]
3606mod bc_fit_objective_tests {
3607 use super::*;
3608
3609 fn velocity_point(range_m: f64, velocity_mps: f64) -> TrajectoryPoint {
3610 TrajectoryPoint {
3611 time: 0.0,
3612 position: Vector3::new(range_m, 0.0, 0.0),
3613 velocity_magnitude: velocity_mps,
3614 kinetic_energy: 0.0,
3615 }
3616 }
3617
3618 #[test]
3619 fn candidate_that_misses_an_observation_has_no_score() {
3620 let trajectory = vec![velocity_point(0.0, 800.0), velocity_point(100.0, 700.0)];
3621 let observations = vec![(50.0, 750.0), (150.0, 600.0)];
3622
3623 assert!(
3624 fit_residual_sse(&trajectory, &observations, BcFitMode::Velocity, 0.0).is_none(),
3625 "a candidate that reaches only one of two observations must not compete on partial SSE"
3626 );
3627
3628 let complete_observations = vec![(50.0, 740.0), (100.0, 680.0)];
3629 assert_eq!(
3630 fit_residual_sse(
3631 &trajectory,
3632 &complete_observations,
3633 BcFitMode::Velocity,
3634 0.0,
3635 ),
3636 Some(500.0)
3637 );
3638 }
3639}
3640
3641#[cfg(test)]
3642mod cluster_bc_reference_space_tests {
3643 use super::*;
3644
3645 fn acceleration_at_1100_fps(inputs: BallisticInputs) -> Vector3<f64> {
3646 let solver = TrajectorySolver::new(
3647 inputs,
3648 WindConditions::default(),
3649 AtmosphericConditions::default(),
3650 );
3651 let position = Vector3::zeros();
3652 let velocity = Vector3::new(1100.0 / 3.28084, 0.0, 0.0);
3653 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
3654 solver.calculate_acceleration(
3655 &position,
3656 &velocity,
3657 &Vector3::zeros(),
3658 (temp_c, pressure_hpa, density / 1.225),
3659 )
3660 }
3661
3662 #[test]
3663 fn solver_passes_g7_reference_model_to_cluster_classifier() {
3664 let mut inputs = BallisticInputs::default();
3665 inputs.bc_value = 0.190;
3666 inputs.bc_type = DragModel::G7;
3667 inputs.bullet_mass = 77.0 * 0.00006479891;
3668 inputs.bullet_diameter = 0.224 * 0.0254;
3669 inputs.use_cluster_bc = true;
3670
3671 let solver = TrajectorySolver::new(
3672 inputs,
3673 WindConditions::default(),
3674 AtmosphericConditions::default(),
3675 );
3676 let corrected = solver.apply_cluster_bc_correction(0.190, 2800.0);
3677
3678 assert!(
3679 (corrected / 0.190 - 1.004).abs() < 1e-12,
3680 "solver selected the wrong G7 cluster multiplier: {}",
3681 corrected / 0.190
3682 );
3683 }
3684
3685 #[test]
3686 fn velocity_bc_segments_are_not_cluster_corrected_twice() {
3687 let segmented_clustered = BallisticInputs {
3688 bc_value: 0.5,
3689 bc_type: DragModel::G7,
3690 use_bc_segments: true,
3691 bc_segments_data: Some(vec![
3692 crate::BCSegmentData {
3693 velocity_min: 0.0,
3694 velocity_max: 1_600.0,
3695 bc_value: 0.4,
3696 },
3697 crate::BCSegmentData {
3698 velocity_min: 1_600.0,
3699 velocity_max: 5_000.0,
3700 bc_value: 0.45,
3701 },
3702 ]),
3703 use_cluster_bc: true,
3704 ..BallisticInputs::default()
3705 };
3706 let mut segmented_only = segmented_clustered.clone();
3707 segmented_only.use_cluster_bc = false;
3708 let mut constant_clustered = segmented_clustered.clone();
3709 constant_clustered.bc_value = 0.4;
3710 constant_clustered.bc_segments_data = None;
3711
3712 let stacked = acceleration_at_1100_fps(segmented_clustered);
3713 let segment_only = acceleration_at_1100_fps(segmented_only);
3714 let cluster_only = acceleration_at_1100_fps(constant_clustered);
3715
3716 assert!(
3717 (stacked.x - segment_only.x).abs() < 1e-12,
3718 "segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
3719 stacked.x,
3720 segment_only.x
3721 );
3722 assert!(
3723 (cluster_only.x - segment_only.x).abs() > 1e-6,
3724 "cluster correction must remain active for a constant BC"
3725 );
3726 }
3727
3728 #[test]
3729 fn mach_bc_segments_are_not_cluster_corrected_twice() {
3730 let mach_segmented_clustered = BallisticInputs {
3731 bc_value: 0.5,
3732 bc_type: DragModel::G7,
3733 use_bc_segments: false,
3734 bc_segments: Some(vec![(0.5, 0.3), (1.5, 0.5)]),
3735 use_cluster_bc: true,
3736 ..BallisticInputs::default()
3737 };
3738 let mut mach_segmented_only = mach_segmented_clustered.clone();
3739 mach_segmented_only.use_cluster_bc = false;
3740
3741 let stacked = acceleration_at_1100_fps(mach_segmented_clustered);
3742 let segment_only = acceleration_at_1100_fps(mach_segmented_only);
3743
3744 assert!(
3745 (stacked.x - segment_only.x).abs() < 1e-12,
3746 "Mach segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
3747 stacked.x,
3748 segment_only.x
3749 );
3750 }
3751}
3752
3753#[cfg(test)]
3754mod velocity_bc_flag_tests {
3755 use super::*;
3756
3757 fn acceleration_at_600_mps(inputs: BallisticInputs) -> Vector3<f64> {
3758 let solver = TrajectorySolver::new(
3759 inputs,
3760 WindConditions::default(),
3761 AtmosphericConditions::default(),
3762 );
3763 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
3764 solver.calculate_acceleration(
3765 &Vector3::zeros(),
3766 &Vector3::new(600.0, 0.0, 0.0),
3767 &Vector3::zeros(),
3768 (temp_c, pressure_hpa, density / 1.225),
3769 )
3770 }
3771
3772 #[test]
3773 fn velocity_bc_data_requires_opt_in_in_trajectory_solver() {
3774 let scalar_inputs = BallisticInputs {
3775 bc_value: 0.5,
3776 bc_type: DragModel::G7,
3777 ..BallisticInputs::default()
3778 };
3779 let mut disabled_inputs = scalar_inputs.clone();
3780 disabled_inputs.bc_segments_data = Some(vec![crate::BCSegmentData {
3781 velocity_min: 0.0,
3782 velocity_max: 4_000.0,
3783 bc_value: 0.46,
3784 }]);
3785 disabled_inputs.use_bc_segments = false;
3786 let mut enabled_inputs = disabled_inputs.clone();
3787 enabled_inputs.use_bc_segments = true;
3788 let mut mach_only_inputs = scalar_inputs.clone();
3789 mach_only_inputs.bc_segments = Some(vec![(0.0, 0.4), (3.0, 0.4)]);
3790 let mut disabled_with_both = mach_only_inputs.clone();
3791 disabled_with_both.bc_segments_data = disabled_inputs.bc_segments_data.clone();
3792
3793 let scalar = acceleration_at_600_mps(scalar_inputs);
3794 let disabled = acceleration_at_600_mps(disabled_inputs);
3795 let enabled = acceleration_at_600_mps(enabled_inputs);
3796 let mach_only = acceleration_at_600_mps(mach_only_inputs);
3797 let disabled_with_both = acceleration_at_600_mps(disabled_with_both);
3798
3799 assert_eq!(
3800 disabled.x.to_bits(),
3801 scalar.x.to_bits(),
3802 "a populated velocity table must not change drag while use_bc_segments is false"
3803 );
3804 assert!(
3805 enabled.x < disabled.x - 1.0,
3806 "enabling the lower BC table must increase drag: disabled ax={} enabled ax={}",
3807 disabled.x,
3808 enabled.x
3809 );
3810 assert_eq!(
3811 disabled_with_both.x.to_bits(),
3812 mach_only.x.to_bits(),
3813 "disabling velocity data must fall through to an explicit Mach table"
3814 );
3815 }
3816}
3817
3818#[cfg(test)]
3819mod mach_bc_segment_tests {
3820 use super::*;
3821
3822 #[test]
3823 fn trajectory_solver_interpolates_explicit_mach_bc_segments() {
3824 let segmented_inputs = BallisticInputs {
3825 bc_value: 0.8,
3826 use_bc_segments: false,
3827 bc_segments: Some(vec![(1.0, 0.2), (2.0, 0.4)]),
3828 bc_segments_data: None,
3829 ..BallisticInputs::default()
3830 };
3831
3832 let mut expected_inputs = segmented_inputs.clone();
3833 expected_inputs.bc_value = 0.3;
3834 expected_inputs.bc_segments = None;
3835
3836 let atmosphere = AtmosphericConditions::default();
3837 let segmented_solver = TrajectorySolver::new(
3838 segmented_inputs,
3839 WindConditions::default(),
3840 atmosphere.clone(),
3841 );
3842 let expected_solver = TrajectorySolver::new(
3843 expected_inputs,
3844 WindConditions::default(),
3845 atmosphere,
3846 );
3847 let position = Vector3::zeros();
3848 let (density, _, temp_c, pressure_hpa) = segmented_solver.resolved_atmosphere();
3849 let (_, local_speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
3850 segmented_solver.atmosphere.altitude,
3851 segmented_solver.atmosphere.altitude,
3852 temp_c,
3853 pressure_hpa,
3854 density / 1.225,
3855 segmented_solver.atmosphere.humidity,
3856 );
3857 let velocity = Vector3::new(1.5 * local_speed_of_sound, 0.0, 0.0);
3858 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
3859
3860 let segmented_acceleration = segmented_solver.calculate_acceleration(
3861 &position,
3862 &velocity,
3863 &Vector3::zeros(),
3864 resolved_atmo,
3865 );
3866 let expected_acceleration = expected_solver.calculate_acceleration(
3867 &position,
3868 &velocity,
3869 &Vector3::zeros(),
3870 resolved_atmo,
3871 );
3872
3873 assert!(
3874 (segmented_acceleration.x - expected_acceleration.x).abs() < 1e-12,
3875 "Mach 1.5 must interpolate BC 0.3: segmented ax={} expected ax={}",
3876 segmented_acceleration.x,
3877 expected_acceleration.x
3878 );
3879 }
3880}
3881
3882#[cfg(test)]
3883mod custom_drag_table_validation_tests {
3884 use super::*;
3885
3886 #[test]
3887 fn solve_accepts_zero_bc_when_custom_table_present() {
3888 let mut inputs = BallisticInputs::default();
3889 inputs.bc_value = 0.0; inputs.bullet_mass = 0.0106;
3891 inputs.bullet_diameter = 0.00782;
3892 inputs.muzzle_velocity = 850.0;
3893 inputs.custom_drag_table = Some(crate::drag::DragTable::new(
3894 vec![0.5, 1.0, 2.0, 3.0],
3895 vec![0.23, 0.40, 0.30, 0.26],
3896 ));
3897 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
3898 assert!(solver.solve().is_ok());
3900 }
3901
3902 #[test]
3903 fn solve_still_requires_bc_without_table() {
3904 let mut inputs = BallisticInputs::default();
3905 inputs.bc_value = 0.0;
3906 inputs.bullet_mass = 0.0106;
3907 inputs.bullet_diameter = 0.00782;
3908 inputs.muzzle_velocity = 850.0;
3909 let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
3910 assert!(solver.solve().is_err());
3911 }
3912}
3913
3914#[cfg(test)]
3915mod humid_local_mach_tests {
3916 use super::*;
3917
3918 fn solver_with_station_humidity(humidity_percent: f64) -> TrajectorySolver {
3919 let inputs = BallisticInputs {
3920 custom_drag_table: Some(crate::drag::DragTable::new(vec![0.5, 1.5], vec![0.1, 1.1])),
3921 ..BallisticInputs::default()
3922 };
3923 TrajectorySolver::new(
3924 inputs,
3925 WindConditions::default(),
3926 AtmosphericConditions {
3927 temperature: 30.0,
3928 pressure: 1013.25,
3929 humidity: humidity_percent,
3930 altitude: 0.0,
3931 },
3932 )
3933 }
3934
3935 fn acceleration(solver: &TrajectorySolver, base_ratio: f64) -> Vector3<f64> {
3936 solver.calculate_acceleration(
3937 &Vector3::zeros(),
3938 &Vector3::new(350.0, 0.0, 0.0),
3939 &Vector3::zeros(),
3940 (30.0, 1013.25, base_ratio),
3941 )
3942 }
3943
3944 #[test]
3945 fn local_mach_uses_station_humidity_when_density_is_held_constant() {
3946 let dry = acceleration(&solver_with_station_humidity(0.0), 1.0);
3947 let humid = acceleration(&solver_with_station_humidity(100.0), 1.0);
3948
3949 assert!(
3950 humid.x > dry.x,
3951 "humid sound speed should lower Mach and drag on the rising test curve: dry ax={} humid ax={}",
3952 dry.x,
3953 humid.x
3954 );
3955 }
3956
3957 #[test]
3958 fn active_atmosphere_zone_uses_zone_humidity_instead_of_station_humidity() {
3959 let zone_humidity = 80.0;
3960 let zone_ratio =
3961 crate::atmosphere::calculate_air_density_cimp(30.0, 1013.25, zone_humidity) / 1.225;
3962 let station_solver = solver_with_station_humidity(zone_humidity);
3963 let mut zoned_solver = solver_with_station_humidity(0.0);
3964 zoned_solver.set_atmo_segments(vec![(30.0, 1013.25, zone_humidity, 1_000.0)]);
3965
3966 let station = acceleration(&station_solver, zone_ratio);
3967 let zoned = acceleration(&zoned_solver, zone_ratio);
3968
3969 assert!(
3970 (zoned - station).norm() < 1e-12,
3971 "active zone T/P/RH should override the station atmosphere: station={station:?} zoned={zoned:?}"
3972 );
3973 }
3974}
3975
3976#[cfg(test)]
3977mod inclined_atmosphere_frame_tests {
3978 use super::*;
3979
3980 fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
3981 let (sin_angle, cos_angle) = angle.sin_cos();
3982 Vector3::new(
3983 level.x * cos_angle + level.y * sin_angle,
3984 -level.x * sin_angle + level.y * cos_angle,
3985 level.z,
3986 )
3987 }
3988
3989 #[test]
3990 fn inclined_positions_at_same_world_altitude_have_same_solver_acceleration() {
3991 let angle = std::f64::consts::FRAC_PI_6;
3992 let inputs = BallisticInputs {
3993 shooting_angle: angle,
3994 ..BallisticInputs::default()
3995 };
3996 let atmosphere = AtmosphericConditions {
3997 altitude: 100.0,
3998 ..AtmosphericConditions::default()
3999 };
4000 let solver = TrajectorySolver::new(inputs, WindConditions::default(), atmosphere);
4001 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4002 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
4003 let velocity = Vector3::new(600.0, 0.0, 0.0);
4004 let along_slant = Vector3::new(1_000.0, 0.0, 0.0);
4005 let across_slant = Vector3::new(0.0, 500.0 / angle.cos(), 0.0);
4006
4007 let a = solver.calculate_acceleration(
4008 &along_slant,
4009 &velocity,
4010 &Vector3::zeros(),
4011 resolved_atmo,
4012 );
4013 let b = solver.calculate_acceleration(
4014 &across_slant,
4015 &velocity,
4016 &Vector3::zeros(),
4017 resolved_atmo,
4018 );
4019
4020 assert!(
4021 (a - b).norm() < 1e-10,
4022 "solver acceleration differs at equal world altitude: {a:?} vs {b:?}"
4023 );
4024 }
4025
4026 #[test]
4027 fn inclined_headwind_is_rotated_into_solver_frame() {
4028 let angle = std::f64::consts::FRAC_PI_6;
4029 let inputs = BallisticInputs {
4030 shooting_angle: angle,
4031 ..BallisticInputs::default()
4032 };
4033 let solver = TrajectorySolver::new(
4034 inputs,
4035 WindConditions::default(),
4036 AtmosphericConditions::default(),
4037 );
4038 let level_headwind = Vector3::new(-100.0, 0.0, 0.0);
4039 let velocity = expected_shot_frame_vector(level_headwind, angle);
4040 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4041 let actual = solver.calculate_acceleration(
4042 &Vector3::zeros(),
4043 &velocity,
4044 &level_headwind,
4045 (temp_c, pressure_hpa, density / 1.225),
4046 );
4047
4048 assert!(
4049 (actual - solver.gravity_acceleration()).norm() < 1e-12,
4050 "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
4051 );
4052 }
4053
4054 #[test]
4055 fn inclined_coriolis_is_rotated_into_solver_frame() {
4056 let angle = std::f64::consts::FRAC_PI_6;
4057 let latitude_deg = 45.0_f64;
4058 let shot_azimuth = 0.4_f64;
4059 let velocity = Vector3::new(600.0, 20.0, 5.0);
4060 let base_inputs = BallisticInputs {
4061 shooting_angle: angle,
4062 latitude: Some(latitude_deg),
4063 shot_azimuth,
4064 ..BallisticInputs::default()
4065 };
4066 let acceleration = |enable_coriolis| {
4067 let mut inputs = base_inputs.clone();
4068 inputs.enable_coriolis = enable_coriolis;
4069 let solver = TrajectorySolver::new(
4070 inputs,
4071 WindConditions::default(),
4072 AtmosphericConditions::default(),
4073 );
4074 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4075 solver.calculate_acceleration(
4076 &Vector3::zeros(),
4077 &velocity,
4078 &Vector3::zeros(),
4079 (temp_c, pressure_hpa, density / 1.225),
4080 )
4081 };
4082
4083 let omega_earth = 7.2921159e-5_f64;
4084 let latitude = latitude_deg.to_radians();
4085 let level_omega = Vector3::new(
4086 omega_earth * latitude.cos() * shot_azimuth.cos(),
4087 omega_earth * latitude.sin(),
4088 -omega_earth * latitude.cos() * shot_azimuth.sin(),
4089 );
4090 let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
4091 let actual = acceleration(true) - acceleration(false);
4092
4093 assert!(
4094 (actual - expected).norm() < 1e-12,
4095 "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
4096 );
4097 }
4098}
4099
4100#[cfg(test)]
4101mod terminal_range_interpolation_tests {
4102 use super::*;
4103
4104 #[test]
4105 fn every_solver_appends_an_exact_max_range_endpoint() {
4106 let target_range = 0.1;
4107 let modes = [
4108 ("Euler", false, false),
4109 ("RK4", true, false),
4110 ("RK45", true, true),
4111 ];
4112
4113 for (name, use_rk4, use_adaptive_rk45) in modes {
4114 let inputs = BallisticInputs {
4115 use_rk4,
4116 use_adaptive_rk45,
4117 ground_threshold: f64::NEG_INFINITY,
4118 enable_trajectory_sampling: true,
4119 sample_interval: target_range,
4120 ..BallisticInputs::default()
4121 };
4122 let mut solver = TrajectorySolver::new(
4123 inputs,
4124 WindConditions::default(),
4125 AtmosphericConditions::default(),
4126 );
4127 solver.set_max_range(target_range);
4128
4129 let result = solver.solve().expect("short-range solve should succeed");
4130 let terminal = result.points.last().expect("terminal point is missing");
4131 let muzzle = result.points.first().expect("muzzle point is missing");
4132
4133 assert_eq!(
4134 terminal.position.x.to_bits(),
4135 target_range.to_bits(),
4136 "{name} did not terminate exactly at max_range"
4137 );
4138 assert_eq!(result.max_range.to_bits(), target_range.to_bits());
4139 assert!(
4140 result.time_of_flight > 0.0 && result.time_of_flight < solver.time_step,
4141 "{name} terminal time was not interpolated within the crossing step: {}",
4142 result.time_of_flight
4143 );
4144 assert_eq!(result.time_of_flight.to_bits(), terminal.time.to_bits());
4145 assert_eq!(
4146 result.impact_velocity.to_bits(),
4147 terminal.velocity_magnitude.to_bits()
4148 );
4149 assert_eq!(
4150 result.impact_energy.to_bits(),
4151 terminal.kinetic_energy.to_bits()
4152 );
4153 let expected_energy = 0.5 * solver.inputs.bullet_mass * result.impact_velocity.powi(2);
4154 assert!((result.impact_energy - expected_energy).abs() < 1e-12);
4155 assert!(terminal.velocity_magnitude < muzzle.velocity_magnitude);
4156 assert!(terminal.kinetic_energy < muzzle.kinetic_energy);
4157
4158 let terminal_sample = result
4159 .sampled_points
4160 .as_ref()
4161 .and_then(|samples| samples.last())
4162 .expect("terminal trajectory sample is missing");
4163 assert_eq!(
4164 terminal_sample.distance_m.to_bits(),
4165 target_range.to_bits(),
4166 "{name} sampling did not include max_range"
4167 );
4168 assert_eq!(
4169 terminal_sample.time_s.to_bits(),
4170 result.time_of_flight.to_bits()
4171 );
4172 assert_eq!(
4173 terminal_sample.velocity_mps.to_bits(),
4174 result.impact_velocity.to_bits()
4175 );
4176 assert!((terminal_sample.energy_j - result.impact_energy).abs() < 1e-12);
4177 }
4178 }
4179}
4180
4181#[cfg(test)]
4182mod precession_inertia_wiring_tests {
4183 use super::*;
4184
4185 #[test]
4186 fn solver_uses_projectile_specific_moments_of_inertia() {
4187 let mass_kg = 55.0 * 0.00006479891;
4188 let caliber_m = 0.224 * 0.0254;
4189 let length_m = 0.75 * 0.0254;
4190 let inputs = BallisticInputs {
4191 bullet_mass: mass_kg,
4192 bullet_diameter: caliber_m,
4193 bullet_length: length_m,
4194 muzzle_velocity: 800.0,
4195 twist_rate: 7.0,
4196 enable_precession_nutation: true,
4197 use_rk4: false,
4198 use_adaptive_rk45: false,
4199 ..BallisticInputs::default()
4200 };
4201 let mut solver = TrajectorySolver::new(
4202 inputs,
4203 WindConditions::default(),
4204 AtmosphericConditions::default(),
4205 );
4206 solver.set_max_range(0.1);
4207
4208 let (air_density, speed_of_sound, _, _) = solver.resolved_atmosphere();
4209 let velocity_mps = solver.inputs.muzzle_velocity;
4210 let velocity_fps = velocity_mps * 3.28084;
4211 let twist_rate_ft = solver.inputs.twist_rate / 12.0;
4212 let spin_rate_rad_s = (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI;
4213 let initial_state = AngularState {
4214 pitch_angle: 0.001,
4215 yaw_angle: 0.001,
4216 pitch_rate: 0.0,
4217 yaw_rate: 0.0,
4218 precession_angle: 0.0,
4219 nutation_phase: 0.0,
4220 };
4221 let params = PrecessionNutationParams {
4222 mass_kg,
4223 caliber_m,
4224 length_m,
4225 spin_rate_rad_s,
4226 spin_inertia: crate::spin_decay::calculate_moment_of_inertia(
4227 mass_kg, caliber_m, length_m, "ogive",
4228 ),
4229 transverse_inertia: crate::pitch_damping::calculate_transverse_moment_of_inertia(
4230 mass_kg, caliber_m, length_m, "ogive",
4231 ),
4232 velocity_mps,
4233 air_density_kg_m3: air_density,
4234 mach: velocity_mps / speed_of_sound,
4235 pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
4236 nutation_damping_factor: 0.05,
4237 };
4238 let expected = calculate_combined_angular_motion(
4239 ¶ms,
4240 &initial_state,
4241 0.0,
4242 solver.time_step,
4243 0.001,
4244 );
4245 let actual = solver
4246 .solve()
4247 .expect("one-step solve should succeed")
4248 .angular_state
4249 .expect("precession/nutation was enabled");
4250
4251 assert!(
4252 (actual.precession_angle - expected.precession_angle).abs() < 1e-15,
4253 "precession phase used the wrong inertia: actual={}, expected={}",
4254 actual.precession_angle,
4255 expected.precession_angle
4256 );
4257 assert!(
4258 (actual.nutation_phase - expected.nutation_phase).abs() < 1e-15,
4259 "nutation phase used the wrong inertia: actual={}, expected={}",
4260 actual.nutation_phase,
4261 expected.nutation_phase
4262 );
4263 }
4264}
4265
4266#[cfg(test)]
4267mod form_factor_drag_tests {
4268 use super::*;
4269
4270 fn acceleration_with_form_factor_flag(enabled: bool) -> Vector3<f64> {
4271 let inputs = BallisticInputs {
4272 bc_value: 0.462,
4273 bc_type: DragModel::G1,
4274 bullet_model: Some("168gr SMK Match".to_string()),
4275 use_form_factor: enabled,
4276 ..BallisticInputs::default()
4277 };
4278 let solver = TrajectorySolver::new(
4279 inputs,
4280 WindConditions::default(),
4281 AtmosphericConditions::default(),
4282 );
4283 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4284 solver.calculate_acceleration(
4285 &Vector3::zeros(),
4286 &Vector3::new(600.0, 0.0, 0.0),
4287 &Vector3::zeros(),
4288 (temp_c, pressure_hpa, density / 1.225),
4289 )
4290 }
4291
4292 #[test]
4293 fn measured_bc_drag_does_not_apply_name_based_form_factor_again() {
4294 let baseline = acceleration_with_form_factor_flag(false);
4295 let flagged = acceleration_with_form_factor_flag(true);
4296
4297 assert!(
4298 (flagged - baseline).norm() < 1e-12,
4299 "published BC already encodes form factor: baseline={baseline:?} flagged={flagged:?}"
4300 );
4301 }
4302}
4303
4304#[cfg(test)]
4305mod rk45_adaptivity_tests {
4306 use super::*;
4307
4308 #[test]
4309 fn cli_rk45_error_norm_scales_components_independently() {
4310 let position = Vector3::new(1.0e9, 0.0, 0.0);
4311 let velocity = Vector3::new(800.0, 0.0, 0.0);
4312 let fifth_position = position;
4313 let fifth_velocity = velocity;
4314 let fourth_position = position;
4315 let fourth_velocity = Vector3::new(800.0, 1.0e-3, 0.0);
4316
4317 let error = cli_rk45_error_norm(
4318 &position,
4319 &velocity,
4320 &fifth_position,
4321 &fifth_velocity,
4322 &fourth_position,
4323 &fourth_velocity,
4324 );
4325 let expected = 1.0e-3 / 6.0_f64.sqrt();
4326
4327 assert!(
4328 (error - expected).abs() <= 1e-15,
4329 "large downrange position masked a velocity-component error: {error}"
4330 );
4331 }
4332
4333 fn discontinuous_wind_solver() -> TrajectorySolver {
4334 let inputs = BallisticInputs::default();
4335 let mut solver = TrajectorySolver::new(
4336 inputs,
4337 WindConditions::default(),
4338 AtmosphericConditions::default(),
4339 );
4340 solver.set_wind_segments(vec![
4341 crate::wind::WindSegment::new(0.0, 90.0, 4.0),
4342 crate::wind::WindSegment::new(1_000.0, 90.0, 10_000.0),
4343 ]);
4344 solver
4345 }
4346
4347 #[test]
4348 fn rk45_retries_discontinuous_trial_before_advancing() {
4349 let solver = discontinuous_wind_solver();
4350 let position = Vector3::new(0.0, solver.inputs.muzzle_height, 0.0);
4351 let velocity = Vector3::new(solver.inputs.muzzle_velocity, 0.0, 0.0);
4352 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4353 let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
4354 let dt = 0.01;
4355
4356 let rejected_trial = solver.rk45_step(
4357 &position,
4358 &velocity,
4359 dt,
4360 &Vector3::zeros(),
4361 RK45_TOLERANCE,
4362 resolved_atmo,
4363 );
4364 assert!(
4365 rejected_trial.error > RK45_TOLERANCE,
4366 "discontinuous full step must exceed tolerance, got {}",
4367 rejected_trial.error
4368 );
4369
4370 let accepted = solver.adaptive_rk45_step(
4371 &position,
4372 &velocity,
4373 dt,
4374 &Vector3::zeros(),
4375 resolved_atmo,
4376 );
4377 assert!(accepted.used_dt < dt, "oversized trial was not retried");
4378 assert!(
4379 accepted.error <= RK45_TOLERANCE || accepted.used_dt <= RK45_MIN_DT,
4380 "accepted error {} exceeds tolerance at dt {}",
4381 accepted.error,
4382 accepted.used_dt
4383 );
4384
4385 let accepted_trial = solver.rk45_step(
4386 &position,
4387 &velocity,
4388 accepted.used_dt,
4389 &Vector3::zeros(),
4390 RK45_TOLERANCE,
4391 resolved_atmo,
4392 );
4393 assert_eq!(accepted.position, accepted_trial.position);
4394 assert_eq!(accepted.velocity, accepted_trial.velocity);
4395 assert!((RK45_MIN_DT..=RK45_MAX_DT).contains(&accepted.next_dt));
4396 }
4397}
4398
4399#[cfg(test)]
4400mod ground_termination_tests {
4401 use super::*;
4402
4403 #[test]
4408 fn rk4_and_rk45_descend_to_ground_threshold() {
4409 for adaptive in [false, true] {
4410 let mut inputs = BallisticInputs::default();
4411 inputs.muzzle_angle = 0.1; inputs.use_rk4 = true;
4413 inputs.use_adaptive_rk45 = adaptive;
4414 assert_eq!(
4415 inputs.ground_threshold, -100.0,
4416 "default ground_threshold is -100 m"
4417 );
4418
4419 let mut solver = TrajectorySolver::new(
4420 inputs,
4421 WindConditions::default(),
4422 AtmosphericConditions::default(),
4423 );
4424 solver.set_max_range(1.0e7);
4426
4427 let result = solver.solve().expect("solve should succeed");
4428 let final_y = result
4429 .points
4430 .last()
4431 .expect("trajectory has points")
4432 .position
4433 .y;
4434 assert!(
4435 final_y < -1.0,
4436 "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
4437 past launch level toward the ground_threshold floor, not stop at y = 0"
4438 );
4439 }
4440 }
4441}
4442
4443#[cfg(test)]
4444mod magnus_stability_tests {
4445 use super::*;
4446
4447 #[test]
4448 fn yaw_of_repose_magnus_force_is_vertical_and_twist_signed() {
4449 let acceleration = |enable_magnus, is_twist_right| {
4450 let inputs = BallisticInputs {
4451 muzzle_velocity: 822.96,
4452 bullet_mass: 168.0 * 0.00006479891,
4453 bullet_diameter: 0.308 * 0.0254,
4454 bullet_length: 1.215 * 0.0254,
4455 twist_rate: 10.0,
4456 is_twist_right,
4457 enable_magnus,
4458 ..BallisticInputs::default()
4459 };
4460 let solver = TrajectorySolver::new(
4461 inputs,
4462 WindConditions::default(),
4463 AtmosphericConditions::default(),
4464 );
4465 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4466 solver.calculate_acceleration(
4467 &Vector3::zeros(),
4468 &Vector3::new(822.96, 0.0, 0.0),
4469 &Vector3::zeros(),
4470 (temp_c, pressure_hpa, density / 1.225),
4471 )
4472 };
4473
4474 let baseline = acceleration(false, true);
4475 let right_twist = acceleration(true, true) - baseline;
4476 let left_twist = acceleration(true, false) - baseline;
4477
4478 assert!(
4479 right_twist.y < 0.0,
4480 "right-hand Magnus must point down, got {right_twist:?}"
4481 );
4482 assert!(
4483 left_twist.y > 0.0,
4484 "left-hand Magnus must point up, got {left_twist:?}"
4485 );
4486 assert!((right_twist.y + left_twist.y).abs() < 1e-12);
4487 assert!(right_twist.x.abs() < 1e-12 && right_twist.z.abs() < 1e-12);
4488 assert!(left_twist.x.abs() < 1e-12 && left_twist.z.abs() < 1e-12);
4489 }
4490
4491 #[test]
4492 fn magnus_uses_velocity_corrected_muzzle_stability_gate() {
4493 let muzzle_velocity = 1_400.0 / 3.28084;
4494 let inputs = BallisticInputs {
4495 muzzle_velocity,
4496 bullet_mass: 168.0 * 0.00006479891,
4497 bullet_diameter: 0.308 * 0.0254,
4498 bullet_length: 1.215 * 0.0254,
4499 twist_rate: 15.0,
4500 enable_magnus: true,
4501 ..BallisticInputs::default()
4502 };
4503 let solver = TrajectorySolver::new(
4504 inputs.clone(),
4505 WindConditions::default(),
4506 AtmosphericConditions::default(),
4507 );
4508
4509 let bare_sg = crate::spin_drift::miller_stability(0.308, 168.0, 15.0, 1.215);
4510 let canonical_sg = solver.effective_spin_drift_sg();
4511 assert!(bare_sg > 1.0, "test requires bare Sg above the Magnus gate");
4512 assert!(
4513 canonical_sg < 1.0,
4514 "velocity-corrected Sg must be below the gate, got {canonical_sg}"
4515 );
4516
4517 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4518 let acceleration = solver.calculate_acceleration(
4519 &Vector3::zeros(),
4520 &Vector3::new(muzzle_velocity, 0.0, 0.0),
4521 &Vector3::zeros(),
4522 (temp_c, pressure_hpa, density / 1.225),
4523 );
4524 let mut baseline_inputs = inputs;
4525 baseline_inputs.enable_magnus = false;
4526 let baseline_solver = TrajectorySolver::new(
4527 baseline_inputs,
4528 WindConditions::default(),
4529 AtmosphericConditions::default(),
4530 );
4531 let baseline = baseline_solver.calculate_acceleration(
4532 &Vector3::zeros(),
4533 &Vector3::new(muzzle_velocity, 0.0, 0.0),
4534 &Vector3::zeros(),
4535 (temp_c, pressure_hpa, density / 1.225),
4536 );
4537
4538 assert_eq!(
4539 acceleration, baseline,
4540 "canonical Sg below 1 must suppress every Magnus acceleration component"
4541 );
4542 }
4543
4544 #[test]
4545 fn magnus_force_grows_as_fixed_spin_projectile_slows() {
4546 let inputs = BallisticInputs {
4547 muzzle_velocity: 800.0,
4548 bullet_mass: 168.0 * 0.00006479891,
4549 bullet_diameter: 0.308 * 0.0254,
4550 bullet_length: 1.215 * 0.0254,
4551 twist_rate: 12.0,
4552 enable_magnus: true,
4553 ..BallisticInputs::default()
4554 };
4555
4556 let magnus_acceleration = |speed_mps| {
4557 let evaluate = |enable_magnus| {
4558 let mut run_inputs = inputs.clone();
4559 run_inputs.enable_magnus = enable_magnus;
4560 let solver = TrajectorySolver::new(
4561 run_inputs,
4562 WindConditions::default(),
4563 AtmosphericConditions::default(),
4564 );
4565 let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4566 solver
4567 .calculate_acceleration(
4568 &Vector3::zeros(),
4569 &Vector3::new(speed_mps, 0.0, 0.0),
4570 &Vector3::zeros(),
4571 (temp_c, pressure_hpa, density / 1.225),
4572 )
4573 .y
4574 };
4575 (evaluate(true) - evaluate(false)).abs()
4576 };
4577
4578 let fast = magnus_acceleration(200.0);
4579 let slow = magnus_acceleration(100.0);
4580 let ratio = slow / fast;
4581 let expected_ratio = 2.0_f64.powf(5.0 / 3.0);
4582
4583 assert!(fast > 0.0 && slow > 0.0, "fast={fast}, slow={slow}");
4584 assert!(
4585 (ratio - expected_ratio).abs() < 1e-3,
4586 "fixed-spin Magnus acceleration must grow downrange; slow/fast={ratio}, \
4587 expected={expected_ratio}"
4588 );
4589 }
4590}
4591
4592#[cfg(test)]
4593mod coriolis_direction_tests {
4594 use super::*;
4595 use std::f64::consts::FRAC_PI_2;
4596
4597 #[test]
4598 fn supersonic_crossing_flags_a_positive_range_sample() {
4599 use crate::trajectory_sampling::TrajectoryFlag;
4603
4604 for (solver_name, use_rk4, use_adaptive_rk45) in [
4605 ("Euler", false, false),
4606 ("RK4", true, false),
4607 ("RK45", true, true),
4608 ] {
4609 let inputs = BallisticInputs {
4610 muzzle_velocity: 850.0,
4611 bc_value: 0.2,
4612 bc_type: DragModel::G7,
4613 muzzle_angle: 0.03,
4614 enable_trajectory_sampling: true,
4615 sample_interval: 50.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(2000.0);
4626 let samples = solver
4627 .solve()
4628 .expect("supersonic solve should succeed")
4629 .sampled_points
4630 .expect("sampling was enabled");
4631 let flagged_distances: Vec<_> = samples
4632 .iter()
4633 .filter(|sample| sample.flags.contains(&TrajectoryFlag::MachTransition))
4634 .map(|sample| sample.distance_m)
4635 .collect();
4636
4637 assert!(
4638 !flagged_distances.is_empty()
4639 && flagged_distances.iter().all(|distance| *distance > 0.0),
4640 "{solver_name} must flag genuine crossings only at positive range: {flagged_distances:?}"
4641 );
4642 }
4643 }
4644
4645 #[test]
4646 fn subsonic_launch_does_not_flag_a_muzzle_transition() {
4647 use crate::trajectory_sampling::TrajectoryFlag;
4648
4649 for (solver_name, use_rk4, use_adaptive_rk45) in [
4650 ("Euler", false, false),
4651 ("RK4", true, false),
4652 ("RK45", true, true),
4653 ] {
4654 let inputs = BallisticInputs {
4655 muzzle_velocity: 250.0,
4656 muzzle_angle: 0.02,
4657 enable_trajectory_sampling: true,
4658 sample_interval: 25.0,
4659 use_rk4,
4660 use_adaptive_rk45,
4661 ..BallisticInputs::default()
4662 };
4663 let mut solver = TrajectorySolver::new(
4664 inputs,
4665 WindConditions::default(),
4666 AtmosphericConditions::default(),
4667 );
4668 solver.set_max_range(300.0);
4669 let samples = solver
4670 .solve()
4671 .expect("subsonic solve should succeed")
4672 .sampled_points
4673 .expect("sampling was enabled");
4674
4675 assert!(
4676 samples
4677 .iter()
4678 .all(|sample| !sample.flags.contains(&TrajectoryFlag::MachTransition)),
4679 "{solver_name} marked a Mach transition for a launch already below Mach 1"
4680 );
4681 }
4682 }
4683
4684 #[test]
4685 fn mach_transition_tracker_requires_a_downward_crossing() {
4686 fn record(mach_values: &[f64]) -> Vec<f64> {
4687 let mut tracker = MachTransitionTracker::default();
4688 let mut distances = Vec::new();
4689 for (index, mach) in mach_values.iter().copied().enumerate() {
4690 tracker.record_downward_crossings(mach, index as f64 * 10.0, &mut distances);
4691 }
4692 distances
4693 }
4694
4695 assert!(record(&[0.9, 0.8, 0.7]).is_empty());
4696 assert_eq!(record(&[1.1, 1.05, 0.99]), vec![20.0]);
4697 assert_eq!(record(&[1.2, 1.19, 1.0, 0.99]), vec![10.0, 30.0]);
4698 assert_eq!(record(&[0.9, 1.3, 1.1, 0.9, 1.3, 0.8]), vec![20.0, 30.0]);
4699 assert!(record(&[1.3, f64::NAN, 1.1]).is_empty());
4700 }
4701
4702 #[test]
4703 fn humidity_percent_converts_and_clamps() {
4704 let mut i = BallisticInputs::default();
4706 i.humidity = 0.5;
4707 assert!((i.humidity_percent() - 50.0).abs() < 1e-9, "0.5 -> 50%");
4708 i.humidity = 0.0;
4709 assert_eq!(i.humidity_percent(), 0.0);
4710 i.humidity = 1.0;
4711 assert_eq!(i.humidity_percent(), 100.0);
4712 i.humidity = 1.5; assert_eq!(i.humidity_percent(), 100.0);
4714 }
4715
4716 fn vertical_at(shot_azimuth: f64, range_m: f64) -> f64 {
4719 let mut inputs = BallisticInputs::default();
4720 inputs.muzzle_velocity = 800.0;
4721 inputs.bc_value = 0.5;
4722 inputs.bc_type = DragModel::G7;
4723 inputs.muzzle_angle = 0.02; inputs.enable_coriolis = true;
4725 inputs.latitude = Some(45.0);
4726 inputs.shot_azimuth = shot_azimuth;
4727 inputs.ground_threshold = f64::NEG_INFINITY; let mut solver = TrajectorySolver::new(
4729 inputs,
4730 WindConditions::default(),
4731 AtmosphericConditions::default(),
4732 );
4733 solver.set_max_range(range_m + 50.0);
4734 let r = solver.solve().expect("solve");
4735 let pts = &r.points;
4736 for i in 1..pts.len() {
4737 if pts[i].position.x >= range_m {
4738 let p1 = &pts[i - 1];
4739 let p2 = &pts[i];
4740 let t = (range_m - p1.position.x) / (p2.position.x - p1.position.x);
4741 return p1.position.y + t * (p2.position.y - p1.position.y);
4742 }
4743 }
4744 panic!("range {range_m} not reached");
4745 }
4746
4747 #[test]
4752 fn eotvos_east_higher_than_west() {
4753 let range = 600.0;
4754 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!(
4758 east > west,
4759 "east ({east:.5}) must be higher than west ({west:.5}) at {range} m (Eötvös)"
4760 );
4761 assert!(
4762 east > north && north > west,
4763 "north ({north:.5}) must lie between east ({east:.5}) and west ({west:.5})"
4764 );
4765 assert!(
4766 (east - west) > 1e-3,
4767 "E-W vertical separation ({:.6} m) should be physically meaningful, not FP noise",
4768 east - west
4769 );
4770 }
4771}
4772
4773#[cfg(test)]
4774mod cant_tests {
4775 use super::*;
4776
4777 fn base_inputs() -> BallisticInputs {
4778 let mut i = BallisticInputs::default();
4779 i.muzzle_velocity = 800.0;
4780 i.bc_value = 0.5;
4781 i.bc_type = DragModel::G7;
4782 i.bullet_mass = 0.0109;
4783 i.bullet_diameter = 0.00782;
4784 i.bullet_length = 0.0309;
4785 i.sight_height = 0.05;
4786 i.twist_rate = 10.0;
4787 i.use_rk4 = true;
4788 i
4789 }
4790
4791 fn solve_with(inputs: BallisticInputs, max_range: f64) -> TrajectoryResult {
4792 let mut s = TrajectorySolver::new(
4793 inputs,
4794 WindConditions::default(),
4795 AtmosphericConditions::default(),
4796 );
4797 s.set_max_range(max_range);
4798 s.solve().expect("solve")
4799 }
4800
4801 fn yz_at(result: &TrajectoryResult, x: f64) -> (f64, f64) {
4803 let pts = &result.points;
4804 for i in 1..pts.len() {
4805 if pts[i].position.x >= x {
4806 let (p1, p2) = (&pts[i - 1], &pts[i]);
4807 let dx = p2.position.x - p1.position.x;
4808 let t = if dx.abs() < 1e-12 { 0.0 } else { (x - p1.position.x) / dx };
4809 return (
4810 p1.position.y + t * (p2.position.y - p1.position.y),
4811 p1.position.z + t * (p2.position.z - p1.position.z),
4812 );
4813 }
4814 }
4815 panic!("trajectory never reached {x} m");
4816 }
4817
4818 #[test]
4819 fn cant_sign_clockwise_up_offset_goes_right_and_low() {
4820 let mut level = base_inputs();
4822 level.muzzle_angle = 0.003; let mut canted = level.clone();
4824 canted.cant_angle = 10f64.to_radians();
4825
4826 let (y0, z0) = yz_at(&solve_with(level, 400.0), 300.0);
4827 let (y1, z1) = yz_at(&solve_with(canted, 400.0), 300.0);
4828 assert!(z1 > z0 + 0.01, "clockwise cant must move POI right: z0={z0} z1={z1}");
4829 assert!(y1 < y0 - 0.001, "clockwise cant must move POI low: y0={y0} y1={y1}");
4830 }
4831
4832 #[test]
4833 fn pure_cant_shows_bore_offset_near_range() {
4834 let mut i = base_inputs();
4837 i.muzzle_angle = 0.0;
4838 i.cant_angle = 10f64.to_radians();
4839 let sh = i.sight_height;
4840 let r = solve_with(i, 60.0);
4841 let first = &r.points[1]; let expected = -sh * 10f64.to_radians().sin();
4843 assert!(
4844 (first.position.z - expected).abs() < 0.005,
4845 "near-muzzle lateral {} should be ~bore offset {expected}",
4846 first.position.z
4847 );
4848 }
4849
4850 #[test]
4851 fn zero_angle_is_independent_of_cant() {
4852 let a = base_inputs();
4853 let mut b = base_inputs();
4854 b.cant_angle = 15f64.to_radians();
4855 let za = calculate_zero_angle(a.clone(), 100.0, 0.0).expect("zero a");
4856 let zb = calculate_zero_angle(b.clone(), 100.0, 0.0).expect("zero b");
4857 assert_eq!(za.to_bits(), zb.to_bits(), "zeroing must ignore cant: {za} vs {zb}");
4858 let _ = (a.cant_angle, b.cant_angle);
4860 }
4861
4862 #[test]
4863 fn nonfinite_cant_is_rejected() {
4864 let mut i = base_inputs();
4865 i.cant_angle = f64::NAN;
4866 let s = TrajectorySolver::new(i, WindConditions::default(), AtmosphericConditions::default());
4867 assert!(s.solve().is_err());
4868 }
4869
4870 #[test]
4871 fn incline_and_cant_compose_without_breaking() {
4872 let mut flat = base_inputs();
4874 flat.muzzle_angle = 0.003;
4875 flat.shooting_angle = 15f64.to_radians();
4876 let mut canted = flat.clone();
4877 canted.cant_angle = 10f64.to_radians();
4878 let (_, z_flat) = yz_at(&solve_with(flat, 400.0), 300.0);
4879 let (_, z_cant) = yz_at(&solve_with(canted, 400.0), 300.0);
4880 assert!(z_cant > z_flat, "cant must still deflect right on an incline");
4881 }
4882}
4883
4884#[cfg(test)]
4885mod vertical_wind_tests {
4886 use super::*;
4887
4888 fn base_inputs() -> BallisticInputs {
4889 let mut i = BallisticInputs::default();
4890 i.muzzle_velocity = 800.0;
4891 i.bc_value = 0.5;
4892 i.bc_type = DragModel::G7;
4893 i.bullet_mass = 0.0109;
4894 i.bullet_diameter = 0.00782;
4895 i.bullet_length = 0.0309;
4896 i.sight_height = 0.05;
4897 i.twist_rate = 10.0;
4898 i.use_rk4 = true;
4899 i
4900 }
4901
4902 fn y_at(result: &TrajectoryResult, x: f64) -> f64 {
4904 let pts = &result.points;
4905 for i in 1..pts.len() {
4906 if pts[i].position.x >= x {
4907 let (p1, p2) = (&pts[i - 1], &pts[i]);
4908 let dx = p2.position.x - p1.position.x;
4909 let t = if dx.abs() < 1e-12 { 0.0 } else { (x - p1.position.x) / dx };
4910 return p1.position.y + t * (p2.position.y - p1.position.y);
4911 }
4912 }
4913 panic!("trajectory never reached {x} m");
4914 }
4915
4916 fn solve_with(inputs: BallisticInputs, wind: WindConditions, max_range: f64) -> TrajectoryResult {
4917 let mut s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
4918 s.set_max_range(max_range);
4919 s.solve().expect("solve")
4920 }
4921
4922 #[test]
4923 fn updraft_raises_poi_downrange() {
4924 let calm_inputs = base_inputs();
4927 let calm_wind = WindConditions::default();
4928 let updraft = WindConditions {
4929 vertical_speed: 5.0,
4930 ..Default::default()
4931 };
4932
4933 let calm = solve_with(calm_inputs.clone(), calm_wind, 500.0);
4934 let updraft_result = solve_with(calm_inputs, updraft, 500.0);
4935
4936 let y_calm = y_at(&calm, 400.0);
4937 let y_updraft = y_at(&updraft_result, 400.0);
4938 assert!(
4939 y_updraft > y_calm,
4940 "5 m/s updraft must raise POI at 400m: calm={y_calm}, updraft={y_updraft}"
4941 );
4942 }
4943
4944 #[test]
4945 fn zero_vertical_is_default_and_finite_required() {
4946 assert_eq!(WindConditions::default().vertical_speed, 0.0);
4947
4948 let inputs = base_inputs();
4949 let wind = WindConditions {
4950 vertical_speed: f64::NAN,
4951 ..Default::default()
4952 };
4953 let s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
4954 assert!(
4955 s.solve().is_err(),
4956 "NaN wind.vertical_speed must be rejected by validate_for_solve"
4957 );
4958 }
4959}