1use crate::cluster_bc::ClusterBCDegradation;
3use crate::pitch_damping::{calculate_pitch_damping_coefficient, PitchDampingCoefficients};
4use crate::precession_nutation::{
5 calculate_combined_angular_motion, AngularState, PrecessionNutationParams,
6};
7use crate::trajectory_sampling::{
8 sample_trajectory, TrajectoryData, TrajectoryOutputs, TrajectorySample,
9};
10use crate::transonic_drag::transonic_correction;
11use crate::wind_shear::WindShearModel;
12use crate::DragModel;
13use nalgebra::Vector3;
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 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, 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,
115 pub enable_magnus: bool, pub enable_coriolis: bool, pub use_powder_sensitivity: bool,
118 pub powder_temp_sensitivity: f64,
119 pub powder_temp: f64, pub tipoff_yaw: f64, pub tipoff_decay_distance: f64, pub use_bc_segments: bool,
123 pub bc_segments: Option<Vec<(f64, f64)>>, pub bc_segments_data: Option<Vec<crate::BCSegmentData>>, pub use_enhanced_spin_drift: bool,
126 pub use_form_factor: bool,
127 pub enable_wind_shear: bool,
128 pub wind_shear_model: String,
129 pub enable_trajectory_sampling: bool,
130 pub sample_interval: f64, pub enable_pitch_damping: bool,
132 pub enable_precession_nutation: bool,
133 pub enable_aerodynamic_jump: bool,
136 pub use_cluster_bc: bool, pub custom_drag_table: Option<crate::drag::DragTable>,
140
141 pub bc_type_str: Option<String>,
143}
144
145impl Default for BallisticInputs {
146 fn default() -> Self {
147 let mass_kg = 0.01;
148 let diameter_m = 0.00762;
149 let bc = 0.5;
150 let muzzle_angle_rad = 0.0;
151 let bc_type = DragModel::G1;
152
153 Self {
154 bc_value: bc,
156 bc_type,
157 bullet_mass: mass_kg,
158 muzzle_velocity: 800.0,
159 bullet_diameter: diameter_m,
160 bullet_length: diameter_m * 4.5, muzzle_angle: muzzle_angle_rad,
164 target_distance: 100.0,
165 azimuth_angle: 0.0,
166 shot_azimuth: 0.0,
167 shooting_angle: 0.0,
168 sight_height: 0.05,
169 muzzle_height: 0.0, target_height: 0.0, ground_threshold: -100.0, altitude: 0.0,
175 temperature: 15.0,
176 pressure: 1013.25, humidity: 0.5, latitude: None,
179
180 wind_speed: 0.0,
182 wind_angle: 0.0,
183
184 twist_rate: 12.0, is_twist_right: true,
187 caliber_inches: diameter_m / 0.0254, weight_grains: mass_kg / 0.00006479891, manufacturer: None,
190 bullet_model: None,
191 bullet_id: None,
192 bullet_cluster: None,
193
194 use_rk4: true, use_adaptive_rk45: true, enable_advanced_effects: false,
200 enable_magnus: false,
201 enable_coriolis: false,
202 use_powder_sensitivity: false,
203 powder_temp_sensitivity: 0.0,
204 powder_temp: 15.0,
205 tipoff_yaw: 0.0,
206 tipoff_decay_distance: 50.0,
207 use_bc_segments: false,
208 bc_segments: None,
209 bc_segments_data: None,
210 use_enhanced_spin_drift: false,
211 use_form_factor: false,
212 enable_wind_shear: false,
213 wind_shear_model: "none".to_string(),
214 enable_trajectory_sampling: false,
215 sample_interval: 10.0, enable_pitch_damping: false,
217 enable_precession_nutation: false,
218 enable_aerodynamic_jump: false,
219 use_cluster_bc: false, custom_drag_table: None,
223
224 bc_type_str: None,
226 }
227 }
228}
229
230#[derive(Debug, Clone)]
232pub struct WindConditions {
233 pub speed: f64, pub direction: f64,
237}
238
239impl Default for WindConditions {
240 fn default() -> Self {
241 Self {
242 speed: 0.0,
243 direction: 0.0,
244 }
245 }
246}
247
248#[derive(Debug, Clone)]
250pub struct AtmosphericConditions {
251 pub temperature: f64, pub pressure: f64, pub humidity: f64, pub altitude: f64, }
256
257impl Default for AtmosphericConditions {
258 fn default() -> Self {
259 Self {
260 temperature: 15.0,
261 pressure: 1013.25,
262 humidity: 50.0,
263 altitude: 0.0,
264 }
265 }
266}
267
268#[derive(Debug, Clone)]
270pub struct TrajectoryPoint {
271 pub time: f64,
272 pub position: Vector3<f64>,
273 pub velocity_magnitude: f64,
274 pub kinetic_energy: f64,
275}
276
277#[derive(Debug, Clone)]
279pub struct TrajectoryResult {
280 pub max_range: f64,
281 pub max_height: f64,
282 pub time_of_flight: f64,
283 pub impact_velocity: f64,
284 pub impact_energy: f64,
285 pub points: Vec<TrajectoryPoint>,
286 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>,
295}
296
297impl TrajectoryResult {
298 pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
302 if self.points.is_empty() {
303 return None;
304 }
305
306 for i in 0..self.points.len() - 1 {
308 let p1 = &self.points[i];
309 let p2 = &self.points[i + 1];
310
311 if p1.position.x <= target_range && p2.position.x >= target_range {
313 let dx = p2.position.x - p1.position.x;
315 if dx.abs() < 1e-10 {
316 return Some(p1.position);
317 }
318 let t = (target_range - p1.position.x) / dx;
319
320 return Some(Vector3::new(
322 target_range,
323 p1.position.y + t * (p2.position.y - p1.position.y),
324 p1.position.z + t * (p2.position.z - p1.position.z),
325 ));
326 }
327 }
328
329 self.points.last().map(|p| p.position)
331 }
332}
333
334pub struct TrajectorySolver {
336 inputs: BallisticInputs,
337 wind: WindConditions,
338 atmosphere: AtmosphericConditions,
339 max_range: f64,
340 time_step: f64,
341 cluster_bc: Option<ClusterBCDegradation>,
342 wind_sock: Option<crate::wind::WindSock>,
347}
348
349impl TrajectorySolver {
350 pub fn new(
351 mut inputs: BallisticInputs,
352 wind: WindConditions,
353 atmosphere: AtmosphericConditions,
354 ) -> Self {
355 inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
357 inputs.weight_grains = inputs.bullet_mass / 0.00006479891;
358
359 let cluster_bc = if inputs.use_cluster_bc {
361 Some(ClusterBCDegradation::new())
362 } else {
363 None
364 };
365
366 Self {
367 inputs,
368 wind,
369 atmosphere,
370 max_range: 1000.0,
371 time_step: 0.001,
372 cluster_bc,
373 wind_sock: None,
374 }
375 }
376
377 pub fn set_max_range(&mut self, range: f64) {
378 self.max_range = range;
379 }
380
381 pub fn set_time_step(&mut self, step: f64) {
382 self.time_step = step;
383 }
384
385 pub fn set_wind_segments(&mut self, segments: Vec<crate::wind::WindSegment>) {
392 self.wind_sock = if segments.is_empty() {
393 None
394 } else {
395 Some(crate::wind::WindSock::new(segments))
396 };
397 }
398
399 fn launch_angles_from(
408 &self,
409 aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
410 ) -> (f64, f64) {
411 let elev = self.inputs.muzzle_angle;
412 let azim = self.inputs.azimuth_angle;
413 match aj {
414 Some(c) => {
415 const MOA_PER_RAD: f64 = 3437.7467707849;
417 (
418 elev + c.vertical_jump_moa / MOA_PER_RAD,
419 azim + c.horizontal_jump_moa / MOA_PER_RAD,
420 )
421 }
422 None => (elev, azim),
423 }
424 }
425
426 fn aerodynamic_jump_components(
434 &self,
435 ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
436 if !self.inputs.enable_aerodynamic_jump {
437 return None;
438 }
439 let diameter_m = self.inputs.bullet_diameter;
443 if !(self.inputs.twist_rate.is_finite() && self.inputs.twist_rate != 0.0)
444 || !(diameter_m.is_finite() && diameter_m > 0.0)
445 || !(self.inputs.bullet_length.is_finite() && self.inputs.bullet_length > 0.0)
446 || !self.inputs.muzzle_velocity.is_finite()
447 {
448 return None;
449 }
450
451 let sg = crate::stability::compute_stability_coefficient(
453 &self.inputs,
454 (
455 self.atmosphere.altitude,
456 self.atmosphere.temperature,
457 self.atmosphere.pressure,
458 0.0,
459 ),
460 );
461 if !(sg.is_finite() && sg > 0.0) {
462 return None;
463 }
464 let length_calibers = self.inputs.bullet_length / diameter_m;
465
466 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
472 let crosswind_from_right_mph = self.wind.speed * self.wind.direction.sin() * MS_TO_MPH;
473
474 let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
475 sg,
476 length_calibers,
477 crosswind_from_right_mph,
478 self.inputs.is_twist_right,
479 );
480 if !vertical_jump_moa.is_finite() {
481 return None;
482 }
483
484 const MOA_PER_RAD: f64 = 3437.7467707849;
485 Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
486 vertical_jump_moa,
487 horizontal_jump_moa: 0.0,
489 jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
490 magnus_component_moa: 0.0,
491 yaw_component_moa: 0.0,
492 stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
493 })
494 }
495
496 fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
497 let model = if self.inputs.wind_shear_model == "logarithmic" {
507 WindShearModel::Logarithmic
508 } else {
509 WindShearModel::PowerLaw };
511 let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
512
513 Vector3::new(
516 -self.wind.speed * self.wind.direction.cos() * speed_ratio, 0.0,
518 -self.wind.speed * self.wind.direction.sin() * speed_ratio, )
520 }
521
522 pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
523 let mut result = if self.inputs.use_rk4 {
524 if self.inputs.use_adaptive_rk45 {
525 self.solve_rk45()?
526 } else {
527 self.solve_rk4()?
528 }
529 } else {
530 self.solve_euler()?
531 };
532 self.apply_spin_drift(&mut result);
533 Ok(result)
534 }
535
536 fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
542 if !self.inputs.use_enhanced_spin_drift {
543 return;
544 }
545 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 {
549 return;
550 }
551
552 let length_in = if self.inputs.bullet_length > 0.0 {
554 self.inputs.bullet_length / 0.0254
555 } else {
556 4.5 * d_in
557 };
558 let temp_k = self.atmosphere.temperature + 273.15; let press_hpa = self.atmosphere.pressure; let density_correction = if press_hpa > 0.0 && temp_k > 0.0 {
566 (temp_k / 288.15) * (1013.25 / press_hpa)
567 } else {
568 1.0
569 };
570 let sg =
571 crate::spin_drift::miller_stability(d_in, m_gr, twist_in, length_in) * density_correction;
572 let sign = if self.inputs.is_twist_right { 1.0 } else { -1.0 };
573
574 for p in result.points.iter_mut() {
575 if p.time <= 0.0 {
576 continue;
577 }
578 let sd_in = 1.25 * (sg + 1.2) * p.time.powf(1.83); p.position.z += sign * sd_in * 0.0254; }
581
582 if let Some(samples) = result.sampled_points.as_mut() {
586 for s in samples.iter_mut() {
587 if s.time_s <= 0.0 {
588 continue;
589 }
590 let sd_in = 1.25 * (sg + 1.2) * s.time_s.powf(1.83);
591 s.wind_drift_m += sign * sd_in * 0.0254;
592 }
593 }
594 }
595
596 fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
597 let mut time = 0.0;
599 let mut position = Vector3::new(
602 0.0,
603 self.inputs.muzzle_height, 0.0,
605 );
606 let aj_components = self.aerodynamic_jump_components();
612 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
613 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
614 let mut velocity = Vector3::new(
615 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
619
620 let mut points = Vec::new();
621 let mut max_height = position.y;
622 let mut min_pitch_damping = 1.0; let mut transonic_mach = None; let mut angular_state = if self.inputs.enable_precession_nutation {
627 Some(AngularState {
628 pitch_angle: 0.001, yaw_angle: 0.001,
630 pitch_rate: 0.0,
631 yaw_rate: 0.0,
632 precession_angle: 0.0,
633 nutation_phase: 0.0,
634 })
635 } else {
636 None
637 };
638 let mut max_yaw_angle = 0.0;
639 let mut max_precession_angle = 0.0;
640
641 let air_density = calculate_air_density(&self.atmosphere);
643
644 let wind_vector = Vector3::new(
648 -self.wind.speed * self.wind.direction.cos(), 0.0,
650 -self.wind.speed * self.wind.direction.sin(), );
652
653 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
656 self.inputs.bullet_model.as_deref().unwrap_or("default"),
657 );
658
659 while position.x < self.max_range && position.y > self.inputs.ground_threshold && time < 100.0 {
661 let velocity_magnitude = velocity.magnitude();
663 let kinetic_energy =
664 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
665
666 points.push(TrajectoryPoint {
667 time,
668 position: position,
669 velocity_magnitude,
670 kinetic_energy,
671 });
672
673 #[cfg(debug_assertions)]
677 if points.len() == 1 || points.len() % 100 == 0 {
678 eprintln!("Trajectory point {}: time={:.3}s, downrange={:.2}m, vertical={:.2}m, lateral={:.2}m, vel={:.1}m/s",
679 points.len(), time, position.x, position.y, position.z, velocity_magnitude);
680 }
681
682 if position.y > max_height {
684 max_height = position.y;
685 }
686
687 if self.inputs.enable_pitch_damping {
689 let temp_c = self.atmosphere.temperature;
690 let temp_k = temp_c + 273.15;
691 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
692 let mach = velocity_magnitude / speed_of_sound;
693
694 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
696 transonic_mach = Some(mach);
697 }
698
699 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
701
702 if pitch_damping < min_pitch_damping {
704 min_pitch_damping = pitch_damping;
705 }
706 }
707
708 if self.inputs.enable_precession_nutation {
710 if let Some(ref mut state) = angular_state {
711 let velocity_magnitude = velocity.magnitude();
712 let temp_c = self.atmosphere.temperature;
713 let temp_k = temp_c + 273.15;
714 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
715 let mach = velocity_magnitude / speed_of_sound;
716
717 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
719 let velocity_fps = velocity_magnitude * 3.28084;
720 let twist_rate_ft = self.inputs.twist_rate / 12.0;
721 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
722 } else {
723 0.0
724 };
725
726 let params = PrecessionNutationParams {
728 mass_kg: self.inputs.bullet_mass,
729 caliber_m: self.inputs.bullet_diameter,
730 length_m: self.inputs.bullet_length,
731 spin_rate_rad_s,
732 spin_inertia: 6.94e-8, transverse_inertia: 9.13e-7, velocity_mps: velocity_magnitude,
735 air_density_kg_m3: air_density,
736 mach,
737 pitch_damping_coeff: -0.8,
738 nutation_damping_factor: 0.05,
739 };
740
741 *state = calculate_combined_angular_motion(
743 ¶ms,
744 state,
745 time,
746 self.time_step,
747 0.001, );
749
750 if state.yaw_angle.abs() > max_yaw_angle {
752 max_yaw_angle = state.yaw_angle.abs();
753 }
754 if state.precession_angle.abs() > max_precession_angle {
755 max_precession_angle = state.precession_angle.abs();
756 }
757 }
758 }
759
760 let acceleration =
767 self.calculate_acceleration(&position, &velocity, air_density, &wind_vector);
768
769 velocity += acceleration * self.time_step;
771 position += velocity * self.time_step;
772 time += self.time_step;
773 }
774
775 let last_point = points.last().ok_or("No trajectory points generated")?;
777
778 let sampled_points = if self.inputs.enable_trajectory_sampling {
780 let trajectory_data = TrajectoryData {
781 times: points.iter().map(|p| p.time).collect(),
782 positions: points.iter().map(|p| p.position).collect(),
783 velocities: points
784 .iter()
785 .map(|p| {
786 Vector3::new(0.0, 0.0, p.velocity_magnitude)
788 })
789 .collect(),
790 transonic_distances: Vec::new(), };
792
793 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
798 let outputs = TrajectoryOutputs {
799 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
801 time_of_flight_s: last_point.time,
802 max_ord_dist_horiz_m: max_height,
803 sight_height_m: sight_position_m,
804 };
805
806 let samples = sample_trajectory(
808 &trajectory_data,
809 &outputs,
810 self.inputs.sample_interval,
811 self.inputs.bullet_mass,
812 );
813 Some(samples)
814 } else {
815 None
816 };
817
818 Ok(TrajectoryResult {
819 max_range: last_point.position.x, max_height,
821 time_of_flight: last_point.time,
822 impact_velocity: last_point.velocity_magnitude,
823 impact_energy: last_point.kinetic_energy,
824 points,
825 sampled_points,
826 min_pitch_damping: if self.inputs.enable_pitch_damping {
827 Some(min_pitch_damping)
828 } else {
829 None
830 },
831 transonic_mach,
832 angular_state,
833 max_yaw_angle: if self.inputs.enable_precession_nutation {
834 Some(max_yaw_angle)
835 } else {
836 None
837 },
838 max_precession_angle: if self.inputs.enable_precession_nutation {
839 Some(max_precession_angle)
840 } else {
841 None
842 },
843 aerodynamic_jump: aj_components,
844 })
845 }
846
847 fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
848 let mut time = 0.0;
850 let mut position = Vector3::new(
854 0.0,
855 self.inputs.muzzle_height, 0.0,
857 );
858
859 let aj_components = self.aerodynamic_jump_components();
865 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
866 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
867 let mut velocity = Vector3::new(
868 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
872
873 let mut points = Vec::new();
874 let mut max_height = position.y;
875 let mut min_pitch_damping = 1.0; let mut transonic_mach = None; let mut angular_state = if self.inputs.enable_precession_nutation {
880 Some(AngularState {
881 pitch_angle: 0.001, yaw_angle: 0.001,
883 pitch_rate: 0.0,
884 yaw_rate: 0.0,
885 precession_angle: 0.0,
886 nutation_phase: 0.0,
887 })
888 } else {
889 None
890 };
891 let mut max_yaw_angle = 0.0;
892 let mut max_precession_angle = 0.0;
893
894 let air_density = calculate_air_density(&self.atmosphere);
896
897 let wind_vector = Vector3::new(
901 -self.wind.speed * self.wind.direction.cos(), 0.0,
903 -self.wind.speed * self.wind.direction.sin(), );
905
906 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
909 self.inputs.bullet_model.as_deref().unwrap_or("default"),
910 );
911
912 while position.x < self.max_range && position.y > self.inputs.ground_threshold && time < 100.0 {
914 let velocity_magnitude = velocity.magnitude();
916 let kinetic_energy =
917 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
918
919 points.push(TrajectoryPoint {
920 time,
921 position: position,
922 velocity_magnitude,
923 kinetic_energy,
924 });
925
926 if position.y > max_height {
927 max_height = position.y;
928 }
929
930 if self.inputs.enable_pitch_damping {
932 let temp_c = self.atmosphere.temperature;
933 let temp_k = temp_c + 273.15;
934 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
935 let mach = velocity_magnitude / speed_of_sound;
936
937 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
939 transonic_mach = Some(mach);
940 }
941
942 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
944
945 if pitch_damping < min_pitch_damping {
947 min_pitch_damping = pitch_damping;
948 }
949 }
950
951 if self.inputs.enable_precession_nutation {
953 if let Some(ref mut state) = angular_state {
954 let velocity_magnitude = velocity.magnitude();
955 let temp_c = self.atmosphere.temperature;
956 let temp_k = temp_c + 273.15;
957 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
958 let mach = velocity_magnitude / speed_of_sound;
959
960 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
962 let velocity_fps = velocity_magnitude * 3.28084;
963 let twist_rate_ft = self.inputs.twist_rate / 12.0;
964 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
965 } else {
966 0.0
967 };
968
969 let params = PrecessionNutationParams {
971 mass_kg: self.inputs.bullet_mass,
972 caliber_m: self.inputs.bullet_diameter,
973 length_m: self.inputs.bullet_length,
974 spin_rate_rad_s,
975 spin_inertia: 6.94e-8, transverse_inertia: 9.13e-7, velocity_mps: velocity_magnitude,
978 air_density_kg_m3: air_density,
979 mach,
980 pitch_damping_coeff: -0.8,
981 nutation_damping_factor: 0.05,
982 };
983
984 *state = calculate_combined_angular_motion(
986 ¶ms,
987 state,
988 time,
989 self.time_step,
990 0.001, );
992
993 if state.yaw_angle.abs() > max_yaw_angle {
995 max_yaw_angle = state.yaw_angle.abs();
996 }
997 if state.precession_angle.abs() > max_precession_angle {
998 max_precession_angle = state.precession_angle.abs();
999 }
1000 }
1001 }
1002
1003 let dt = self.time_step;
1005
1006 let acc1 = self.calculate_acceleration(&position, &velocity, air_density, &wind_vector);
1008
1009 let pos2 = position + velocity * (dt * 0.5);
1011 let vel2 = velocity + acc1 * (dt * 0.5);
1012 let acc2 = self.calculate_acceleration(&pos2, &vel2, air_density, &wind_vector);
1013
1014 let pos3 = position + vel2 * (dt * 0.5);
1016 let vel3 = velocity + acc2 * (dt * 0.5);
1017 let acc3 = self.calculate_acceleration(&pos3, &vel3, air_density, &wind_vector);
1018
1019 let pos4 = position + vel3 * dt;
1021 let vel4 = velocity + acc3 * dt;
1022 let acc4 = self.calculate_acceleration(&pos4, &vel4, air_density, &wind_vector);
1023
1024 position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
1026 velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
1027 time += dt;
1028 }
1029
1030 let last_point = points.last().ok_or("No trajectory points generated")?;
1032
1033 let sampled_points = if self.inputs.enable_trajectory_sampling {
1035 let trajectory_data = TrajectoryData {
1036 times: points.iter().map(|p| p.time).collect(),
1037 positions: points.iter().map(|p| p.position).collect(),
1038 velocities: points
1039 .iter()
1040 .map(|p| {
1041 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1043 })
1044 .collect(),
1045 transonic_distances: Vec::new(), };
1047
1048 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1053 let outputs = TrajectoryOutputs {
1054 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
1056 time_of_flight_s: last_point.time,
1057 max_ord_dist_horiz_m: max_height,
1058 sight_height_m: sight_position_m,
1059 };
1060
1061 let samples = sample_trajectory(
1063 &trajectory_data,
1064 &outputs,
1065 self.inputs.sample_interval,
1066 self.inputs.bullet_mass,
1067 );
1068 Some(samples)
1069 } else {
1070 None
1071 };
1072
1073 Ok(TrajectoryResult {
1074 max_range: last_point.position.x, max_height,
1076 time_of_flight: last_point.time,
1077 impact_velocity: last_point.velocity_magnitude,
1078 impact_energy: last_point.kinetic_energy,
1079 points,
1080 sampled_points,
1081 min_pitch_damping: if self.inputs.enable_pitch_damping {
1082 Some(min_pitch_damping)
1083 } else {
1084 None
1085 },
1086 transonic_mach,
1087 angular_state,
1088 max_yaw_angle: if self.inputs.enable_precession_nutation {
1089 Some(max_yaw_angle)
1090 } else {
1091 None
1092 },
1093 max_precession_angle: if self.inputs.enable_precession_nutation {
1094 Some(max_precession_angle)
1095 } else {
1096 None
1097 },
1098 aerodynamic_jump: aj_components,
1099 })
1100 }
1101
1102 fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
1103 let mut time = 0.0;
1105 let mut position = Vector3::new(
1108 0.0,
1109 self.inputs.muzzle_height, 0.0,
1111 );
1112
1113 let aj_components = self.aerodynamic_jump_components();
1119 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1120 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1121 let mut velocity = Vector3::new(
1122 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1126
1127 let mut points = Vec::new();
1128 let mut max_height = position.y;
1129 let mut dt = 0.001; let tolerance = 1e-6; let safety_factor = 0.9; let max_dt = 0.01; let min_dt = 1e-6; let mut iteration_count = 0;
1137 const MAX_ITERATIONS: usize = 100000;
1138
1139 let air_density = calculate_air_density(&self.atmosphere);
1142 let wind_vector = Vector3::new(
1145 -self.wind.speed * self.wind.direction.cos(), 0.0,
1147 -self.wind.speed * self.wind.direction.sin(), );
1149
1150 while position.x < self.max_range
1151 && position.y > self.inputs.ground_threshold
1152 && time < 100.0
1153 {
1154 iteration_count += 1;
1156 if iteration_count > MAX_ITERATIONS {
1157 break; }
1159
1160 let velocity_magnitude = velocity.magnitude();
1162 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
1163
1164 points.push(TrajectoryPoint {
1165 time,
1166 position: position,
1167 velocity_magnitude,
1168 kinetic_energy,
1169 });
1170
1171 if position.y > max_height {
1172 max_height = position.y;
1173 }
1174
1175 let (new_pos, new_vel, new_dt) = self.rk45_step(
1177 &position,
1178 &velocity,
1179 dt,
1180 air_density,
1181 &wind_vector,
1182 tolerance,
1183 );
1184
1185 position = new_pos;
1190 velocity = new_vel;
1191 time += dt;
1192
1193 dt = (safety_factor * new_dt).clamp(min_dt, max_dt);
1195 }
1196
1197 if points.is_empty() {
1199 return Err(BallisticsError::from("No trajectory points calculated"));
1200 }
1201
1202 let last_point = points.last().unwrap();
1203
1204 let sampled_points = if self.inputs.enable_trajectory_sampling {
1206 let trajectory_data = TrajectoryData {
1208 times: points.iter().map(|p| p.time).collect(),
1209 positions: points.iter().map(|p| p.position).collect(),
1210 velocities: points
1211 .iter()
1212 .map(|p| {
1213 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1215 })
1216 .collect(),
1217 transonic_distances: Vec::new(),
1218 };
1219
1220 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1225 let outputs = TrajectoryOutputs {
1226 target_distance_horiz_m: last_point.position.x,
1227 target_vertical_height_m: sight_position_m,
1228 time_of_flight_s: last_point.time,
1229 max_ord_dist_horiz_m: max_height,
1230 sight_height_m: sight_position_m,
1231 };
1232
1233 let samples = sample_trajectory(
1234 &trajectory_data,
1235 &outputs,
1236 self.inputs.sample_interval,
1237 self.inputs.bullet_mass,
1238 );
1239 Some(samples)
1240 } else {
1241 None
1242 };
1243
1244 Ok(TrajectoryResult {
1245 max_range: last_point.position.x, max_height,
1247 time_of_flight: last_point.time,
1248 impact_velocity: last_point.velocity_magnitude,
1249 impact_energy: last_point.kinetic_energy,
1250 points,
1251 sampled_points,
1252 min_pitch_damping: None,
1253 transonic_mach: None,
1254 angular_state: None,
1255 max_yaw_angle: None,
1256 max_precession_angle: None,
1257 aerodynamic_jump: aj_components,
1258 })
1259 }
1260
1261 fn rk45_step(
1262 &self,
1263 position: &Vector3<f64>,
1264 velocity: &Vector3<f64>,
1265 dt: f64,
1266 air_density: f64,
1267 wind_vector: &Vector3<f64>,
1268 tolerance: f64,
1269 ) -> (Vector3<f64>, Vector3<f64>, f64) {
1270 const A21: f64 = 1.0 / 5.0;
1272 const A31: f64 = 3.0 / 40.0;
1273 const A32: f64 = 9.0 / 40.0;
1274 const A41: f64 = 44.0 / 45.0;
1275 const A42: f64 = -56.0 / 15.0;
1276 const A43: f64 = 32.0 / 9.0;
1277 const A51: f64 = 19372.0 / 6561.0;
1278 const A52: f64 = -25360.0 / 2187.0;
1279 const A53: f64 = 64448.0 / 6561.0;
1280 const A54: f64 = -212.0 / 729.0;
1281 const A61: f64 = 9017.0 / 3168.0;
1282 const A62: f64 = -355.0 / 33.0;
1283 const A63: f64 = 46732.0 / 5247.0;
1284 const A64: f64 = 49.0 / 176.0;
1285 const A65: f64 = -5103.0 / 18656.0;
1286 const A71: f64 = 35.0 / 384.0;
1287 const A73: f64 = 500.0 / 1113.0;
1288 const A74: f64 = 125.0 / 192.0;
1289 const A75: f64 = -2187.0 / 6784.0;
1290 const A76: f64 = 11.0 / 84.0;
1291
1292 const B1: f64 = 35.0 / 384.0;
1294 const B3: f64 = 500.0 / 1113.0;
1295 const B4: f64 = 125.0 / 192.0;
1296 const B5: f64 = -2187.0 / 6784.0;
1297 const B6: f64 = 11.0 / 84.0;
1298
1299 const B1_ERR: f64 = 5179.0 / 57600.0;
1301 const B3_ERR: f64 = 7571.0 / 16695.0;
1302 const B4_ERR: f64 = 393.0 / 640.0;
1303 const B5_ERR: f64 = -92097.0 / 339200.0;
1304 const B6_ERR: f64 = 187.0 / 2100.0;
1305 const B7_ERR: f64 = 1.0 / 40.0;
1306
1307 let k1_v = self.calculate_acceleration(position, velocity, air_density, wind_vector);
1309 let k1_p = *velocity;
1310
1311 let p2 = position + dt * A21 * k1_p;
1312 let v2 = velocity + dt * A21 * k1_v;
1313 let k2_v = self.calculate_acceleration(&p2, &v2, air_density, wind_vector);
1314 let k2_p = v2;
1315
1316 let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
1317 let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
1318 let k3_v = self.calculate_acceleration(&p3, &v3, air_density, wind_vector);
1319 let k3_p = v3;
1320
1321 let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
1322 let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
1323 let k4_v = self.calculate_acceleration(&p4, &v4, air_density, wind_vector);
1324 let k4_p = v4;
1325
1326 let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
1327 let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
1328 let k5_v = self.calculate_acceleration(&p5, &v5, air_density, wind_vector);
1329 let k5_p = v5;
1330
1331 let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
1332 let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
1333 let k6_v = self.calculate_acceleration(&p6, &v6, air_density, wind_vector);
1334 let k6_p = v6;
1335
1336 let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
1337 let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
1338 let k7_v = self.calculate_acceleration(&p7, &v7, air_density, wind_vector);
1339 let k7_p = v7;
1340
1341 let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
1343 let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
1344
1345 let pos_err = position
1347 + dt * (B1_ERR * k1_p
1348 + B3_ERR * k3_p
1349 + B4_ERR * k4_p
1350 + B5_ERR * k5_p
1351 + B6_ERR * k6_p
1352 + B7_ERR * k7_p);
1353 let vel_err = velocity
1354 + dt * (B1_ERR * k1_v
1355 + B3_ERR * k3_v
1356 + B4_ERR * k4_v
1357 + B5_ERR * k5_v
1358 + B6_ERR * k6_v
1359 + B7_ERR * k7_v);
1360
1361 let pos_error = (new_pos - pos_err).magnitude();
1363 let vel_error = (new_vel - vel_err).magnitude();
1364 let error = (pos_error + vel_error) / (1.0 + position.magnitude() + velocity.magnitude());
1365
1366 let dt_new = if error < tolerance {
1368 dt * (tolerance / error).powf(0.2).min(2.0)
1369 } else {
1370 dt * (tolerance / error).powf(0.25).max(0.1)
1371 };
1372
1373 (new_pos, new_vel, dt_new)
1374 }
1375
1376 fn calculate_acceleration(
1377 &self,
1378 position: &Vector3<f64>,
1379 velocity: &Vector3<f64>,
1380 air_density: f64,
1381 wind_vector: &Vector3<f64>,
1382 ) -> Vector3<f64> {
1383 let actual_wind = if let Some(ref sock) = self.wind_sock {
1389 sock.vector_for_range_stateless(position.x)
1390 } else if self.inputs.enable_wind_shear {
1391 self.get_wind_at_altitude(position.y)
1392 } else {
1393 *wind_vector
1394 };
1395
1396 let relative_velocity = velocity - actual_wind;
1397 let velocity_magnitude = relative_velocity.magnitude();
1398
1399 if velocity_magnitude < 0.001 {
1400 return Vector3::new(0.0, -crate::constants::G_ACCEL_MPS2, 0.0);
1401 }
1402
1403 let cd = self.calculate_drag_coefficient(velocity_magnitude);
1405
1406 let velocity_fps = velocity_magnitude * 3.28084;
1408
1409 let base_bc = if let Some(ref segments) = self.inputs.bc_segments_data {
1411 segments
1413 .iter()
1414 .find(|seg| velocity_fps >= seg.velocity_min && velocity_fps < seg.velocity_max)
1415 .map(|seg| seg.bc_value)
1416 .unwrap_or(self.inputs.bc_value)
1417 } else {
1418 self.inputs.bc_value
1419 };
1420
1421 let effective_bc = if let Some(ref cluster_bc) = self.cluster_bc {
1423 cluster_bc.apply_correction(
1424 base_bc,
1425 self.inputs.caliber_inches, self.inputs.weight_grains,
1427 velocity_fps,
1428 )
1429 } else {
1430 base_bc
1431 };
1432 let effective_bc = effective_bc.max(1e-6);
1436
1437 let cd_to_retard = 0.000683 * 0.30; let standard_factor = cd * cd_to_retard;
1443 let density_scale = air_density / 1.225; let a_drag_ft_s2 =
1447 (velocity_fps * velocity_fps) * standard_factor * density_scale / effective_bc;
1448 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
1452
1453 let mut accel = drag_acceleration + Vector3::new(0.0, -crate::constants::G_ACCEL_MPS2, 0.0);
1455
1456 if self.inputs.enable_coriolis {
1459 if let Some(lat_deg) = self.inputs.latitude {
1460 let omega_earth = 7.2921159e-5_f64; let lat = lat_deg.to_radians();
1462 let az = self.inputs.shot_azimuth; let omega = Vector3::new(
1469 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), );
1473 accel += -2.0 * omega.cross(velocity);
1478 }
1479 }
1480
1481 if self.inputs.enable_magnus
1483 && self.inputs.bullet_diameter > 0.0
1484 && self.inputs.twist_rate > 0.0
1485 {
1486 let (_, spin_rad_s) =
1487 crate::spin_drift::calculate_spin_rate(velocity_magnitude, self.inputs.twist_rate);
1488 let temp_k = self.atmosphere.temperature + 273.15;
1489 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
1490 let mach = velocity_magnitude / speed_of_sound;
1491
1492 let d_in = self.inputs.bullet_diameter / 0.0254;
1494 let m_gr = self.inputs.bullet_mass / 0.00006479891;
1495 let l_in = if self.inputs.bullet_length > 0.0 {
1496 self.inputs.bullet_length / 0.0254
1497 } else {
1498 4.5 * d_in
1499 };
1500 let press_hpa = self.atmosphere.pressure;
1504 let density_correction = if press_hpa > 0.0 && temp_k > 0.0 {
1505 (temp_k / 288.15) * (1013.25 / press_hpa)
1506 } else {
1507 1.0
1508 };
1509 let sg = crate::spin_drift::miller_stability(d_in, m_gr, self.inputs.twist_rate, l_in)
1510 * density_correction;
1511
1512 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
1514 sg,
1515 velocity_magnitude,
1516 spin_rad_s,
1517 0.0, 0.0, air_density,
1520 d_in,
1521 l_in,
1522 m_gr,
1523 mach,
1524 "match",
1525 false,
1526 );
1527
1528 let diameter_m = self.inputs.bullet_diameter; let spin_param = spin_rad_s * diameter_m / (2.0 * velocity_magnitude);
1531 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
1532 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
1533 let magnus_force = 0.5
1534 * air_density
1535 * velocity_magnitude.powi(2)
1536 * area
1537 * c_np
1538 * spin_param
1539 * yaw_rad.sin();
1540
1541 let velocity_unit = relative_velocity / velocity_magnitude;
1544 let up = Vector3::new(0.0, 1.0, 0.0);
1545 let mut dir = velocity_unit.cross(&up);
1546 let dir_norm = dir.norm();
1547 if dir_norm > 1e-12 && magnus_force.abs() > 1e-12 {
1548 dir /= dir_norm;
1549 if !self.inputs.is_twist_right {
1550 dir = -dir;
1551 }
1552 accel += (magnus_force / self.inputs.bullet_mass) * dir;
1553 }
1554 }
1555
1556 accel
1557 }
1558
1559 fn calculate_drag_coefficient(&self, velocity: f64) -> f64 {
1560 let temp_c = self.atmosphere.temperature;
1562 let temp_k = temp_c + 273.15;
1563 let gamma = 1.4; let r_specific = 287.05; let speed_of_sound = (gamma * r_specific * temp_k).sqrt();
1566 let mach = velocity / speed_of_sound;
1567
1568 if let Some(ref table) = self.inputs.custom_drag_table {
1572 return table.interpolate(mach);
1573 }
1574
1575 let base_cd = crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type);
1577
1578 let bc_type_str: &str = match self.inputs.bc_type {
1582 crate::DragModel::G1 => "G1",
1583 crate::DragModel::G2 => "G2",
1584 crate::DragModel::G5 => "G5",
1585 crate::DragModel::G6 => "G6",
1586 crate::DragModel::G7 => "G7",
1587 crate::DragModel::G8 => "G8",
1588 crate::DragModel::GI => "GI",
1589 crate::DragModel::GS => "GS",
1590 };
1591
1592 let projectile_shape = crate::transonic_drag::resolve_projectile_shape(
1595 self.inputs.bullet_model.as_deref(),
1596 self.inputs.caliber_inches,
1597 self.inputs.bullet_mass / 0.00006479891, bc_type_str,
1599 );
1600
1601 let include_wave_drag = false;
1607 let cd = transonic_correction(mach, base_cd, projectile_shape, include_wave_drag);
1608
1609 crate::form_factor::apply_form_factor_to_drag(
1614 cd,
1615 self.inputs.bullet_model.as_deref(),
1616 &self.inputs.bc_type,
1617 self.inputs.use_form_factor,
1618 )
1619 }
1620}
1621
1622#[derive(Debug, Clone)]
1624pub struct MonteCarloParams {
1625 pub num_simulations: usize,
1626 pub velocity_std_dev: f64,
1627 pub angle_std_dev: f64,
1628 pub bc_std_dev: f64,
1629 pub wind_speed_std_dev: f64,
1630 pub target_distance: Option<f64>,
1631 pub base_wind_speed: f64,
1632 pub base_wind_direction: f64,
1633 pub azimuth_std_dev: f64, }
1635
1636impl Default for MonteCarloParams {
1637 fn default() -> Self {
1638 Self {
1639 num_simulations: 1000,
1640 velocity_std_dev: 1.0,
1641 angle_std_dev: 0.001,
1642 bc_std_dev: 0.01,
1643 wind_speed_std_dev: 1.0,
1644 target_distance: None,
1645 base_wind_speed: 0.0,
1646 base_wind_direction: 0.0,
1647 azimuth_std_dev: 0.001, }
1649 }
1650}
1651
1652#[derive(Debug, Clone)]
1654pub struct MonteCarloResults {
1655 pub ranges: Vec<f64>,
1656 pub impact_velocities: Vec<f64>,
1657 pub impact_positions: Vec<Vector3<f64>>,
1658}
1659
1660pub fn run_monte_carlo(
1662 base_inputs: BallisticInputs,
1663 params: MonteCarloParams,
1664) -> Result<MonteCarloResults, BallisticsError> {
1665 let base_wind = WindConditions {
1666 speed: params.base_wind_speed,
1667 direction: params.base_wind_direction,
1668 };
1669 run_monte_carlo_with_wind(base_inputs, base_wind, params)
1670}
1671
1672pub fn run_monte_carlo_with_wind(
1674 base_inputs: BallisticInputs,
1675 base_wind: WindConditions,
1676 params: MonteCarloParams,
1677) -> Result<MonteCarloResults, BallisticsError> {
1678 use rand_distr::{Distribution, Normal};
1679
1680 let mut rng = rand::rng();
1681 let mut ranges = Vec::new();
1682 let mut impact_velocities = Vec::new();
1683 let mut impact_positions = Vec::new();
1684
1685 let baseline_solver =
1687 TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), Default::default());
1688 let baseline_result = baseline_solver.solve()?;
1689
1690 let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
1692
1693 let baseline_at_target = baseline_result
1695 .position_at_range(target_distance)
1696 .ok_or("Could not interpolate baseline at target distance")?;
1697
1698 let velocity_dist = Normal::new(base_inputs.muzzle_velocity, params.velocity_std_dev)
1700 .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
1701 let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
1702 .map_err(|e| format!("Invalid angle distribution: {}", e))?;
1703 let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
1704 .map_err(|e| format!("Invalid BC distribution: {}", e))?;
1705 let wind_speed_dist = Normal::new(base_wind.speed, params.wind_speed_std_dev)
1706 .map_err(|e| format!("Invalid wind speed distribution: {}", e))?;
1707 let wind_dir_dist =
1712 Normal::new(base_wind.direction, params.wind_speed_std_dev * 0.1)
1713 .map_err(|e| format!("Invalid wind direction distribution: {}", e))?;
1714 let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
1715 .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
1716
1717 let pointing_error_dist = Normal::new(0.0, params.angle_std_dev * target_distance)
1719 .map_err(|e| format!("Invalid pointing distribution: {}", e))?;
1720
1721 for _ in 0..params.num_simulations {
1722 let mut inputs = base_inputs.clone();
1724 inputs.muzzle_velocity = velocity_dist.sample(&mut rng).max(0.0);
1725 inputs.muzzle_angle = angle_dist.sample(&mut rng);
1726 inputs.bc_value = bc_dist.sample(&mut rng).max(0.01);
1727 inputs.azimuth_angle = azimuth_dist.sample(&mut rng); let wind = WindConditions {
1731 speed: wind_speed_dist.sample(&mut rng).abs(),
1732 direction: wind_dir_dist.sample(&mut rng),
1733 };
1734
1735 let solver = TrajectorySolver::new(inputs, wind, Default::default());
1737 match solver.solve() {
1738 Ok(result) => {
1739 if result.max_range < target_distance {
1747 continue;
1748 }
1749 let pos_at_target = match result.position_at_range(target_distance) {
1753 Some(p) => p,
1754 None => continue,
1755 };
1756
1757 ranges.push(result.max_range);
1758 impact_velocities.push(result.impact_velocity);
1759
1760 let mut deviation = Vector3::new(
1763 0.0, pos_at_target.y - baseline_at_target.y, pos_at_target.z - baseline_at_target.z, );
1767
1768 let pointing_error_y = pointing_error_dist.sample(&mut rng);
1771 deviation.y += pointing_error_y;
1772
1773 impact_positions.push(deviation);
1774 }
1775 Err(_) => {
1776 continue;
1778 }
1779 }
1780 }
1781
1782 if ranges.is_empty() {
1783 return Err("No successful simulations".into());
1784 }
1785
1786 Ok(MonteCarloResults {
1787 ranges,
1788 impact_velocities,
1789 impact_positions,
1790 })
1791}
1792
1793pub fn calculate_zero_angle(
1795 inputs: BallisticInputs,
1796 target_distance: f64,
1797 target_height: f64,
1798) -> Result<f64, BallisticsError> {
1799 calculate_zero_angle_with_conditions(
1800 inputs,
1801 target_distance,
1802 target_height,
1803 WindConditions::default(),
1804 AtmosphericConditions::default(),
1805 )
1806}
1807
1808pub fn calculate_zero_angle_with_conditions(
1809 inputs: BallisticInputs,
1810 target_distance: f64,
1811 target_height: f64,
1812 wind: WindConditions,
1813 atmosphere: AtmosphericConditions,
1814) -> Result<f64, BallisticsError> {
1815 let get_height_at_angle = |angle: f64| -> Result<Option<f64>, BallisticsError> {
1817 let mut test_inputs = inputs.clone();
1818 test_inputs.muzzle_angle = angle;
1819 test_inputs.enable_aerodynamic_jump = false;
1824
1825 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
1826 solver.set_max_range(target_distance * 2.0);
1827 solver.set_time_step(0.001);
1828 let result = solver.solve()?;
1829
1830 for i in 0..result.points.len() {
1832 if result.points[i].position.x >= target_distance {
1833 if i > 0 {
1834 let p1 = &result.points[i - 1];
1835 let p2 = &result.points[i];
1836 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
1837 return Ok(Some(p1.position.y + t * (p2.position.y - p1.position.y)));
1838 } else {
1839 return Ok(Some(result.points[i].position.y));
1840 }
1841 }
1842 }
1843 Ok(None)
1844 };
1845
1846 let mut low_angle = 0.0; let mut high_angle = 0.2; let tolerance = 0.00001; let max_iterations = 50;
1852
1853 let low_height = get_height_at_angle(low_angle)?;
1856 let high_height = get_height_at_angle(high_angle)?;
1857
1858 match (low_height, high_height) {
1859 (Some(lh), Some(hh)) => {
1860 let low_error = lh - target_height;
1861 let high_error = hh - target_height;
1862
1863 if low_error > 0.0 && high_error > 0.0 {
1866 } else if low_error < 0.0 && high_error < 0.0 {
1871 let mut expanded = false;
1874 for multiplier in [2.0, 3.0, 4.0] {
1875 let new_high = (high_angle * multiplier).min(0.785);
1876 if let Ok(Some(h)) = get_height_at_angle(new_high) {
1877 if h - target_height > 0.0 {
1878 high_angle = new_high;
1879 expanded = true;
1880 break;
1881 }
1882 }
1883 if new_high >= 0.785 {
1884 break;
1885 }
1886 }
1887 if !expanded {
1888 return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
1889 }
1890 }
1891 }
1893 (None, Some(_hh)) => {
1894 }
1897 (Some(_lh), None) => {
1898 return Err(
1900 "Cannot find zero angle: high angle trajectory doesn't reach target distance"
1901 .into(),
1902 );
1903 }
1904 (None, None) => {
1905 return Err(
1907 "Cannot find zero angle: trajectory cannot reach target distance at any angle"
1908 .into(),
1909 );
1910 }
1911 }
1912
1913 for _iteration in 0..max_iterations {
1914 let mid_angle = (low_angle + high_angle) / 2.0;
1915
1916 let mut test_inputs = inputs.clone();
1917 test_inputs.muzzle_angle = mid_angle;
1918 test_inputs.enable_aerodynamic_jump = false;
1920
1921 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
1922 solver.set_max_range(target_distance * 2.0);
1924 solver.set_time_step(0.001);
1925 let result = solver.solve()?;
1926
1927 let mut height_at_target = None;
1929 for i in 0..result.points.len() {
1930 if result.points[i].position.x >= target_distance {
1931 if i > 0 {
1932 let p1 = &result.points[i - 1];
1934 let p2 = &result.points[i];
1935 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
1936 height_at_target = Some(p1.position.y + t * (p2.position.y - p1.position.y));
1937 } else {
1938 height_at_target = Some(result.points[i].position.y);
1939 }
1940 break;
1941 }
1942 }
1943
1944 match height_at_target {
1945 Some(height) => {
1946 let error = height - target_height;
1947 if error.abs() < 0.001 {
1950 return Ok(mid_angle);
1951 }
1952
1953 if (high_angle - low_angle).abs() < tolerance {
1957 if error.abs() < 0.01 {
1958 return Ok(mid_angle);
1960 }
1961 return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
1966 }
1967
1968 if error > 0.0 {
1969 high_angle = mid_angle;
1970 } else {
1971 low_angle = mid_angle;
1972 }
1973 }
1974 None => {
1975 low_angle = mid_angle;
1977
1978 if (high_angle - low_angle).abs() < tolerance {
1980 return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
1981 }
1982 }
1983 }
1984 }
1985
1986 Err("Failed to find zero angle".into())
1987}
1988
1989pub fn estimate_bc_from_trajectory(
1991 velocity: f64,
1992 mass: f64,
1993 diameter: f64,
1994 points: &[(f64, f64)], ) -> Result<f64, BallisticsError> {
1996 let mut best_bc = 0.5;
1998 let mut best_error = f64::MAX;
1999 let mut found_valid = false;
2000
2001 for bc in (100..1000).step_by(10) {
2003 let bc_value = bc as f64 / 1000.0;
2004
2005 let inputs = BallisticInputs {
2006 muzzle_velocity: velocity,
2007 bc_value,
2008 bullet_mass: mass,
2009 bullet_diameter: diameter,
2010 ..Default::default()
2011 };
2012
2013 let mut solver = TrajectorySolver::new(inputs, Default::default(), Default::default());
2014 solver.set_max_range(points.last().map(|(d, _)| *d * 1.5).unwrap_or(1000.0));
2016
2017 let result = match solver.solve() {
2018 Ok(r) => r,
2019 Err(_) => continue, };
2021
2022 let mut total_error = 0.0;
2024 for (target_dist, target_drop) in points {
2025 let mut calculated_drop = None;
2027 for i in 0..result.points.len() {
2028 if result.points[i].position.x >= *target_dist {
2029 if i > 0 {
2030 let p1 = &result.points[i - 1];
2032 let p2 = &result.points[i];
2033 let t = (target_dist - p1.position.x) / (p2.position.x - p1.position.x);
2034 calculated_drop =
2035 Some(-(p1.position.y + t * (p2.position.y - p1.position.y)));
2036 } else {
2037 calculated_drop = Some(-result.points[i].position.y);
2038 }
2039 break;
2040 }
2041 }
2042
2043 if let Some(drop) = calculated_drop {
2044 let error = (drop - target_drop).abs();
2045 total_error += error * error;
2046 }
2047 }
2048
2049 if total_error < best_error {
2050 best_error = total_error;
2051 best_bc = bc_value;
2052 found_valid = true;
2053 }
2054 }
2055
2056 if !found_valid {
2057 return Err(BallisticsError::from("Unable to estimate BC from provided data. Check that drop values are in correct units.".to_string()));
2058 }
2059
2060 Ok(best_bc)
2061}
2062
2063fn calculate_air_density(atmosphere: &AtmosphericConditions) -> f64 {
2065 crate::atmosphere::calculate_atmosphere(
2073 atmosphere.altitude,
2074 crate::atmosphere::resolve_station_temperature(atmosphere.temperature, atmosphere.altitude),
2075 crate::atmosphere::resolve_station_pressure(atmosphere.pressure, atmosphere.altitude),
2076 atmosphere.humidity,
2077 )
2078 .0
2079}
2080
2081use rand;
2083use rand_distr;
2084
2085#[cfg(test)]
2086mod ground_termination_tests {
2087 use super::*;
2088
2089 #[test]
2094 fn rk4_and_rk45_descend_to_ground_threshold() {
2095 for adaptive in [false, true] {
2096 let mut inputs = BallisticInputs::default();
2097 inputs.muzzle_angle = 0.1; inputs.use_rk4 = true;
2099 inputs.use_adaptive_rk45 = adaptive;
2100 assert_eq!(inputs.ground_threshold, -100.0, "default ground_threshold is -100 m");
2101
2102 let mut solver = TrajectorySolver::new(
2103 inputs,
2104 WindConditions::default(),
2105 AtmosphericConditions::default(),
2106 );
2107 solver.set_max_range(1.0e7);
2109
2110 let result = solver.solve().expect("solve should succeed");
2111 let final_y = result
2112 .points
2113 .last()
2114 .expect("trajectory has points")
2115 .position
2116 .y;
2117 assert!(
2118 final_y < -1.0,
2119 "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
2120 past launch level toward the ground_threshold floor, not stop at y = 0"
2121 );
2122 }
2123 }
2124}
2125
2126#[cfg(test)]
2127mod coriolis_direction_tests {
2128 use super::*;
2129 use std::f64::consts::FRAC_PI_2;
2130
2131 fn vertical_at(shot_azimuth: f64, range_m: f64) -> f64 {
2134 let mut inputs = BallisticInputs::default();
2135 inputs.muzzle_velocity = 800.0;
2136 inputs.bc_value = 0.5;
2137 inputs.bc_type = DragModel::G7;
2138 inputs.muzzle_angle = 0.02; inputs.enable_coriolis = true;
2140 inputs.latitude = Some(45.0);
2141 inputs.shot_azimuth = shot_azimuth;
2142 inputs.ground_threshold = f64::NEG_INFINITY; let mut solver = TrajectorySolver::new(
2144 inputs,
2145 WindConditions::default(),
2146 AtmosphericConditions::default(),
2147 );
2148 solver.set_max_range(range_m + 50.0);
2149 let r = solver.solve().expect("solve");
2150 let pts = &r.points;
2151 for i in 1..pts.len() {
2152 if pts[i].position.x >= range_m {
2153 let p1 = &pts[i - 1];
2154 let p2 = &pts[i];
2155 let t = (range_m - p1.position.x) / (p2.position.x - p1.position.x);
2156 return p1.position.y + t * (p2.position.y - p1.position.y);
2157 }
2158 }
2159 panic!("range {range_m} not reached");
2160 }
2161
2162 #[test]
2167 fn eotvos_east_higher_than_west() {
2168 let range = 600.0;
2169 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!(
2173 east > west,
2174 "east ({east:.5}) must be higher than west ({west:.5}) at {range} m (Eötvös)"
2175 );
2176 assert!(
2177 east > north && north > west,
2178 "north ({north:.5}) must lie between east ({east:.5}) and west ({west:.5})"
2179 );
2180 assert!(
2181 (east - west) > 1e-3,
2182 "E-W vertical separation ({:.6} m) should be physically meaningful, not FP noise",
2183 east - west
2184 );
2185 }
2186}