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,
231}
232
233impl Default for WindConditions {
234 fn default() -> Self {
235 Self {
236 speed: 0.0,
237 direction: 0.0,
238 }
239 }
240}
241
242#[derive(Debug, Clone)]
244pub struct AtmosphericConditions {
245 pub temperature: f64, pub pressure: f64, pub humidity: f64, pub altitude: f64, }
250
251impl Default for AtmosphericConditions {
252 fn default() -> Self {
253 Self {
254 temperature: 15.0,
255 pressure: 1013.25,
256 humidity: 50.0,
257 altitude: 0.0,
258 }
259 }
260}
261
262#[derive(Debug, Clone)]
264pub struct TrajectoryPoint {
265 pub time: f64,
266 pub position: Vector3<f64>,
267 pub velocity_magnitude: f64,
268 pub kinetic_energy: f64,
269}
270
271#[derive(Debug, Clone)]
273pub struct TrajectoryResult {
274 pub max_range: f64,
275 pub max_height: f64,
276 pub time_of_flight: f64,
277 pub impact_velocity: f64,
278 pub impact_energy: f64,
279 pub points: Vec<TrajectoryPoint>,
280 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>,
289}
290
291impl TrajectoryResult {
292 pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
296 if self.points.is_empty() {
297 return None;
298 }
299
300 for i in 0..self.points.len() - 1 {
302 let p1 = &self.points[i];
303 let p2 = &self.points[i + 1];
304
305 if p1.position.x <= target_range && p2.position.x >= target_range {
307 let dx = p2.position.x - p1.position.x;
309 if dx.abs() < 1e-10 {
310 return Some(p1.position);
311 }
312 let t = (target_range - p1.position.x) / dx;
313
314 return Some(Vector3::new(
316 target_range,
317 p1.position.y + t * (p2.position.y - p1.position.y),
318 p1.position.z + t * (p2.position.z - p1.position.z),
319 ));
320 }
321 }
322
323 self.points.last().map(|p| p.position)
325 }
326}
327
328pub struct TrajectorySolver {
330 inputs: BallisticInputs,
331 wind: WindConditions,
332 atmosphere: AtmosphericConditions,
333 max_range: f64,
334 time_step: f64,
335 cluster_bc: Option<ClusterBCDegradation>,
336 wind_sock: Option<crate::wind::WindSock>,
341}
342
343impl TrajectorySolver {
344 pub fn new(
345 mut inputs: BallisticInputs,
346 wind: WindConditions,
347 atmosphere: AtmosphericConditions,
348 ) -> Self {
349 inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
351 inputs.weight_grains = inputs.bullet_mass / 0.00006479891;
352
353 let cluster_bc = if inputs.use_cluster_bc {
355 Some(ClusterBCDegradation::new())
356 } else {
357 None
358 };
359
360 Self {
361 inputs,
362 wind,
363 atmosphere,
364 max_range: 1000.0,
365 time_step: 0.001,
366 cluster_bc,
367 wind_sock: None,
368 }
369 }
370
371 pub fn set_max_range(&mut self, range: f64) {
372 self.max_range = range;
373 }
374
375 pub fn set_time_step(&mut self, step: f64) {
376 self.time_step = step;
377 }
378
379 pub fn set_wind_segments(&mut self, segments: Vec<crate::wind::WindSegment>) {
386 self.wind_sock = if segments.is_empty() {
387 None
388 } else {
389 Some(crate::wind::WindSock::new(segments))
390 };
391 }
392
393 fn launch_angles_from(
402 &self,
403 aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
404 ) -> (f64, f64) {
405 let elev = self.inputs.muzzle_angle;
406 let azim = self.inputs.azimuth_angle;
407 match aj {
408 Some(c) => {
409 const MOA_PER_RAD: f64 = 3437.7467707849;
411 (
412 elev + c.vertical_jump_moa / MOA_PER_RAD,
413 azim + c.horizontal_jump_moa / MOA_PER_RAD,
414 )
415 }
416 None => (elev, azim),
417 }
418 }
419
420 fn aerodynamic_jump_components(
428 &self,
429 ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
430 if !self.inputs.enable_aerodynamic_jump {
431 return None;
432 }
433 let diameter_m = self.inputs.bullet_diameter;
437 if !(self.inputs.twist_rate.is_finite() && self.inputs.twist_rate != 0.0)
438 || !(diameter_m.is_finite() && diameter_m > 0.0)
439 || !(self.inputs.bullet_length.is_finite() && self.inputs.bullet_length > 0.0)
440 || !self.inputs.muzzle_velocity.is_finite()
441 {
442 return None;
443 }
444
445 let sg = crate::stability::compute_stability_coefficient(
447 &self.inputs,
448 (
449 self.atmosphere.altitude,
450 self.atmosphere.temperature,
451 self.atmosphere.pressure,
452 0.0,
453 ),
454 );
455 if !(sg.is_finite() && sg > 0.0) {
456 return None;
457 }
458 let length_calibers = self.inputs.bullet_length / diameter_m;
459
460 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
466 let crosswind_from_right_mph = self.wind.speed * self.wind.direction.sin() * MS_TO_MPH;
467
468 let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
469 sg,
470 length_calibers,
471 crosswind_from_right_mph,
472 self.inputs.is_twist_right,
473 );
474 if !vertical_jump_moa.is_finite() {
475 return None;
476 }
477
478 const MOA_PER_RAD: f64 = 3437.7467707849;
479 Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
480 vertical_jump_moa,
481 horizontal_jump_moa: 0.0,
483 jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
484 magnus_component_moa: 0.0,
485 yaw_component_moa: 0.0,
486 stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
487 })
488 }
489
490 fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
491 let model = if self.inputs.wind_shear_model == "logarithmic" {
501 WindShearModel::Logarithmic
502 } else {
503 WindShearModel::PowerLaw };
505 let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
506
507 Vector3::new(
510 -self.wind.speed * self.wind.direction.cos() * speed_ratio, 0.0,
512 -self.wind.speed * self.wind.direction.sin() * speed_ratio, )
514 }
515
516 pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
517 let mut result = if self.inputs.use_rk4 {
518 if self.inputs.use_adaptive_rk45 {
519 self.solve_rk45()?
520 } else {
521 self.solve_rk4()?
522 }
523 } else {
524 self.solve_euler()?
525 };
526 self.apply_spin_drift(&mut result);
527 Ok(result)
528 }
529
530 fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
536 if !self.inputs.use_enhanced_spin_drift {
537 return;
538 }
539 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 {
543 return;
544 }
545
546 let length_in = if self.inputs.bullet_length > 0.0 {
548 self.inputs.bullet_length / 0.0254
549 } else {
550 4.5 * d_in
551 };
552 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 {
560 (temp_k / 288.15) * (1013.25 / press_hpa)
561 } else {
562 1.0
563 };
564 let sg =
565 crate::spin_drift::miller_stability(d_in, m_gr, twist_in, length_in) * density_correction;
566 let sign = if self.inputs.is_twist_right { 1.0 } else { -1.0 };
567
568 for p in result.points.iter_mut() {
569 if p.time <= 0.0 {
570 continue;
571 }
572 let sd_in = 1.25 * (sg + 1.2) * p.time.powf(1.83); p.position.z += sign * sd_in * 0.0254; }
575
576 if let Some(samples) = result.sampled_points.as_mut() {
580 for s in samples.iter_mut() {
581 if s.time_s <= 0.0 {
582 continue;
583 }
584 let sd_in = 1.25 * (sg + 1.2) * s.time_s.powf(1.83);
585 s.wind_drift_m += sign * sd_in * 0.0254;
586 }
587 }
588 }
589
590 fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
591 let mut time = 0.0;
593 let mut position = Vector3::new(
596 0.0,
597 self.inputs.muzzle_height, 0.0,
599 );
600 let aj_components = self.aerodynamic_jump_components();
606 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
607 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
608 let mut velocity = Vector3::new(
609 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
613
614 let mut points = Vec::new();
615 let mut max_height = position.y;
616 let mut min_pitch_damping = 1.0; let mut transonic_mach = None; let mut angular_state = if self.inputs.enable_precession_nutation {
621 Some(AngularState {
622 pitch_angle: 0.001, yaw_angle: 0.001,
624 pitch_rate: 0.0,
625 yaw_rate: 0.0,
626 precession_angle: 0.0,
627 nutation_phase: 0.0,
628 })
629 } else {
630 None
631 };
632 let mut max_yaw_angle = 0.0;
633 let mut max_precession_angle = 0.0;
634
635 let air_density = calculate_air_density(&self.atmosphere);
637
638 let wind_vector = Vector3::new(
642 -self.wind.speed * self.wind.direction.cos(), 0.0,
644 -self.wind.speed * self.wind.direction.sin(), );
646
647 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
650 self.inputs.bullet_model.as_deref().unwrap_or("default"),
651 );
652
653 while position.x < self.max_range && position.y > self.inputs.ground_threshold && time < 100.0 {
655 let velocity_magnitude = velocity.magnitude();
657 let kinetic_energy =
658 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
659
660 points.push(TrajectoryPoint {
661 time,
662 position: position,
663 velocity_magnitude,
664 kinetic_energy,
665 });
666
667 #[cfg(debug_assertions)]
671 if points.len() == 1 || points.len() % 100 == 0 {
672 eprintln!("Trajectory point {}: time={:.3}s, downrange={:.2}m, vertical={:.2}m, lateral={:.2}m, vel={:.1}m/s",
673 points.len(), time, position.x, position.y, position.z, velocity_magnitude);
674 }
675
676 if position.y > max_height {
678 max_height = position.y;
679 }
680
681 if self.inputs.enable_pitch_damping {
683 let temp_c = self.atmosphere.temperature;
684 let temp_k = temp_c + 273.15;
685 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
686 let mach = velocity_magnitude / speed_of_sound;
687
688 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
690 transonic_mach = Some(mach);
691 }
692
693 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
695
696 if pitch_damping < min_pitch_damping {
698 min_pitch_damping = pitch_damping;
699 }
700 }
701
702 if self.inputs.enable_precession_nutation {
704 if let Some(ref mut state) = angular_state {
705 let velocity_magnitude = velocity.magnitude();
706 let temp_c = self.atmosphere.temperature;
707 let temp_k = temp_c + 273.15;
708 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
709 let mach = velocity_magnitude / speed_of_sound;
710
711 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
713 let velocity_fps = velocity_magnitude * 3.28084;
714 let twist_rate_ft = self.inputs.twist_rate / 12.0;
715 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
716 } else {
717 0.0
718 };
719
720 let params = PrecessionNutationParams {
722 mass_kg: self.inputs.bullet_mass,
723 caliber_m: self.inputs.bullet_diameter,
724 length_m: self.inputs.bullet_length,
725 spin_rate_rad_s,
726 spin_inertia: 6.94e-8, transverse_inertia: 9.13e-7, velocity_mps: velocity_magnitude,
729 air_density_kg_m3: air_density,
730 mach,
731 pitch_damping_coeff: -0.8,
732 nutation_damping_factor: 0.05,
733 };
734
735 *state = calculate_combined_angular_motion(
737 ¶ms,
738 state,
739 time,
740 self.time_step,
741 0.001, );
743
744 if state.yaw_angle.abs() > max_yaw_angle {
746 max_yaw_angle = state.yaw_angle.abs();
747 }
748 if state.precession_angle.abs() > max_precession_angle {
749 max_precession_angle = state.precession_angle.abs();
750 }
751 }
752 }
753
754 let acceleration =
761 self.calculate_acceleration(&position, &velocity, air_density, &wind_vector);
762
763 velocity += acceleration * self.time_step;
765 position += velocity * self.time_step;
766 time += self.time_step;
767 }
768
769 let last_point = points.last().ok_or("No trajectory points generated")?;
771
772 let sampled_points = if self.inputs.enable_trajectory_sampling {
774 let trajectory_data = TrajectoryData {
775 times: points.iter().map(|p| p.time).collect(),
776 positions: points.iter().map(|p| p.position).collect(),
777 velocities: points
778 .iter()
779 .map(|p| {
780 Vector3::new(0.0, 0.0, p.velocity_magnitude)
782 })
783 .collect(),
784 transonic_distances: Vec::new(), };
786
787 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
792 let outputs = TrajectoryOutputs {
793 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
795 time_of_flight_s: last_point.time,
796 max_ord_dist_horiz_m: max_height,
797 sight_height_m: sight_position_m,
798 };
799
800 let samples = sample_trajectory(
802 &trajectory_data,
803 &outputs,
804 self.inputs.sample_interval,
805 self.inputs.bullet_mass,
806 );
807 Some(samples)
808 } else {
809 None
810 };
811
812 Ok(TrajectoryResult {
813 max_range: last_point.position.x, max_height,
815 time_of_flight: last_point.time,
816 impact_velocity: last_point.velocity_magnitude,
817 impact_energy: last_point.kinetic_energy,
818 points,
819 sampled_points,
820 min_pitch_damping: if self.inputs.enable_pitch_damping {
821 Some(min_pitch_damping)
822 } else {
823 None
824 },
825 transonic_mach,
826 angular_state,
827 max_yaw_angle: if self.inputs.enable_precession_nutation {
828 Some(max_yaw_angle)
829 } else {
830 None
831 },
832 max_precession_angle: if self.inputs.enable_precession_nutation {
833 Some(max_precession_angle)
834 } else {
835 None
836 },
837 aerodynamic_jump: aj_components,
838 })
839 }
840
841 fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
842 let mut time = 0.0;
844 let mut position = Vector3::new(
848 0.0,
849 self.inputs.muzzle_height, 0.0,
851 );
852
853 let aj_components = self.aerodynamic_jump_components();
859 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
860 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
861 let mut velocity = Vector3::new(
862 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
866
867 let mut points = Vec::new();
868 let mut max_height = position.y;
869 let mut min_pitch_damping = 1.0; let mut transonic_mach = None; let mut angular_state = if self.inputs.enable_precession_nutation {
874 Some(AngularState {
875 pitch_angle: 0.001, yaw_angle: 0.001,
877 pitch_rate: 0.0,
878 yaw_rate: 0.0,
879 precession_angle: 0.0,
880 nutation_phase: 0.0,
881 })
882 } else {
883 None
884 };
885 let mut max_yaw_angle = 0.0;
886 let mut max_precession_angle = 0.0;
887
888 let air_density = calculate_air_density(&self.atmosphere);
890
891 let wind_vector = Vector3::new(
895 -self.wind.speed * self.wind.direction.cos(), 0.0,
897 -self.wind.speed * self.wind.direction.sin(), );
899
900 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
903 self.inputs.bullet_model.as_deref().unwrap_or("default"),
904 );
905
906 while position.x < self.max_range && position.y > self.inputs.ground_threshold && time < 100.0 {
908 let velocity_magnitude = velocity.magnitude();
910 let kinetic_energy =
911 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
912
913 points.push(TrajectoryPoint {
914 time,
915 position: position,
916 velocity_magnitude,
917 kinetic_energy,
918 });
919
920 if position.y > max_height {
921 max_height = position.y;
922 }
923
924 if self.inputs.enable_pitch_damping {
926 let temp_c = self.atmosphere.temperature;
927 let temp_k = temp_c + 273.15;
928 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
929 let mach = velocity_magnitude / speed_of_sound;
930
931 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
933 transonic_mach = Some(mach);
934 }
935
936 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
938
939 if pitch_damping < min_pitch_damping {
941 min_pitch_damping = pitch_damping;
942 }
943 }
944
945 if self.inputs.enable_precession_nutation {
947 if let Some(ref mut state) = angular_state {
948 let velocity_magnitude = velocity.magnitude();
949 let temp_c = self.atmosphere.temperature;
950 let temp_k = temp_c + 273.15;
951 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
952 let mach = velocity_magnitude / speed_of_sound;
953
954 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
956 let velocity_fps = velocity_magnitude * 3.28084;
957 let twist_rate_ft = self.inputs.twist_rate / 12.0;
958 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
959 } else {
960 0.0
961 };
962
963 let params = PrecessionNutationParams {
965 mass_kg: self.inputs.bullet_mass,
966 caliber_m: self.inputs.bullet_diameter,
967 length_m: self.inputs.bullet_length,
968 spin_rate_rad_s,
969 spin_inertia: 6.94e-8, transverse_inertia: 9.13e-7, velocity_mps: velocity_magnitude,
972 air_density_kg_m3: air_density,
973 mach,
974 pitch_damping_coeff: -0.8,
975 nutation_damping_factor: 0.05,
976 };
977
978 *state = calculate_combined_angular_motion(
980 ¶ms,
981 state,
982 time,
983 self.time_step,
984 0.001, );
986
987 if state.yaw_angle.abs() > max_yaw_angle {
989 max_yaw_angle = state.yaw_angle.abs();
990 }
991 if state.precession_angle.abs() > max_precession_angle {
992 max_precession_angle = state.precession_angle.abs();
993 }
994 }
995 }
996
997 let dt = self.time_step;
999
1000 let acc1 = self.calculate_acceleration(&position, &velocity, air_density, &wind_vector);
1002
1003 let pos2 = position + velocity * (dt * 0.5);
1005 let vel2 = velocity + acc1 * (dt * 0.5);
1006 let acc2 = self.calculate_acceleration(&pos2, &vel2, air_density, &wind_vector);
1007
1008 let pos3 = position + vel2 * (dt * 0.5);
1010 let vel3 = velocity + acc2 * (dt * 0.5);
1011 let acc3 = self.calculate_acceleration(&pos3, &vel3, air_density, &wind_vector);
1012
1013 let pos4 = position + vel3 * dt;
1015 let vel4 = velocity + acc3 * dt;
1016 let acc4 = self.calculate_acceleration(&pos4, &vel4, air_density, &wind_vector);
1017
1018 position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
1020 velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
1021 time += dt;
1022 }
1023
1024 let last_point = points.last().ok_or("No trajectory points generated")?;
1026
1027 let sampled_points = if self.inputs.enable_trajectory_sampling {
1029 let trajectory_data = TrajectoryData {
1030 times: points.iter().map(|p| p.time).collect(),
1031 positions: points.iter().map(|p| p.position).collect(),
1032 velocities: points
1033 .iter()
1034 .map(|p| {
1035 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1037 })
1038 .collect(),
1039 transonic_distances: Vec::new(), };
1041
1042 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1047 let outputs = TrajectoryOutputs {
1048 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
1050 time_of_flight_s: last_point.time,
1051 max_ord_dist_horiz_m: max_height,
1052 sight_height_m: sight_position_m,
1053 };
1054
1055 let samples = sample_trajectory(
1057 &trajectory_data,
1058 &outputs,
1059 self.inputs.sample_interval,
1060 self.inputs.bullet_mass,
1061 );
1062 Some(samples)
1063 } else {
1064 None
1065 };
1066
1067 Ok(TrajectoryResult {
1068 max_range: last_point.position.x, max_height,
1070 time_of_flight: last_point.time,
1071 impact_velocity: last_point.velocity_magnitude,
1072 impact_energy: last_point.kinetic_energy,
1073 points,
1074 sampled_points,
1075 min_pitch_damping: if self.inputs.enable_pitch_damping {
1076 Some(min_pitch_damping)
1077 } else {
1078 None
1079 },
1080 transonic_mach,
1081 angular_state,
1082 max_yaw_angle: if self.inputs.enable_precession_nutation {
1083 Some(max_yaw_angle)
1084 } else {
1085 None
1086 },
1087 max_precession_angle: if self.inputs.enable_precession_nutation {
1088 Some(max_precession_angle)
1089 } else {
1090 None
1091 },
1092 aerodynamic_jump: aj_components,
1093 })
1094 }
1095
1096 fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
1097 let mut time = 0.0;
1099 let mut position = Vector3::new(
1102 0.0,
1103 self.inputs.muzzle_height, 0.0,
1105 );
1106
1107 let aj_components = self.aerodynamic_jump_components();
1113 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1114 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1115 let mut velocity = Vector3::new(
1116 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1120
1121 let mut points = Vec::new();
1122 let mut max_height = position.y;
1123 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;
1131 const MAX_ITERATIONS: usize = 100000;
1132
1133 let air_density = calculate_air_density(&self.atmosphere);
1136 let wind_vector = Vector3::new(
1139 -self.wind.speed * self.wind.direction.cos(), 0.0,
1141 -self.wind.speed * self.wind.direction.sin(), );
1143
1144 while position.x < self.max_range
1145 && position.y > self.inputs.ground_threshold
1146 && time < 100.0
1147 {
1148 iteration_count += 1;
1150 if iteration_count > MAX_ITERATIONS {
1151 break; }
1153
1154 let velocity_magnitude = velocity.magnitude();
1156 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
1157
1158 points.push(TrajectoryPoint {
1159 time,
1160 position: position,
1161 velocity_magnitude,
1162 kinetic_energy,
1163 });
1164
1165 if position.y > max_height {
1166 max_height = position.y;
1167 }
1168
1169 let (new_pos, new_vel, new_dt) = self.rk45_step(
1171 &position,
1172 &velocity,
1173 dt,
1174 air_density,
1175 &wind_vector,
1176 tolerance,
1177 );
1178
1179 position = new_pos;
1184 velocity = new_vel;
1185 time += dt;
1186
1187 dt = (safety_factor * new_dt).clamp(min_dt, max_dt);
1189 }
1190
1191 if points.is_empty() {
1193 return Err(BallisticsError::from("No trajectory points calculated"));
1194 }
1195
1196 let last_point = points.last().unwrap();
1197
1198 let sampled_points = if self.inputs.enable_trajectory_sampling {
1200 let trajectory_data = TrajectoryData {
1202 times: points.iter().map(|p| p.time).collect(),
1203 positions: points.iter().map(|p| p.position).collect(),
1204 velocities: points
1205 .iter()
1206 .map(|p| {
1207 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1209 })
1210 .collect(),
1211 transonic_distances: Vec::new(),
1212 };
1213
1214 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1219 let outputs = TrajectoryOutputs {
1220 target_distance_horiz_m: last_point.position.x,
1221 target_vertical_height_m: sight_position_m,
1222 time_of_flight_s: last_point.time,
1223 max_ord_dist_horiz_m: max_height,
1224 sight_height_m: sight_position_m,
1225 };
1226
1227 let samples = sample_trajectory(
1228 &trajectory_data,
1229 &outputs,
1230 self.inputs.sample_interval,
1231 self.inputs.bullet_mass,
1232 );
1233 Some(samples)
1234 } else {
1235 None
1236 };
1237
1238 Ok(TrajectoryResult {
1239 max_range: last_point.position.x, max_height,
1241 time_of_flight: last_point.time,
1242 impact_velocity: last_point.velocity_magnitude,
1243 impact_energy: last_point.kinetic_energy,
1244 points,
1245 sampled_points,
1246 min_pitch_damping: None,
1247 transonic_mach: None,
1248 angular_state: None,
1249 max_yaw_angle: None,
1250 max_precession_angle: None,
1251 aerodynamic_jump: aj_components,
1252 })
1253 }
1254
1255 fn rk45_step(
1256 &self,
1257 position: &Vector3<f64>,
1258 velocity: &Vector3<f64>,
1259 dt: f64,
1260 air_density: f64,
1261 wind_vector: &Vector3<f64>,
1262 tolerance: f64,
1263 ) -> (Vector3<f64>, Vector3<f64>, f64) {
1264 const A21: f64 = 1.0 / 5.0;
1266 const A31: f64 = 3.0 / 40.0;
1267 const A32: f64 = 9.0 / 40.0;
1268 const A41: f64 = 44.0 / 45.0;
1269 const A42: f64 = -56.0 / 15.0;
1270 const A43: f64 = 32.0 / 9.0;
1271 const A51: f64 = 19372.0 / 6561.0;
1272 const A52: f64 = -25360.0 / 2187.0;
1273 const A53: f64 = 64448.0 / 6561.0;
1274 const A54: f64 = -212.0 / 729.0;
1275 const A61: f64 = 9017.0 / 3168.0;
1276 const A62: f64 = -355.0 / 33.0;
1277 const A63: f64 = 46732.0 / 5247.0;
1278 const A64: f64 = 49.0 / 176.0;
1279 const A65: f64 = -5103.0 / 18656.0;
1280 const A71: f64 = 35.0 / 384.0;
1281 const A73: f64 = 500.0 / 1113.0;
1282 const A74: f64 = 125.0 / 192.0;
1283 const A75: f64 = -2187.0 / 6784.0;
1284 const A76: f64 = 11.0 / 84.0;
1285
1286 const B1: f64 = 35.0 / 384.0;
1288 const B3: f64 = 500.0 / 1113.0;
1289 const B4: f64 = 125.0 / 192.0;
1290 const B5: f64 = -2187.0 / 6784.0;
1291 const B6: f64 = 11.0 / 84.0;
1292
1293 const B1_ERR: f64 = 5179.0 / 57600.0;
1295 const B3_ERR: f64 = 7571.0 / 16695.0;
1296 const B4_ERR: f64 = 393.0 / 640.0;
1297 const B5_ERR: f64 = -92097.0 / 339200.0;
1298 const B6_ERR: f64 = 187.0 / 2100.0;
1299 const B7_ERR: f64 = 1.0 / 40.0;
1300
1301 let k1_v = self.calculate_acceleration(position, velocity, air_density, wind_vector);
1303 let k1_p = *velocity;
1304
1305 let p2 = position + dt * A21 * k1_p;
1306 let v2 = velocity + dt * A21 * k1_v;
1307 let k2_v = self.calculate_acceleration(&p2, &v2, air_density, wind_vector);
1308 let k2_p = v2;
1309
1310 let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
1311 let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
1312 let k3_v = self.calculate_acceleration(&p3, &v3, air_density, wind_vector);
1313 let k3_p = v3;
1314
1315 let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
1316 let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
1317 let k4_v = self.calculate_acceleration(&p4, &v4, air_density, wind_vector);
1318 let k4_p = v4;
1319
1320 let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
1321 let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
1322 let k5_v = self.calculate_acceleration(&p5, &v5, air_density, wind_vector);
1323 let k5_p = v5;
1324
1325 let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
1326 let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
1327 let k6_v = self.calculate_acceleration(&p6, &v6, air_density, wind_vector);
1328 let k6_p = v6;
1329
1330 let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
1331 let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
1332 let k7_v = self.calculate_acceleration(&p7, &v7, air_density, wind_vector);
1333 let k7_p = v7;
1334
1335 let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
1337 let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
1338
1339 let pos_err = position
1341 + dt * (B1_ERR * k1_p
1342 + B3_ERR * k3_p
1343 + B4_ERR * k4_p
1344 + B5_ERR * k5_p
1345 + B6_ERR * k6_p
1346 + B7_ERR * k7_p);
1347 let vel_err = velocity
1348 + dt * (B1_ERR * k1_v
1349 + B3_ERR * k3_v
1350 + B4_ERR * k4_v
1351 + B5_ERR * k5_v
1352 + B6_ERR * k6_v
1353 + B7_ERR * k7_v);
1354
1355 let pos_error = (new_pos - pos_err).magnitude();
1357 let vel_error = (new_vel - vel_err).magnitude();
1358 let error = (pos_error + vel_error) / (1.0 + position.magnitude() + velocity.magnitude());
1359
1360 let dt_new = if error < tolerance {
1362 dt * (tolerance / error).powf(0.2).min(2.0)
1363 } else {
1364 dt * (tolerance / error).powf(0.25).max(0.1)
1365 };
1366
1367 (new_pos, new_vel, dt_new)
1368 }
1369
1370 fn calculate_acceleration(
1371 &self,
1372 position: &Vector3<f64>,
1373 velocity: &Vector3<f64>,
1374 air_density: f64,
1375 wind_vector: &Vector3<f64>,
1376 ) -> Vector3<f64> {
1377 let actual_wind = if let Some(ref sock) = self.wind_sock {
1383 sock.vector_for_range_stateless(position.x)
1384 } else if self.inputs.enable_wind_shear {
1385 self.get_wind_at_altitude(position.y)
1386 } else {
1387 *wind_vector
1388 };
1389
1390 let relative_velocity = velocity - actual_wind;
1391 let velocity_magnitude = relative_velocity.magnitude();
1392
1393 if velocity_magnitude < 0.001 {
1394 return Vector3::new(0.0, -crate::constants::G_ACCEL_MPS2, 0.0);
1395 }
1396
1397 let cd = self.calculate_drag_coefficient(velocity_magnitude);
1399
1400 let velocity_fps = velocity_magnitude * 3.28084;
1402
1403 let base_bc = if let Some(ref segments) = self.inputs.bc_segments_data {
1405 segments
1407 .iter()
1408 .find(|seg| velocity_fps >= seg.velocity_min && velocity_fps < seg.velocity_max)
1409 .map(|seg| seg.bc_value)
1410 .unwrap_or(self.inputs.bc_value)
1411 } else {
1412 self.inputs.bc_value
1413 };
1414
1415 let effective_bc = if let Some(ref cluster_bc) = self.cluster_bc {
1417 cluster_bc.apply_correction(
1418 base_bc,
1419 self.inputs.caliber_inches, self.inputs.weight_grains,
1421 velocity_fps,
1422 )
1423 } else {
1424 base_bc
1425 };
1426 let effective_bc = effective_bc.max(1e-6);
1430
1431 let cd_to_retard = 0.000683 * 0.30; let standard_factor = cd * cd_to_retard;
1437 let density_scale = air_density / 1.225; let a_drag_ft_s2 =
1441 (velocity_fps * velocity_fps) * standard_factor * density_scale / effective_bc;
1442 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
1446
1447 let mut accel = drag_acceleration + Vector3::new(0.0, -crate::constants::G_ACCEL_MPS2, 0.0);
1449
1450 if self.inputs.enable_coriolis {
1453 if let Some(lat_deg) = self.inputs.latitude {
1454 let omega_earth = 7.2921159e-5_f64; let lat = lat_deg.to_radians();
1456 let az = self.inputs.azimuth_angle;
1457 let omega = Vector3::new(
1463 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), );
1467 accel += -2.0 * omega.cross(velocity);
1472 }
1473 }
1474
1475 if self.inputs.enable_magnus
1477 && self.inputs.bullet_diameter > 0.0
1478 && self.inputs.twist_rate > 0.0
1479 {
1480 let (_, spin_rad_s) =
1481 crate::spin_drift::calculate_spin_rate(velocity_magnitude, self.inputs.twist_rate);
1482 let temp_k = self.atmosphere.temperature + 273.15;
1483 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
1484 let mach = velocity_magnitude / speed_of_sound;
1485
1486 let d_in = self.inputs.bullet_diameter / 0.0254;
1488 let m_gr = self.inputs.bullet_mass / 0.00006479891;
1489 let l_in = if self.inputs.bullet_length > 0.0 {
1490 self.inputs.bullet_length / 0.0254
1491 } else {
1492 4.5 * d_in
1493 };
1494 let press_hpa = self.atmosphere.pressure;
1498 let density_correction = if press_hpa > 0.0 && temp_k > 0.0 {
1499 (temp_k / 288.15) * (1013.25 / press_hpa)
1500 } else {
1501 1.0
1502 };
1503 let sg = crate::spin_drift::miller_stability(d_in, m_gr, self.inputs.twist_rate, l_in)
1504 * density_correction;
1505
1506 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
1508 sg,
1509 velocity_magnitude,
1510 spin_rad_s,
1511 0.0, 0.0, air_density,
1514 d_in,
1515 l_in,
1516 m_gr,
1517 mach,
1518 "match",
1519 false,
1520 );
1521
1522 let diameter_m = self.inputs.bullet_diameter; let spin_param = spin_rad_s * diameter_m / (2.0 * velocity_magnitude);
1525 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
1526 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
1527 let magnus_force = 0.5
1528 * air_density
1529 * velocity_magnitude.powi(2)
1530 * area
1531 * c_np
1532 * spin_param
1533 * yaw_rad.sin();
1534
1535 let velocity_unit = relative_velocity / velocity_magnitude;
1538 let up = Vector3::new(0.0, 1.0, 0.0);
1539 let mut dir = velocity_unit.cross(&up);
1540 let dir_norm = dir.norm();
1541 if dir_norm > 1e-12 && magnus_force.abs() > 1e-12 {
1542 dir /= dir_norm;
1543 if !self.inputs.is_twist_right {
1544 dir = -dir;
1545 }
1546 accel += (magnus_force / self.inputs.bullet_mass) * dir;
1547 }
1548 }
1549
1550 accel
1551 }
1552
1553 fn calculate_drag_coefficient(&self, velocity: f64) -> f64 {
1554 let temp_c = self.atmosphere.temperature;
1556 let temp_k = temp_c + 273.15;
1557 let gamma = 1.4; let r_specific = 287.05; let speed_of_sound = (gamma * r_specific * temp_k).sqrt();
1560 let mach = velocity / speed_of_sound;
1561
1562 if let Some(ref table) = self.inputs.custom_drag_table {
1566 return table.interpolate(mach);
1567 }
1568
1569 let base_cd = crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type);
1571
1572 let bc_type_str: &str = match self.inputs.bc_type {
1576 crate::DragModel::G1 => "G1",
1577 crate::DragModel::G2 => "G2",
1578 crate::DragModel::G5 => "G5",
1579 crate::DragModel::G6 => "G6",
1580 crate::DragModel::G7 => "G7",
1581 crate::DragModel::G8 => "G8",
1582 crate::DragModel::GI => "GI",
1583 crate::DragModel::GS => "GS",
1584 };
1585
1586 let projectile_shape = crate::transonic_drag::resolve_projectile_shape(
1589 self.inputs.bullet_model.as_deref(),
1590 self.inputs.caliber_inches,
1591 self.inputs.bullet_mass / 0.00006479891, bc_type_str,
1593 );
1594
1595 let include_wave_drag = false;
1601 let cd = transonic_correction(mach, base_cd, projectile_shape, include_wave_drag);
1602
1603 crate::form_factor::apply_form_factor_to_drag(
1608 cd,
1609 self.inputs.bullet_model.as_deref(),
1610 &self.inputs.bc_type,
1611 self.inputs.use_form_factor,
1612 )
1613 }
1614}
1615
1616#[derive(Debug, Clone)]
1618pub struct MonteCarloParams {
1619 pub num_simulations: usize,
1620 pub velocity_std_dev: f64,
1621 pub angle_std_dev: f64,
1622 pub bc_std_dev: f64,
1623 pub wind_speed_std_dev: f64,
1624 pub target_distance: Option<f64>,
1625 pub base_wind_speed: f64,
1626 pub base_wind_direction: f64,
1627 pub azimuth_std_dev: f64, }
1629
1630impl Default for MonteCarloParams {
1631 fn default() -> Self {
1632 Self {
1633 num_simulations: 1000,
1634 velocity_std_dev: 1.0,
1635 angle_std_dev: 0.001,
1636 bc_std_dev: 0.01,
1637 wind_speed_std_dev: 1.0,
1638 target_distance: None,
1639 base_wind_speed: 0.0,
1640 base_wind_direction: 0.0,
1641 azimuth_std_dev: 0.001, }
1643 }
1644}
1645
1646#[derive(Debug, Clone)]
1648pub struct MonteCarloResults {
1649 pub ranges: Vec<f64>,
1650 pub impact_velocities: Vec<f64>,
1651 pub impact_positions: Vec<Vector3<f64>>,
1652}
1653
1654pub fn run_monte_carlo(
1656 base_inputs: BallisticInputs,
1657 params: MonteCarloParams,
1658) -> Result<MonteCarloResults, BallisticsError> {
1659 let base_wind = WindConditions {
1660 speed: params.base_wind_speed,
1661 direction: params.base_wind_direction,
1662 };
1663 run_monte_carlo_with_wind(base_inputs, base_wind, params)
1664}
1665
1666pub fn run_monte_carlo_with_wind(
1668 base_inputs: BallisticInputs,
1669 base_wind: WindConditions,
1670 params: MonteCarloParams,
1671) -> Result<MonteCarloResults, BallisticsError> {
1672 use rand_distr::{Distribution, Normal};
1673
1674 let mut rng = rand::rng();
1675 let mut ranges = Vec::new();
1676 let mut impact_velocities = Vec::new();
1677 let mut impact_positions = Vec::new();
1678
1679 let baseline_solver =
1681 TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), Default::default());
1682 let baseline_result = baseline_solver.solve()?;
1683
1684 let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
1686
1687 let baseline_at_target = baseline_result
1689 .position_at_range(target_distance)
1690 .ok_or("Could not interpolate baseline at target distance")?;
1691
1692 let velocity_dist = Normal::new(base_inputs.muzzle_velocity, params.velocity_std_dev)
1694 .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
1695 let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
1696 .map_err(|e| format!("Invalid angle distribution: {}", e))?;
1697 let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
1698 .map_err(|e| format!("Invalid BC distribution: {}", e))?;
1699 let wind_speed_dist = Normal::new(base_wind.speed, params.wind_speed_std_dev)
1700 .map_err(|e| format!("Invalid wind speed distribution: {}", e))?;
1701 let wind_dir_dist =
1706 Normal::new(base_wind.direction, params.wind_speed_std_dev * 0.1)
1707 .map_err(|e| format!("Invalid wind direction distribution: {}", e))?;
1708 let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
1709 .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
1710
1711 let pointing_error_dist = Normal::new(0.0, params.angle_std_dev * target_distance)
1713 .map_err(|e| format!("Invalid pointing distribution: {}", e))?;
1714
1715 for _ in 0..params.num_simulations {
1716 let mut inputs = base_inputs.clone();
1718 inputs.muzzle_velocity = velocity_dist.sample(&mut rng).max(0.0);
1719 inputs.muzzle_angle = angle_dist.sample(&mut rng);
1720 inputs.bc_value = bc_dist.sample(&mut rng).max(0.01);
1721 inputs.azimuth_angle = azimuth_dist.sample(&mut rng); let wind = WindConditions {
1725 speed: wind_speed_dist.sample(&mut rng).abs(),
1726 direction: wind_dir_dist.sample(&mut rng),
1727 };
1728
1729 let solver = TrajectorySolver::new(inputs, wind, Default::default());
1731 match solver.solve() {
1732 Ok(result) => {
1733 if result.max_range < target_distance {
1741 continue;
1742 }
1743 let pos_at_target = match result.position_at_range(target_distance) {
1747 Some(p) => p,
1748 None => continue,
1749 };
1750
1751 ranges.push(result.max_range);
1752 impact_velocities.push(result.impact_velocity);
1753
1754 let mut deviation = Vector3::new(
1757 0.0, pos_at_target.y - baseline_at_target.y, pos_at_target.z - baseline_at_target.z, );
1761
1762 let pointing_error_y = pointing_error_dist.sample(&mut rng);
1765 deviation.y += pointing_error_y;
1766
1767 impact_positions.push(deviation);
1768 }
1769 Err(_) => {
1770 continue;
1772 }
1773 }
1774 }
1775
1776 if ranges.is_empty() {
1777 return Err("No successful simulations".into());
1778 }
1779
1780 Ok(MonteCarloResults {
1781 ranges,
1782 impact_velocities,
1783 impact_positions,
1784 })
1785}
1786
1787pub fn calculate_zero_angle(
1789 inputs: BallisticInputs,
1790 target_distance: f64,
1791 target_height: f64,
1792) -> Result<f64, BallisticsError> {
1793 calculate_zero_angle_with_conditions(
1794 inputs,
1795 target_distance,
1796 target_height,
1797 WindConditions::default(),
1798 AtmosphericConditions::default(),
1799 )
1800}
1801
1802pub fn calculate_zero_angle_with_conditions(
1803 inputs: BallisticInputs,
1804 target_distance: f64,
1805 target_height: f64,
1806 wind: WindConditions,
1807 atmosphere: AtmosphericConditions,
1808) -> Result<f64, BallisticsError> {
1809 let get_height_at_angle = |angle: f64| -> Result<Option<f64>, BallisticsError> {
1811 let mut test_inputs = inputs.clone();
1812 test_inputs.muzzle_angle = angle;
1813 test_inputs.enable_aerodynamic_jump = false;
1818
1819 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
1820 solver.set_max_range(target_distance * 2.0);
1821 solver.set_time_step(0.001);
1822 let result = solver.solve()?;
1823
1824 for i in 0..result.points.len() {
1826 if result.points[i].position.x >= target_distance {
1827 if i > 0 {
1828 let p1 = &result.points[i - 1];
1829 let p2 = &result.points[i];
1830 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
1831 return Ok(Some(p1.position.y + t * (p2.position.y - p1.position.y)));
1832 } else {
1833 return Ok(Some(result.points[i].position.y));
1834 }
1835 }
1836 }
1837 Ok(None)
1838 };
1839
1840 let mut low_angle = 0.0; let mut high_angle = 0.2; let tolerance = 0.00001; let max_iterations = 50;
1846
1847 let low_height = get_height_at_angle(low_angle)?;
1850 let high_height = get_height_at_angle(high_angle)?;
1851
1852 match (low_height, high_height) {
1853 (Some(lh), Some(hh)) => {
1854 let low_error = lh - target_height;
1855 let high_error = hh - target_height;
1856
1857 if low_error > 0.0 && high_error > 0.0 {
1860 } else if low_error < 0.0 && high_error < 0.0 {
1865 let mut expanded = false;
1868 for multiplier in [2.0, 3.0, 4.0] {
1869 let new_high = (high_angle * multiplier).min(0.785);
1870 if let Ok(Some(h)) = get_height_at_angle(new_high) {
1871 if h - target_height > 0.0 {
1872 high_angle = new_high;
1873 expanded = true;
1874 break;
1875 }
1876 }
1877 if new_high >= 0.785 {
1878 break;
1879 }
1880 }
1881 if !expanded {
1882 return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
1883 }
1884 }
1885 }
1887 (None, Some(_hh)) => {
1888 }
1891 (Some(_lh), None) => {
1892 return Err(
1894 "Cannot find zero angle: high angle trajectory doesn't reach target distance"
1895 .into(),
1896 );
1897 }
1898 (None, None) => {
1899 return Err(
1901 "Cannot find zero angle: trajectory cannot reach target distance at any angle"
1902 .into(),
1903 );
1904 }
1905 }
1906
1907 for _iteration in 0..max_iterations {
1908 let mid_angle = (low_angle + high_angle) / 2.0;
1909
1910 let mut test_inputs = inputs.clone();
1911 test_inputs.muzzle_angle = mid_angle;
1912 test_inputs.enable_aerodynamic_jump = false;
1914
1915 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
1916 solver.set_max_range(target_distance * 2.0);
1918 solver.set_time_step(0.001);
1919 let result = solver.solve()?;
1920
1921 let mut height_at_target = None;
1923 for i in 0..result.points.len() {
1924 if result.points[i].position.x >= target_distance {
1925 if i > 0 {
1926 let p1 = &result.points[i - 1];
1928 let p2 = &result.points[i];
1929 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
1930 height_at_target = Some(p1.position.y + t * (p2.position.y - p1.position.y));
1931 } else {
1932 height_at_target = Some(result.points[i].position.y);
1933 }
1934 break;
1935 }
1936 }
1937
1938 match height_at_target {
1939 Some(height) => {
1940 let error = height - target_height;
1941 if error.abs() < 0.001 {
1944 return Ok(mid_angle);
1945 }
1946
1947 if (high_angle - low_angle).abs() < tolerance {
1951 if error.abs() < 0.01 {
1952 return Ok(mid_angle);
1954 }
1955 return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
1960 }
1961
1962 if error > 0.0 {
1963 high_angle = mid_angle;
1964 } else {
1965 low_angle = mid_angle;
1966 }
1967 }
1968 None => {
1969 low_angle = mid_angle;
1971
1972 if (high_angle - low_angle).abs() < tolerance {
1974 return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
1975 }
1976 }
1977 }
1978 }
1979
1980 Err("Failed to find zero angle".into())
1981}
1982
1983pub fn estimate_bc_from_trajectory(
1985 velocity: f64,
1986 mass: f64,
1987 diameter: f64,
1988 points: &[(f64, f64)], ) -> Result<f64, BallisticsError> {
1990 let mut best_bc = 0.5;
1992 let mut best_error = f64::MAX;
1993 let mut found_valid = false;
1994
1995 for bc in (100..1000).step_by(10) {
1997 let bc_value = bc as f64 / 1000.0;
1998
1999 let inputs = BallisticInputs {
2000 muzzle_velocity: velocity,
2001 bc_value,
2002 bullet_mass: mass,
2003 bullet_diameter: diameter,
2004 ..Default::default()
2005 };
2006
2007 let mut solver = TrajectorySolver::new(inputs, Default::default(), Default::default());
2008 solver.set_max_range(points.last().map(|(d, _)| *d * 1.5).unwrap_or(1000.0));
2010
2011 let result = match solver.solve() {
2012 Ok(r) => r,
2013 Err(_) => continue, };
2015
2016 let mut total_error = 0.0;
2018 for (target_dist, target_drop) in points {
2019 let mut calculated_drop = None;
2021 for i in 0..result.points.len() {
2022 if result.points[i].position.x >= *target_dist {
2023 if i > 0 {
2024 let p1 = &result.points[i - 1];
2026 let p2 = &result.points[i];
2027 let t = (target_dist - p1.position.x) / (p2.position.x - p1.position.x);
2028 calculated_drop =
2029 Some(-(p1.position.y + t * (p2.position.y - p1.position.y)));
2030 } else {
2031 calculated_drop = Some(-result.points[i].position.y);
2032 }
2033 break;
2034 }
2035 }
2036
2037 if let Some(drop) = calculated_drop {
2038 let error = (drop - target_drop).abs();
2039 total_error += error * error;
2040 }
2041 }
2042
2043 if total_error < best_error {
2044 best_error = total_error;
2045 best_bc = bc_value;
2046 found_valid = true;
2047 }
2048 }
2049
2050 if !found_valid {
2051 return Err(BallisticsError::from("Unable to estimate BC from provided data. Check that drop values are in correct units.".to_string()));
2052 }
2053
2054 Ok(best_bc)
2055}
2056
2057fn calculate_air_density(atmosphere: &AtmosphericConditions) -> f64 {
2059 crate::atmosphere::calculate_atmosphere(
2067 atmosphere.altitude,
2068 crate::atmosphere::resolve_station_temperature(atmosphere.temperature, atmosphere.altitude),
2069 crate::atmosphere::resolve_station_pressure(atmosphere.pressure, atmosphere.altitude),
2070 atmosphere.humidity,
2071 )
2072 .0
2073}
2074
2075use rand;
2077use rand_distr;
2078
2079#[cfg(test)]
2080mod ground_termination_tests {
2081 use super::*;
2082
2083 #[test]
2088 fn rk4_and_rk45_descend_to_ground_threshold() {
2089 for adaptive in [false, true] {
2090 let mut inputs = BallisticInputs::default();
2091 inputs.muzzle_angle = 0.1; inputs.use_rk4 = true;
2093 inputs.use_adaptive_rk45 = adaptive;
2094 assert_eq!(inputs.ground_threshold, -100.0, "default ground_threshold is -100 m");
2095
2096 let mut solver = TrajectorySolver::new(
2097 inputs,
2098 WindConditions::default(),
2099 AtmosphericConditions::default(),
2100 );
2101 solver.set_max_range(1.0e7);
2103
2104 let result = solver.solve().expect("solve should succeed");
2105 let final_y = result
2106 .points
2107 .last()
2108 .expect("trajectory has points")
2109 .position
2110 .y;
2111 assert!(
2112 final_y < -1.0,
2113 "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
2114 past launch level toward the ground_threshold floor, not stop at y = 0"
2115 );
2116 }
2117 }
2118}