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 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,
110 pub enable_magnus: bool, pub enable_coriolis: bool, pub use_powder_sensitivity: bool,
113 pub powder_temp_sensitivity: f64,
114 pub powder_temp: f64, pub tipoff_yaw: f64, pub tipoff_decay_distance: f64, pub use_bc_segments: bool,
118 pub bc_segments: Option<Vec<(f64, f64)>>, pub bc_segments_data: Option<Vec<crate::BCSegmentData>>, pub use_enhanced_spin_drift: bool,
121 pub use_form_factor: bool,
122 pub enable_wind_shear: bool,
123 pub wind_shear_model: String,
124 pub enable_trajectory_sampling: bool,
125 pub sample_interval: f64, pub enable_pitch_damping: bool,
127 pub enable_precession_nutation: bool,
128 pub enable_aerodynamic_jump: bool,
131 pub use_cluster_bc: bool, pub custom_drag_table: Option<crate::drag::DragTable>,
135
136 pub bc_type_str: Option<String>,
138}
139
140impl Default for BallisticInputs {
141 fn default() -> Self {
142 let mass_kg = 0.01;
143 let diameter_m = 0.00762;
144 let bc = 0.5;
145 let muzzle_angle_rad = 0.0;
146 let bc_type = DragModel::G1;
147
148 Self {
149 bc_value: bc,
151 bc_type,
152 bullet_mass: mass_kg,
153 muzzle_velocity: 800.0,
154 bullet_diameter: diameter_m,
155 bullet_length: diameter_m * 4.5, muzzle_angle: muzzle_angle_rad,
159 target_distance: 100.0,
160 azimuth_angle: 0.0,
161 shooting_angle: 0.0,
162 sight_height: 0.05,
163 muzzle_height: 0.0, target_height: 0.0, ground_threshold: -100.0, altitude: 0.0,
169 temperature: 15.0,
170 pressure: 1013.25, humidity: 0.5, latitude: None,
173
174 wind_speed: 0.0,
176 wind_angle: 0.0,
177
178 twist_rate: 12.0, is_twist_right: true,
181 caliber_inches: diameter_m / 0.0254, weight_grains: mass_kg / 0.00006479891, manufacturer: None,
184 bullet_model: None,
185 bullet_id: None,
186 bullet_cluster: None,
187
188 use_rk4: true, use_adaptive_rk45: true, enable_advanced_effects: false,
194 enable_magnus: false,
195 enable_coriolis: false,
196 use_powder_sensitivity: false,
197 powder_temp_sensitivity: 0.0,
198 powder_temp: 15.0,
199 tipoff_yaw: 0.0,
200 tipoff_decay_distance: 50.0,
201 use_bc_segments: false,
202 bc_segments: None,
203 bc_segments_data: None,
204 use_enhanced_spin_drift: false,
205 use_form_factor: false,
206 enable_wind_shear: false,
207 wind_shear_model: "none".to_string(),
208 enable_trajectory_sampling: false,
209 sample_interval: 10.0, enable_pitch_damping: false,
211 enable_precession_nutation: false,
212 enable_aerodynamic_jump: false,
213 use_cluster_bc: false, custom_drag_table: None,
217
218 bc_type_str: None,
220 }
221 }
222}
223
224#[derive(Debug, Clone)]
226pub struct WindConditions {
227 pub speed: f64, pub direction: f64, }
230
231impl Default for WindConditions {
232 fn default() -> Self {
233 Self {
234 speed: 0.0,
235 direction: 0.0,
236 }
237 }
238}
239
240#[derive(Debug, Clone)]
242pub struct AtmosphericConditions {
243 pub temperature: f64, pub pressure: f64, pub humidity: f64, pub altitude: f64, }
248
249impl Default for AtmosphericConditions {
250 fn default() -> Self {
251 Self {
252 temperature: 15.0,
253 pressure: 1013.25,
254 humidity: 50.0,
255 altitude: 0.0,
256 }
257 }
258}
259
260#[derive(Debug, Clone)]
262pub struct TrajectoryPoint {
263 pub time: f64,
264 pub position: Vector3<f64>,
265 pub velocity_magnitude: f64,
266 pub kinetic_energy: f64,
267}
268
269#[derive(Debug, Clone)]
271pub struct TrajectoryResult {
272 pub max_range: f64,
273 pub max_height: f64,
274 pub time_of_flight: f64,
275 pub impact_velocity: f64,
276 pub impact_energy: f64,
277 pub points: Vec<TrajectoryPoint>,
278 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>,
287}
288
289impl TrajectoryResult {
290 pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
294 if self.points.is_empty() {
295 return None;
296 }
297
298 for i in 0..self.points.len() - 1 {
300 let p1 = &self.points[i];
301 let p2 = &self.points[i + 1];
302
303 if p1.position.x <= target_range && p2.position.x >= target_range {
305 let dx = p2.position.x - p1.position.x;
307 if dx.abs() < 1e-10 {
308 return Some(p1.position);
309 }
310 let t = (target_range - p1.position.x) / dx;
311
312 return Some(Vector3::new(
314 target_range,
315 p1.position.y + t * (p2.position.y - p1.position.y),
316 p1.position.z + t * (p2.position.z - p1.position.z),
317 ));
318 }
319 }
320
321 self.points.last().map(|p| p.position)
323 }
324}
325
326pub struct TrajectorySolver {
328 inputs: BallisticInputs,
329 wind: WindConditions,
330 atmosphere: AtmosphericConditions,
331 max_range: f64,
332 time_step: f64,
333 cluster_bc: Option<ClusterBCDegradation>,
334}
335
336impl TrajectorySolver {
337 pub fn new(
338 mut inputs: BallisticInputs,
339 wind: WindConditions,
340 atmosphere: AtmosphericConditions,
341 ) -> Self {
342 inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
344 inputs.weight_grains = inputs.bullet_mass / 0.00006479891;
345
346 let cluster_bc = if inputs.use_cluster_bc {
348 Some(ClusterBCDegradation::new())
349 } else {
350 None
351 };
352
353 Self {
354 inputs,
355 wind,
356 atmosphere,
357 max_range: 1000.0,
358 time_step: 0.001,
359 cluster_bc,
360 }
361 }
362
363 pub fn set_max_range(&mut self, range: f64) {
364 self.max_range = range;
365 }
366
367 pub fn set_time_step(&mut self, step: f64) {
368 self.time_step = step;
369 }
370
371 fn launch_angles_from(
380 &self,
381 aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
382 ) -> (f64, f64) {
383 let elev = self.inputs.muzzle_angle;
384 let azim = self.inputs.azimuth_angle;
385 match aj {
386 Some(c) => {
387 const MOA_PER_RAD: f64 = 3437.7467707849;
389 (
390 elev + c.vertical_jump_moa / MOA_PER_RAD,
391 azim + c.horizontal_jump_moa / MOA_PER_RAD,
392 )
393 }
394 None => (elev, azim),
395 }
396 }
397
398 fn aerodynamic_jump_components(
406 &self,
407 ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
408 if !self.inputs.enable_aerodynamic_jump {
409 return None;
410 }
411 let diameter_m = self.inputs.bullet_diameter;
415 if !(self.inputs.twist_rate.is_finite() && self.inputs.twist_rate != 0.0)
416 || !(diameter_m.is_finite() && diameter_m > 0.0)
417 || !(self.inputs.bullet_length.is_finite() && self.inputs.bullet_length > 0.0)
418 || !self.inputs.muzzle_velocity.is_finite()
419 {
420 return None;
421 }
422
423 let sg = crate::stability::compute_stability_coefficient(
425 &self.inputs,
426 (
427 self.atmosphere.altitude,
428 self.atmosphere.temperature,
429 self.atmosphere.pressure,
430 0.0,
431 ),
432 );
433 if !(sg.is_finite() && sg > 0.0) {
434 return None;
435 }
436 let length_calibers = self.inputs.bullet_length / diameter_m;
437
438 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
442 let crosswind_z_mps = self.wind.speed * self.wind.direction.sin();
443 let crosswind_from_right_mph = -crosswind_z_mps * MS_TO_MPH;
444
445 let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
446 sg,
447 length_calibers,
448 crosswind_from_right_mph,
449 self.inputs.is_twist_right,
450 );
451 if !vertical_jump_moa.is_finite() {
452 return None;
453 }
454
455 const MOA_PER_RAD: f64 = 3437.7467707849;
456 Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
457 vertical_jump_moa,
458 horizontal_jump_moa: 0.0,
460 jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
461 magnus_component_moa: 0.0,
462 yaw_component_moa: 0.0,
463 stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
464 })
465 }
466
467 fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
468 let model = if self.inputs.wind_shear_model == "logarithmic" {
477 WindShearModel::Logarithmic
478 } else {
479 WindShearModel::PowerLaw };
481 let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
482
483 Vector3::new(
484 self.wind.speed * self.wind.direction.cos() * speed_ratio, 0.0,
486 self.wind.speed * self.wind.direction.sin() * speed_ratio, )
488 }
489
490 pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
491 let mut result = if self.inputs.use_rk4 {
492 if self.inputs.use_adaptive_rk45 {
493 self.solve_rk45()?
494 } else {
495 self.solve_rk4()?
496 }
497 } else {
498 self.solve_euler()?
499 };
500 self.apply_spin_drift(&mut result);
501 Ok(result)
502 }
503
504 fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
510 if !self.inputs.use_enhanced_spin_drift {
511 return;
512 }
513 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 {
517 return;
518 }
519
520 let length_in = if self.inputs.bullet_length > 0.0 {
522 self.inputs.bullet_length / 0.0254
523 } else {
524 4.5 * d_in
525 };
526 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 {
534 (temp_k / 288.15) * (1013.25 / press_hpa)
535 } else {
536 1.0
537 };
538 let sg =
539 crate::spin_drift::miller_stability(d_in, m_gr, twist_in, length_in) * density_correction;
540 let sign = if self.inputs.is_twist_right { 1.0 } else { -1.0 };
541
542 for p in result.points.iter_mut() {
543 if p.time <= 0.0 {
544 continue;
545 }
546 let sd_in = 1.25 * (sg + 1.2) * p.time.powf(1.83); p.position.z += sign * sd_in * 0.0254; }
549
550 if let Some(samples) = result.sampled_points.as_mut() {
554 for s in samples.iter_mut() {
555 if s.time_s <= 0.0 {
556 continue;
557 }
558 let sd_in = 1.25 * (sg + 1.2) * s.time_s.powf(1.83);
559 s.wind_drift_m += sign * sd_in * 0.0254;
560 }
561 }
562 }
563
564 fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
565 let mut time = 0.0;
567 let mut position = Vector3::new(
570 0.0,
571 self.inputs.muzzle_height, 0.0,
573 );
574 let aj_components = self.aerodynamic_jump_components();
580 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
581 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
582 let mut velocity = Vector3::new(
583 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
587
588 let mut points = Vec::new();
589 let mut max_height = position.y;
590 let mut min_pitch_damping = 1.0; let mut transonic_mach = None; let mut angular_state = if self.inputs.enable_precession_nutation {
595 Some(AngularState {
596 pitch_angle: 0.001, yaw_angle: 0.001,
598 pitch_rate: 0.0,
599 yaw_rate: 0.0,
600 precession_angle: 0.0,
601 nutation_phase: 0.0,
602 })
603 } else {
604 None
605 };
606 let mut max_yaw_angle = 0.0;
607 let mut max_precession_angle = 0.0;
608
609 let air_density = calculate_air_density(&self.atmosphere);
611
612 let wind_vector = Vector3::new(
614 self.wind.speed * self.wind.direction.cos(), 0.0,
616 self.wind.speed * self.wind.direction.sin(), );
618
619 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
622 self.inputs.bullet_model.as_deref().unwrap_or("default"),
623 );
624
625 while position.x < self.max_range && position.y > self.inputs.ground_threshold && time < 100.0 {
627 let velocity_magnitude = velocity.magnitude();
629 let kinetic_energy =
630 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
631
632 points.push(TrajectoryPoint {
633 time,
634 position: position,
635 velocity_magnitude,
636 kinetic_energy,
637 });
638
639 #[cfg(debug_assertions)]
643 if points.len() == 1 || points.len() % 100 == 0 {
644 eprintln!("Trajectory point {}: time={:.3}s, downrange={:.2}m, vertical={:.2}m, lateral={:.2}m, vel={:.1}m/s",
645 points.len(), time, position.x, position.y, position.z, velocity_magnitude);
646 }
647
648 if position.y > max_height {
650 max_height = position.y;
651 }
652
653 if self.inputs.enable_pitch_damping {
655 let temp_c = self.atmosphere.temperature;
656 let temp_k = temp_c + 273.15;
657 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
658 let mach = velocity_magnitude / speed_of_sound;
659
660 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
662 transonic_mach = Some(mach);
663 }
664
665 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
667
668 if pitch_damping < min_pitch_damping {
670 min_pitch_damping = pitch_damping;
671 }
672 }
673
674 if self.inputs.enable_precession_nutation {
676 if let Some(ref mut state) = angular_state {
677 let velocity_magnitude = velocity.magnitude();
678 let temp_c = self.atmosphere.temperature;
679 let temp_k = temp_c + 273.15;
680 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
681 let mach = velocity_magnitude / speed_of_sound;
682
683 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
685 let velocity_fps = velocity_magnitude * 3.28084;
686 let twist_rate_ft = self.inputs.twist_rate / 12.0;
687 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
688 } else {
689 0.0
690 };
691
692 let params = PrecessionNutationParams {
694 mass_kg: self.inputs.bullet_mass,
695 caliber_m: self.inputs.bullet_diameter,
696 length_m: self.inputs.bullet_length,
697 spin_rate_rad_s,
698 spin_inertia: 6.94e-8, transverse_inertia: 9.13e-7, velocity_mps: velocity_magnitude,
701 air_density_kg_m3: air_density,
702 mach,
703 pitch_damping_coeff: -0.8,
704 nutation_damping_factor: 0.05,
705 };
706
707 *state = calculate_combined_angular_motion(
709 ¶ms,
710 state,
711 time,
712 self.time_step,
713 0.001, );
715
716 if state.yaw_angle.abs() > max_yaw_angle {
718 max_yaw_angle = state.yaw_angle.abs();
719 }
720 if state.precession_angle.abs() > max_precession_angle {
721 max_precession_angle = state.precession_angle.abs();
722 }
723 }
724 }
725
726 let acceleration =
733 self.calculate_acceleration(&position, &velocity, air_density, &wind_vector);
734
735 velocity += acceleration * self.time_step;
737 position += velocity * self.time_step;
738 time += self.time_step;
739 }
740
741 let last_point = points.last().ok_or("No trajectory points generated")?;
743
744 let sampled_points = if self.inputs.enable_trajectory_sampling {
746 let trajectory_data = TrajectoryData {
747 times: points.iter().map(|p| p.time).collect(),
748 positions: points.iter().map(|p| p.position).collect(),
749 velocities: points
750 .iter()
751 .map(|p| {
752 Vector3::new(0.0, 0.0, p.velocity_magnitude)
754 })
755 .collect(),
756 transonic_distances: Vec::new(), };
758
759 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
764 let outputs = TrajectoryOutputs {
765 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
767 time_of_flight_s: last_point.time,
768 max_ord_dist_horiz_m: max_height,
769 sight_height_m: sight_position_m,
770 };
771
772 let samples = sample_trajectory(
774 &trajectory_data,
775 &outputs,
776 self.inputs.sample_interval,
777 self.inputs.bullet_mass,
778 );
779 Some(samples)
780 } else {
781 None
782 };
783
784 Ok(TrajectoryResult {
785 max_range: last_point.position.x, max_height,
787 time_of_flight: last_point.time,
788 impact_velocity: last_point.velocity_magnitude,
789 impact_energy: last_point.kinetic_energy,
790 points,
791 sampled_points,
792 min_pitch_damping: if self.inputs.enable_pitch_damping {
793 Some(min_pitch_damping)
794 } else {
795 None
796 },
797 transonic_mach,
798 angular_state,
799 max_yaw_angle: if self.inputs.enable_precession_nutation {
800 Some(max_yaw_angle)
801 } else {
802 None
803 },
804 max_precession_angle: if self.inputs.enable_precession_nutation {
805 Some(max_precession_angle)
806 } else {
807 None
808 },
809 aerodynamic_jump: aj_components,
810 })
811 }
812
813 fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
814 let mut time = 0.0;
816 let mut position = Vector3::new(
820 0.0,
821 self.inputs.muzzle_height, 0.0,
823 );
824
825 let aj_components = self.aerodynamic_jump_components();
831 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
832 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
833 let mut velocity = Vector3::new(
834 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
838
839 let mut points = Vec::new();
840 let mut max_height = position.y;
841 let mut min_pitch_damping = 1.0; let mut transonic_mach = None; let mut angular_state = if self.inputs.enable_precession_nutation {
846 Some(AngularState {
847 pitch_angle: 0.001, yaw_angle: 0.001,
849 pitch_rate: 0.0,
850 yaw_rate: 0.0,
851 precession_angle: 0.0,
852 nutation_phase: 0.0,
853 })
854 } else {
855 None
856 };
857 let mut max_yaw_angle = 0.0;
858 let mut max_precession_angle = 0.0;
859
860 let air_density = calculate_air_density(&self.atmosphere);
862
863 let wind_vector = Vector3::new(
865 self.wind.speed * self.wind.direction.cos(), 0.0,
867 self.wind.speed * self.wind.direction.sin(), );
869
870 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
873 self.inputs.bullet_model.as_deref().unwrap_or("default"),
874 );
875
876 while position.x < self.max_range && position.y > self.inputs.ground_threshold && time < 100.0 {
878 let velocity_magnitude = velocity.magnitude();
880 let kinetic_energy =
881 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
882
883 points.push(TrajectoryPoint {
884 time,
885 position: position,
886 velocity_magnitude,
887 kinetic_energy,
888 });
889
890 if position.y > max_height {
891 max_height = position.y;
892 }
893
894 if self.inputs.enable_pitch_damping {
896 let temp_c = self.atmosphere.temperature;
897 let temp_k = temp_c + 273.15;
898 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
899 let mach = velocity_magnitude / speed_of_sound;
900
901 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
903 transonic_mach = Some(mach);
904 }
905
906 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
908
909 if pitch_damping < min_pitch_damping {
911 min_pitch_damping = pitch_damping;
912 }
913 }
914
915 if self.inputs.enable_precession_nutation {
917 if let Some(ref mut state) = angular_state {
918 let velocity_magnitude = velocity.magnitude();
919 let temp_c = self.atmosphere.temperature;
920 let temp_k = temp_c + 273.15;
921 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
922 let mach = velocity_magnitude / speed_of_sound;
923
924 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
926 let velocity_fps = velocity_magnitude * 3.28084;
927 let twist_rate_ft = self.inputs.twist_rate / 12.0;
928 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
929 } else {
930 0.0
931 };
932
933 let params = PrecessionNutationParams {
935 mass_kg: self.inputs.bullet_mass,
936 caliber_m: self.inputs.bullet_diameter,
937 length_m: self.inputs.bullet_length,
938 spin_rate_rad_s,
939 spin_inertia: 6.94e-8, transverse_inertia: 9.13e-7, velocity_mps: velocity_magnitude,
942 air_density_kg_m3: air_density,
943 mach,
944 pitch_damping_coeff: -0.8,
945 nutation_damping_factor: 0.05,
946 };
947
948 *state = calculate_combined_angular_motion(
950 ¶ms,
951 state,
952 time,
953 self.time_step,
954 0.001, );
956
957 if state.yaw_angle.abs() > max_yaw_angle {
959 max_yaw_angle = state.yaw_angle.abs();
960 }
961 if state.precession_angle.abs() > max_precession_angle {
962 max_precession_angle = state.precession_angle.abs();
963 }
964 }
965 }
966
967 let dt = self.time_step;
969
970 let acc1 = self.calculate_acceleration(&position, &velocity, air_density, &wind_vector);
972
973 let pos2 = position + velocity * (dt * 0.5);
975 let vel2 = velocity + acc1 * (dt * 0.5);
976 let acc2 = self.calculate_acceleration(&pos2, &vel2, air_density, &wind_vector);
977
978 let pos3 = position + vel2 * (dt * 0.5);
980 let vel3 = velocity + acc2 * (dt * 0.5);
981 let acc3 = self.calculate_acceleration(&pos3, &vel3, air_density, &wind_vector);
982
983 let pos4 = position + vel3 * dt;
985 let vel4 = velocity + acc3 * dt;
986 let acc4 = self.calculate_acceleration(&pos4, &vel4, air_density, &wind_vector);
987
988 position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
990 velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
991 time += dt;
992 }
993
994 let last_point = points.last().ok_or("No trajectory points generated")?;
996
997 let sampled_points = if self.inputs.enable_trajectory_sampling {
999 let trajectory_data = TrajectoryData {
1000 times: points.iter().map(|p| p.time).collect(),
1001 positions: points.iter().map(|p| p.position).collect(),
1002 velocities: points
1003 .iter()
1004 .map(|p| {
1005 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1007 })
1008 .collect(),
1009 transonic_distances: Vec::new(), };
1011
1012 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1017 let outputs = TrajectoryOutputs {
1018 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
1020 time_of_flight_s: last_point.time,
1021 max_ord_dist_horiz_m: max_height,
1022 sight_height_m: sight_position_m,
1023 };
1024
1025 let samples = sample_trajectory(
1027 &trajectory_data,
1028 &outputs,
1029 self.inputs.sample_interval,
1030 self.inputs.bullet_mass,
1031 );
1032 Some(samples)
1033 } else {
1034 None
1035 };
1036
1037 Ok(TrajectoryResult {
1038 max_range: last_point.position.x, max_height,
1040 time_of_flight: last_point.time,
1041 impact_velocity: last_point.velocity_magnitude,
1042 impact_energy: last_point.kinetic_energy,
1043 points,
1044 sampled_points,
1045 min_pitch_damping: if self.inputs.enable_pitch_damping {
1046 Some(min_pitch_damping)
1047 } else {
1048 None
1049 },
1050 transonic_mach,
1051 angular_state,
1052 max_yaw_angle: if self.inputs.enable_precession_nutation {
1053 Some(max_yaw_angle)
1054 } else {
1055 None
1056 },
1057 max_precession_angle: if self.inputs.enable_precession_nutation {
1058 Some(max_precession_angle)
1059 } else {
1060 None
1061 },
1062 aerodynamic_jump: aj_components,
1063 })
1064 }
1065
1066 fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
1067 let mut time = 0.0;
1069 let mut position = Vector3::new(
1072 0.0,
1073 self.inputs.muzzle_height, 0.0,
1075 );
1076
1077 let aj_components = self.aerodynamic_jump_components();
1083 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1084 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1085 let mut velocity = Vector3::new(
1086 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1090
1091 let mut points = Vec::new();
1092 let mut max_height = position.y;
1093 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;
1101 const MAX_ITERATIONS: usize = 100000;
1102
1103 let air_density = calculate_air_density(&self.atmosphere);
1106 let wind_vector = Vector3::new(
1107 self.wind.speed * self.wind.direction.cos(), 0.0,
1109 self.wind.speed * self.wind.direction.sin(), );
1111
1112 while position.x < self.max_range
1113 && position.y > self.inputs.ground_threshold
1114 && time < 100.0
1115 {
1116 iteration_count += 1;
1118 if iteration_count > MAX_ITERATIONS {
1119 break; }
1121
1122 let velocity_magnitude = velocity.magnitude();
1124 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
1125
1126 points.push(TrajectoryPoint {
1127 time,
1128 position: position,
1129 velocity_magnitude,
1130 kinetic_energy,
1131 });
1132
1133 if position.y > max_height {
1134 max_height = position.y;
1135 }
1136
1137 let (new_pos, new_vel, new_dt) = self.rk45_step(
1139 &position,
1140 &velocity,
1141 dt,
1142 air_density,
1143 &wind_vector,
1144 tolerance,
1145 );
1146
1147 position = new_pos;
1152 velocity = new_vel;
1153 time += dt;
1154
1155 dt = (safety_factor * new_dt).clamp(min_dt, max_dt);
1157 }
1158
1159 if points.is_empty() {
1161 return Err(BallisticsError::from("No trajectory points calculated"));
1162 }
1163
1164 let last_point = points.last().unwrap();
1165
1166 let sampled_points = if self.inputs.enable_trajectory_sampling {
1168 let trajectory_data = TrajectoryData {
1170 times: points.iter().map(|p| p.time).collect(),
1171 positions: points.iter().map(|p| p.position).collect(),
1172 velocities: points
1173 .iter()
1174 .map(|p| {
1175 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1177 })
1178 .collect(),
1179 transonic_distances: Vec::new(),
1180 };
1181
1182 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1187 let outputs = TrajectoryOutputs {
1188 target_distance_horiz_m: last_point.position.x,
1189 target_vertical_height_m: sight_position_m,
1190 time_of_flight_s: last_point.time,
1191 max_ord_dist_horiz_m: max_height,
1192 sight_height_m: sight_position_m,
1193 };
1194
1195 let samples = sample_trajectory(
1196 &trajectory_data,
1197 &outputs,
1198 self.inputs.sample_interval,
1199 self.inputs.bullet_mass,
1200 );
1201 Some(samples)
1202 } else {
1203 None
1204 };
1205
1206 Ok(TrajectoryResult {
1207 max_range: last_point.position.x, max_height,
1209 time_of_flight: last_point.time,
1210 impact_velocity: last_point.velocity_magnitude,
1211 impact_energy: last_point.kinetic_energy,
1212 points,
1213 sampled_points,
1214 min_pitch_damping: None,
1215 transonic_mach: None,
1216 angular_state: None,
1217 max_yaw_angle: None,
1218 max_precession_angle: None,
1219 aerodynamic_jump: aj_components,
1220 })
1221 }
1222
1223 fn rk45_step(
1224 &self,
1225 position: &Vector3<f64>,
1226 velocity: &Vector3<f64>,
1227 dt: f64,
1228 air_density: f64,
1229 wind_vector: &Vector3<f64>,
1230 tolerance: f64,
1231 ) -> (Vector3<f64>, Vector3<f64>, f64) {
1232 const A21: f64 = 1.0 / 5.0;
1234 const A31: f64 = 3.0 / 40.0;
1235 const A32: f64 = 9.0 / 40.0;
1236 const A41: f64 = 44.0 / 45.0;
1237 const A42: f64 = -56.0 / 15.0;
1238 const A43: f64 = 32.0 / 9.0;
1239 const A51: f64 = 19372.0 / 6561.0;
1240 const A52: f64 = -25360.0 / 2187.0;
1241 const A53: f64 = 64448.0 / 6561.0;
1242 const A54: f64 = -212.0 / 729.0;
1243 const A61: f64 = 9017.0 / 3168.0;
1244 const A62: f64 = -355.0 / 33.0;
1245 const A63: f64 = 46732.0 / 5247.0;
1246 const A64: f64 = 49.0 / 176.0;
1247 const A65: f64 = -5103.0 / 18656.0;
1248 const A71: f64 = 35.0 / 384.0;
1249 const A73: f64 = 500.0 / 1113.0;
1250 const A74: f64 = 125.0 / 192.0;
1251 const A75: f64 = -2187.0 / 6784.0;
1252 const A76: f64 = 11.0 / 84.0;
1253
1254 const B1: f64 = 35.0 / 384.0;
1256 const B3: f64 = 500.0 / 1113.0;
1257 const B4: f64 = 125.0 / 192.0;
1258 const B5: f64 = -2187.0 / 6784.0;
1259 const B6: f64 = 11.0 / 84.0;
1260
1261 const B1_ERR: f64 = 5179.0 / 57600.0;
1263 const B3_ERR: f64 = 7571.0 / 16695.0;
1264 const B4_ERR: f64 = 393.0 / 640.0;
1265 const B5_ERR: f64 = -92097.0 / 339200.0;
1266 const B6_ERR: f64 = 187.0 / 2100.0;
1267 const B7_ERR: f64 = 1.0 / 40.0;
1268
1269 let k1_v = self.calculate_acceleration(position, velocity, air_density, wind_vector);
1271 let k1_p = *velocity;
1272
1273 let p2 = position + dt * A21 * k1_p;
1274 let v2 = velocity + dt * A21 * k1_v;
1275 let k2_v = self.calculate_acceleration(&p2, &v2, air_density, wind_vector);
1276 let k2_p = v2;
1277
1278 let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
1279 let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
1280 let k3_v = self.calculate_acceleration(&p3, &v3, air_density, wind_vector);
1281 let k3_p = v3;
1282
1283 let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
1284 let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
1285 let k4_v = self.calculate_acceleration(&p4, &v4, air_density, wind_vector);
1286 let k4_p = v4;
1287
1288 let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
1289 let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
1290 let k5_v = self.calculate_acceleration(&p5, &v5, air_density, wind_vector);
1291 let k5_p = v5;
1292
1293 let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
1294 let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
1295 let k6_v = self.calculate_acceleration(&p6, &v6, air_density, wind_vector);
1296 let k6_p = v6;
1297
1298 let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
1299 let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
1300 let k7_v = self.calculate_acceleration(&p7, &v7, air_density, wind_vector);
1301 let k7_p = v7;
1302
1303 let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
1305 let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
1306
1307 let pos_err = position
1309 + dt * (B1_ERR * k1_p
1310 + B3_ERR * k3_p
1311 + B4_ERR * k4_p
1312 + B5_ERR * k5_p
1313 + B6_ERR * k6_p
1314 + B7_ERR * k7_p);
1315 let vel_err = velocity
1316 + dt * (B1_ERR * k1_v
1317 + B3_ERR * k3_v
1318 + B4_ERR * k4_v
1319 + B5_ERR * k5_v
1320 + B6_ERR * k6_v
1321 + B7_ERR * k7_v);
1322
1323 let pos_error = (new_pos - pos_err).magnitude();
1325 let vel_error = (new_vel - vel_err).magnitude();
1326 let error = (pos_error + vel_error) / (1.0 + position.magnitude() + velocity.magnitude());
1327
1328 let dt_new = if error < tolerance {
1330 dt * (tolerance / error).powf(0.2).min(2.0)
1331 } else {
1332 dt * (tolerance / error).powf(0.25).max(0.1)
1333 };
1334
1335 (new_pos, new_vel, dt_new)
1336 }
1337
1338 fn calculate_acceleration(
1339 &self,
1340 position: &Vector3<f64>,
1341 velocity: &Vector3<f64>,
1342 air_density: f64,
1343 wind_vector: &Vector3<f64>,
1344 ) -> Vector3<f64> {
1345 let actual_wind = if self.inputs.enable_wind_shear {
1347 self.get_wind_at_altitude(position.y)
1348 } else {
1349 *wind_vector
1350 };
1351
1352 let relative_velocity = velocity - actual_wind;
1353 let velocity_magnitude = relative_velocity.magnitude();
1354
1355 if velocity_magnitude < 0.001 {
1356 return Vector3::new(0.0, -crate::constants::G_ACCEL_MPS2, 0.0);
1357 }
1358
1359 let cd = self.calculate_drag_coefficient(velocity_magnitude);
1361
1362 let velocity_fps = velocity_magnitude * 3.28084;
1364
1365 let base_bc = if let Some(ref segments) = self.inputs.bc_segments_data {
1367 segments
1369 .iter()
1370 .find(|seg| velocity_fps >= seg.velocity_min && velocity_fps < seg.velocity_max)
1371 .map(|seg| seg.bc_value)
1372 .unwrap_or(self.inputs.bc_value)
1373 } else {
1374 self.inputs.bc_value
1375 };
1376
1377 let effective_bc = if let Some(ref cluster_bc) = self.cluster_bc {
1379 cluster_bc.apply_correction(
1380 base_bc,
1381 self.inputs.caliber_inches, self.inputs.weight_grains,
1383 velocity_fps,
1384 )
1385 } else {
1386 base_bc
1387 };
1388 let effective_bc = effective_bc.max(1e-6);
1392
1393 let cd_to_retard = 0.000683 * 0.30; let standard_factor = cd * cd_to_retard;
1399 let density_scale = air_density / 1.225; let a_drag_ft_s2 =
1403 (velocity_fps * velocity_fps) * standard_factor * density_scale / effective_bc;
1404 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
1408
1409 let mut accel = drag_acceleration + Vector3::new(0.0, -crate::constants::G_ACCEL_MPS2, 0.0);
1411
1412 if self.inputs.enable_coriolis {
1415 if let Some(lat_deg) = self.inputs.latitude {
1416 let omega_earth = 7.2921159e-5_f64; let lat = lat_deg.to_radians();
1418 let az = self.inputs.azimuth_angle;
1419 let omega = Vector3::new(
1425 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), );
1429 accel += -2.0 * omega.cross(velocity);
1434 }
1435 }
1436
1437 if self.inputs.enable_magnus
1439 && self.inputs.bullet_diameter > 0.0
1440 && self.inputs.twist_rate > 0.0
1441 {
1442 let (_, spin_rad_s) =
1443 crate::spin_drift::calculate_spin_rate(velocity_magnitude, self.inputs.twist_rate);
1444 let temp_k = self.atmosphere.temperature + 273.15;
1445 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
1446 let mach = velocity_magnitude / speed_of_sound;
1447
1448 let d_in = self.inputs.bullet_diameter / 0.0254;
1450 let m_gr = self.inputs.bullet_mass / 0.00006479891;
1451 let l_in = if self.inputs.bullet_length > 0.0 {
1452 self.inputs.bullet_length / 0.0254
1453 } else {
1454 4.5 * d_in
1455 };
1456 let press_hpa = self.atmosphere.pressure;
1460 let density_correction = if press_hpa > 0.0 && temp_k > 0.0 {
1461 (temp_k / 288.15) * (1013.25 / press_hpa)
1462 } else {
1463 1.0
1464 };
1465 let sg = crate::spin_drift::miller_stability(d_in, m_gr, self.inputs.twist_rate, l_in)
1466 * density_correction;
1467
1468 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
1470 sg,
1471 velocity_magnitude,
1472 spin_rad_s,
1473 0.0, 0.0, air_density,
1476 d_in,
1477 l_in,
1478 m_gr,
1479 mach,
1480 "match",
1481 false,
1482 );
1483
1484 let diameter_m = self.inputs.bullet_diameter; let spin_param = spin_rad_s * diameter_m / (2.0 * velocity_magnitude);
1487 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
1488 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
1489 let magnus_force = 0.5
1490 * air_density
1491 * velocity_magnitude.powi(2)
1492 * area
1493 * c_np
1494 * spin_param
1495 * yaw_rad.sin();
1496
1497 let velocity_unit = relative_velocity / velocity_magnitude;
1500 let up = Vector3::new(0.0, 1.0, 0.0);
1501 let mut dir = velocity_unit.cross(&up);
1502 let dir_norm = dir.norm();
1503 if dir_norm > 1e-12 && magnus_force.abs() > 1e-12 {
1504 dir /= dir_norm;
1505 if !self.inputs.is_twist_right {
1506 dir = -dir;
1507 }
1508 accel += (magnus_force / self.inputs.bullet_mass) * dir;
1509 }
1510 }
1511
1512 accel
1513 }
1514
1515 fn calculate_drag_coefficient(&self, velocity: f64) -> f64 {
1516 let temp_c = self.atmosphere.temperature;
1518 let temp_k = temp_c + 273.15;
1519 let gamma = 1.4; let r_specific = 287.05; let speed_of_sound = (gamma * r_specific * temp_k).sqrt();
1522 let mach = velocity / speed_of_sound;
1523
1524 if let Some(ref table) = self.inputs.custom_drag_table {
1528 return table.interpolate(mach);
1529 }
1530
1531 let base_cd = crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type);
1533
1534 let bc_type_str: &str = match self.inputs.bc_type {
1538 crate::DragModel::G1 => "G1",
1539 crate::DragModel::G2 => "G2",
1540 crate::DragModel::G5 => "G5",
1541 crate::DragModel::G6 => "G6",
1542 crate::DragModel::G7 => "G7",
1543 crate::DragModel::G8 => "G8",
1544 crate::DragModel::GI => "GI",
1545 crate::DragModel::GS => "GS",
1546 };
1547
1548 let projectile_shape = crate::transonic_drag::resolve_projectile_shape(
1551 self.inputs.bullet_model.as_deref(),
1552 self.inputs.caliber_inches,
1553 self.inputs.bullet_mass / 0.00006479891, bc_type_str,
1555 );
1556
1557 let include_wave_drag = false;
1563 let cd = transonic_correction(mach, base_cd, projectile_shape, include_wave_drag);
1564
1565 crate::form_factor::apply_form_factor_to_drag(
1570 cd,
1571 self.inputs.bullet_model.as_deref(),
1572 &self.inputs.bc_type,
1573 self.inputs.use_form_factor,
1574 )
1575 }
1576}
1577
1578#[derive(Debug, Clone)]
1580pub struct MonteCarloParams {
1581 pub num_simulations: usize,
1582 pub velocity_std_dev: f64,
1583 pub angle_std_dev: f64,
1584 pub bc_std_dev: f64,
1585 pub wind_speed_std_dev: f64,
1586 pub target_distance: Option<f64>,
1587 pub base_wind_speed: f64,
1588 pub base_wind_direction: f64,
1589 pub azimuth_std_dev: f64, }
1591
1592impl Default for MonteCarloParams {
1593 fn default() -> Self {
1594 Self {
1595 num_simulations: 1000,
1596 velocity_std_dev: 1.0,
1597 angle_std_dev: 0.001,
1598 bc_std_dev: 0.01,
1599 wind_speed_std_dev: 1.0,
1600 target_distance: None,
1601 base_wind_speed: 0.0,
1602 base_wind_direction: 0.0,
1603 azimuth_std_dev: 0.001, }
1605 }
1606}
1607
1608#[derive(Debug, Clone)]
1610pub struct MonteCarloResults {
1611 pub ranges: Vec<f64>,
1612 pub impact_velocities: Vec<f64>,
1613 pub impact_positions: Vec<Vector3<f64>>,
1614}
1615
1616pub fn run_monte_carlo(
1618 base_inputs: BallisticInputs,
1619 params: MonteCarloParams,
1620) -> Result<MonteCarloResults, BallisticsError> {
1621 let base_wind = WindConditions {
1622 speed: params.base_wind_speed,
1623 direction: params.base_wind_direction,
1624 };
1625 run_monte_carlo_with_wind(base_inputs, base_wind, params)
1626}
1627
1628pub fn run_monte_carlo_with_wind(
1630 base_inputs: BallisticInputs,
1631 base_wind: WindConditions,
1632 params: MonteCarloParams,
1633) -> Result<MonteCarloResults, BallisticsError> {
1634 use rand::thread_rng;
1635 use rand_distr::{Distribution, Normal};
1636
1637 let mut rng = thread_rng();
1638 let mut ranges = Vec::new();
1639 let mut impact_velocities = Vec::new();
1640 let mut impact_positions = Vec::new();
1641
1642 let baseline_solver =
1644 TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), Default::default());
1645 let baseline_result = baseline_solver.solve()?;
1646
1647 let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
1649
1650 let baseline_at_target = baseline_result
1652 .position_at_range(target_distance)
1653 .ok_or("Could not interpolate baseline at target distance")?;
1654
1655 let velocity_dist = Normal::new(base_inputs.muzzle_velocity, params.velocity_std_dev)
1657 .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
1658 let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
1659 .map_err(|e| format!("Invalid angle distribution: {}", e))?;
1660 let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
1661 .map_err(|e| format!("Invalid BC distribution: {}", e))?;
1662 let wind_speed_dist = Normal::new(base_wind.speed, params.wind_speed_std_dev)
1663 .map_err(|e| format!("Invalid wind speed distribution: {}", e))?;
1664 let wind_dir_dist =
1669 Normal::new(base_wind.direction, params.wind_speed_std_dev * 0.1)
1670 .map_err(|e| format!("Invalid wind direction distribution: {}", e))?;
1671 let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
1672 .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
1673
1674 let pointing_error_dist = Normal::new(0.0, params.angle_std_dev * target_distance)
1676 .map_err(|e| format!("Invalid pointing distribution: {}", e))?;
1677
1678 for _ in 0..params.num_simulations {
1679 let mut inputs = base_inputs.clone();
1681 inputs.muzzle_velocity = velocity_dist.sample(&mut rng).max(0.0);
1682 inputs.muzzle_angle = angle_dist.sample(&mut rng);
1683 inputs.bc_value = bc_dist.sample(&mut rng).max(0.01);
1684 inputs.azimuth_angle = azimuth_dist.sample(&mut rng); let wind = WindConditions {
1688 speed: wind_speed_dist.sample(&mut rng).abs(),
1689 direction: wind_dir_dist.sample(&mut rng),
1690 };
1691
1692 let solver = TrajectorySolver::new(inputs, wind, Default::default());
1694 match solver.solve() {
1695 Ok(result) => {
1696 if result.max_range < target_distance {
1704 continue;
1705 }
1706 let pos_at_target = match result.position_at_range(target_distance) {
1710 Some(p) => p,
1711 None => continue,
1712 };
1713
1714 ranges.push(result.max_range);
1715 impact_velocities.push(result.impact_velocity);
1716
1717 let mut deviation = Vector3::new(
1720 0.0, pos_at_target.y - baseline_at_target.y, pos_at_target.z - baseline_at_target.z, );
1724
1725 let pointing_error_y = pointing_error_dist.sample(&mut rng);
1728 deviation.y += pointing_error_y;
1729
1730 impact_positions.push(deviation);
1731 }
1732 Err(_) => {
1733 continue;
1735 }
1736 }
1737 }
1738
1739 if ranges.is_empty() {
1740 return Err("No successful simulations".into());
1741 }
1742
1743 Ok(MonteCarloResults {
1744 ranges,
1745 impact_velocities,
1746 impact_positions,
1747 })
1748}
1749
1750pub fn calculate_zero_angle(
1752 inputs: BallisticInputs,
1753 target_distance: f64,
1754 target_height: f64,
1755) -> Result<f64, BallisticsError> {
1756 calculate_zero_angle_with_conditions(
1757 inputs,
1758 target_distance,
1759 target_height,
1760 WindConditions::default(),
1761 AtmosphericConditions::default(),
1762 )
1763}
1764
1765pub fn calculate_zero_angle_with_conditions(
1766 inputs: BallisticInputs,
1767 target_distance: f64,
1768 target_height: f64,
1769 wind: WindConditions,
1770 atmosphere: AtmosphericConditions,
1771) -> Result<f64, BallisticsError> {
1772 let get_height_at_angle = |angle: f64| -> Result<Option<f64>, BallisticsError> {
1774 let mut test_inputs = inputs.clone();
1775 test_inputs.muzzle_angle = angle;
1776 test_inputs.enable_aerodynamic_jump = false;
1781
1782 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
1783 solver.set_max_range(target_distance * 2.0);
1784 solver.set_time_step(0.001);
1785 let result = solver.solve()?;
1786
1787 for i in 0..result.points.len() {
1789 if result.points[i].position.x >= target_distance {
1790 if i > 0 {
1791 let p1 = &result.points[i - 1];
1792 let p2 = &result.points[i];
1793 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
1794 return Ok(Some(p1.position.y + t * (p2.position.y - p1.position.y)));
1795 } else {
1796 return Ok(Some(result.points[i].position.y));
1797 }
1798 }
1799 }
1800 Ok(None)
1801 };
1802
1803 let mut low_angle = 0.0; let mut high_angle = 0.2; let tolerance = 0.00001; let max_iterations = 50;
1809
1810 let low_height = get_height_at_angle(low_angle)?;
1813 let high_height = get_height_at_angle(high_angle)?;
1814
1815 match (low_height, high_height) {
1816 (Some(lh), Some(hh)) => {
1817 let low_error = lh - target_height;
1818 let high_error = hh - target_height;
1819
1820 if low_error > 0.0 && high_error > 0.0 {
1823 } else if low_error < 0.0 && high_error < 0.0 {
1828 let mut expanded = false;
1831 for multiplier in [2.0, 3.0, 4.0] {
1832 let new_high = (high_angle * multiplier).min(0.785);
1833 if let Ok(Some(h)) = get_height_at_angle(new_high) {
1834 if h - target_height > 0.0 {
1835 high_angle = new_high;
1836 expanded = true;
1837 break;
1838 }
1839 }
1840 if new_high >= 0.785 {
1841 break;
1842 }
1843 }
1844 if !expanded {
1845 return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
1846 }
1847 }
1848 }
1850 (None, Some(_hh)) => {
1851 }
1854 (Some(_lh), None) => {
1855 return Err(
1857 "Cannot find zero angle: high angle trajectory doesn't reach target distance"
1858 .into(),
1859 );
1860 }
1861 (None, None) => {
1862 return Err(
1864 "Cannot find zero angle: trajectory cannot reach target distance at any angle"
1865 .into(),
1866 );
1867 }
1868 }
1869
1870 for _iteration in 0..max_iterations {
1871 let mid_angle = (low_angle + high_angle) / 2.0;
1872
1873 let mut test_inputs = inputs.clone();
1874 test_inputs.muzzle_angle = mid_angle;
1875 test_inputs.enable_aerodynamic_jump = false;
1877
1878 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
1879 solver.set_max_range(target_distance * 2.0);
1881 solver.set_time_step(0.001);
1882 let result = solver.solve()?;
1883
1884 let mut height_at_target = None;
1886 for i in 0..result.points.len() {
1887 if result.points[i].position.x >= target_distance {
1888 if i > 0 {
1889 let p1 = &result.points[i - 1];
1891 let p2 = &result.points[i];
1892 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
1893 height_at_target = Some(p1.position.y + t * (p2.position.y - p1.position.y));
1894 } else {
1895 height_at_target = Some(result.points[i].position.y);
1896 }
1897 break;
1898 }
1899 }
1900
1901 match height_at_target {
1902 Some(height) => {
1903 let error = height - target_height;
1904 if error.abs() < 0.001 {
1907 return Ok(mid_angle);
1908 }
1909
1910 if (high_angle - low_angle).abs() < tolerance {
1914 if error.abs() < 0.01 {
1915 return Ok(mid_angle);
1917 }
1918 return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
1923 }
1924
1925 if error > 0.0 {
1926 high_angle = mid_angle;
1927 } else {
1928 low_angle = mid_angle;
1929 }
1930 }
1931 None => {
1932 low_angle = mid_angle;
1934
1935 if (high_angle - low_angle).abs() < tolerance {
1937 return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
1938 }
1939 }
1940 }
1941 }
1942
1943 Err("Failed to find zero angle".into())
1944}
1945
1946pub fn estimate_bc_from_trajectory(
1948 velocity: f64,
1949 mass: f64,
1950 diameter: f64,
1951 points: &[(f64, f64)], ) -> Result<f64, BallisticsError> {
1953 let mut best_bc = 0.5;
1955 let mut best_error = f64::MAX;
1956 let mut found_valid = false;
1957
1958 for bc in (100..1000).step_by(10) {
1960 let bc_value = bc as f64 / 1000.0;
1961
1962 let inputs = BallisticInputs {
1963 muzzle_velocity: velocity,
1964 bc_value,
1965 bullet_mass: mass,
1966 bullet_diameter: diameter,
1967 ..Default::default()
1968 };
1969
1970 let mut solver = TrajectorySolver::new(inputs, Default::default(), Default::default());
1971 solver.set_max_range(points.last().map(|(d, _)| *d * 1.5).unwrap_or(1000.0));
1973
1974 let result = match solver.solve() {
1975 Ok(r) => r,
1976 Err(_) => continue, };
1978
1979 let mut total_error = 0.0;
1981 for (target_dist, target_drop) in points {
1982 let mut calculated_drop = None;
1984 for i in 0..result.points.len() {
1985 if result.points[i].position.x >= *target_dist {
1986 if i > 0 {
1987 let p1 = &result.points[i - 1];
1989 let p2 = &result.points[i];
1990 let t = (target_dist - p1.position.x) / (p2.position.x - p1.position.x);
1991 calculated_drop =
1992 Some(-(p1.position.y + t * (p2.position.y - p1.position.y)));
1993 } else {
1994 calculated_drop = Some(-result.points[i].position.y);
1995 }
1996 break;
1997 }
1998 }
1999
2000 if let Some(drop) = calculated_drop {
2001 let error = (drop - target_drop).abs();
2002 total_error += error * error;
2003 }
2004 }
2005
2006 if total_error < best_error {
2007 best_error = total_error;
2008 best_bc = bc_value;
2009 found_valid = true;
2010 }
2011 }
2012
2013 if !found_valid {
2014 return Err(BallisticsError::from("Unable to estimate BC from provided data. Check that drop values are in correct units.".to_string()));
2015 }
2016
2017 Ok(best_bc)
2018}
2019
2020fn calculate_air_density(atmosphere: &AtmosphericConditions) -> f64 {
2022 crate::atmosphere::calculate_atmosphere(
2030 atmosphere.altitude,
2031 crate::atmosphere::resolve_station_temperature(atmosphere.temperature, atmosphere.altitude),
2032 crate::atmosphere::resolve_station_pressure(atmosphere.pressure, atmosphere.altitude),
2033 atmosphere.humidity,
2034 )
2035 .0
2036}
2037
2038use rand;
2040use rand_distr;
2041
2042#[cfg(test)]
2043mod ground_termination_tests {
2044 use super::*;
2045
2046 #[test]
2051 fn rk4_and_rk45_descend_to_ground_threshold() {
2052 for adaptive in [false, true] {
2053 let mut inputs = BallisticInputs::default();
2054 inputs.muzzle_angle = 0.1; inputs.use_rk4 = true;
2056 inputs.use_adaptive_rk45 = adaptive;
2057 assert_eq!(inputs.ground_threshold, -100.0, "default ground_threshold is -100 m");
2058
2059 let mut solver = TrajectorySolver::new(
2060 inputs,
2061 WindConditions::default(),
2062 AtmosphericConditions::default(),
2063 );
2064 solver.set_max_range(1.0e7);
2066
2067 let result = solver.solve().expect("solve should succeed");
2068 let final_y = result
2069 .points
2070 .last()
2071 .expect("trajectory has points")
2072 .position
2073 .y;
2074 assert!(
2075 final_y < -1.0,
2076 "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
2077 past launch level toward the ground_threshold floor, not stop at y = 0"
2078 );
2079 }
2080 }
2081}