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 if inputs.use_powder_sensitivity {
383 let temp_delta_c = inputs.temperature - inputs.powder_temp;
384 inputs.muzzle_velocity += inputs.powder_temp_sensitivity * temp_delta_c;
385 }
386
387 let cluster_bc = if inputs.use_cluster_bc {
389 Some(ClusterBCDegradation::new())
390 } else {
391 None
392 };
393
394 Self {
395 inputs,
396 wind,
397 atmosphere,
398 max_range: 1000.0,
399 time_step: 0.001,
400 cluster_bc,
401 wind_sock: None,
402 }
403 }
404
405 pub fn set_max_range(&mut self, range: f64) {
406 self.max_range = range;
407 }
408
409 pub fn set_time_step(&mut self, step: f64) {
410 self.time_step = step;
411 }
412
413 pub fn set_wind_segments(&mut self, segments: Vec<crate::wind::WindSegment>) {
420 self.wind_sock = if segments.is_empty() {
421 None
422 } else {
423 Some(crate::wind::WindSock::new(segments))
424 };
425 }
426
427 fn launch_angles_from(
436 &self,
437 aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
438 ) -> (f64, f64) {
439 let elev = self.inputs.muzzle_angle;
440 let azim = self.inputs.azimuth_angle;
441 match aj {
442 Some(c) => {
443 const MOA_PER_RAD: f64 = 3437.7467707849;
445 (
446 elev + c.vertical_jump_moa / MOA_PER_RAD,
447 azim + c.horizontal_jump_moa / MOA_PER_RAD,
448 )
449 }
450 None => (elev, azim),
451 }
452 }
453
454 fn aerodynamic_jump_components(
462 &self,
463 ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
464 if !self.inputs.enable_aerodynamic_jump {
465 return None;
466 }
467 let diameter_m = self.inputs.bullet_diameter;
471 if !(self.inputs.twist_rate.is_finite() && self.inputs.twist_rate != 0.0)
472 || !(diameter_m.is_finite() && diameter_m > 0.0)
473 || !(self.inputs.bullet_length.is_finite() && self.inputs.bullet_length > 0.0)
474 || !self.inputs.muzzle_velocity.is_finite()
475 {
476 return None;
477 }
478
479 let sg = crate::stability::compute_stability_coefficient(
481 &self.inputs,
482 (
483 self.atmosphere.altitude,
484 self.atmosphere.temperature,
485 self.atmosphere.pressure,
486 0.0,
487 ),
488 );
489 if !(sg.is_finite() && sg > 0.0) {
490 return None;
491 }
492 let length_calibers = self.inputs.bullet_length / diameter_m;
493
494 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
500 let crosswind_from_right_mph = self.wind.speed * self.wind.direction.sin() * MS_TO_MPH;
501
502 let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
503 sg,
504 length_calibers,
505 crosswind_from_right_mph,
506 self.inputs.is_twist_right,
507 );
508 if !vertical_jump_moa.is_finite() {
509 return None;
510 }
511
512 const MOA_PER_RAD: f64 = 3437.7467707849;
513 Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
514 vertical_jump_moa,
515 horizontal_jump_moa: 0.0,
517 jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
518 magnus_component_moa: 0.0,
519 yaw_component_moa: 0.0,
520 stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
521 })
522 }
523
524 fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
525 let model = match self.inputs.wind_shear_model.as_str() {
540 "logarithmic" => WindShearModel::Logarithmic,
541 "power_law" | "powerlaw" | "exponential" => WindShearModel::PowerLaw,
542 "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
543 "custom_layers" | "custom" => WindShearModel::CustomLayers,
544 _ => WindShearModel::PowerLaw,
545 };
546 let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
547
548 Vector3::new(
551 -self.wind.speed * self.wind.direction.cos() * speed_ratio, 0.0,
553 -self.wind.speed * self.wind.direction.sin() * speed_ratio, )
555 }
556
557 pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
558 let mut result = if self.inputs.use_rk4 {
559 if self.inputs.use_adaptive_rk45 {
560 self.solve_rk45()?
561 } else {
562 self.solve_rk4()?
563 }
564 } else {
565 self.solve_euler()?
566 };
567 self.apply_spin_drift(&mut result);
568 Ok(result)
569 }
570
571 fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
577 if !self.inputs.use_enhanced_spin_drift {
578 return;
579 }
580 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 {
584 return;
585 }
586
587 let length_in = if self.inputs.bullet_length > 0.0 {
589 self.inputs.bullet_length / 0.0254
590 } else {
591 4.5 * d_in
592 };
593 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 {
601 (temp_k / 288.15) * (1013.25 / press_hpa)
602 } else {
603 1.0
604 };
605 let sg =
606 crate::spin_drift::miller_stability(d_in, m_gr, twist_in, length_in) * density_correction;
607 let sign = if self.inputs.is_twist_right { 1.0 } else { -1.0 };
608
609 for p in result.points.iter_mut() {
610 if p.time <= 0.0 {
611 continue;
612 }
613 let sd_in = 1.25 * (sg + 1.2) * p.time.powf(1.83); p.position.z += sign * sd_in * 0.0254; }
616
617 if let Some(samples) = result.sampled_points.as_mut() {
621 for s in samples.iter_mut() {
622 if s.time_s <= 0.0 {
623 continue;
624 }
625 let sd_in = 1.25 * (sg + 1.2) * s.time_s.powf(1.83);
626 s.wind_drift_m += sign * sd_in * 0.0254;
627 }
628 }
629 }
630
631 fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
632 let mut time = 0.0;
634 let mut position = Vector3::new(
637 0.0,
638 self.inputs.muzzle_height, 0.0,
640 );
641 let aj_components = self.aerodynamic_jump_components();
647 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
648 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
649 let mut velocity = Vector3::new(
650 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
654
655 let mut points = Vec::new();
656 let mut max_height = position.y;
657 let mut min_pitch_damping = 1.0; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
663 let mut crossed_transonic = false;
664 let mut crossed_subsonic = false;
665
666 let mut angular_state = if self.inputs.enable_precession_nutation {
668 Some(AngularState {
669 pitch_angle: 0.001, yaw_angle: 0.001,
671 pitch_rate: 0.0,
672 yaw_rate: 0.0,
673 precession_angle: 0.0,
674 nutation_phase: 0.0,
675 })
676 } else {
677 None
678 };
679 let mut max_yaw_angle = 0.0;
680 let mut max_precession_angle = 0.0;
681
682 let air_density = calculate_air_density(&self.atmosphere);
684
685 let wind_vector = Vector3::new(
689 -self.wind.speed * self.wind.direction.cos(), 0.0,
691 -self.wind.speed * self.wind.direction.sin(), );
693
694 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
697 self.inputs.bullet_model.as_deref().unwrap_or("default"),
698 );
699
700 while position.x < self.max_range && position.y > self.inputs.ground_threshold && time < 100.0 {
702 let velocity_magnitude = velocity.magnitude();
704 let kinetic_energy =
705 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
706
707 points.push(TrajectoryPoint {
708 time,
709 position: position,
710 velocity_magnitude,
711 kinetic_energy,
712 });
713
714 {
717 let sos = (1.4 * 287.05 * (self.atmosphere.temperature + 273.15)).sqrt();
718 let mach_here = if sos > 0.0 { velocity_magnitude / sos } else { 0.0 };
719 if !crossed_transonic && mach_here < 1.2 {
720 crossed_transonic = true;
721 transonic_distances.push(position.x);
722 }
723 if !crossed_subsonic && mach_here < 1.0 {
724 crossed_subsonic = true;
725 transonic_distances.push(position.x);
726 }
727 }
728
729 #[cfg(debug_assertions)]
733 if points.len() == 1 || points.len() % 100 == 0 {
734 eprintln!("Trajectory point {}: time={:.3}s, downrange={:.2}m, vertical={:.2}m, lateral={:.2}m, vel={:.1}m/s",
735 points.len(), time, position.x, position.y, position.z, velocity_magnitude);
736 }
737
738 if position.y > max_height {
740 max_height = position.y;
741 }
742
743 if self.inputs.enable_pitch_damping {
745 let temp_c = self.atmosphere.temperature;
746 let temp_k = temp_c + 273.15;
747 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
748 let mach = velocity_magnitude / speed_of_sound;
749
750 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
752 transonic_mach = Some(mach);
753 }
754
755 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
757
758 if pitch_damping < min_pitch_damping {
760 min_pitch_damping = pitch_damping;
761 }
762 }
763
764 if self.inputs.enable_precession_nutation {
766 if let Some(ref mut state) = angular_state {
767 let velocity_magnitude = velocity.magnitude();
768 let temp_c = self.atmosphere.temperature;
769 let temp_k = temp_c + 273.15;
770 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
771 let mach = velocity_magnitude / speed_of_sound;
772
773 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
775 let velocity_fps = velocity_magnitude * 3.28084;
776 let twist_rate_ft = self.inputs.twist_rate / 12.0;
777 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
778 } else {
779 0.0
780 };
781
782 let params = PrecessionNutationParams {
784 mass_kg: self.inputs.bullet_mass,
785 caliber_m: self.inputs.bullet_diameter,
786 length_m: self.inputs.bullet_length,
787 spin_rate_rad_s,
788 spin_inertia: 6.94e-8, transverse_inertia: 9.13e-7, velocity_mps: velocity_magnitude,
791 air_density_kg_m3: air_density,
792 mach,
793 pitch_damping_coeff: -0.8,
794 nutation_damping_factor: 0.05,
795 };
796
797 *state = calculate_combined_angular_motion(
799 ¶ms,
800 state,
801 time,
802 self.time_step,
803 0.001, );
805
806 if state.yaw_angle.abs() > max_yaw_angle {
808 max_yaw_angle = state.yaw_angle.abs();
809 }
810 if state.precession_angle.abs() > max_precession_angle {
811 max_precession_angle = state.precession_angle.abs();
812 }
813 }
814 }
815
816 let acceleration =
823 self.calculate_acceleration(&position, &velocity, air_density, &wind_vector);
824
825 velocity += acceleration * self.time_step;
827 position += velocity * self.time_step;
828 time += self.time_step;
829 }
830
831 let last_point = points.last().ok_or("No trajectory points generated")?;
833
834 let sampled_points = if self.inputs.enable_trajectory_sampling {
836 let trajectory_data = TrajectoryData {
837 times: points.iter().map(|p| p.time).collect(),
838 positions: points.iter().map(|p| p.position).collect(),
839 velocities: points
840 .iter()
841 .map(|p| {
842 Vector3::new(0.0, 0.0, p.velocity_magnitude)
844 })
845 .collect(),
846 transonic_distances, };
848
849 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
854 let outputs = TrajectoryOutputs {
855 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
857 time_of_flight_s: last_point.time,
858 max_ord_dist_horiz_m: max_height,
859 sight_height_m: sight_position_m,
860 };
861
862 let samples = sample_trajectory(
864 &trajectory_data,
865 &outputs,
866 self.inputs.sample_interval,
867 self.inputs.bullet_mass,
868 );
869 Some(samples)
870 } else {
871 None
872 };
873
874 Ok(TrajectoryResult {
875 max_range: last_point.position.x, max_height,
877 time_of_flight: last_point.time,
878 impact_velocity: last_point.velocity_magnitude,
879 impact_energy: last_point.kinetic_energy,
880 points,
881 sampled_points,
882 min_pitch_damping: if self.inputs.enable_pitch_damping {
883 Some(min_pitch_damping)
884 } else {
885 None
886 },
887 transonic_mach,
888 angular_state,
889 max_yaw_angle: if self.inputs.enable_precession_nutation {
890 Some(max_yaw_angle)
891 } else {
892 None
893 },
894 max_precession_angle: if self.inputs.enable_precession_nutation {
895 Some(max_precession_angle)
896 } else {
897 None
898 },
899 aerodynamic_jump: aj_components,
900 })
901 }
902
903 fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
904 let mut time = 0.0;
906 let mut position = Vector3::new(
910 0.0,
911 self.inputs.muzzle_height, 0.0,
913 );
914
915 let aj_components = self.aerodynamic_jump_components();
921 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
922 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
923 let mut velocity = Vector3::new(
924 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
928
929 let mut points = Vec::new();
930 let mut max_height = position.y;
931 let mut min_pitch_damping = 1.0; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
937 let mut crossed_transonic = false;
938 let mut crossed_subsonic = false;
939
940 let mut angular_state = if self.inputs.enable_precession_nutation {
942 Some(AngularState {
943 pitch_angle: 0.001, yaw_angle: 0.001,
945 pitch_rate: 0.0,
946 yaw_rate: 0.0,
947 precession_angle: 0.0,
948 nutation_phase: 0.0,
949 })
950 } else {
951 None
952 };
953 let mut max_yaw_angle = 0.0;
954 let mut max_precession_angle = 0.0;
955
956 let air_density = calculate_air_density(&self.atmosphere);
958
959 let wind_vector = Vector3::new(
963 -self.wind.speed * self.wind.direction.cos(), 0.0,
965 -self.wind.speed * self.wind.direction.sin(), );
967
968 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
971 self.inputs.bullet_model.as_deref().unwrap_or("default"),
972 );
973
974 while position.x < self.max_range && position.y > self.inputs.ground_threshold && time < 100.0 {
976 let velocity_magnitude = velocity.magnitude();
978 let kinetic_energy =
979 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
980
981 points.push(TrajectoryPoint {
982 time,
983 position: position,
984 velocity_magnitude,
985 kinetic_energy,
986 });
987
988 {
991 let sos = (1.4 * 287.05 * (self.atmosphere.temperature + 273.15)).sqrt();
992 let mach_here = if sos > 0.0 { velocity_magnitude / sos } else { 0.0 };
993 if !crossed_transonic && mach_here < 1.2 {
994 crossed_transonic = true;
995 transonic_distances.push(position.x);
996 }
997 if !crossed_subsonic && mach_here < 1.0 {
998 crossed_subsonic = true;
999 transonic_distances.push(position.x);
1000 }
1001 }
1002
1003 if position.y > max_height {
1004 max_height = position.y;
1005 }
1006
1007 if self.inputs.enable_pitch_damping {
1009 let temp_c = self.atmosphere.temperature;
1010 let temp_k = temp_c + 273.15;
1011 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
1012 let mach = velocity_magnitude / speed_of_sound;
1013
1014 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1016 transonic_mach = Some(mach);
1017 }
1018
1019 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1021
1022 if pitch_damping < min_pitch_damping {
1024 min_pitch_damping = pitch_damping;
1025 }
1026 }
1027
1028 if self.inputs.enable_precession_nutation {
1030 if let Some(ref mut state) = angular_state {
1031 let velocity_magnitude = velocity.magnitude();
1032 let temp_c = self.atmosphere.temperature;
1033 let temp_k = temp_c + 273.15;
1034 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
1035 let mach = velocity_magnitude / speed_of_sound;
1036
1037 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
1039 let velocity_fps = velocity_magnitude * 3.28084;
1040 let twist_rate_ft = self.inputs.twist_rate / 12.0;
1041 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
1042 } else {
1043 0.0
1044 };
1045
1046 let params = PrecessionNutationParams {
1048 mass_kg: self.inputs.bullet_mass,
1049 caliber_m: self.inputs.bullet_diameter,
1050 length_m: self.inputs.bullet_length,
1051 spin_rate_rad_s,
1052 spin_inertia: 6.94e-8, transverse_inertia: 9.13e-7, velocity_mps: velocity_magnitude,
1055 air_density_kg_m3: air_density,
1056 mach,
1057 pitch_damping_coeff: -0.8,
1058 nutation_damping_factor: 0.05,
1059 };
1060
1061 *state = calculate_combined_angular_motion(
1063 ¶ms,
1064 state,
1065 time,
1066 self.time_step,
1067 0.001, );
1069
1070 if state.yaw_angle.abs() > max_yaw_angle {
1072 max_yaw_angle = state.yaw_angle.abs();
1073 }
1074 if state.precession_angle.abs() > max_precession_angle {
1075 max_precession_angle = state.precession_angle.abs();
1076 }
1077 }
1078 }
1079
1080 let dt = self.time_step;
1082
1083 let acc1 = self.calculate_acceleration(&position, &velocity, air_density, &wind_vector);
1085
1086 let pos2 = position + velocity * (dt * 0.5);
1088 let vel2 = velocity + acc1 * (dt * 0.5);
1089 let acc2 = self.calculate_acceleration(&pos2, &vel2, air_density, &wind_vector);
1090
1091 let pos3 = position + vel2 * (dt * 0.5);
1093 let vel3 = velocity + acc2 * (dt * 0.5);
1094 let acc3 = self.calculate_acceleration(&pos3, &vel3, air_density, &wind_vector);
1095
1096 let pos4 = position + vel3 * dt;
1098 let vel4 = velocity + acc3 * dt;
1099 let acc4 = self.calculate_acceleration(&pos4, &vel4, air_density, &wind_vector);
1100
1101 position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
1103 velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
1104 time += dt;
1105 }
1106
1107 let last_point = points.last().ok_or("No trajectory points generated")?;
1109
1110 let sampled_points = if self.inputs.enable_trajectory_sampling {
1112 let trajectory_data = TrajectoryData {
1113 times: points.iter().map(|p| p.time).collect(),
1114 positions: points.iter().map(|p| p.position).collect(),
1115 velocities: points
1116 .iter()
1117 .map(|p| {
1118 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1120 })
1121 .collect(),
1122 transonic_distances, };
1124
1125 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1130 let outputs = TrajectoryOutputs {
1131 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
1133 time_of_flight_s: last_point.time,
1134 max_ord_dist_horiz_m: max_height,
1135 sight_height_m: sight_position_m,
1136 };
1137
1138 let samples = sample_trajectory(
1140 &trajectory_data,
1141 &outputs,
1142 self.inputs.sample_interval,
1143 self.inputs.bullet_mass,
1144 );
1145 Some(samples)
1146 } else {
1147 None
1148 };
1149
1150 Ok(TrajectoryResult {
1151 max_range: last_point.position.x, max_height,
1153 time_of_flight: last_point.time,
1154 impact_velocity: last_point.velocity_magnitude,
1155 impact_energy: last_point.kinetic_energy,
1156 points,
1157 sampled_points,
1158 min_pitch_damping: if self.inputs.enable_pitch_damping {
1159 Some(min_pitch_damping)
1160 } else {
1161 None
1162 },
1163 transonic_mach,
1164 angular_state,
1165 max_yaw_angle: if self.inputs.enable_precession_nutation {
1166 Some(max_yaw_angle)
1167 } else {
1168 None
1169 },
1170 max_precession_angle: if self.inputs.enable_precession_nutation {
1171 Some(max_precession_angle)
1172 } else {
1173 None
1174 },
1175 aerodynamic_jump: aj_components,
1176 })
1177 }
1178
1179 fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
1180 let mut time = 0.0;
1182 let mut position = Vector3::new(
1185 0.0,
1186 self.inputs.muzzle_height, 0.0,
1188 );
1189
1190 let aj_components = self.aerodynamic_jump_components();
1196 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1197 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1198 let mut velocity = Vector3::new(
1199 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1203
1204 let mut points = Vec::new();
1205 let mut max_height = position.y;
1206 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;
1214 const MAX_ITERATIONS: usize = 100000;
1215
1216 let air_density = calculate_air_density(&self.atmosphere);
1219 let wind_vector = Vector3::new(
1222 -self.wind.speed * self.wind.direction.cos(), 0.0,
1224 -self.wind.speed * self.wind.direction.sin(), );
1226
1227 let mut transonic_distances: Vec<f64> = Vec::new();
1229 let mut crossed_transonic = false;
1230 let mut crossed_subsonic = false;
1231
1232 let mut min_pitch_damping = 1.0;
1237 let mut transonic_mach: Option<f64> = None;
1238 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1239 self.inputs.bullet_model.as_deref().unwrap_or("default"),
1240 );
1241 let mut angular_state = if self.inputs.enable_precession_nutation {
1242 Some(AngularState {
1243 pitch_angle: 0.001,
1244 yaw_angle: 0.001,
1245 pitch_rate: 0.0,
1246 yaw_rate: 0.0,
1247 precession_angle: 0.0,
1248 nutation_phase: 0.0,
1249 })
1250 } else {
1251 None
1252 };
1253 let mut max_yaw_angle = 0.0;
1254 let mut max_precession_angle = 0.0;
1255
1256 while position.x < self.max_range
1257 && position.y > self.inputs.ground_threshold
1258 && time < 100.0
1259 {
1260 iteration_count += 1;
1262 if iteration_count > MAX_ITERATIONS {
1263 break; }
1265
1266 let velocity_magnitude = velocity.magnitude();
1268 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
1269
1270 points.push(TrajectoryPoint {
1271 time,
1272 position: position,
1273 velocity_magnitude,
1274 kinetic_energy,
1275 });
1276
1277 {
1280 let sos = (1.4 * 287.05 * (self.atmosphere.temperature + 273.15)).sqrt();
1281 let mach_here = if sos > 0.0 { velocity_magnitude / sos } else { 0.0 };
1282 if !crossed_transonic && mach_here < 1.2 {
1283 crossed_transonic = true;
1284 transonic_distances.push(position.x);
1285 }
1286 if !crossed_subsonic && mach_here < 1.0 {
1287 crossed_subsonic = true;
1288 transonic_distances.push(position.x);
1289 }
1290 }
1291
1292 if position.y > max_height {
1293 max_height = position.y;
1294 }
1295
1296 if self.inputs.enable_pitch_damping {
1299 let temp_k = self.atmosphere.temperature + 273.15;
1300 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
1301 let mach = velocity_magnitude / speed_of_sound;
1302 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1303 transonic_mach = Some(mach);
1304 }
1305 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1306 if pitch_damping < min_pitch_damping {
1307 min_pitch_damping = pitch_damping;
1308 }
1309 }
1310
1311 if self.inputs.enable_precession_nutation {
1315 if let Some(ref mut state) = angular_state {
1316 let temp_k = self.atmosphere.temperature + 273.15;
1317 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
1318 let mach = velocity_magnitude / speed_of_sound;
1319
1320 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
1321 let velocity_fps = velocity_magnitude * 3.28084;
1322 let twist_rate_ft = self.inputs.twist_rate / 12.0;
1323 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
1324 } else {
1325 0.0
1326 };
1327
1328 let params = PrecessionNutationParams {
1329 mass_kg: self.inputs.bullet_mass,
1330 caliber_m: self.inputs.bullet_diameter,
1331 length_m: self.inputs.bullet_length,
1332 spin_rate_rad_s,
1333 spin_inertia: 6.94e-8,
1334 transverse_inertia: 9.13e-7,
1335 velocity_mps: velocity_magnitude,
1336 air_density_kg_m3: air_density,
1337 mach,
1338 pitch_damping_coeff: -0.8,
1339 nutation_damping_factor: 0.05,
1340 };
1341
1342 *state = calculate_combined_angular_motion(
1343 ¶ms, state, time, dt, 0.001,
1344 );
1345
1346 if state.yaw_angle.abs() > max_yaw_angle {
1347 max_yaw_angle = state.yaw_angle.abs();
1348 }
1349 if state.precession_angle.abs() > max_precession_angle {
1350 max_precession_angle = state.precession_angle.abs();
1351 }
1352 }
1353 }
1354
1355 let (new_pos, new_vel, new_dt) = self.rk45_step(
1357 &position,
1358 &velocity,
1359 dt,
1360 air_density,
1361 &wind_vector,
1362 tolerance,
1363 );
1364
1365 position = new_pos;
1370 velocity = new_vel;
1371 time += dt;
1372
1373 dt = (safety_factor * new_dt).clamp(min_dt, max_dt);
1375 }
1376
1377 if points.is_empty() {
1379 return Err(BallisticsError::from("No trajectory points calculated"));
1380 }
1381
1382 {
1391 let prev = points.last().unwrap().clone();
1392 let overshoot_x = position.x;
1393 let crossed_range = overshoot_x >= self.max_range && prev.position.x < self.max_range;
1394 if crossed_range {
1395 let span = overshoot_x - prev.position.x;
1396 if span > 1e-9 {
1397 let frac = (self.max_range - prev.position.x) / span;
1398 let interp_pos = prev.position + (position - prev.position) * frac;
1399 let interp_vel_mag = prev.velocity_magnitude
1400 + (velocity.magnitude() - prev.velocity_magnitude) * frac;
1401 let interp_time = prev.time + (time - prev.time) * frac;
1402 let interp_ke =
1403 0.5 * self.inputs.bullet_mass * interp_vel_mag * interp_vel_mag;
1404 points.push(TrajectoryPoint {
1405 time: interp_time,
1406 position: interp_pos,
1407 velocity_magnitude: interp_vel_mag,
1408 kinetic_energy: interp_ke,
1409 });
1410 if interp_pos.y > max_height {
1411 max_height = interp_pos.y;
1412 }
1413 }
1414 }
1415 }
1416
1417 let last_point = points.last().unwrap();
1418
1419 let sampled_points = if self.inputs.enable_trajectory_sampling {
1421 let trajectory_data = TrajectoryData {
1423 times: points.iter().map(|p| p.time).collect(),
1424 positions: points.iter().map(|p| p.position).collect(),
1425 velocities: points
1426 .iter()
1427 .map(|p| {
1428 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1430 })
1431 .collect(),
1432 transonic_distances, };
1434
1435 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1440 let outputs = TrajectoryOutputs {
1441 target_distance_horiz_m: last_point.position.x,
1442 target_vertical_height_m: sight_position_m,
1443 time_of_flight_s: last_point.time,
1444 max_ord_dist_horiz_m: max_height,
1445 sight_height_m: sight_position_m,
1446 };
1447
1448 let samples = sample_trajectory(
1449 &trajectory_data,
1450 &outputs,
1451 self.inputs.sample_interval,
1452 self.inputs.bullet_mass,
1453 );
1454 Some(samples)
1455 } else {
1456 None
1457 };
1458
1459 Ok(TrajectoryResult {
1460 max_range: last_point.position.x, max_height,
1462 time_of_flight: last_point.time,
1463 impact_velocity: last_point.velocity_magnitude,
1464 impact_energy: last_point.kinetic_energy,
1465 points,
1466 sampled_points,
1467 min_pitch_damping: if self.inputs.enable_pitch_damping {
1468 Some(min_pitch_damping)
1469 } else {
1470 None
1471 },
1472 transonic_mach,
1473 angular_state,
1474 max_yaw_angle: if self.inputs.enable_precession_nutation {
1475 Some(max_yaw_angle)
1476 } else {
1477 None
1478 },
1479 max_precession_angle: if self.inputs.enable_precession_nutation {
1480 Some(max_precession_angle)
1481 } else {
1482 None
1483 },
1484 aerodynamic_jump: aj_components,
1485 })
1486 }
1487
1488 fn rk45_step(
1489 &self,
1490 position: &Vector3<f64>,
1491 velocity: &Vector3<f64>,
1492 dt: f64,
1493 air_density: f64,
1494 wind_vector: &Vector3<f64>,
1495 tolerance: f64,
1496 ) -> (Vector3<f64>, Vector3<f64>, f64) {
1497 const A21: f64 = 1.0 / 5.0;
1499 const A31: f64 = 3.0 / 40.0;
1500 const A32: f64 = 9.0 / 40.0;
1501 const A41: f64 = 44.0 / 45.0;
1502 const A42: f64 = -56.0 / 15.0;
1503 const A43: f64 = 32.0 / 9.0;
1504 const A51: f64 = 19372.0 / 6561.0;
1505 const A52: f64 = -25360.0 / 2187.0;
1506 const A53: f64 = 64448.0 / 6561.0;
1507 const A54: f64 = -212.0 / 729.0;
1508 const A61: f64 = 9017.0 / 3168.0;
1509 const A62: f64 = -355.0 / 33.0;
1510 const A63: f64 = 46732.0 / 5247.0;
1511 const A64: f64 = 49.0 / 176.0;
1512 const A65: f64 = -5103.0 / 18656.0;
1513 const A71: f64 = 35.0 / 384.0;
1514 const A73: f64 = 500.0 / 1113.0;
1515 const A74: f64 = 125.0 / 192.0;
1516 const A75: f64 = -2187.0 / 6784.0;
1517 const A76: f64 = 11.0 / 84.0;
1518
1519 const B1: f64 = 35.0 / 384.0;
1521 const B3: f64 = 500.0 / 1113.0;
1522 const B4: f64 = 125.0 / 192.0;
1523 const B5: f64 = -2187.0 / 6784.0;
1524 const B6: f64 = 11.0 / 84.0;
1525
1526 const B1_ERR: f64 = 5179.0 / 57600.0;
1528 const B3_ERR: f64 = 7571.0 / 16695.0;
1529 const B4_ERR: f64 = 393.0 / 640.0;
1530 const B5_ERR: f64 = -92097.0 / 339200.0;
1531 const B6_ERR: f64 = 187.0 / 2100.0;
1532 const B7_ERR: f64 = 1.0 / 40.0;
1533
1534 let k1_v = self.calculate_acceleration(position, velocity, air_density, wind_vector);
1536 let k1_p = *velocity;
1537
1538 let p2 = position + dt * A21 * k1_p;
1539 let v2 = velocity + dt * A21 * k1_v;
1540 let k2_v = self.calculate_acceleration(&p2, &v2, air_density, wind_vector);
1541 let k2_p = v2;
1542
1543 let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
1544 let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
1545 let k3_v = self.calculate_acceleration(&p3, &v3, air_density, wind_vector);
1546 let k3_p = v3;
1547
1548 let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
1549 let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
1550 let k4_v = self.calculate_acceleration(&p4, &v4, air_density, wind_vector);
1551 let k4_p = v4;
1552
1553 let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
1554 let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
1555 let k5_v = self.calculate_acceleration(&p5, &v5, air_density, wind_vector);
1556 let k5_p = v5;
1557
1558 let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
1559 let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
1560 let k6_v = self.calculate_acceleration(&p6, &v6, air_density, wind_vector);
1561 let k6_p = v6;
1562
1563 let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
1564 let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
1565 let k7_v = self.calculate_acceleration(&p7, &v7, air_density, wind_vector);
1566 let k7_p = v7;
1567
1568 let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
1570 let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
1571
1572 let pos_err = position
1574 + dt * (B1_ERR * k1_p
1575 + B3_ERR * k3_p
1576 + B4_ERR * k4_p
1577 + B5_ERR * k5_p
1578 + B6_ERR * k6_p
1579 + B7_ERR * k7_p);
1580 let vel_err = velocity
1581 + dt * (B1_ERR * k1_v
1582 + B3_ERR * k3_v
1583 + B4_ERR * k4_v
1584 + B5_ERR * k5_v
1585 + B6_ERR * k6_v
1586 + B7_ERR * k7_v);
1587
1588 let pos_error = (new_pos - pos_err).magnitude();
1590 let vel_error = (new_vel - vel_err).magnitude();
1591 let error = (pos_error + vel_error) / (1.0 + position.magnitude() + velocity.magnitude());
1592
1593 let dt_new = if error < tolerance {
1595 dt * (tolerance / error).powf(0.2).min(2.0)
1596 } else {
1597 dt * (tolerance / error).powf(0.25).max(0.1)
1598 };
1599
1600 (new_pos, new_vel, dt_new)
1601 }
1602
1603 fn calculate_acceleration(
1604 &self,
1605 position: &Vector3<f64>,
1606 velocity: &Vector3<f64>,
1607 air_density: f64,
1608 wind_vector: &Vector3<f64>,
1609 ) -> Vector3<f64> {
1610 let actual_wind = if let Some(ref sock) = self.wind_sock {
1616 sock.vector_for_range_stateless(position.x)
1617 } else if self.inputs.enable_wind_shear {
1618 self.get_wind_at_altitude(position.y)
1619 } else {
1620 *wind_vector
1621 };
1622
1623 let relative_velocity = velocity - actual_wind;
1624 let velocity_magnitude = relative_velocity.magnitude();
1625
1626 if velocity_magnitude < 0.001 {
1627 return Vector3::new(0.0, -crate::constants::G_ACCEL_MPS2, 0.0);
1628 }
1629
1630 let cd = self.calculate_drag_coefficient(velocity_magnitude);
1632
1633 let velocity_fps = velocity_magnitude * 3.28084;
1635
1636 let base_bc = if let Some(ref segments) = self.inputs.bc_segments_data {
1638 segments
1640 .iter()
1641 .find(|seg| velocity_fps >= seg.velocity_min && velocity_fps < seg.velocity_max)
1642 .map(|seg| seg.bc_value)
1643 .unwrap_or(self.inputs.bc_value)
1644 } else {
1645 self.inputs.bc_value
1646 };
1647
1648 let effective_bc = if let Some(ref cluster_bc) = self.cluster_bc {
1650 cluster_bc.apply_correction(
1651 base_bc,
1652 self.inputs.caliber_inches, self.inputs.weight_grains,
1654 velocity_fps,
1655 )
1656 } else {
1657 base_bc
1658 };
1659 let effective_bc = effective_bc.max(1e-6);
1663
1664 let cd_to_retard = 0.000683 * 0.30; let standard_factor = cd * cd_to_retard;
1670 let density_scale = air_density / 1.225; let a_drag_ft_s2 =
1674 (velocity_fps * velocity_fps) * standard_factor * density_scale / effective_bc;
1675 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
1679
1680 let mut accel = drag_acceleration + Vector3::new(0.0, -crate::constants::G_ACCEL_MPS2, 0.0);
1682
1683 if self.inputs.enable_coriolis {
1686 if let Some(lat_deg) = self.inputs.latitude {
1687 let omega_earth = 7.2921159e-5_f64; let lat = lat_deg.to_radians();
1689 let az = self.inputs.shot_azimuth; let omega = Vector3::new(
1696 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), );
1700 accel += -2.0 * omega.cross(velocity);
1705 }
1706 }
1707
1708 if self.inputs.enable_magnus
1710 && self.inputs.bullet_diameter > 0.0
1711 && self.inputs.twist_rate > 0.0
1712 {
1713 let (_, spin_rad_s) =
1714 crate::spin_drift::calculate_spin_rate(velocity_magnitude, self.inputs.twist_rate);
1715 let temp_k = self.atmosphere.temperature + 273.15;
1716 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
1717 let mach = velocity_magnitude / speed_of_sound;
1718
1719 let d_in = self.inputs.bullet_diameter / 0.0254;
1721 let m_gr = self.inputs.bullet_mass / 0.00006479891;
1722 let l_in = if self.inputs.bullet_length > 0.0 {
1723 self.inputs.bullet_length / 0.0254
1724 } else {
1725 4.5 * d_in
1726 };
1727 let press_hpa = self.atmosphere.pressure;
1731 let density_correction = if press_hpa > 0.0 && temp_k > 0.0 {
1732 (temp_k / 288.15) * (1013.25 / press_hpa)
1733 } else {
1734 1.0
1735 };
1736 let sg = crate::spin_drift::miller_stability(d_in, m_gr, self.inputs.twist_rate, l_in)
1737 * density_correction;
1738
1739 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
1741 sg,
1742 velocity_magnitude,
1743 spin_rad_s,
1744 0.0, 0.0, air_density,
1747 d_in,
1748 l_in,
1749 m_gr,
1750 mach,
1751 "match",
1752 false,
1753 );
1754
1755 let diameter_m = self.inputs.bullet_diameter; let spin_param = spin_rad_s * diameter_m / (2.0 * velocity_magnitude);
1758 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
1759 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
1760 let magnus_force = 0.5
1761 * air_density
1762 * velocity_magnitude.powi(2)
1763 * area
1764 * c_np
1765 * spin_param
1766 * yaw_rad.sin();
1767
1768 let velocity_unit = relative_velocity / velocity_magnitude;
1771 let up = Vector3::new(0.0, 1.0, 0.0);
1772 let mut dir = velocity_unit.cross(&up);
1773 let dir_norm = dir.norm();
1774 if dir_norm > 1e-12 && magnus_force.abs() > 1e-12 {
1775 dir /= dir_norm;
1776 if !self.inputs.is_twist_right {
1777 dir = -dir;
1778 }
1779 accel += (magnus_force / self.inputs.bullet_mass) * dir;
1780 }
1781 }
1782
1783 accel
1784 }
1785
1786 fn calculate_drag_coefficient(&self, velocity: f64) -> f64 {
1787 let temp_c = self.atmosphere.temperature;
1789 let temp_k = temp_c + 273.15;
1790 let gamma = 1.4; let r_specific = 287.05; let speed_of_sound = (gamma * r_specific * temp_k).sqrt();
1793 let mach = velocity / speed_of_sound;
1794
1795 if let Some(ref table) = self.inputs.custom_drag_table {
1799 return table.interpolate(mach);
1800 }
1801
1802 let base_cd = crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type);
1804
1805 let bc_type_str: &str = match self.inputs.bc_type {
1809 crate::DragModel::G1 => "G1",
1810 crate::DragModel::G2 => "G2",
1811 crate::DragModel::G5 => "G5",
1812 crate::DragModel::G6 => "G6",
1813 crate::DragModel::G7 => "G7",
1814 crate::DragModel::G8 => "G8",
1815 crate::DragModel::GI => "GI",
1816 crate::DragModel::GS => "GS",
1817 };
1818
1819 let projectile_shape = crate::transonic_drag::resolve_projectile_shape(
1822 self.inputs.bullet_model.as_deref(),
1823 self.inputs.caliber_inches,
1824 self.inputs.bullet_mass / 0.00006479891, bc_type_str,
1826 );
1827
1828 let include_wave_drag = false;
1834 let cd = transonic_correction(mach, base_cd, projectile_shape, include_wave_drag);
1835
1836 crate::form_factor::apply_form_factor_to_drag(
1841 cd,
1842 self.inputs.bullet_model.as_deref(),
1843 &self.inputs.bc_type,
1844 self.inputs.use_form_factor,
1845 )
1846 }
1847}
1848
1849#[derive(Debug, Clone)]
1851pub struct MonteCarloParams {
1852 pub num_simulations: usize,
1853 pub velocity_std_dev: f64,
1854 pub angle_std_dev: f64,
1855 pub bc_std_dev: f64,
1856 pub wind_speed_std_dev: f64,
1857 pub target_distance: Option<f64>,
1858 pub base_wind_speed: f64,
1859 pub base_wind_direction: f64,
1860 pub azimuth_std_dev: f64, }
1862
1863impl Default for MonteCarloParams {
1864 fn default() -> Self {
1865 Self {
1866 num_simulations: 1000,
1867 velocity_std_dev: 1.0,
1868 angle_std_dev: 0.001,
1869 bc_std_dev: 0.01,
1870 wind_speed_std_dev: 1.0,
1871 target_distance: None,
1872 base_wind_speed: 0.0,
1873 base_wind_direction: 0.0,
1874 azimuth_std_dev: 0.001, }
1876 }
1877}
1878
1879#[derive(Debug, Clone)]
1881pub struct MonteCarloResults {
1882 pub ranges: Vec<f64>,
1883 pub impact_velocities: Vec<f64>,
1884 pub impact_positions: Vec<Vector3<f64>>,
1885}
1886
1887pub fn run_monte_carlo(
1889 base_inputs: BallisticInputs,
1890 params: MonteCarloParams,
1891) -> Result<MonteCarloResults, BallisticsError> {
1892 let base_wind = WindConditions {
1893 speed: params.base_wind_speed,
1894 direction: params.base_wind_direction,
1895 };
1896 run_monte_carlo_with_wind(base_inputs, base_wind, params)
1897}
1898
1899pub fn run_monte_carlo_with_wind(
1901 base_inputs: BallisticInputs,
1902 base_wind: WindConditions,
1903 params: MonteCarloParams,
1904) -> Result<MonteCarloResults, BallisticsError> {
1905 use rand_distr::{Distribution, Normal};
1906
1907 let mut rng = rand::rng();
1908 let mut ranges = Vec::new();
1909 let mut impact_velocities = Vec::new();
1910 let mut impact_positions = Vec::new();
1911
1912 let baseline_solver =
1914 TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), Default::default());
1915 let baseline_result = baseline_solver.solve()?;
1916
1917 let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
1919
1920 let baseline_at_target = baseline_result
1922 .position_at_range(target_distance)
1923 .ok_or("Could not interpolate baseline at target distance")?;
1924
1925 let velocity_dist = Normal::new(base_inputs.muzzle_velocity, params.velocity_std_dev)
1927 .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
1928 let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
1929 .map_err(|e| format!("Invalid angle distribution: {}", e))?;
1930 let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
1931 .map_err(|e| format!("Invalid BC distribution: {}", e))?;
1932 let wind_speed_dist = Normal::new(base_wind.speed, params.wind_speed_std_dev)
1933 .map_err(|e| format!("Invalid wind speed distribution: {}", e))?;
1934 let wind_dir_dist =
1939 Normal::new(base_wind.direction, params.wind_speed_std_dev * 0.1)
1940 .map_err(|e| format!("Invalid wind direction distribution: {}", e))?;
1941 let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
1942 .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
1943
1944 let pointing_error_dist = Normal::new(0.0, params.angle_std_dev * target_distance)
1946 .map_err(|e| format!("Invalid pointing distribution: {}", e))?;
1947
1948 for _ in 0..params.num_simulations {
1949 let mut inputs = base_inputs.clone();
1951 inputs.muzzle_velocity = velocity_dist.sample(&mut rng).max(0.0);
1952 inputs.muzzle_angle = angle_dist.sample(&mut rng);
1953 inputs.bc_value = bc_dist.sample(&mut rng).max(0.01);
1954 inputs.azimuth_angle = azimuth_dist.sample(&mut rng); let wind = WindConditions {
1958 speed: wind_speed_dist.sample(&mut rng).abs(),
1959 direction: wind_dir_dist.sample(&mut rng),
1960 };
1961
1962 let solver = TrajectorySolver::new(inputs, wind, Default::default());
1964 match solver.solve() {
1965 Ok(result) => {
1966 if result.max_range < target_distance {
1974 continue;
1975 }
1976 let pos_at_target = match result.position_at_range(target_distance) {
1980 Some(p) => p,
1981 None => continue,
1982 };
1983
1984 ranges.push(result.max_range);
1985 impact_velocities.push(result.impact_velocity);
1986
1987 let mut deviation = Vector3::new(
1990 0.0, pos_at_target.y - baseline_at_target.y, pos_at_target.z - baseline_at_target.z, );
1994
1995 let pointing_error_y = pointing_error_dist.sample(&mut rng);
1998 deviation.y += pointing_error_y;
1999
2000 impact_positions.push(deviation);
2001 }
2002 Err(_) => {
2003 continue;
2005 }
2006 }
2007 }
2008
2009 if ranges.is_empty() {
2010 return Err("No successful simulations".into());
2011 }
2012
2013 Ok(MonteCarloResults {
2014 ranges,
2015 impact_velocities,
2016 impact_positions,
2017 })
2018}
2019
2020pub fn calculate_zero_angle(
2022 inputs: BallisticInputs,
2023 target_distance: f64,
2024 target_height: f64,
2025) -> Result<f64, BallisticsError> {
2026 calculate_zero_angle_with_conditions(
2027 inputs,
2028 target_distance,
2029 target_height,
2030 WindConditions::default(),
2031 AtmosphericConditions::default(),
2032 )
2033}
2034
2035pub fn calculate_zero_angle_with_conditions(
2036 inputs: BallisticInputs,
2037 target_distance: f64,
2038 target_height: f64,
2039 wind: WindConditions,
2040 atmosphere: AtmosphericConditions,
2041) -> Result<f64, BallisticsError> {
2042 let get_height_at_angle = |angle: f64| -> Result<Option<f64>, BallisticsError> {
2044 let mut test_inputs = inputs.clone();
2045 test_inputs.muzzle_angle = angle;
2046 test_inputs.enable_aerodynamic_jump = false;
2051
2052 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
2053 solver.set_max_range(target_distance * 2.0);
2054 solver.set_time_step(0.001);
2055 let result = solver.solve()?;
2056
2057 for i in 0..result.points.len() {
2059 if result.points[i].position.x >= target_distance {
2060 if i > 0 {
2061 let p1 = &result.points[i - 1];
2062 let p2 = &result.points[i];
2063 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
2064 return Ok(Some(p1.position.y + t * (p2.position.y - p1.position.y)));
2065 } else {
2066 return Ok(Some(result.points[i].position.y));
2067 }
2068 }
2069 }
2070 Ok(None)
2071 };
2072
2073 let mut low_angle = 0.0; let mut high_angle = 0.2; let tolerance = 0.00001; let max_iterations = 50;
2079
2080 let low_height = get_height_at_angle(low_angle)?;
2083 let high_height = get_height_at_angle(high_angle)?;
2084
2085 match (low_height, high_height) {
2086 (Some(lh), Some(hh)) => {
2087 let low_error = lh - target_height;
2088 let high_error = hh - target_height;
2089
2090 if low_error > 0.0 && high_error > 0.0 {
2093 } else if low_error < 0.0 && high_error < 0.0 {
2098 let mut expanded = false;
2101 for multiplier in [2.0, 3.0, 4.0] {
2102 let new_high = (high_angle * multiplier).min(0.785);
2103 if let Ok(Some(h)) = get_height_at_angle(new_high) {
2104 if h - target_height > 0.0 {
2105 high_angle = new_high;
2106 expanded = true;
2107 break;
2108 }
2109 }
2110 if new_high >= 0.785 {
2111 break;
2112 }
2113 }
2114 if !expanded {
2115 return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
2116 }
2117 }
2118 }
2120 (None, Some(_hh)) => {
2121 }
2124 (Some(_lh), None) => {
2125 return Err(
2127 "Cannot find zero angle: high angle trajectory doesn't reach target distance"
2128 .into(),
2129 );
2130 }
2131 (None, None) => {
2132 return Err(
2134 "Cannot find zero angle: trajectory cannot reach target distance at any angle"
2135 .into(),
2136 );
2137 }
2138 }
2139
2140 for _iteration in 0..max_iterations {
2141 let mid_angle = (low_angle + high_angle) / 2.0;
2142
2143 let mut test_inputs = inputs.clone();
2144 test_inputs.muzzle_angle = mid_angle;
2145 test_inputs.enable_aerodynamic_jump = false;
2147
2148 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
2149 solver.set_max_range(target_distance * 2.0);
2151 solver.set_time_step(0.001);
2152 let result = solver.solve()?;
2153
2154 let mut height_at_target = None;
2156 for i in 0..result.points.len() {
2157 if result.points[i].position.x >= target_distance {
2158 if i > 0 {
2159 let p1 = &result.points[i - 1];
2161 let p2 = &result.points[i];
2162 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
2163 height_at_target = Some(p1.position.y + t * (p2.position.y - p1.position.y));
2164 } else {
2165 height_at_target = Some(result.points[i].position.y);
2166 }
2167 break;
2168 }
2169 }
2170
2171 match height_at_target {
2172 Some(height) => {
2173 let error = height - target_height;
2174 if error.abs() < 0.001 {
2177 return Ok(mid_angle);
2178 }
2179
2180 if (high_angle - low_angle).abs() < tolerance {
2184 if error.abs() < 0.01 {
2185 return Ok(mid_angle);
2187 }
2188 return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
2193 }
2194
2195 if error > 0.0 {
2196 high_angle = mid_angle;
2197 } else {
2198 low_angle = mid_angle;
2199 }
2200 }
2201 None => {
2202 low_angle = mid_angle;
2204
2205 if (high_angle - low_angle).abs() < tolerance {
2207 return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
2208 }
2209 }
2210 }
2211 }
2212
2213 Err("Failed to find zero angle".into())
2214}
2215
2216pub fn estimate_bc_from_trajectory(
2218 velocity: f64,
2219 mass: f64,
2220 diameter: f64,
2221 points: &[(f64, f64)], ) -> Result<f64, BallisticsError> {
2223 let mut best_bc = 0.5;
2225 let mut best_error = f64::MAX;
2226 let mut found_valid = false;
2227
2228 for bc in (100..1000).step_by(10) {
2230 let bc_value = bc as f64 / 1000.0;
2231
2232 let inputs = BallisticInputs {
2233 muzzle_velocity: velocity,
2234 bc_value,
2235 bullet_mass: mass,
2236 bullet_diameter: diameter,
2237 ..Default::default()
2238 };
2239
2240 let mut solver = TrajectorySolver::new(inputs, Default::default(), Default::default());
2241 solver.set_max_range(points.last().map(|(d, _)| *d * 1.5).unwrap_or(1000.0));
2243
2244 let result = match solver.solve() {
2245 Ok(r) => r,
2246 Err(_) => continue, };
2248
2249 let mut total_error = 0.0;
2251 for (target_dist, target_drop) in points {
2252 let mut calculated_drop = None;
2254 for i in 0..result.points.len() {
2255 if result.points[i].position.x >= *target_dist {
2256 if i > 0 {
2257 let p1 = &result.points[i - 1];
2259 let p2 = &result.points[i];
2260 let t = (target_dist - p1.position.x) / (p2.position.x - p1.position.x);
2261 calculated_drop =
2262 Some(-(p1.position.y + t * (p2.position.y - p1.position.y)));
2263 } else {
2264 calculated_drop = Some(-result.points[i].position.y);
2265 }
2266 break;
2267 }
2268 }
2269
2270 if let Some(drop) = calculated_drop {
2271 let error = (drop - target_drop).abs();
2272 total_error += error * error;
2273 }
2274 }
2275
2276 if total_error < best_error {
2277 best_error = total_error;
2278 best_bc = bc_value;
2279 found_valid = true;
2280 }
2281 }
2282
2283 if !found_valid {
2284 return Err(BallisticsError::from("Unable to estimate BC from provided data. Check that drop values are in correct units.".to_string()));
2285 }
2286
2287 Ok(best_bc)
2288}
2289
2290fn calculate_air_density(atmosphere: &AtmosphericConditions) -> f64 {
2292 crate::atmosphere::calculate_atmosphere(
2300 atmosphere.altitude,
2301 crate::atmosphere::resolve_station_temperature(atmosphere.temperature, atmosphere.altitude),
2302 crate::atmosphere::resolve_station_pressure(atmosphere.pressure, atmosphere.altitude),
2303 atmosphere.humidity,
2304 )
2305 .0
2306}
2307
2308use rand;
2310use rand_distr;
2311
2312#[cfg(test)]
2313mod ground_termination_tests {
2314 use super::*;
2315
2316 #[test]
2321 fn rk4_and_rk45_descend_to_ground_threshold() {
2322 for adaptive in [false, true] {
2323 let mut inputs = BallisticInputs::default();
2324 inputs.muzzle_angle = 0.1; inputs.use_rk4 = true;
2326 inputs.use_adaptive_rk45 = adaptive;
2327 assert_eq!(inputs.ground_threshold, -100.0, "default ground_threshold is -100 m");
2328
2329 let mut solver = TrajectorySolver::new(
2330 inputs,
2331 WindConditions::default(),
2332 AtmosphericConditions::default(),
2333 );
2334 solver.set_max_range(1.0e7);
2336
2337 let result = solver.solve().expect("solve should succeed");
2338 let final_y = result
2339 .points
2340 .last()
2341 .expect("trajectory has points")
2342 .position
2343 .y;
2344 assert!(
2345 final_y < -1.0,
2346 "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
2347 past launch level toward the ground_threshold floor, not stop at y = 0"
2348 );
2349 }
2350 }
2351}
2352
2353#[cfg(test)]
2354mod coriolis_direction_tests {
2355 use super::*;
2356 use std::f64::consts::FRAC_PI_2;
2357
2358 #[test]
2359 fn transonic_crossing_flags_a_sampled_point() {
2360 use crate::trajectory_sampling::TrajectoryFlag;
2364 let mut inputs = BallisticInputs::default();
2365 inputs.muzzle_velocity = 850.0; inputs.bc_value = 0.2; inputs.bc_type = DragModel::G7;
2368 inputs.muzzle_angle = 0.03;
2369 inputs.enable_trajectory_sampling = true;
2370 inputs.sample_interval = 50.0;
2371 let mut solver = TrajectorySolver::new(
2372 inputs,
2373 WindConditions::default(),
2374 AtmosphericConditions::default(),
2375 );
2376 solver.set_max_range(2000.0);
2377 let r = solver.solve().expect("solve");
2378 let samples = r
2379 .sampled_points
2380 .expect("sampling enabled -> sampled_points present");
2381 assert!(
2382 samples
2383 .iter()
2384 .any(|s| s.flags.contains(&TrajectoryFlag::MachTransition)),
2385 "a shot that crosses Mach 1 must flag at least one Mach-transition sample"
2386 );
2387 }
2388
2389 #[test]
2390 fn humidity_percent_converts_and_clamps() {
2391 let mut i = BallisticInputs::default();
2393 i.humidity = 0.5;
2394 assert!((i.humidity_percent() - 50.0).abs() < 1e-9, "0.5 -> 50%");
2395 i.humidity = 0.0;
2396 assert_eq!(i.humidity_percent(), 0.0);
2397 i.humidity = 1.0;
2398 assert_eq!(i.humidity_percent(), 100.0);
2399 i.humidity = 1.5; assert_eq!(i.humidity_percent(), 100.0);
2401 }
2402
2403 fn vertical_at(shot_azimuth: f64, range_m: f64) -> f64 {
2406 let mut inputs = BallisticInputs::default();
2407 inputs.muzzle_velocity = 800.0;
2408 inputs.bc_value = 0.5;
2409 inputs.bc_type = DragModel::G7;
2410 inputs.muzzle_angle = 0.02; inputs.enable_coriolis = true;
2412 inputs.latitude = Some(45.0);
2413 inputs.shot_azimuth = shot_azimuth;
2414 inputs.ground_threshold = f64::NEG_INFINITY; let mut solver = TrajectorySolver::new(
2416 inputs,
2417 WindConditions::default(),
2418 AtmosphericConditions::default(),
2419 );
2420 solver.set_max_range(range_m + 50.0);
2421 let r = solver.solve().expect("solve");
2422 let pts = &r.points;
2423 for i in 1..pts.len() {
2424 if pts[i].position.x >= range_m {
2425 let p1 = &pts[i - 1];
2426 let p2 = &pts[i];
2427 let t = (range_m - p1.position.x) / (p2.position.x - p1.position.x);
2428 return p1.position.y + t * (p2.position.y - p1.position.y);
2429 }
2430 }
2431 panic!("range {range_m} not reached");
2432 }
2433
2434 #[test]
2439 fn eotvos_east_higher_than_west() {
2440 let range = 600.0;
2441 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!(
2445 east > west,
2446 "east ({east:.5}) must be higher than west ({west:.5}) at {range} m (Eötvös)"
2447 );
2448 assert!(
2449 east > north && north > west,
2450 "north ({north:.5}) must lie between east ({east:.5}) and west ({west:.5})"
2451 );
2452 assert!(
2453 (east - west) > 1e-3,
2454 "E-W vertical separation ({:.6} m) should be physically meaningful, not FP noise",
2455 east - west
2456 );
2457 }
2458}