1use crate::cluster_bc::ClusterBCDegradation;
3use crate::pitch_damping::{calculate_pitch_damping_coefficient, PitchDampingCoefficients};
4use crate::precession_nutation::{
5 calculate_combined_angular_motion, AngularState, PrecessionNutationParams,
6};
7use crate::trajectory_sampling::{
8 sample_trajectory, TrajectoryData, TrajectoryOutputs, TrajectorySample,
9};
10use crate::transonic_drag::transonic_correction;
11use crate::wind_shear::WindShearModel;
12use crate::DragModel;
13use nalgebra::Vector3;
14use std::error::Error;
15use std::fmt;
16
17#[derive(Debug, Clone, Copy, PartialEq)]
19pub enum UnitSystem {
20 Imperial,
21 Metric,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq)]
26pub enum OutputFormat {
27 Table,
28 Json,
29 Csv,
30}
31
32#[derive(Debug)]
34pub struct BallisticsError {
35 message: String,
36}
37
38impl fmt::Display for BallisticsError {
39 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
40 write!(f, "{}", self.message)
41 }
42}
43
44impl Error for BallisticsError {}
45
46impl From<String> for BallisticsError {
47 fn from(msg: String) -> Self {
48 BallisticsError { message: msg }
49 }
50}
51
52impl From<&str> for BallisticsError {
53 fn from(msg: &str) -> Self {
54 BallisticsError {
55 message: msg.to_string(),
56 }
57 }
58}
59
60#[derive(Debug, Clone)]
64pub struct BallisticInputs {
65 pub bc_value: f64, pub bc_type: DragModel, pub bullet_mass: f64, pub muzzle_velocity: f64, pub bullet_diameter: f64, pub bullet_length: f64, pub muzzle_angle: f64, pub target_distance: f64, pub azimuth_angle: f64, pub shot_azimuth: f64,
82 pub shooting_angle: f64, pub sight_height: f64, pub muzzle_height: f64, pub target_height: f64, pub ground_threshold: f64, pub altitude: f64, pub temperature: f64, pub pressure: f64, pub humidity: f64,
97 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,
119 pub enable_magnus: bool, pub enable_coriolis: bool, pub use_powder_sensitivity: bool,
122 pub powder_temp_sensitivity: f64,
123 pub powder_temp: f64, pub tipoff_yaw: f64, pub tipoff_decay_distance: f64, pub use_bc_segments: bool,
127 pub bc_segments: Option<Vec<(f64, f64)>>, pub bc_segments_data: Option<Vec<crate::BCSegmentData>>, pub use_enhanced_spin_drift: bool,
130 pub use_form_factor: bool,
131 pub enable_wind_shear: bool,
132 pub wind_shear_model: String,
133 pub enable_trajectory_sampling: bool,
134 pub sample_interval: f64, pub enable_pitch_damping: bool,
136 pub enable_precession_nutation: bool,
137 pub enable_aerodynamic_jump: bool,
140 pub use_cluster_bc: bool, pub custom_drag_table: Option<crate::drag::DragTable>,
144
145 pub bc_type_str: Option<String>,
147}
148
149impl BallisticInputs {
150 pub fn humidity_percent(&self) -> f64 {
155 (self.humidity * 100.0).clamp(0.0, 100.0)
156 }
157}
158
159impl Default for BallisticInputs {
160 fn default() -> Self {
161 let mass_kg = 0.01;
162 let diameter_m = 0.00762;
163 let bc = 0.5;
164 let muzzle_angle_rad = 0.0;
165 let bc_type = DragModel::G1;
166
167 Self {
168 bc_value: bc,
170 bc_type,
171 bullet_mass: mass_kg,
172 muzzle_velocity: 800.0,
173 bullet_diameter: diameter_m,
174 bullet_length: diameter_m * 4.5, muzzle_angle: muzzle_angle_rad,
178 target_distance: 100.0,
179 azimuth_angle: 0.0,
180 shot_azimuth: 0.0,
181 shooting_angle: 0.0,
182 sight_height: 0.05,
183 muzzle_height: 0.0, target_height: 0.0, ground_threshold: -100.0, altitude: 0.0,
189 temperature: 15.0,
190 pressure: 1013.25, humidity: 0.5, latitude: None,
193
194 wind_speed: 0.0,
196 wind_angle: 0.0,
197
198 twist_rate: 12.0, is_twist_right: true,
201 caliber_inches: diameter_m / 0.0254, weight_grains: mass_kg / 0.00006479891, manufacturer: None,
204 bullet_model: None,
205 bullet_id: None,
206 bullet_cluster: None,
207
208 use_rk4: true, use_adaptive_rk45: true, enable_advanced_effects: false,
214 enable_magnus: false,
215 enable_coriolis: false,
216 use_powder_sensitivity: false,
217 powder_temp_sensitivity: 0.0,
218 powder_temp: 15.0,
219 tipoff_yaw: 0.0,
220 tipoff_decay_distance: 50.0,
221 use_bc_segments: false,
222 bc_segments: None,
223 bc_segments_data: None,
224 use_enhanced_spin_drift: false,
225 use_form_factor: false,
226 enable_wind_shear: false,
227 wind_shear_model: "none".to_string(),
228 enable_trajectory_sampling: false,
229 sample_interval: 10.0, enable_pitch_damping: false,
231 enable_precession_nutation: false,
232 enable_aerodynamic_jump: false,
233 use_cluster_bc: false, custom_drag_table: None,
237
238 bc_type_str: None,
240 }
241 }
242}
243
244#[derive(Debug, Clone)]
246pub struct WindConditions {
247 pub speed: f64, pub direction: f64,
251}
252
253impl Default for WindConditions {
254 fn default() -> Self {
255 Self {
256 speed: 0.0,
257 direction: 0.0,
258 }
259 }
260}
261
262#[derive(Debug, Clone)]
264pub struct AtmosphericConditions {
265 pub temperature: f64, pub pressure: f64, pub humidity: f64,
271 pub altitude: f64, }
273
274impl Default for AtmosphericConditions {
275 fn default() -> Self {
276 Self {
277 temperature: 15.0,
278 pressure: 1013.25,
279 humidity: 50.0,
280 altitude: 0.0,
281 }
282 }
283}
284
285#[derive(Debug, Clone)]
287pub struct TrajectoryPoint {
288 pub time: f64,
289 pub position: Vector3<f64>,
290 pub velocity_magnitude: f64,
291 pub kinetic_energy: f64,
292}
293
294#[derive(Debug, Clone)]
296pub struct TrajectoryResult {
297 pub max_range: f64,
298 pub max_height: f64,
299 pub time_of_flight: f64,
300 pub impact_velocity: f64,
301 pub impact_energy: f64,
302 pub points: Vec<TrajectoryPoint>,
303 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>,
312}
313
314impl TrajectoryResult {
315 pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
319 if self.points.is_empty() {
320 return None;
321 }
322
323 for i in 0..self.points.len() - 1 {
325 let p1 = &self.points[i];
326 let p2 = &self.points[i + 1];
327
328 if p1.position.x <= target_range && p2.position.x >= target_range {
330 let dx = p2.position.x - p1.position.x;
332 if dx.abs() < 1e-10 {
333 return Some(p1.position);
334 }
335 let t = (target_range - p1.position.x) / dx;
336
337 return Some(Vector3::new(
339 target_range,
340 p1.position.y + t * (p2.position.y - p1.position.y),
341 p1.position.z + t * (p2.position.z - p1.position.z),
342 ));
343 }
344 }
345
346 self.points.last().map(|p| p.position)
348 }
349}
350
351pub struct TrajectorySolver {
353 inputs: BallisticInputs,
354 wind: WindConditions,
355 atmosphere: AtmosphericConditions,
356 max_range: f64,
357 time_step: f64,
358 cluster_bc: Option<ClusterBCDegradation>,
359 wind_sock: Option<crate::wind::WindSock>,
364}
365
366impl TrajectorySolver {
367 pub fn new(
368 mut inputs: BallisticInputs,
369 wind: WindConditions,
370 atmosphere: AtmosphericConditions,
371 ) -> Self {
372 inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
374 inputs.weight_grains = inputs.bullet_mass / 0.00006479891;
375
376 let cluster_bc = if inputs.use_cluster_bc {
378 Some(ClusterBCDegradation::new())
379 } else {
380 None
381 };
382
383 Self {
384 inputs,
385 wind,
386 atmosphere,
387 max_range: 1000.0,
388 time_step: 0.001,
389 cluster_bc,
390 wind_sock: None,
391 }
392 }
393
394 pub fn set_max_range(&mut self, range: f64) {
395 self.max_range = range;
396 }
397
398 pub fn set_time_step(&mut self, step: f64) {
399 self.time_step = step;
400 }
401
402 pub fn set_wind_segments(&mut self, segments: Vec<crate::wind::WindSegment>) {
409 self.wind_sock = if segments.is_empty() {
410 None
411 } else {
412 Some(crate::wind::WindSock::new(segments))
413 };
414 }
415
416 fn launch_angles_from(
425 &self,
426 aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
427 ) -> (f64, f64) {
428 let elev = self.inputs.muzzle_angle;
429 let azim = self.inputs.azimuth_angle;
430 match aj {
431 Some(c) => {
432 const MOA_PER_RAD: f64 = 3437.7467707849;
434 (
435 elev + c.vertical_jump_moa / MOA_PER_RAD,
436 azim + c.horizontal_jump_moa / MOA_PER_RAD,
437 )
438 }
439 None => (elev, azim),
440 }
441 }
442
443 fn aerodynamic_jump_components(
451 &self,
452 ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
453 if !self.inputs.enable_aerodynamic_jump {
454 return None;
455 }
456 let diameter_m = self.inputs.bullet_diameter;
460 if !(self.inputs.twist_rate.is_finite() && self.inputs.twist_rate != 0.0)
461 || !(diameter_m.is_finite() && diameter_m > 0.0)
462 || !(self.inputs.bullet_length.is_finite() && self.inputs.bullet_length > 0.0)
463 || !self.inputs.muzzle_velocity.is_finite()
464 {
465 return None;
466 }
467
468 let sg = crate::stability::compute_stability_coefficient(
470 &self.inputs,
471 (
472 self.atmosphere.altitude,
473 self.atmosphere.temperature,
474 self.atmosphere.pressure,
475 0.0,
476 ),
477 );
478 if !(sg.is_finite() && sg > 0.0) {
479 return None;
480 }
481 let length_calibers = self.inputs.bullet_length / diameter_m;
482
483 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
489 let crosswind_from_right_mph = self.wind.speed * self.wind.direction.sin() * MS_TO_MPH;
490
491 let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
492 sg,
493 length_calibers,
494 crosswind_from_right_mph,
495 self.inputs.is_twist_right,
496 );
497 if !vertical_jump_moa.is_finite() {
498 return None;
499 }
500
501 const MOA_PER_RAD: f64 = 3437.7467707849;
502 Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
503 vertical_jump_moa,
504 horizontal_jump_moa: 0.0,
506 jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
507 magnus_component_moa: 0.0,
508 yaw_component_moa: 0.0,
509 stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
510 })
511 }
512
513 fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
514 let model = if self.inputs.wind_shear_model == "logarithmic" {
524 WindShearModel::Logarithmic
525 } else {
526 WindShearModel::PowerLaw };
528 let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
529
530 Vector3::new(
533 -self.wind.speed * self.wind.direction.cos() * speed_ratio, 0.0,
535 -self.wind.speed * self.wind.direction.sin() * speed_ratio, )
537 }
538
539 pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
540 let mut result = if self.inputs.use_rk4 {
541 if self.inputs.use_adaptive_rk45 {
542 self.solve_rk45()?
543 } else {
544 self.solve_rk4()?
545 }
546 } else {
547 self.solve_euler()?
548 };
549 self.apply_spin_drift(&mut result);
550 Ok(result)
551 }
552
553 fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
559 if !self.inputs.use_enhanced_spin_drift {
560 return;
561 }
562 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 {
566 return;
567 }
568
569 let length_in = if self.inputs.bullet_length > 0.0 {
571 self.inputs.bullet_length / 0.0254
572 } else {
573 4.5 * d_in
574 };
575 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 {
583 (temp_k / 288.15) * (1013.25 / press_hpa)
584 } else {
585 1.0
586 };
587 let sg =
588 crate::spin_drift::miller_stability(d_in, m_gr, twist_in, length_in) * density_correction;
589 let sign = if self.inputs.is_twist_right { 1.0 } else { -1.0 };
590
591 for p in result.points.iter_mut() {
592 if p.time <= 0.0 {
593 continue;
594 }
595 let sd_in = 1.25 * (sg + 1.2) * p.time.powf(1.83); p.position.z += sign * sd_in * 0.0254; }
598
599 if let Some(samples) = result.sampled_points.as_mut() {
603 for s in samples.iter_mut() {
604 if s.time_s <= 0.0 {
605 continue;
606 }
607 let sd_in = 1.25 * (sg + 1.2) * s.time_s.powf(1.83);
608 s.wind_drift_m += sign * sd_in * 0.0254;
609 }
610 }
611 }
612
613 fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
614 let mut time = 0.0;
616 let mut position = Vector3::new(
619 0.0,
620 self.inputs.muzzle_height, 0.0,
622 );
623 let aj_components = self.aerodynamic_jump_components();
629 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
630 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
631 let mut velocity = Vector3::new(
632 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
636
637 let mut points = Vec::new();
638 let mut max_height = position.y;
639 let mut min_pitch_damping = 1.0; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
645 let mut crossed_transonic = false;
646 let mut crossed_subsonic = false;
647
648 let mut angular_state = if self.inputs.enable_precession_nutation {
650 Some(AngularState {
651 pitch_angle: 0.001, yaw_angle: 0.001,
653 pitch_rate: 0.0,
654 yaw_rate: 0.0,
655 precession_angle: 0.0,
656 nutation_phase: 0.0,
657 })
658 } else {
659 None
660 };
661 let mut max_yaw_angle = 0.0;
662 let mut max_precession_angle = 0.0;
663
664 let air_density = calculate_air_density(&self.atmosphere);
666
667 let wind_vector = Vector3::new(
671 -self.wind.speed * self.wind.direction.cos(), 0.0,
673 -self.wind.speed * self.wind.direction.sin(), );
675
676 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
679 self.inputs.bullet_model.as_deref().unwrap_or("default"),
680 );
681
682 while position.x < self.max_range && position.y > self.inputs.ground_threshold && time < 100.0 {
684 let velocity_magnitude = velocity.magnitude();
686 let kinetic_energy =
687 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
688
689 points.push(TrajectoryPoint {
690 time,
691 position: position,
692 velocity_magnitude,
693 kinetic_energy,
694 });
695
696 {
699 let sos = (1.4 * 287.05 * (self.atmosphere.temperature + 273.15)).sqrt();
700 let mach_here = if sos > 0.0 { velocity_magnitude / sos } else { 0.0 };
701 if !crossed_transonic && mach_here < 1.2 {
702 crossed_transonic = true;
703 transonic_distances.push(position.x);
704 }
705 if !crossed_subsonic && mach_here < 1.0 {
706 crossed_subsonic = true;
707 transonic_distances.push(position.x);
708 }
709 }
710
711 #[cfg(debug_assertions)]
715 if points.len() == 1 || points.len() % 100 == 0 {
716 eprintln!("Trajectory point {}: time={:.3}s, downrange={:.2}m, vertical={:.2}m, lateral={:.2}m, vel={:.1}m/s",
717 points.len(), time, position.x, position.y, position.z, velocity_magnitude);
718 }
719
720 if position.y > max_height {
722 max_height = position.y;
723 }
724
725 if self.inputs.enable_pitch_damping {
727 let temp_c = self.atmosphere.temperature;
728 let temp_k = temp_c + 273.15;
729 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
730 let mach = velocity_magnitude / speed_of_sound;
731
732 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
734 transonic_mach = Some(mach);
735 }
736
737 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
739
740 if pitch_damping < min_pitch_damping {
742 min_pitch_damping = pitch_damping;
743 }
744 }
745
746 if self.inputs.enable_precession_nutation {
748 if let Some(ref mut state) = angular_state {
749 let velocity_magnitude = velocity.magnitude();
750 let temp_c = self.atmosphere.temperature;
751 let temp_k = temp_c + 273.15;
752 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
753 let mach = velocity_magnitude / speed_of_sound;
754
755 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
757 let velocity_fps = velocity_magnitude * 3.28084;
758 let twist_rate_ft = self.inputs.twist_rate / 12.0;
759 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
760 } else {
761 0.0
762 };
763
764 let params = PrecessionNutationParams {
766 mass_kg: self.inputs.bullet_mass,
767 caliber_m: self.inputs.bullet_diameter,
768 length_m: self.inputs.bullet_length,
769 spin_rate_rad_s,
770 spin_inertia: 6.94e-8, transverse_inertia: 9.13e-7, velocity_mps: velocity_magnitude,
773 air_density_kg_m3: air_density,
774 mach,
775 pitch_damping_coeff: -0.8,
776 nutation_damping_factor: 0.05,
777 };
778
779 *state = calculate_combined_angular_motion(
781 ¶ms,
782 state,
783 time,
784 self.time_step,
785 0.001, );
787
788 if state.yaw_angle.abs() > max_yaw_angle {
790 max_yaw_angle = state.yaw_angle.abs();
791 }
792 if state.precession_angle.abs() > max_precession_angle {
793 max_precession_angle = state.precession_angle.abs();
794 }
795 }
796 }
797
798 let acceleration =
805 self.calculate_acceleration(&position, &velocity, air_density, &wind_vector);
806
807 velocity += acceleration * self.time_step;
809 position += velocity * self.time_step;
810 time += self.time_step;
811 }
812
813 let last_point = points.last().ok_or("No trajectory points generated")?;
815
816 let sampled_points = if self.inputs.enable_trajectory_sampling {
818 let trajectory_data = TrajectoryData {
819 times: points.iter().map(|p| p.time).collect(),
820 positions: points.iter().map(|p| p.position).collect(),
821 velocities: points
822 .iter()
823 .map(|p| {
824 Vector3::new(0.0, 0.0, p.velocity_magnitude)
826 })
827 .collect(),
828 transonic_distances, };
830
831 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
836 let outputs = TrajectoryOutputs {
837 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
839 time_of_flight_s: last_point.time,
840 max_ord_dist_horiz_m: max_height,
841 sight_height_m: sight_position_m,
842 };
843
844 let samples = sample_trajectory(
846 &trajectory_data,
847 &outputs,
848 self.inputs.sample_interval,
849 self.inputs.bullet_mass,
850 );
851 Some(samples)
852 } else {
853 None
854 };
855
856 Ok(TrajectoryResult {
857 max_range: last_point.position.x, max_height,
859 time_of_flight: last_point.time,
860 impact_velocity: last_point.velocity_magnitude,
861 impact_energy: last_point.kinetic_energy,
862 points,
863 sampled_points,
864 min_pitch_damping: if self.inputs.enable_pitch_damping {
865 Some(min_pitch_damping)
866 } else {
867 None
868 },
869 transonic_mach,
870 angular_state,
871 max_yaw_angle: if self.inputs.enable_precession_nutation {
872 Some(max_yaw_angle)
873 } else {
874 None
875 },
876 max_precession_angle: if self.inputs.enable_precession_nutation {
877 Some(max_precession_angle)
878 } else {
879 None
880 },
881 aerodynamic_jump: aj_components,
882 })
883 }
884
885 fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
886 let mut time = 0.0;
888 let mut position = Vector3::new(
892 0.0,
893 self.inputs.muzzle_height, 0.0,
895 );
896
897 let aj_components = self.aerodynamic_jump_components();
903 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
904 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
905 let mut velocity = Vector3::new(
906 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
910
911 let mut points = Vec::new();
912 let mut max_height = position.y;
913 let mut min_pitch_damping = 1.0; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
919 let mut crossed_transonic = false;
920 let mut crossed_subsonic = false;
921
922 let mut angular_state = if self.inputs.enable_precession_nutation {
924 Some(AngularState {
925 pitch_angle: 0.001, yaw_angle: 0.001,
927 pitch_rate: 0.0,
928 yaw_rate: 0.0,
929 precession_angle: 0.0,
930 nutation_phase: 0.0,
931 })
932 } else {
933 None
934 };
935 let mut max_yaw_angle = 0.0;
936 let mut max_precession_angle = 0.0;
937
938 let air_density = calculate_air_density(&self.atmosphere);
940
941 let wind_vector = Vector3::new(
945 -self.wind.speed * self.wind.direction.cos(), 0.0,
947 -self.wind.speed * self.wind.direction.sin(), );
949
950 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
953 self.inputs.bullet_model.as_deref().unwrap_or("default"),
954 );
955
956 while position.x < self.max_range && position.y > self.inputs.ground_threshold && time < 100.0 {
958 let velocity_magnitude = velocity.magnitude();
960 let kinetic_energy =
961 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
962
963 points.push(TrajectoryPoint {
964 time,
965 position: position,
966 velocity_magnitude,
967 kinetic_energy,
968 });
969
970 {
973 let sos = (1.4 * 287.05 * (self.atmosphere.temperature + 273.15)).sqrt();
974 let mach_here = if sos > 0.0 { velocity_magnitude / sos } else { 0.0 };
975 if !crossed_transonic && mach_here < 1.2 {
976 crossed_transonic = true;
977 transonic_distances.push(position.x);
978 }
979 if !crossed_subsonic && mach_here < 1.0 {
980 crossed_subsonic = true;
981 transonic_distances.push(position.x);
982 }
983 }
984
985 if position.y > max_height {
986 max_height = position.y;
987 }
988
989 if self.inputs.enable_pitch_damping {
991 let temp_c = self.atmosphere.temperature;
992 let temp_k = temp_c + 273.15;
993 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
994 let mach = velocity_magnitude / speed_of_sound;
995
996 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
998 transonic_mach = Some(mach);
999 }
1000
1001 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1003
1004 if pitch_damping < min_pitch_damping {
1006 min_pitch_damping = pitch_damping;
1007 }
1008 }
1009
1010 if self.inputs.enable_precession_nutation {
1012 if let Some(ref mut state) = angular_state {
1013 let velocity_magnitude = velocity.magnitude();
1014 let temp_c = self.atmosphere.temperature;
1015 let temp_k = temp_c + 273.15;
1016 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
1017 let mach = velocity_magnitude / speed_of_sound;
1018
1019 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
1021 let velocity_fps = velocity_magnitude * 3.28084;
1022 let twist_rate_ft = self.inputs.twist_rate / 12.0;
1023 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
1024 } else {
1025 0.0
1026 };
1027
1028 let params = PrecessionNutationParams {
1030 mass_kg: self.inputs.bullet_mass,
1031 caliber_m: self.inputs.bullet_diameter,
1032 length_m: self.inputs.bullet_length,
1033 spin_rate_rad_s,
1034 spin_inertia: 6.94e-8, transverse_inertia: 9.13e-7, velocity_mps: velocity_magnitude,
1037 air_density_kg_m3: air_density,
1038 mach,
1039 pitch_damping_coeff: -0.8,
1040 nutation_damping_factor: 0.05,
1041 };
1042
1043 *state = calculate_combined_angular_motion(
1045 ¶ms,
1046 state,
1047 time,
1048 self.time_step,
1049 0.001, );
1051
1052 if state.yaw_angle.abs() > max_yaw_angle {
1054 max_yaw_angle = state.yaw_angle.abs();
1055 }
1056 if state.precession_angle.abs() > max_precession_angle {
1057 max_precession_angle = state.precession_angle.abs();
1058 }
1059 }
1060 }
1061
1062 let dt = self.time_step;
1064
1065 let acc1 = self.calculate_acceleration(&position, &velocity, air_density, &wind_vector);
1067
1068 let pos2 = position + velocity * (dt * 0.5);
1070 let vel2 = velocity + acc1 * (dt * 0.5);
1071 let acc2 = self.calculate_acceleration(&pos2, &vel2, air_density, &wind_vector);
1072
1073 let pos3 = position + vel2 * (dt * 0.5);
1075 let vel3 = velocity + acc2 * (dt * 0.5);
1076 let acc3 = self.calculate_acceleration(&pos3, &vel3, air_density, &wind_vector);
1077
1078 let pos4 = position + vel3 * dt;
1080 let vel4 = velocity + acc3 * dt;
1081 let acc4 = self.calculate_acceleration(&pos4, &vel4, air_density, &wind_vector);
1082
1083 position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
1085 velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
1086 time += dt;
1087 }
1088
1089 let last_point = points.last().ok_or("No trajectory points generated")?;
1091
1092 let sampled_points = if self.inputs.enable_trajectory_sampling {
1094 let trajectory_data = TrajectoryData {
1095 times: points.iter().map(|p| p.time).collect(),
1096 positions: points.iter().map(|p| p.position).collect(),
1097 velocities: points
1098 .iter()
1099 .map(|p| {
1100 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1102 })
1103 .collect(),
1104 transonic_distances, };
1106
1107 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1112 let outputs = TrajectoryOutputs {
1113 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
1115 time_of_flight_s: last_point.time,
1116 max_ord_dist_horiz_m: max_height,
1117 sight_height_m: sight_position_m,
1118 };
1119
1120 let samples = sample_trajectory(
1122 &trajectory_data,
1123 &outputs,
1124 self.inputs.sample_interval,
1125 self.inputs.bullet_mass,
1126 );
1127 Some(samples)
1128 } else {
1129 None
1130 };
1131
1132 Ok(TrajectoryResult {
1133 max_range: last_point.position.x, max_height,
1135 time_of_flight: last_point.time,
1136 impact_velocity: last_point.velocity_magnitude,
1137 impact_energy: last_point.kinetic_energy,
1138 points,
1139 sampled_points,
1140 min_pitch_damping: if self.inputs.enable_pitch_damping {
1141 Some(min_pitch_damping)
1142 } else {
1143 None
1144 },
1145 transonic_mach,
1146 angular_state,
1147 max_yaw_angle: if self.inputs.enable_precession_nutation {
1148 Some(max_yaw_angle)
1149 } else {
1150 None
1151 },
1152 max_precession_angle: if self.inputs.enable_precession_nutation {
1153 Some(max_precession_angle)
1154 } else {
1155 None
1156 },
1157 aerodynamic_jump: aj_components,
1158 })
1159 }
1160
1161 fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
1162 let mut time = 0.0;
1164 let mut position = Vector3::new(
1167 0.0,
1168 self.inputs.muzzle_height, 0.0,
1170 );
1171
1172 let aj_components = self.aerodynamic_jump_components();
1178 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1179 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1180 let mut velocity = Vector3::new(
1181 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1185
1186 let mut points = Vec::new();
1187 let mut max_height = position.y;
1188 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;
1196 const MAX_ITERATIONS: usize = 100000;
1197
1198 let air_density = calculate_air_density(&self.atmosphere);
1201 let wind_vector = Vector3::new(
1204 -self.wind.speed * self.wind.direction.cos(), 0.0,
1206 -self.wind.speed * self.wind.direction.sin(), );
1208
1209 let mut transonic_distances: Vec<f64> = Vec::new();
1211 let mut crossed_transonic = false;
1212 let mut crossed_subsonic = false;
1213
1214 while position.x < self.max_range
1215 && position.y > self.inputs.ground_threshold
1216 && time < 100.0
1217 {
1218 iteration_count += 1;
1220 if iteration_count > MAX_ITERATIONS {
1221 break; }
1223
1224 let velocity_magnitude = velocity.magnitude();
1226 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
1227
1228 points.push(TrajectoryPoint {
1229 time,
1230 position: position,
1231 velocity_magnitude,
1232 kinetic_energy,
1233 });
1234
1235 {
1238 let sos = (1.4 * 287.05 * (self.atmosphere.temperature + 273.15)).sqrt();
1239 let mach_here = if sos > 0.0 { velocity_magnitude / sos } else { 0.0 };
1240 if !crossed_transonic && mach_here < 1.2 {
1241 crossed_transonic = true;
1242 transonic_distances.push(position.x);
1243 }
1244 if !crossed_subsonic && mach_here < 1.0 {
1245 crossed_subsonic = true;
1246 transonic_distances.push(position.x);
1247 }
1248 }
1249
1250 if position.y > max_height {
1251 max_height = position.y;
1252 }
1253
1254 let (new_pos, new_vel, new_dt) = self.rk45_step(
1256 &position,
1257 &velocity,
1258 dt,
1259 air_density,
1260 &wind_vector,
1261 tolerance,
1262 );
1263
1264 position = new_pos;
1269 velocity = new_vel;
1270 time += dt;
1271
1272 dt = (safety_factor * new_dt).clamp(min_dt, max_dt);
1274 }
1275
1276 if points.is_empty() {
1278 return Err(BallisticsError::from("No trajectory points calculated"));
1279 }
1280
1281 let last_point = points.last().unwrap();
1282
1283 let sampled_points = if self.inputs.enable_trajectory_sampling {
1285 let trajectory_data = TrajectoryData {
1287 times: points.iter().map(|p| p.time).collect(),
1288 positions: points.iter().map(|p| p.position).collect(),
1289 velocities: points
1290 .iter()
1291 .map(|p| {
1292 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1294 })
1295 .collect(),
1296 transonic_distances, };
1298
1299 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1304 let outputs = TrajectoryOutputs {
1305 target_distance_horiz_m: last_point.position.x,
1306 target_vertical_height_m: sight_position_m,
1307 time_of_flight_s: last_point.time,
1308 max_ord_dist_horiz_m: max_height,
1309 sight_height_m: sight_position_m,
1310 };
1311
1312 let samples = sample_trajectory(
1313 &trajectory_data,
1314 &outputs,
1315 self.inputs.sample_interval,
1316 self.inputs.bullet_mass,
1317 );
1318 Some(samples)
1319 } else {
1320 None
1321 };
1322
1323 Ok(TrajectoryResult {
1324 max_range: last_point.position.x, max_height,
1326 time_of_flight: last_point.time,
1327 impact_velocity: last_point.velocity_magnitude,
1328 impact_energy: last_point.kinetic_energy,
1329 points,
1330 sampled_points,
1331 min_pitch_damping: None,
1332 transonic_mach: None,
1333 angular_state: None,
1334 max_yaw_angle: None,
1335 max_precession_angle: None,
1336 aerodynamic_jump: aj_components,
1337 })
1338 }
1339
1340 fn rk45_step(
1341 &self,
1342 position: &Vector3<f64>,
1343 velocity: &Vector3<f64>,
1344 dt: f64,
1345 air_density: f64,
1346 wind_vector: &Vector3<f64>,
1347 tolerance: f64,
1348 ) -> (Vector3<f64>, Vector3<f64>, f64) {
1349 const A21: f64 = 1.0 / 5.0;
1351 const A31: f64 = 3.0 / 40.0;
1352 const A32: f64 = 9.0 / 40.0;
1353 const A41: f64 = 44.0 / 45.0;
1354 const A42: f64 = -56.0 / 15.0;
1355 const A43: f64 = 32.0 / 9.0;
1356 const A51: f64 = 19372.0 / 6561.0;
1357 const A52: f64 = -25360.0 / 2187.0;
1358 const A53: f64 = 64448.0 / 6561.0;
1359 const A54: f64 = -212.0 / 729.0;
1360 const A61: f64 = 9017.0 / 3168.0;
1361 const A62: f64 = -355.0 / 33.0;
1362 const A63: f64 = 46732.0 / 5247.0;
1363 const A64: f64 = 49.0 / 176.0;
1364 const A65: f64 = -5103.0 / 18656.0;
1365 const A71: f64 = 35.0 / 384.0;
1366 const A73: f64 = 500.0 / 1113.0;
1367 const A74: f64 = 125.0 / 192.0;
1368 const A75: f64 = -2187.0 / 6784.0;
1369 const A76: f64 = 11.0 / 84.0;
1370
1371 const B1: f64 = 35.0 / 384.0;
1373 const B3: f64 = 500.0 / 1113.0;
1374 const B4: f64 = 125.0 / 192.0;
1375 const B5: f64 = -2187.0 / 6784.0;
1376 const B6: f64 = 11.0 / 84.0;
1377
1378 const B1_ERR: f64 = 5179.0 / 57600.0;
1380 const B3_ERR: f64 = 7571.0 / 16695.0;
1381 const B4_ERR: f64 = 393.0 / 640.0;
1382 const B5_ERR: f64 = -92097.0 / 339200.0;
1383 const B6_ERR: f64 = 187.0 / 2100.0;
1384 const B7_ERR: f64 = 1.0 / 40.0;
1385
1386 let k1_v = self.calculate_acceleration(position, velocity, air_density, wind_vector);
1388 let k1_p = *velocity;
1389
1390 let p2 = position + dt * A21 * k1_p;
1391 let v2 = velocity + dt * A21 * k1_v;
1392 let k2_v = self.calculate_acceleration(&p2, &v2, air_density, wind_vector);
1393 let k2_p = v2;
1394
1395 let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
1396 let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
1397 let k3_v = self.calculate_acceleration(&p3, &v3, air_density, wind_vector);
1398 let k3_p = v3;
1399
1400 let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
1401 let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
1402 let k4_v = self.calculate_acceleration(&p4, &v4, air_density, wind_vector);
1403 let k4_p = v4;
1404
1405 let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
1406 let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
1407 let k5_v = self.calculate_acceleration(&p5, &v5, air_density, wind_vector);
1408 let k5_p = v5;
1409
1410 let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
1411 let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
1412 let k6_v = self.calculate_acceleration(&p6, &v6, air_density, wind_vector);
1413 let k6_p = v6;
1414
1415 let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
1416 let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
1417 let k7_v = self.calculate_acceleration(&p7, &v7, air_density, wind_vector);
1418 let k7_p = v7;
1419
1420 let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
1422 let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
1423
1424 let pos_err = position
1426 + dt * (B1_ERR * k1_p
1427 + B3_ERR * k3_p
1428 + B4_ERR * k4_p
1429 + B5_ERR * k5_p
1430 + B6_ERR * k6_p
1431 + B7_ERR * k7_p);
1432 let vel_err = velocity
1433 + dt * (B1_ERR * k1_v
1434 + B3_ERR * k3_v
1435 + B4_ERR * k4_v
1436 + B5_ERR * k5_v
1437 + B6_ERR * k6_v
1438 + B7_ERR * k7_v);
1439
1440 let pos_error = (new_pos - pos_err).magnitude();
1442 let vel_error = (new_vel - vel_err).magnitude();
1443 let error = (pos_error + vel_error) / (1.0 + position.magnitude() + velocity.magnitude());
1444
1445 let dt_new = if error < tolerance {
1447 dt * (tolerance / error).powf(0.2).min(2.0)
1448 } else {
1449 dt * (tolerance / error).powf(0.25).max(0.1)
1450 };
1451
1452 (new_pos, new_vel, dt_new)
1453 }
1454
1455 fn calculate_acceleration(
1456 &self,
1457 position: &Vector3<f64>,
1458 velocity: &Vector3<f64>,
1459 air_density: f64,
1460 wind_vector: &Vector3<f64>,
1461 ) -> Vector3<f64> {
1462 let actual_wind = if let Some(ref sock) = self.wind_sock {
1468 sock.vector_for_range_stateless(position.x)
1469 } else if self.inputs.enable_wind_shear {
1470 self.get_wind_at_altitude(position.y)
1471 } else {
1472 *wind_vector
1473 };
1474
1475 let relative_velocity = velocity - actual_wind;
1476 let velocity_magnitude = relative_velocity.magnitude();
1477
1478 if velocity_magnitude < 0.001 {
1479 return Vector3::new(0.0, -crate::constants::G_ACCEL_MPS2, 0.0);
1480 }
1481
1482 let cd = self.calculate_drag_coefficient(velocity_magnitude);
1484
1485 let velocity_fps = velocity_magnitude * 3.28084;
1487
1488 let base_bc = if let Some(ref segments) = self.inputs.bc_segments_data {
1490 segments
1492 .iter()
1493 .find(|seg| velocity_fps >= seg.velocity_min && velocity_fps < seg.velocity_max)
1494 .map(|seg| seg.bc_value)
1495 .unwrap_or(self.inputs.bc_value)
1496 } else {
1497 self.inputs.bc_value
1498 };
1499
1500 let effective_bc = if let Some(ref cluster_bc) = self.cluster_bc {
1502 cluster_bc.apply_correction(
1503 base_bc,
1504 self.inputs.caliber_inches, self.inputs.weight_grains,
1506 velocity_fps,
1507 )
1508 } else {
1509 base_bc
1510 };
1511 let effective_bc = effective_bc.max(1e-6);
1515
1516 let cd_to_retard = 0.000683 * 0.30; let standard_factor = cd * cd_to_retard;
1522 let density_scale = air_density / 1.225; let a_drag_ft_s2 =
1526 (velocity_fps * velocity_fps) * standard_factor * density_scale / effective_bc;
1527 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
1531
1532 let mut accel = drag_acceleration + Vector3::new(0.0, -crate::constants::G_ACCEL_MPS2, 0.0);
1534
1535 if self.inputs.enable_coriolis {
1538 if let Some(lat_deg) = self.inputs.latitude {
1539 let omega_earth = 7.2921159e-5_f64; let lat = lat_deg.to_radians();
1541 let az = self.inputs.shot_azimuth; let omega = Vector3::new(
1548 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), );
1552 accel += -2.0 * omega.cross(velocity);
1557 }
1558 }
1559
1560 if self.inputs.enable_magnus
1562 && self.inputs.bullet_diameter > 0.0
1563 && self.inputs.twist_rate > 0.0
1564 {
1565 let (_, spin_rad_s) =
1566 crate::spin_drift::calculate_spin_rate(velocity_magnitude, self.inputs.twist_rate);
1567 let temp_k = self.atmosphere.temperature + 273.15;
1568 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
1569 let mach = velocity_magnitude / speed_of_sound;
1570
1571 let d_in = self.inputs.bullet_diameter / 0.0254;
1573 let m_gr = self.inputs.bullet_mass / 0.00006479891;
1574 let l_in = if self.inputs.bullet_length > 0.0 {
1575 self.inputs.bullet_length / 0.0254
1576 } else {
1577 4.5 * d_in
1578 };
1579 let press_hpa = self.atmosphere.pressure;
1583 let density_correction = if press_hpa > 0.0 && temp_k > 0.0 {
1584 (temp_k / 288.15) * (1013.25 / press_hpa)
1585 } else {
1586 1.0
1587 };
1588 let sg = crate::spin_drift::miller_stability(d_in, m_gr, self.inputs.twist_rate, l_in)
1589 * density_correction;
1590
1591 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
1593 sg,
1594 velocity_magnitude,
1595 spin_rad_s,
1596 0.0, 0.0, air_density,
1599 d_in,
1600 l_in,
1601 m_gr,
1602 mach,
1603 "match",
1604 false,
1605 );
1606
1607 let diameter_m = self.inputs.bullet_diameter; let spin_param = spin_rad_s * diameter_m / (2.0 * velocity_magnitude);
1610 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
1611 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
1612 let magnus_force = 0.5
1613 * air_density
1614 * velocity_magnitude.powi(2)
1615 * area
1616 * c_np
1617 * spin_param
1618 * yaw_rad.sin();
1619
1620 let velocity_unit = relative_velocity / velocity_magnitude;
1623 let up = Vector3::new(0.0, 1.0, 0.0);
1624 let mut dir = velocity_unit.cross(&up);
1625 let dir_norm = dir.norm();
1626 if dir_norm > 1e-12 && magnus_force.abs() > 1e-12 {
1627 dir /= dir_norm;
1628 if !self.inputs.is_twist_right {
1629 dir = -dir;
1630 }
1631 accel += (magnus_force / self.inputs.bullet_mass) * dir;
1632 }
1633 }
1634
1635 accel
1636 }
1637
1638 fn calculate_drag_coefficient(&self, velocity: f64) -> f64 {
1639 let temp_c = self.atmosphere.temperature;
1641 let temp_k = temp_c + 273.15;
1642 let gamma = 1.4; let r_specific = 287.05; let speed_of_sound = (gamma * r_specific * temp_k).sqrt();
1645 let mach = velocity / speed_of_sound;
1646
1647 if let Some(ref table) = self.inputs.custom_drag_table {
1651 return table.interpolate(mach);
1652 }
1653
1654 let base_cd = crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type);
1656
1657 let bc_type_str: &str = match self.inputs.bc_type {
1661 crate::DragModel::G1 => "G1",
1662 crate::DragModel::G2 => "G2",
1663 crate::DragModel::G5 => "G5",
1664 crate::DragModel::G6 => "G6",
1665 crate::DragModel::G7 => "G7",
1666 crate::DragModel::G8 => "G8",
1667 crate::DragModel::GI => "GI",
1668 crate::DragModel::GS => "GS",
1669 };
1670
1671 let projectile_shape = crate::transonic_drag::resolve_projectile_shape(
1674 self.inputs.bullet_model.as_deref(),
1675 self.inputs.caliber_inches,
1676 self.inputs.bullet_mass / 0.00006479891, bc_type_str,
1678 );
1679
1680 let include_wave_drag = false;
1686 let cd = transonic_correction(mach, base_cd, projectile_shape, include_wave_drag);
1687
1688 crate::form_factor::apply_form_factor_to_drag(
1693 cd,
1694 self.inputs.bullet_model.as_deref(),
1695 &self.inputs.bc_type,
1696 self.inputs.use_form_factor,
1697 )
1698 }
1699}
1700
1701#[derive(Debug, Clone)]
1703pub struct MonteCarloParams {
1704 pub num_simulations: usize,
1705 pub velocity_std_dev: f64,
1706 pub angle_std_dev: f64,
1707 pub bc_std_dev: f64,
1708 pub wind_speed_std_dev: f64,
1709 pub target_distance: Option<f64>,
1710 pub base_wind_speed: f64,
1711 pub base_wind_direction: f64,
1712 pub azimuth_std_dev: f64, }
1714
1715impl Default for MonteCarloParams {
1716 fn default() -> Self {
1717 Self {
1718 num_simulations: 1000,
1719 velocity_std_dev: 1.0,
1720 angle_std_dev: 0.001,
1721 bc_std_dev: 0.01,
1722 wind_speed_std_dev: 1.0,
1723 target_distance: None,
1724 base_wind_speed: 0.0,
1725 base_wind_direction: 0.0,
1726 azimuth_std_dev: 0.001, }
1728 }
1729}
1730
1731#[derive(Debug, Clone)]
1733pub struct MonteCarloResults {
1734 pub ranges: Vec<f64>,
1735 pub impact_velocities: Vec<f64>,
1736 pub impact_positions: Vec<Vector3<f64>>,
1737}
1738
1739pub fn run_monte_carlo(
1741 base_inputs: BallisticInputs,
1742 params: MonteCarloParams,
1743) -> Result<MonteCarloResults, BallisticsError> {
1744 let base_wind = WindConditions {
1745 speed: params.base_wind_speed,
1746 direction: params.base_wind_direction,
1747 };
1748 run_monte_carlo_with_wind(base_inputs, base_wind, params)
1749}
1750
1751pub fn run_monte_carlo_with_wind(
1753 base_inputs: BallisticInputs,
1754 base_wind: WindConditions,
1755 params: MonteCarloParams,
1756) -> Result<MonteCarloResults, BallisticsError> {
1757 use rand_distr::{Distribution, Normal};
1758
1759 let mut rng = rand::rng();
1760 let mut ranges = Vec::new();
1761 let mut impact_velocities = Vec::new();
1762 let mut impact_positions = Vec::new();
1763
1764 let baseline_solver =
1766 TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), Default::default());
1767 let baseline_result = baseline_solver.solve()?;
1768
1769 let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
1771
1772 let baseline_at_target = baseline_result
1774 .position_at_range(target_distance)
1775 .ok_or("Could not interpolate baseline at target distance")?;
1776
1777 let velocity_dist = Normal::new(base_inputs.muzzle_velocity, params.velocity_std_dev)
1779 .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
1780 let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
1781 .map_err(|e| format!("Invalid angle distribution: {}", e))?;
1782 let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
1783 .map_err(|e| format!("Invalid BC distribution: {}", e))?;
1784 let wind_speed_dist = Normal::new(base_wind.speed, params.wind_speed_std_dev)
1785 .map_err(|e| format!("Invalid wind speed distribution: {}", e))?;
1786 let wind_dir_dist =
1791 Normal::new(base_wind.direction, params.wind_speed_std_dev * 0.1)
1792 .map_err(|e| format!("Invalid wind direction distribution: {}", e))?;
1793 let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
1794 .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
1795
1796 let pointing_error_dist = Normal::new(0.0, params.angle_std_dev * target_distance)
1798 .map_err(|e| format!("Invalid pointing distribution: {}", e))?;
1799
1800 for _ in 0..params.num_simulations {
1801 let mut inputs = base_inputs.clone();
1803 inputs.muzzle_velocity = velocity_dist.sample(&mut rng).max(0.0);
1804 inputs.muzzle_angle = angle_dist.sample(&mut rng);
1805 inputs.bc_value = bc_dist.sample(&mut rng).max(0.01);
1806 inputs.azimuth_angle = azimuth_dist.sample(&mut rng); let wind = WindConditions {
1810 speed: wind_speed_dist.sample(&mut rng).abs(),
1811 direction: wind_dir_dist.sample(&mut rng),
1812 };
1813
1814 let solver = TrajectorySolver::new(inputs, wind, Default::default());
1816 match solver.solve() {
1817 Ok(result) => {
1818 if result.max_range < target_distance {
1826 continue;
1827 }
1828 let pos_at_target = match result.position_at_range(target_distance) {
1832 Some(p) => p,
1833 None => continue,
1834 };
1835
1836 ranges.push(result.max_range);
1837 impact_velocities.push(result.impact_velocity);
1838
1839 let mut deviation = Vector3::new(
1842 0.0, pos_at_target.y - baseline_at_target.y, pos_at_target.z - baseline_at_target.z, );
1846
1847 let pointing_error_y = pointing_error_dist.sample(&mut rng);
1850 deviation.y += pointing_error_y;
1851
1852 impact_positions.push(deviation);
1853 }
1854 Err(_) => {
1855 continue;
1857 }
1858 }
1859 }
1860
1861 if ranges.is_empty() {
1862 return Err("No successful simulations".into());
1863 }
1864
1865 Ok(MonteCarloResults {
1866 ranges,
1867 impact_velocities,
1868 impact_positions,
1869 })
1870}
1871
1872pub fn calculate_zero_angle(
1874 inputs: BallisticInputs,
1875 target_distance: f64,
1876 target_height: f64,
1877) -> Result<f64, BallisticsError> {
1878 calculate_zero_angle_with_conditions(
1879 inputs,
1880 target_distance,
1881 target_height,
1882 WindConditions::default(),
1883 AtmosphericConditions::default(),
1884 )
1885}
1886
1887pub fn calculate_zero_angle_with_conditions(
1888 inputs: BallisticInputs,
1889 target_distance: f64,
1890 target_height: f64,
1891 wind: WindConditions,
1892 atmosphere: AtmosphericConditions,
1893) -> Result<f64, BallisticsError> {
1894 let get_height_at_angle = |angle: f64| -> Result<Option<f64>, BallisticsError> {
1896 let mut test_inputs = inputs.clone();
1897 test_inputs.muzzle_angle = angle;
1898 test_inputs.enable_aerodynamic_jump = false;
1903
1904 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
1905 solver.set_max_range(target_distance * 2.0);
1906 solver.set_time_step(0.001);
1907 let result = solver.solve()?;
1908
1909 for i in 0..result.points.len() {
1911 if result.points[i].position.x >= target_distance {
1912 if i > 0 {
1913 let p1 = &result.points[i - 1];
1914 let p2 = &result.points[i];
1915 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
1916 return Ok(Some(p1.position.y + t * (p2.position.y - p1.position.y)));
1917 } else {
1918 return Ok(Some(result.points[i].position.y));
1919 }
1920 }
1921 }
1922 Ok(None)
1923 };
1924
1925 let mut low_angle = 0.0; let mut high_angle = 0.2; let tolerance = 0.00001; let max_iterations = 50;
1931
1932 let low_height = get_height_at_angle(low_angle)?;
1935 let high_height = get_height_at_angle(high_angle)?;
1936
1937 match (low_height, high_height) {
1938 (Some(lh), Some(hh)) => {
1939 let low_error = lh - target_height;
1940 let high_error = hh - target_height;
1941
1942 if low_error > 0.0 && high_error > 0.0 {
1945 } else if low_error < 0.0 && high_error < 0.0 {
1950 let mut expanded = false;
1953 for multiplier in [2.0, 3.0, 4.0] {
1954 let new_high = (high_angle * multiplier).min(0.785);
1955 if let Ok(Some(h)) = get_height_at_angle(new_high) {
1956 if h - target_height > 0.0 {
1957 high_angle = new_high;
1958 expanded = true;
1959 break;
1960 }
1961 }
1962 if new_high >= 0.785 {
1963 break;
1964 }
1965 }
1966 if !expanded {
1967 return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
1968 }
1969 }
1970 }
1972 (None, Some(_hh)) => {
1973 }
1976 (Some(_lh), None) => {
1977 return Err(
1979 "Cannot find zero angle: high angle trajectory doesn't reach target distance"
1980 .into(),
1981 );
1982 }
1983 (None, None) => {
1984 return Err(
1986 "Cannot find zero angle: trajectory cannot reach target distance at any angle"
1987 .into(),
1988 );
1989 }
1990 }
1991
1992 for _iteration in 0..max_iterations {
1993 let mid_angle = (low_angle + high_angle) / 2.0;
1994
1995 let mut test_inputs = inputs.clone();
1996 test_inputs.muzzle_angle = mid_angle;
1997 test_inputs.enable_aerodynamic_jump = false;
1999
2000 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
2001 solver.set_max_range(target_distance * 2.0);
2003 solver.set_time_step(0.001);
2004 let result = solver.solve()?;
2005
2006 let mut height_at_target = None;
2008 for i in 0..result.points.len() {
2009 if result.points[i].position.x >= target_distance {
2010 if i > 0 {
2011 let p1 = &result.points[i - 1];
2013 let p2 = &result.points[i];
2014 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
2015 height_at_target = Some(p1.position.y + t * (p2.position.y - p1.position.y));
2016 } else {
2017 height_at_target = Some(result.points[i].position.y);
2018 }
2019 break;
2020 }
2021 }
2022
2023 match height_at_target {
2024 Some(height) => {
2025 let error = height - target_height;
2026 if error.abs() < 0.001 {
2029 return Ok(mid_angle);
2030 }
2031
2032 if (high_angle - low_angle).abs() < tolerance {
2036 if error.abs() < 0.01 {
2037 return Ok(mid_angle);
2039 }
2040 return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
2045 }
2046
2047 if error > 0.0 {
2048 high_angle = mid_angle;
2049 } else {
2050 low_angle = mid_angle;
2051 }
2052 }
2053 None => {
2054 low_angle = mid_angle;
2056
2057 if (high_angle - low_angle).abs() < tolerance {
2059 return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
2060 }
2061 }
2062 }
2063 }
2064
2065 Err("Failed to find zero angle".into())
2066}
2067
2068pub fn estimate_bc_from_trajectory(
2070 velocity: f64,
2071 mass: f64,
2072 diameter: f64,
2073 points: &[(f64, f64)], ) -> Result<f64, BallisticsError> {
2075 let mut best_bc = 0.5;
2077 let mut best_error = f64::MAX;
2078 let mut found_valid = false;
2079
2080 for bc in (100..1000).step_by(10) {
2082 let bc_value = bc as f64 / 1000.0;
2083
2084 let inputs = BallisticInputs {
2085 muzzle_velocity: velocity,
2086 bc_value,
2087 bullet_mass: mass,
2088 bullet_diameter: diameter,
2089 ..Default::default()
2090 };
2091
2092 let mut solver = TrajectorySolver::new(inputs, Default::default(), Default::default());
2093 solver.set_max_range(points.last().map(|(d, _)| *d * 1.5).unwrap_or(1000.0));
2095
2096 let result = match solver.solve() {
2097 Ok(r) => r,
2098 Err(_) => continue, };
2100
2101 let mut total_error = 0.0;
2103 for (target_dist, target_drop) in points {
2104 let mut calculated_drop = None;
2106 for i in 0..result.points.len() {
2107 if result.points[i].position.x >= *target_dist {
2108 if i > 0 {
2109 let p1 = &result.points[i - 1];
2111 let p2 = &result.points[i];
2112 let t = (target_dist - p1.position.x) / (p2.position.x - p1.position.x);
2113 calculated_drop =
2114 Some(-(p1.position.y + t * (p2.position.y - p1.position.y)));
2115 } else {
2116 calculated_drop = Some(-result.points[i].position.y);
2117 }
2118 break;
2119 }
2120 }
2121
2122 if let Some(drop) = calculated_drop {
2123 let error = (drop - target_drop).abs();
2124 total_error += error * error;
2125 }
2126 }
2127
2128 if total_error < best_error {
2129 best_error = total_error;
2130 best_bc = bc_value;
2131 found_valid = true;
2132 }
2133 }
2134
2135 if !found_valid {
2136 return Err(BallisticsError::from("Unable to estimate BC from provided data. Check that drop values are in correct units.".to_string()));
2137 }
2138
2139 Ok(best_bc)
2140}
2141
2142fn calculate_air_density(atmosphere: &AtmosphericConditions) -> f64 {
2144 crate::atmosphere::calculate_atmosphere(
2152 atmosphere.altitude,
2153 crate::atmosphere::resolve_station_temperature(atmosphere.temperature, atmosphere.altitude),
2154 crate::atmosphere::resolve_station_pressure(atmosphere.pressure, atmosphere.altitude),
2155 atmosphere.humidity,
2156 )
2157 .0
2158}
2159
2160use rand;
2162use rand_distr;
2163
2164#[cfg(test)]
2165mod ground_termination_tests {
2166 use super::*;
2167
2168 #[test]
2173 fn rk4_and_rk45_descend_to_ground_threshold() {
2174 for adaptive in [false, true] {
2175 let mut inputs = BallisticInputs::default();
2176 inputs.muzzle_angle = 0.1; inputs.use_rk4 = true;
2178 inputs.use_adaptive_rk45 = adaptive;
2179 assert_eq!(inputs.ground_threshold, -100.0, "default ground_threshold is -100 m");
2180
2181 let mut solver = TrajectorySolver::new(
2182 inputs,
2183 WindConditions::default(),
2184 AtmosphericConditions::default(),
2185 );
2186 solver.set_max_range(1.0e7);
2188
2189 let result = solver.solve().expect("solve should succeed");
2190 let final_y = result
2191 .points
2192 .last()
2193 .expect("trajectory has points")
2194 .position
2195 .y;
2196 assert!(
2197 final_y < -1.0,
2198 "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
2199 past launch level toward the ground_threshold floor, not stop at y = 0"
2200 );
2201 }
2202 }
2203}
2204
2205#[cfg(test)]
2206mod coriolis_direction_tests {
2207 use super::*;
2208 use std::f64::consts::FRAC_PI_2;
2209
2210 #[test]
2211 fn transonic_crossing_flags_a_sampled_point() {
2212 use crate::trajectory_sampling::TrajectoryFlag;
2216 let mut inputs = BallisticInputs::default();
2217 inputs.muzzle_velocity = 850.0; inputs.bc_value = 0.2; inputs.bc_type = DragModel::G7;
2220 inputs.muzzle_angle = 0.03;
2221 inputs.enable_trajectory_sampling = true;
2222 inputs.sample_interval = 50.0;
2223 let mut solver = TrajectorySolver::new(
2224 inputs,
2225 WindConditions::default(),
2226 AtmosphericConditions::default(),
2227 );
2228 solver.set_max_range(2000.0);
2229 let r = solver.solve().expect("solve");
2230 let samples = r
2231 .sampled_points
2232 .expect("sampling enabled -> sampled_points present");
2233 assert!(
2234 samples
2235 .iter()
2236 .any(|s| s.flags.contains(&TrajectoryFlag::MachTransition)),
2237 "a shot that crosses Mach 1 must flag at least one Mach-transition sample"
2238 );
2239 }
2240
2241 #[test]
2242 fn humidity_percent_converts_and_clamps() {
2243 let mut i = BallisticInputs::default();
2245 i.humidity = 0.5;
2246 assert!((i.humidity_percent() - 50.0).abs() < 1e-9, "0.5 -> 50%");
2247 i.humidity = 0.0;
2248 assert_eq!(i.humidity_percent(), 0.0);
2249 i.humidity = 1.0;
2250 assert_eq!(i.humidity_percent(), 100.0);
2251 i.humidity = 1.5; assert_eq!(i.humidity_percent(), 100.0);
2253 }
2254
2255 fn vertical_at(shot_azimuth: f64, range_m: f64) -> f64 {
2258 let mut inputs = BallisticInputs::default();
2259 inputs.muzzle_velocity = 800.0;
2260 inputs.bc_value = 0.5;
2261 inputs.bc_type = DragModel::G7;
2262 inputs.muzzle_angle = 0.02; inputs.enable_coriolis = true;
2264 inputs.latitude = Some(45.0);
2265 inputs.shot_azimuth = shot_azimuth;
2266 inputs.ground_threshold = f64::NEG_INFINITY; let mut solver = TrajectorySolver::new(
2268 inputs,
2269 WindConditions::default(),
2270 AtmosphericConditions::default(),
2271 );
2272 solver.set_max_range(range_m + 50.0);
2273 let r = solver.solve().expect("solve");
2274 let pts = &r.points;
2275 for i in 1..pts.len() {
2276 if pts[i].position.x >= range_m {
2277 let p1 = &pts[i - 1];
2278 let p2 = &pts[i];
2279 let t = (range_m - p1.position.x) / (p2.position.x - p1.position.x);
2280 return p1.position.y + t * (p2.position.y - p1.position.y);
2281 }
2282 }
2283 panic!("range {range_m} not reached");
2284 }
2285
2286 #[test]
2291 fn eotvos_east_higher_than_west() {
2292 let range = 600.0;
2293 let east = vertical_at(FRAC_PI_2, range); let west = vertical_at(3.0 * FRAC_PI_2, range); let north = vertical_at(0.0, range); assert!(
2297 east > west,
2298 "east ({east:.5}) must be higher than west ({west:.5}) at {range} m (Eötvös)"
2299 );
2300 assert!(
2301 east > north && north > west,
2302 "north ({north:.5}) must lie between east ({east:.5}) and west ({west:.5})"
2303 );
2304 assert!(
2305 (east - west) > 1e-3,
2306 "E-W vertical separation ({:.6} m) should be physically meaningful, not FP noise",
2307 east - west
2308 );
2309 }
2310}