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::wind_shear::WindShearModel;
11use crate::DragModel;
12use nalgebra::Vector3;
13use std::error::Error;
14use std::fmt;
15
16#[derive(Debug, Clone, Copy, PartialEq)]
18pub enum UnitSystem {
19 Imperial,
20 Metric,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq)]
25pub enum OutputFormat {
26 Table,
27 Json,
28 Csv,
29}
30
31#[derive(Debug)]
33pub struct BallisticsError {
34 message: String,
35}
36
37impl fmt::Display for BallisticsError {
38 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
39 write!(f, "{}", self.message)
40 }
41}
42
43impl Error for BallisticsError {}
44
45impl From<String> for BallisticsError {
46 fn from(msg: String) -> Self {
47 BallisticsError { message: msg }
48 }
49}
50
51impl From<&str> for BallisticsError {
52 fn from(msg: &str) -> Self {
53 BallisticsError {
54 message: msg.to_string(),
55 }
56 }
57}
58
59#[derive(Debug, Clone)]
63pub struct BallisticInputs {
64 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,
81 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,
96 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,
118 pub enable_magnus: bool, pub enable_coriolis: bool, pub use_powder_sensitivity: bool,
121 pub powder_temp_sensitivity: f64,
122 pub powder_temp: f64, pub tipoff_yaw: f64, pub tipoff_decay_distance: f64, pub use_bc_segments: bool,
126 pub bc_segments: Option<Vec<(f64, f64)>>, pub bc_segments_data: Option<Vec<crate::BCSegmentData>>, pub use_enhanced_spin_drift: bool,
129 pub use_form_factor: bool,
130 pub enable_wind_shear: bool,
131 pub wind_shear_model: String,
132 pub enable_trajectory_sampling: bool,
133 pub sample_interval: f64, pub enable_pitch_damping: bool,
135 pub enable_precession_nutation: bool,
136 pub enable_aerodynamic_jump: bool,
139 pub use_cluster_bc: bool, pub custom_drag_table: Option<crate::drag::DragTable>,
143
144 pub bc_type_str: Option<String>,
146}
147
148impl BallisticInputs {
149 pub fn humidity_percent(&self) -> f64 {
154 (self.humidity * 100.0).clamp(0.0, 100.0)
155 }
156}
157
158impl Default for BallisticInputs {
159 fn default() -> Self {
160 let mass_kg = 0.01;
161 let diameter_m = 0.00762;
162 let bc = 0.5;
163 let muzzle_angle_rad = 0.0;
164 let bc_type = DragModel::G1;
165
166 Self {
167 bc_value: bc,
169 bc_type,
170 bullet_mass: mass_kg,
171 muzzle_velocity: 800.0,
172 bullet_diameter: diameter_m,
173 bullet_length: diameter_m * 4.5, muzzle_angle: muzzle_angle_rad,
177 target_distance: 100.0,
178 azimuth_angle: 0.0,
179 shot_azimuth: 0.0,
180 shooting_angle: 0.0,
181 sight_height: 0.05,
182 muzzle_height: 0.0, target_height: 0.0, ground_threshold: -100.0, altitude: 0.0,
188 temperature: 15.0,
189 pressure: 1013.25, humidity: 0.5, latitude: None,
192
193 wind_speed: 0.0,
195 wind_angle: 0.0,
196
197 twist_rate: 12.0, is_twist_right: true,
200 caliber_inches: diameter_m / 0.0254, weight_grains: mass_kg / 0.00006479891, manufacturer: None,
203 bullet_model: None,
204 bullet_id: None,
205 bullet_cluster: None,
206
207 use_rk4: true, use_adaptive_rk45: true, enable_advanced_effects: false,
213 enable_magnus: false,
214 enable_coriolis: false,
215 use_powder_sensitivity: false,
216 powder_temp_sensitivity: 0.0,
217 powder_temp: 15.0,
218 tipoff_yaw: 0.0,
219 tipoff_decay_distance: 50.0,
220 use_bc_segments: false,
221 bc_segments: None,
222 bc_segments_data: None,
223 use_enhanced_spin_drift: false,
224 use_form_factor: false,
225 enable_wind_shear: false,
226 wind_shear_model: "none".to_string(),
227 enable_trajectory_sampling: false,
228 sample_interval: 10.0, enable_pitch_damping: false,
230 enable_precession_nutation: false,
231 enable_aerodynamic_jump: false,
232 use_cluster_bc: false, custom_drag_table: None,
236
237 bc_type_str: None,
239 }
240 }
241}
242
243#[derive(Debug, Clone)]
245pub struct WindConditions {
246 pub speed: f64, pub direction: f64,
250}
251
252impl Default for WindConditions {
253 fn default() -> Self {
254 Self {
255 speed: 0.0,
256 direction: 0.0,
257 }
258 }
259}
260
261#[derive(Debug, Clone)]
263pub struct AtmosphericConditions {
264 pub temperature: f64, pub pressure: f64, pub humidity: f64,
270 pub altitude: f64, }
272
273impl Default for AtmosphericConditions {
274 fn default() -> Self {
275 Self {
276 temperature: 15.0,
277 pressure: 1013.25,
278 humidity: 50.0,
279 altitude: 0.0,
280 }
281 }
282}
283
284#[derive(Debug, Clone)]
286pub struct TrajectoryPoint {
287 pub time: f64,
288 pub position: Vector3<f64>,
289 pub velocity_magnitude: f64,
290 pub kinetic_energy: f64,
291}
292
293#[derive(Debug, Clone)]
295pub struct TrajectoryResult {
296 pub max_range: f64,
297 pub max_height: f64,
298 pub time_of_flight: f64,
299 pub impact_velocity: f64,
300 pub impact_energy: f64,
301 pub points: Vec<TrajectoryPoint>,
302 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>,
311}
312
313impl TrajectoryResult {
314 pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
318 if self.points.is_empty() {
319 return None;
320 }
321
322 for i in 0..self.points.len() - 1 {
324 let p1 = &self.points[i];
325 let p2 = &self.points[i + 1];
326
327 if p1.position.x <= target_range && p2.position.x >= target_range {
329 let dx = p2.position.x - p1.position.x;
331 if dx.abs() < 1e-10 {
332 return Some(p1.position);
333 }
334 let t = (target_range - p1.position.x) / dx;
335
336 return Some(Vector3::new(
338 target_range,
339 p1.position.y + t * (p2.position.y - p1.position.y),
340 p1.position.z + t * (p2.position.z - p1.position.z),
341 ));
342 }
343 }
344
345 self.points.last().map(|p| p.position)
347 }
348}
349
350pub struct TrajectorySolver {
352 inputs: BallisticInputs,
353 wind: WindConditions,
354 atmosphere: AtmosphericConditions,
355 max_range: f64,
356 time_step: f64,
357 cluster_bc: Option<ClusterBCDegradation>,
358 wind_sock: Option<crate::wind::WindSock>,
363}
364
365impl TrajectorySolver {
366 pub fn new(
367 mut inputs: BallisticInputs,
368 wind: WindConditions,
369 atmosphere: AtmosphericConditions,
370 ) -> Self {
371 inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
373 inputs.weight_grains = inputs.bullet_mass / 0.00006479891;
374
375 if inputs.use_powder_sensitivity {
382 let temp_delta_c = inputs.temperature - inputs.powder_temp;
383 inputs.muzzle_velocity += inputs.powder_temp_sensitivity * temp_delta_c;
384 }
385
386 let cluster_bc = if inputs.use_cluster_bc {
388 Some(ClusterBCDegradation::new())
389 } else {
390 None
391 };
392
393 Self {
394 inputs,
395 wind,
396 atmosphere,
397 max_range: 1000.0,
398 time_step: 0.001,
399 cluster_bc,
400 wind_sock: None,
401 }
402 }
403
404 pub fn set_max_range(&mut self, range: f64) {
405 self.max_range = range;
406 }
407
408 pub fn set_time_step(&mut self, step: f64) {
409 self.time_step = step;
410 }
411
412 pub fn set_wind_segments(&mut self, segments: Vec<crate::wind::WindSegment>) {
419 self.wind_sock = if segments.is_empty() {
420 None
421 } else {
422 Some(crate::wind::WindSock::new(segments))
423 };
424 }
425
426 fn launch_angles_from(
435 &self,
436 aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
437 ) -> (f64, f64) {
438 let elev = self.inputs.muzzle_angle;
439 let azim = self.inputs.azimuth_angle;
440 match aj {
441 Some(c) => {
442 const MOA_PER_RAD: f64 = 3437.7467707849;
444 (
445 elev + c.vertical_jump_moa / MOA_PER_RAD,
446 azim + c.horizontal_jump_moa / MOA_PER_RAD,
447 )
448 }
449 None => (elev, azim),
450 }
451 }
452
453 fn aerodynamic_jump_components(
461 &self,
462 ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
463 if !self.inputs.enable_aerodynamic_jump {
464 return None;
465 }
466 let diameter_m = self.inputs.bullet_diameter;
470 if !(self.inputs.twist_rate.is_finite() && self.inputs.twist_rate != 0.0)
471 || !(diameter_m.is_finite() && diameter_m > 0.0)
472 || !(self.inputs.bullet_length.is_finite() && self.inputs.bullet_length > 0.0)
473 || !self.inputs.muzzle_velocity.is_finite()
474 {
475 return None;
476 }
477
478 let (_, _, temp_c, pressure_hpa) = self.resolved_atmosphere();
480 let sg = crate::stability::compute_stability_coefficient(
481 &self.inputs,
482 (self.atmosphere.altitude, temp_c, pressure_hpa, 0.0),
483 );
484 if !(sg.is_finite() && sg > 0.0) {
485 return None;
486 }
487 let length_calibers = self.inputs.bullet_length / diameter_m;
488
489 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
495 let crosswind_from_right_mph = self.wind.speed * self.wind.direction.sin() * MS_TO_MPH;
496
497 let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
498 sg,
499 length_calibers,
500 crosswind_from_right_mph,
501 self.inputs.is_twist_right,
502 );
503 if !vertical_jump_moa.is_finite() {
504 return None;
505 }
506
507 const MOA_PER_RAD: f64 = 3437.7467707849;
508 Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
509 vertical_jump_moa,
510 horizontal_jump_moa: 0.0,
512 jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
513 magnus_component_moa: 0.0,
514 yaw_component_moa: 0.0,
515 stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
516 })
517 }
518
519 fn resolved_atmosphere(&self) -> (f64, f64, f64, f64) {
520 let (temp_c, pressure_hpa) = crate::atmosphere::resolve_station_conditions(
521 self.atmosphere.temperature,
522 self.atmosphere.pressure,
523 self.atmosphere.altitude,
524 );
525 let (density, speed_of_sound) = crate::atmosphere::calculate_atmosphere(
526 self.atmosphere.altitude,
527 Some(temp_c),
528 Some(pressure_hpa),
529 self.atmosphere.humidity,
530 );
531 (density, speed_of_sound, temp_c, pressure_hpa)
532 }
533
534 fn gravity_acceleration(&self) -> Vector3<f64> {
535 let theta = self.inputs.shooting_angle;
536 Vector3::new(
537 -crate::constants::G_ACCEL_MPS2 * theta.sin(),
538 -crate::constants::G_ACCEL_MPS2 * theta.cos(),
539 0.0,
540 )
541 }
542
543 fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
544 let model = match self.inputs.wind_shear_model.as_str() {
559 "logarithmic" => WindShearModel::Logarithmic,
560 "power_law" | "powerlaw" | "exponential" => WindShearModel::PowerLaw,
561 "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
562 "custom_layers" | "custom" => WindShearModel::CustomLayers,
563 _ => WindShearModel::PowerLaw,
564 };
565 let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
566
567 Vector3::new(
570 -self.wind.speed * self.wind.direction.cos() * speed_ratio, 0.0,
572 -self.wind.speed * self.wind.direction.sin() * speed_ratio, )
574 }
575
576 pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
577 let mut result = if self.inputs.use_rk4 {
578 if self.inputs.use_adaptive_rk45 {
579 self.solve_rk45()?
580 } else {
581 self.solve_rk4()?
582 }
583 } else {
584 self.solve_euler()?
585 };
586 self.apply_spin_drift(&mut result);
587 Ok(result)
588 }
589
590 fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
596 if !self.inputs.use_enhanced_spin_drift {
597 return;
598 }
599 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 {
603 return;
604 }
605
606 let length_in = if self.inputs.bullet_length > 0.0 {
608 self.inputs.bullet_length / 0.0254
609 } else {
610 4.5 * d_in
611 };
612 let (_, _, temp_c, press_hpa) = self.resolved_atmosphere();
618 let temp_k = temp_c + 273.15; let density_correction = if press_hpa > 0.0 && temp_k > 0.0 {
620 (temp_k / 288.15) * (1013.25 / press_hpa)
621 } else {
622 1.0
623 };
624 let sg = crate::spin_drift::miller_stability(d_in, m_gr, twist_in, length_in)
625 * density_correction;
626 let sign = if self.inputs.is_twist_right {
627 1.0
628 } else {
629 -1.0
630 };
631
632 for p in result.points.iter_mut() {
633 if p.time <= 0.0 {
634 continue;
635 }
636 let sd_in = 1.25 * (sg + 1.2) * p.time.powf(1.83); p.position.z += sign * sd_in * 0.0254; }
639
640 if let Some(samples) = result.sampled_points.as_mut() {
644 for s in samples.iter_mut() {
645 if s.time_s <= 0.0 {
646 continue;
647 }
648 let sd_in = 1.25 * (sg + 1.2) * s.time_s.powf(1.83);
649 s.wind_drift_m += sign * sd_in * 0.0254;
650 }
651 }
652 }
653
654 fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
655 let mut time = 0.0;
657 let mut position = Vector3::new(
660 0.0,
661 self.inputs.muzzle_height, 0.0,
663 );
664 let aj_components = self.aerodynamic_jump_components();
670 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
671 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
672 let mut velocity = Vector3::new(
673 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
677
678 let mut points = Vec::new();
679 let mut max_height = position.y;
680 let mut min_pitch_damping = 1.0; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
686 let mut crossed_transonic = false;
687 let mut crossed_subsonic = false;
688
689 let mut angular_state = if self.inputs.enable_precession_nutation {
691 Some(AngularState {
692 pitch_angle: 0.001, yaw_angle: 0.001,
694 pitch_rate: 0.0,
695 yaw_rate: 0.0,
696 precession_angle: 0.0,
697 nutation_phase: 0.0,
698 })
699 } else {
700 None
701 };
702 let mut max_yaw_angle = 0.0;
703 let mut max_precession_angle = 0.0;
704
705 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) = self.resolved_atmosphere();
707
708 let wind_vector = Vector3::new(
712 -self.wind.speed * self.wind.direction.cos(), 0.0,
714 -self.wind.speed * self.wind.direction.sin(), );
716
717 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
720 self.inputs.bullet_model.as_deref().unwrap_or("default"),
721 );
722
723 while position.x < self.max_range
725 && position.y > self.inputs.ground_threshold
726 && time < 100.0
727 {
728 let velocity_magnitude = velocity.magnitude();
730 let kinetic_energy =
731 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
732
733 points.push(TrajectoryPoint {
734 time,
735 position: position,
736 velocity_magnitude,
737 kinetic_energy,
738 });
739
740 {
743 let mach_here = if speed_of_sound > 0.0 {
744 velocity_magnitude / speed_of_sound
745 } else {
746 0.0
747 };
748 if !crossed_transonic && mach_here < 1.2 {
749 crossed_transonic = true;
750 transonic_distances.push(position.x);
751 }
752 if !crossed_subsonic && mach_here < 1.0 {
753 crossed_subsonic = true;
754 transonic_distances.push(position.x);
755 }
756 }
757
758 #[cfg(debug_assertions)]
762 if points.len() == 1 || points.len() % 100 == 0 {
763 eprintln!("Trajectory point {}: time={:.3}s, downrange={:.2}m, vertical={:.2}m, lateral={:.2}m, vel={:.1}m/s",
764 points.len(), time, position.x, position.y, position.z, velocity_magnitude);
765 }
766
767 if position.y > max_height {
769 max_height = position.y;
770 }
771
772 if self.inputs.enable_pitch_damping {
774 let mach = velocity_magnitude / speed_of_sound;
775
776 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
778 transonic_mach = Some(mach);
779 }
780
781 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
783
784 if pitch_damping < min_pitch_damping {
786 min_pitch_damping = pitch_damping;
787 }
788 }
789
790 if self.inputs.enable_precession_nutation {
792 if let Some(ref mut state) = angular_state {
793 let velocity_magnitude = velocity.magnitude();
794 let mach = velocity_magnitude / speed_of_sound;
795
796 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
798 let velocity_fps = velocity_magnitude * 3.28084;
799 let twist_rate_ft = self.inputs.twist_rate / 12.0;
800 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
801 } else {
802 0.0
803 };
804
805 let params = PrecessionNutationParams {
807 mass_kg: self.inputs.bullet_mass,
808 caliber_m: self.inputs.bullet_diameter,
809 length_m: self.inputs.bullet_length,
810 spin_rate_rad_s,
811 spin_inertia: 6.94e-8, transverse_inertia: 9.13e-7, velocity_mps: velocity_magnitude,
814 air_density_kg_m3: air_density,
815 mach,
816 pitch_damping_coeff: -0.8,
817 nutation_damping_factor: 0.05,
818 };
819
820 *state = calculate_combined_angular_motion(
822 ¶ms,
823 state,
824 time,
825 self.time_step,
826 0.001, );
828
829 if state.yaw_angle.abs() > max_yaw_angle {
831 max_yaw_angle = state.yaw_angle.abs();
832 }
833 if state.precession_angle.abs() > max_precession_angle {
834 max_precession_angle = state.precession_angle.abs();
835 }
836 }
837 }
838
839 let acceleration =
846 self.calculate_acceleration(&position, &velocity, air_density,
847 &wind_vector,
848 (speed_of_sound, resolved_temp_c, resolved_press_hpa),
849 );
850
851 velocity += acceleration * self.time_step;
853 position += velocity * self.time_step;
854 time += self.time_step;
855 }
856
857 let last_point = points.last().ok_or("No trajectory points generated")?;
859
860 let sampled_points = if self.inputs.enable_trajectory_sampling {
862 let trajectory_data = TrajectoryData {
863 times: points.iter().map(|p| p.time).collect(),
864 positions: points.iter().map(|p| p.position).collect(),
865 velocities: points
866 .iter()
867 .map(|p| {
868 Vector3::new(0.0, 0.0, p.velocity_magnitude)
870 })
871 .collect(),
872 transonic_distances, };
874
875 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
880 let outputs = TrajectoryOutputs {
881 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
883 time_of_flight_s: last_point.time,
884 max_ord_dist_horiz_m: max_height,
885 sight_height_m: sight_position_m,
886 };
887
888 let samples = sample_trajectory(
890 &trajectory_data,
891 &outputs,
892 self.inputs.sample_interval,
893 self.inputs.bullet_mass,
894 );
895 Some(samples)
896 } else {
897 None
898 };
899
900 Ok(TrajectoryResult {
901 max_range: last_point.position.x, max_height,
903 time_of_flight: last_point.time,
904 impact_velocity: last_point.velocity_magnitude,
905 impact_energy: last_point.kinetic_energy,
906 points,
907 sampled_points,
908 min_pitch_damping: if self.inputs.enable_pitch_damping {
909 Some(min_pitch_damping)
910 } else {
911 None
912 },
913 transonic_mach,
914 angular_state,
915 max_yaw_angle: if self.inputs.enable_precession_nutation {
916 Some(max_yaw_angle)
917 } else {
918 None
919 },
920 max_precession_angle: if self.inputs.enable_precession_nutation {
921 Some(max_precession_angle)
922 } else {
923 None
924 },
925 aerodynamic_jump: aj_components,
926 })
927 }
928
929 fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
930 let mut time = 0.0;
932 let mut position = Vector3::new(
936 0.0,
937 self.inputs.muzzle_height, 0.0,
939 );
940
941 let aj_components = self.aerodynamic_jump_components();
947 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
948 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
949 let mut velocity = Vector3::new(
950 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
954
955 let mut points = Vec::new();
956 let mut max_height = position.y;
957 let mut min_pitch_damping = 1.0; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
963 let mut crossed_transonic = false;
964 let mut crossed_subsonic = false;
965
966 let mut angular_state = if self.inputs.enable_precession_nutation {
968 Some(AngularState {
969 pitch_angle: 0.001, yaw_angle: 0.001,
971 pitch_rate: 0.0,
972 yaw_rate: 0.0,
973 precession_angle: 0.0,
974 nutation_phase: 0.0,
975 })
976 } else {
977 None
978 };
979 let mut max_yaw_angle = 0.0;
980 let mut max_precession_angle = 0.0;
981
982 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) = self.resolved_atmosphere();
984
985 let wind_vector = Vector3::new(
989 -self.wind.speed * self.wind.direction.cos(), 0.0,
991 -self.wind.speed * self.wind.direction.sin(), );
993
994 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
997 self.inputs.bullet_model.as_deref().unwrap_or("default"),
998 );
999
1000 while position.x < self.max_range
1002 && position.y > self.inputs.ground_threshold
1003 && time < 100.0
1004 {
1005 let velocity_magnitude = velocity.magnitude();
1007 let kinetic_energy =
1008 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1009
1010 points.push(TrajectoryPoint {
1011 time,
1012 position: position,
1013 velocity_magnitude,
1014 kinetic_energy,
1015 });
1016
1017 {
1020 let mach_here = if speed_of_sound > 0.0 {
1021 velocity_magnitude / speed_of_sound
1022 } else {
1023 0.0
1024 };
1025 if !crossed_transonic && mach_here < 1.2 {
1026 crossed_transonic = true;
1027 transonic_distances.push(position.x);
1028 }
1029 if !crossed_subsonic && mach_here < 1.0 {
1030 crossed_subsonic = true;
1031 transonic_distances.push(position.x);
1032 }
1033 }
1034
1035 if position.y > max_height {
1036 max_height = position.y;
1037 }
1038
1039 if self.inputs.enable_pitch_damping {
1041 let mach = velocity_magnitude / speed_of_sound;
1042
1043 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1045 transonic_mach = Some(mach);
1046 }
1047
1048 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1050
1051 if pitch_damping < min_pitch_damping {
1053 min_pitch_damping = pitch_damping;
1054 }
1055 }
1056
1057 if self.inputs.enable_precession_nutation {
1059 if let Some(ref mut state) = angular_state {
1060 let velocity_magnitude = velocity.magnitude();
1061 let mach = velocity_magnitude / speed_of_sound;
1062
1063 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
1065 let velocity_fps = velocity_magnitude * 3.28084;
1066 let twist_rate_ft = self.inputs.twist_rate / 12.0;
1067 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
1068 } else {
1069 0.0
1070 };
1071
1072 let params = PrecessionNutationParams {
1074 mass_kg: self.inputs.bullet_mass,
1075 caliber_m: self.inputs.bullet_diameter,
1076 length_m: self.inputs.bullet_length,
1077 spin_rate_rad_s,
1078 spin_inertia: 6.94e-8, transverse_inertia: 9.13e-7, velocity_mps: velocity_magnitude,
1081 air_density_kg_m3: air_density,
1082 mach,
1083 pitch_damping_coeff: -0.8,
1084 nutation_damping_factor: 0.05,
1085 };
1086
1087 *state = calculate_combined_angular_motion(
1089 ¶ms,
1090 state,
1091 time,
1092 self.time_step,
1093 0.001, );
1095
1096 if state.yaw_angle.abs() > max_yaw_angle {
1098 max_yaw_angle = state.yaw_angle.abs();
1099 }
1100 if state.precession_angle.abs() > max_precession_angle {
1101 max_precession_angle = state.precession_angle.abs();
1102 }
1103 }
1104 }
1105
1106 let dt = self.time_step;
1108
1109 let acc1 = self.calculate_acceleration(&position, &velocity, air_density, &wind_vector, (speed_of_sound, resolved_temp_c, resolved_press_hpa));
1111
1112 let pos2 = position + velocity * (dt * 0.5);
1114 let vel2 = velocity + acc1 * (dt * 0.5);
1115 let acc2 = self.calculate_acceleration(&pos2, &vel2, air_density, &wind_vector, (speed_of_sound, resolved_temp_c, resolved_press_hpa));
1116
1117 let pos3 = position + vel2 * (dt * 0.5);
1119 let vel3 = velocity + acc2 * (dt * 0.5);
1120 let acc3 = self.calculate_acceleration(&pos3, &vel3, air_density, &wind_vector, (speed_of_sound, resolved_temp_c, resolved_press_hpa));
1121
1122 let pos4 = position + vel3 * dt;
1124 let vel4 = velocity + acc3 * dt;
1125 let acc4 = self.calculate_acceleration(&pos4, &vel4, air_density, &wind_vector, (speed_of_sound, resolved_temp_c, resolved_press_hpa));
1126
1127 position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
1129 velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
1130 time += dt;
1131 }
1132
1133 let last_point = points.last().ok_or("No trajectory points generated")?;
1135
1136 let sampled_points = if self.inputs.enable_trajectory_sampling {
1138 let trajectory_data = TrajectoryData {
1139 times: points.iter().map(|p| p.time).collect(),
1140 positions: points.iter().map(|p| p.position).collect(),
1141 velocities: points
1142 .iter()
1143 .map(|p| {
1144 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1146 })
1147 .collect(),
1148 transonic_distances, };
1150
1151 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1156 let outputs = TrajectoryOutputs {
1157 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
1159 time_of_flight_s: last_point.time,
1160 max_ord_dist_horiz_m: max_height,
1161 sight_height_m: sight_position_m,
1162 };
1163
1164 let samples = sample_trajectory(
1166 &trajectory_data,
1167 &outputs,
1168 self.inputs.sample_interval,
1169 self.inputs.bullet_mass,
1170 );
1171 Some(samples)
1172 } else {
1173 None
1174 };
1175
1176 Ok(TrajectoryResult {
1177 max_range: last_point.position.x, max_height,
1179 time_of_flight: last_point.time,
1180 impact_velocity: last_point.velocity_magnitude,
1181 impact_energy: last_point.kinetic_energy,
1182 points,
1183 sampled_points,
1184 min_pitch_damping: if self.inputs.enable_pitch_damping {
1185 Some(min_pitch_damping)
1186 } else {
1187 None
1188 },
1189 transonic_mach,
1190 angular_state,
1191 max_yaw_angle: if self.inputs.enable_precession_nutation {
1192 Some(max_yaw_angle)
1193 } else {
1194 None
1195 },
1196 max_precession_angle: if self.inputs.enable_precession_nutation {
1197 Some(max_precession_angle)
1198 } else {
1199 None
1200 },
1201 aerodynamic_jump: aj_components,
1202 })
1203 }
1204
1205 fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
1206 let mut time = 0.0;
1208 let mut position = Vector3::new(
1211 0.0,
1212 self.inputs.muzzle_height, 0.0,
1214 );
1215
1216 let aj_components = self.aerodynamic_jump_components();
1222 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1223 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1224 let mut velocity = Vector3::new(
1225 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1229
1230 let mut points = Vec::new();
1231 let mut max_height = position.y;
1232 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;
1240 const MAX_ITERATIONS: usize = 100000;
1241
1242 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) = self.resolved_atmosphere();
1245 let wind_vector = Vector3::new(
1248 -self.wind.speed * self.wind.direction.cos(), 0.0,
1250 -self.wind.speed * self.wind.direction.sin(), );
1252
1253 let mut transonic_distances: Vec<f64> = Vec::new();
1255 let mut crossed_transonic = false;
1256 let mut crossed_subsonic = false;
1257
1258 let mut min_pitch_damping = 1.0;
1263 let mut transonic_mach: Option<f64> = None;
1264 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1265 self.inputs.bullet_model.as_deref().unwrap_or("default"),
1266 );
1267 let mut angular_state = if self.inputs.enable_precession_nutation {
1268 Some(AngularState {
1269 pitch_angle: 0.001,
1270 yaw_angle: 0.001,
1271 pitch_rate: 0.0,
1272 yaw_rate: 0.0,
1273 precession_angle: 0.0,
1274 nutation_phase: 0.0,
1275 })
1276 } else {
1277 None
1278 };
1279 let mut max_yaw_angle = 0.0;
1280 let mut max_precession_angle = 0.0;
1281
1282 while position.x < self.max_range
1283 && position.y > self.inputs.ground_threshold
1284 && time < 100.0
1285 {
1286 iteration_count += 1;
1288 if iteration_count > MAX_ITERATIONS {
1289 break; }
1291
1292 let velocity_magnitude = velocity.magnitude();
1294 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
1295
1296 points.push(TrajectoryPoint {
1297 time,
1298 position: position,
1299 velocity_magnitude,
1300 kinetic_energy,
1301 });
1302
1303 {
1306 let mach_here = if speed_of_sound > 0.0 {
1307 velocity_magnitude / speed_of_sound
1308 } else {
1309 0.0
1310 };
1311 if !crossed_transonic && mach_here < 1.2 {
1312 crossed_transonic = true;
1313 transonic_distances.push(position.x);
1314 }
1315 if !crossed_subsonic && mach_here < 1.0 {
1316 crossed_subsonic = true;
1317 transonic_distances.push(position.x);
1318 }
1319 }
1320
1321 if position.y > max_height {
1322 max_height = position.y;
1323 }
1324
1325 if self.inputs.enable_pitch_damping {
1328 let mach = velocity_magnitude / speed_of_sound;
1329 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1330 transonic_mach = Some(mach);
1331 }
1332 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1333 if pitch_damping < min_pitch_damping {
1334 min_pitch_damping = pitch_damping;
1335 }
1336 }
1337
1338 if self.inputs.enable_precession_nutation {
1342 if let Some(ref mut state) = angular_state {
1343 let mach = velocity_magnitude / speed_of_sound;
1344
1345 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
1346 let velocity_fps = velocity_magnitude * 3.28084;
1347 let twist_rate_ft = self.inputs.twist_rate / 12.0;
1348 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
1349 } else {
1350 0.0
1351 };
1352
1353 let params = PrecessionNutationParams {
1354 mass_kg: self.inputs.bullet_mass,
1355 caliber_m: self.inputs.bullet_diameter,
1356 length_m: self.inputs.bullet_length,
1357 spin_rate_rad_s,
1358 spin_inertia: 6.94e-8,
1359 transverse_inertia: 9.13e-7,
1360 velocity_mps: velocity_magnitude,
1361 air_density_kg_m3: air_density,
1362 mach,
1363 pitch_damping_coeff: -0.8,
1364 nutation_damping_factor: 0.05,
1365 };
1366
1367 *state = calculate_combined_angular_motion(¶ms, state, time, dt, 0.001);
1368
1369 if state.yaw_angle.abs() > max_yaw_angle {
1370 max_yaw_angle = state.yaw_angle.abs();
1371 }
1372 if state.precession_angle.abs() > max_precession_angle {
1373 max_precession_angle = state.precession_angle.abs();
1374 }
1375 }
1376 }
1377
1378 let (new_pos, new_vel, new_dt) = self.rk45_step(
1380 &position,
1381 &velocity,
1382 dt,
1383 air_density,
1384 &wind_vector,
1385 tolerance,
1386 (speed_of_sound, resolved_temp_c, resolved_press_hpa),
1387 );
1388
1389 position = new_pos;
1394 velocity = new_vel;
1395 time += dt;
1396
1397 dt = (safety_factor * new_dt).clamp(min_dt, max_dt);
1399 }
1400
1401 if points.is_empty() {
1403 return Err(BallisticsError::from("No trajectory points calculated"));
1404 }
1405
1406 {
1415 let prev = points.last().unwrap().clone();
1416 let overshoot_x = position.x;
1417 let crossed_range = overshoot_x >= self.max_range && prev.position.x < self.max_range;
1418 if crossed_range {
1419 let span = overshoot_x - prev.position.x;
1420 if span > 1e-9 {
1421 let frac = (self.max_range - prev.position.x) / span;
1422 let interp_pos = prev.position + (position - prev.position) * frac;
1423 let interp_vel_mag = prev.velocity_magnitude
1424 + (velocity.magnitude() - prev.velocity_magnitude) * frac;
1425 let interp_time = prev.time + (time - prev.time) * frac;
1426 let interp_ke = 0.5 * self.inputs.bullet_mass * interp_vel_mag * interp_vel_mag;
1427 points.push(TrajectoryPoint {
1428 time: interp_time,
1429 position: interp_pos,
1430 velocity_magnitude: interp_vel_mag,
1431 kinetic_energy: interp_ke,
1432 });
1433 if interp_pos.y > max_height {
1434 max_height = interp_pos.y;
1435 }
1436 }
1437 }
1438 }
1439
1440 let last_point = points.last().unwrap();
1441
1442 let sampled_points = if self.inputs.enable_trajectory_sampling {
1444 let trajectory_data = TrajectoryData {
1446 times: points.iter().map(|p| p.time).collect(),
1447 positions: points.iter().map(|p| p.position).collect(),
1448 velocities: points
1449 .iter()
1450 .map(|p| {
1451 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1453 })
1454 .collect(),
1455 transonic_distances, };
1457
1458 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1463 let outputs = TrajectoryOutputs {
1464 target_distance_horiz_m: last_point.position.x,
1465 target_vertical_height_m: sight_position_m,
1466 time_of_flight_s: last_point.time,
1467 max_ord_dist_horiz_m: max_height,
1468 sight_height_m: sight_position_m,
1469 };
1470
1471 let samples = sample_trajectory(
1472 &trajectory_data,
1473 &outputs,
1474 self.inputs.sample_interval,
1475 self.inputs.bullet_mass,
1476 );
1477 Some(samples)
1478 } else {
1479 None
1480 };
1481
1482 Ok(TrajectoryResult {
1483 max_range: last_point.position.x, max_height,
1485 time_of_flight: last_point.time,
1486 impact_velocity: last_point.velocity_magnitude,
1487 impact_energy: last_point.kinetic_energy,
1488 points,
1489 sampled_points,
1490 min_pitch_damping: if self.inputs.enable_pitch_damping {
1491 Some(min_pitch_damping)
1492 } else {
1493 None
1494 },
1495 transonic_mach,
1496 angular_state,
1497 max_yaw_angle: if self.inputs.enable_precession_nutation {
1498 Some(max_yaw_angle)
1499 } else {
1500 None
1501 },
1502 max_precession_angle: if self.inputs.enable_precession_nutation {
1503 Some(max_precession_angle)
1504 } else {
1505 None
1506 },
1507 aerodynamic_jump: aj_components,
1508 })
1509 }
1510
1511 fn rk45_step(
1512 &self,
1513 position: &Vector3<f64>,
1514 velocity: &Vector3<f64>,
1515 dt: f64,
1516 air_density: f64,
1517 wind_vector: &Vector3<f64>,
1518 tolerance: f64,
1519 resolved_atmo: (f64, f64, f64), ) -> (Vector3<f64>, Vector3<f64>, f64) {
1521 const A21: f64 = 1.0 / 5.0;
1523 const A31: f64 = 3.0 / 40.0;
1524 const A32: f64 = 9.0 / 40.0;
1525 const A41: f64 = 44.0 / 45.0;
1526 const A42: f64 = -56.0 / 15.0;
1527 const A43: f64 = 32.0 / 9.0;
1528 const A51: f64 = 19372.0 / 6561.0;
1529 const A52: f64 = -25360.0 / 2187.0;
1530 const A53: f64 = 64448.0 / 6561.0;
1531 const A54: f64 = -212.0 / 729.0;
1532 const A61: f64 = 9017.0 / 3168.0;
1533 const A62: f64 = -355.0 / 33.0;
1534 const A63: f64 = 46732.0 / 5247.0;
1535 const A64: f64 = 49.0 / 176.0;
1536 const A65: f64 = -5103.0 / 18656.0;
1537 const A71: f64 = 35.0 / 384.0;
1538 const A73: f64 = 500.0 / 1113.0;
1539 const A74: f64 = 125.0 / 192.0;
1540 const A75: f64 = -2187.0 / 6784.0;
1541 const A76: f64 = 11.0 / 84.0;
1542
1543 const B1: f64 = 35.0 / 384.0;
1545 const B3: f64 = 500.0 / 1113.0;
1546 const B4: f64 = 125.0 / 192.0;
1547 const B5: f64 = -2187.0 / 6784.0;
1548 const B6: f64 = 11.0 / 84.0;
1549
1550 const B1_ERR: f64 = 5179.0 / 57600.0;
1552 const B3_ERR: f64 = 7571.0 / 16695.0;
1553 const B4_ERR: f64 = 393.0 / 640.0;
1554 const B5_ERR: f64 = -92097.0 / 339200.0;
1555 const B6_ERR: f64 = 187.0 / 2100.0;
1556 const B7_ERR: f64 = 1.0 / 40.0;
1557
1558 let k1_v = self.calculate_acceleration(position, velocity, air_density, wind_vector, resolved_atmo);
1560 let k1_p = *velocity;
1561
1562 let p2 = position + dt * A21 * k1_p;
1563 let v2 = velocity + dt * A21 * k1_v;
1564 let k2_v = self.calculate_acceleration(&p2, &v2, air_density, wind_vector, resolved_atmo);
1565 let k2_p = v2;
1566
1567 let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
1568 let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
1569 let k3_v = self.calculate_acceleration(&p3, &v3, air_density, wind_vector, resolved_atmo);
1570 let k3_p = v3;
1571
1572 let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
1573 let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
1574 let k4_v = self.calculate_acceleration(&p4, &v4, air_density, wind_vector, resolved_atmo);
1575 let k4_p = v4;
1576
1577 let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
1578 let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
1579 let k5_v = self.calculate_acceleration(&p5, &v5, air_density, wind_vector, resolved_atmo);
1580 let k5_p = v5;
1581
1582 let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
1583 let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
1584 let k6_v = self.calculate_acceleration(&p6, &v6, air_density, wind_vector, resolved_atmo);
1585 let k6_p = v6;
1586
1587 let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
1588 let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
1589 let k7_v = self.calculate_acceleration(&p7, &v7, air_density, wind_vector, resolved_atmo);
1590 let k7_p = v7;
1591
1592 let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
1594 let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
1595
1596 let pos_err = position
1598 + dt * (B1_ERR * k1_p
1599 + B3_ERR * k3_p
1600 + B4_ERR * k4_p
1601 + B5_ERR * k5_p
1602 + B6_ERR * k6_p
1603 + B7_ERR * k7_p);
1604 let vel_err = velocity
1605 + dt * (B1_ERR * k1_v
1606 + B3_ERR * k3_v
1607 + B4_ERR * k4_v
1608 + B5_ERR * k5_v
1609 + B6_ERR * k6_v
1610 + B7_ERR * k7_v);
1611
1612 let pos_error = (new_pos - pos_err).magnitude();
1614 let vel_error = (new_vel - vel_err).magnitude();
1615 let error = (pos_error + vel_error) / (1.0 + position.magnitude() + velocity.magnitude());
1616
1617 let dt_new = if error < tolerance {
1619 dt * (tolerance / error).powf(0.2).min(2.0)
1620 } else {
1621 dt * (tolerance / error).powf(0.25).max(0.1)
1622 };
1623
1624 (new_pos, new_vel, dt_new)
1625 }
1626
1627 fn calculate_acceleration(
1628 &self,
1629 position: &Vector3<f64>,
1630 velocity: &Vector3<f64>,
1631 air_density: f64,
1632 wind_vector: &Vector3<f64>,
1633 resolved_atmo: (f64, f64, f64), ) -> Vector3<f64> {
1635 let actual_wind = if let Some(ref sock) = self.wind_sock {
1641 sock.vector_for_range_stateless(position.x)
1642 } else if self.inputs.enable_wind_shear {
1643 self.get_wind_at_altitude(position.y)
1644 } else {
1645 *wind_vector
1646 };
1647
1648 let relative_velocity = velocity - actual_wind;
1649 let velocity_magnitude = relative_velocity.magnitude();
1650
1651 if velocity_magnitude < 0.001 {
1652 return self.gravity_acceleration();
1653 }
1654
1655 let cd = self.calculate_drag_coefficient(velocity_magnitude, resolved_atmo.0);
1657
1658 let velocity_fps = velocity_magnitude * 3.28084;
1660
1661 let base_bc = if let Some(ref segments) = self.inputs.bc_segments_data {
1663 segments
1665 .iter()
1666 .find(|seg| velocity_fps >= seg.velocity_min && velocity_fps < seg.velocity_max)
1667 .map(|seg| seg.bc_value)
1668 .unwrap_or(self.inputs.bc_value)
1669 } else {
1670 self.inputs.bc_value
1671 };
1672
1673 let effective_bc = if let Some(ref cluster_bc) = self.cluster_bc {
1675 cluster_bc.apply_correction(
1676 base_bc,
1677 self.inputs.caliber_inches, self.inputs.weight_grains,
1679 velocity_fps,
1680 )
1681 } else {
1682 base_bc
1683 };
1684 let effective_bc = effective_bc.max(1e-6);
1688
1689 let cd_to_retard = crate::constants::CD_TO_RETARD;
1694 let standard_factor = cd * cd_to_retard;
1695 let density_scale = air_density / 1.225; let a_drag_ft_s2 =
1699 (velocity_fps * velocity_fps) * standard_factor * density_scale / effective_bc;
1700 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
1704
1705 let mut accel = drag_acceleration + self.gravity_acceleration();
1708
1709 if self.inputs.enable_coriolis {
1712 if let Some(lat_deg) = self.inputs.latitude {
1713 let omega_earth = 7.2921159e-5_f64; let lat = lat_deg.to_radians();
1715 let az = self.inputs.shot_azimuth; let omega = Vector3::new(
1722 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), );
1726 accel += -2.0 * omega.cross(velocity);
1731 }
1732 }
1733
1734 if self.inputs.enable_magnus
1736 && self.inputs.bullet_diameter > 0.0
1737 && self.inputs.twist_rate > 0.0
1738 {
1739 let (_, spin_rad_s) =
1740 crate::spin_drift::calculate_spin_rate(velocity_magnitude, self.inputs.twist_rate);
1741 let (speed_of_sound, temp_c, press_hpa) = resolved_atmo;
1742 let temp_k = temp_c + 273.15;
1743 let mach = velocity_magnitude / speed_of_sound;
1744
1745 let d_in = self.inputs.bullet_diameter / 0.0254;
1747 let m_gr = self.inputs.bullet_mass / 0.00006479891;
1748 let l_in = if self.inputs.bullet_length > 0.0 {
1749 self.inputs.bullet_length / 0.0254
1750 } else {
1751 4.5 * d_in
1752 };
1753 let density_correction = if press_hpa > 0.0 && temp_k > 0.0 {
1757 (temp_k / 288.15) * (1013.25 / press_hpa)
1758 } else {
1759 1.0
1760 };
1761 let sg = crate::spin_drift::miller_stability(d_in, m_gr, self.inputs.twist_rate, l_in)
1762 * density_correction;
1763
1764 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
1766 sg,
1767 velocity_magnitude,
1768 spin_rad_s,
1769 0.0, 0.0, air_density,
1772 d_in,
1773 l_in,
1774 m_gr,
1775 mach,
1776 "match",
1777 false,
1778 );
1779
1780 let diameter_m = self.inputs.bullet_diameter; let spin_param = spin_rad_s * diameter_m / (2.0 * velocity_magnitude);
1783 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
1784 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
1785 let magnus_force = 0.5
1786 * air_density
1787 * velocity_magnitude.powi(2)
1788 * area
1789 * c_np
1790 * spin_param
1791 * yaw_rad.sin();
1792
1793 let velocity_unit = relative_velocity / velocity_magnitude;
1796 let up = Vector3::new(0.0, 1.0, 0.0);
1797 let mut dir = velocity_unit.cross(&up);
1798 let dir_norm = dir.norm();
1799 if dir_norm > 1e-12 && magnus_force.abs() > 1e-12 {
1800 dir /= dir_norm;
1801 if !self.inputs.is_twist_right {
1802 dir = -dir;
1803 }
1804 accel += (magnus_force / self.inputs.bullet_mass) * dir;
1805 }
1806 }
1807
1808 accel
1809 }
1810
1811 fn calculate_drag_coefficient(&self, velocity: f64, speed_of_sound: f64) -> f64 {
1812 let mach = velocity / speed_of_sound;
1813
1814 if let Some(ref table) = self.inputs.custom_drag_table {
1818 return table.interpolate(mach);
1819 }
1820
1821 let base_cd = crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type);
1823
1824 crate::form_factor::apply_form_factor_to_drag(
1829 base_cd,
1830 self.inputs.bullet_model.as_deref(),
1831 &self.inputs.bc_type,
1832 self.inputs.use_form_factor,
1833 )
1834 }
1835}
1836
1837#[derive(Debug, Clone)]
1839pub struct MonteCarloParams {
1840 pub num_simulations: usize,
1841 pub velocity_std_dev: f64,
1842 pub angle_std_dev: f64,
1843 pub bc_std_dev: f64,
1844 pub wind_speed_std_dev: f64,
1845 pub target_distance: Option<f64>,
1846 pub base_wind_speed: f64,
1847 pub base_wind_direction: f64,
1848 pub azimuth_std_dev: f64, }
1850
1851impl Default for MonteCarloParams {
1852 fn default() -> Self {
1853 Self {
1854 num_simulations: 1000,
1855 velocity_std_dev: 1.0,
1856 angle_std_dev: 0.001,
1857 bc_std_dev: 0.01,
1858 wind_speed_std_dev: 1.0,
1859 target_distance: None,
1860 base_wind_speed: 0.0,
1861 base_wind_direction: 0.0,
1862 azimuth_std_dev: 0.001, }
1864 }
1865}
1866
1867#[derive(Debug, Clone)]
1869pub struct MonteCarloResults {
1870 pub ranges: Vec<f64>,
1871 pub impact_velocities: Vec<f64>,
1872 pub impact_positions: Vec<Vector3<f64>>,
1873}
1874
1875pub const DEFAULT_HIT_RADIUS_M: f64 = 0.3;
1878
1879impl MonteCarloResults {
1880 pub fn hit_probability(&self, hit_radius_m: f64) -> f64 {
1889 if self.impact_positions.is_empty() {
1890 return 0.0;
1891 }
1892 let hits = self
1893 .impact_positions
1894 .iter()
1895 .filter(|p| p.norm() < hit_radius_m)
1896 .count();
1897 hits as f64 / self.impact_positions.len() as f64
1898 }
1899}
1900
1901pub fn run_monte_carlo(
1903 base_inputs: BallisticInputs,
1904 params: MonteCarloParams,
1905) -> Result<MonteCarloResults, BallisticsError> {
1906 let base_wind = WindConditions {
1907 speed: params.base_wind_speed,
1908 direction: params.base_wind_direction,
1909 };
1910 run_monte_carlo_with_wind(base_inputs, base_wind, params)
1911}
1912
1913pub fn run_monte_carlo_with_wind(
1915 base_inputs: BallisticInputs,
1916 base_wind: WindConditions,
1917 params: MonteCarloParams,
1918) -> Result<MonteCarloResults, BallisticsError> {
1919 use rand_distr::{Distribution, Normal};
1920
1921 let mut rng = rand::rng();
1922 let mut ranges = Vec::new();
1923 let mut impact_velocities = Vec::new();
1924 let mut impact_positions = Vec::new();
1925
1926 let atmosphere = AtmosphericConditions {
1927 temperature: base_inputs.temperature,
1928 pressure: base_inputs.pressure,
1929 humidity: base_inputs.humidity_percent(),
1930 altitude: base_inputs.altitude,
1931 };
1932 let target_hint = params
1933 .target_distance
1934 .unwrap_or(base_inputs.target_distance);
1935 let solver_max_range = target_hint.max(1000.0) * 2.0;
1936
1937 let mut baseline_solver =
1939 TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), atmosphere.clone());
1940 baseline_solver.set_max_range(solver_max_range);
1941 let baseline_result = baseline_solver.solve()?;
1942
1943 let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
1945
1946 let baseline_at_target = baseline_result
1948 .position_at_range(target_distance)
1949 .ok_or("Could not interpolate baseline at target distance")?;
1950
1951 let velocity_dist = Normal::new(base_inputs.muzzle_velocity, params.velocity_std_dev)
1953 .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
1954 let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
1955 .map_err(|e| format!("Invalid angle distribution: {}", e))?;
1956 let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
1957 .map_err(|e| format!("Invalid BC distribution: {}", e))?;
1958 let wind_speed_dist = Normal::new(base_wind.speed, params.wind_speed_std_dev)
1959 .map_err(|e| format!("Invalid wind speed distribution: {}", e))?;
1960 let wind_dir_dist = Normal::new(base_wind.direction, params.wind_speed_std_dev * 0.1)
1965 .map_err(|e| format!("Invalid wind direction distribution: {}", e))?;
1966 let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
1967 .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
1968
1969 for _ in 0..params.num_simulations {
1970 let mut inputs = base_inputs.clone();
1972 inputs.muzzle_velocity = velocity_dist.sample(&mut rng).max(0.0);
1973 inputs.muzzle_angle = angle_dist.sample(&mut rng);
1974 inputs.bc_value = bc_dist.sample(&mut rng).max(0.01);
1975 inputs.azimuth_angle = azimuth_dist.sample(&mut rng); let wind = WindConditions {
1979 speed: wind_speed_dist.sample(&mut rng).abs(),
1980 direction: wind_dir_dist.sample(&mut rng),
1981 };
1982
1983 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere.clone());
1985 solver.set_max_range(solver_max_range);
1986 match solver.solve() {
1987 Ok(result) => {
1988 let deviation = if result.max_range < target_distance {
1994 Vector3::new(0.0, -1.0e9, 0.0)
1997 } else {
1998 let pos_at_target = match result.position_at_range(target_distance) {
1999 Some(p) => p,
2000 None => continue, };
2002 Vector3::new(
2007 0.0,
2008 pos_at_target.y - baseline_at_target.y,
2009 pos_at_target.z - baseline_at_target.z,
2010 )
2011 };
2012
2013 ranges.push(result.max_range);
2014 impact_velocities.push(result.impact_velocity);
2015 impact_positions.push(deviation);
2016 }
2017 Err(_) => {
2018 continue;
2020 }
2021 }
2022 }
2023
2024 if ranges.is_empty() {
2025 return Err("No successful simulations".into());
2026 }
2027
2028 Ok(MonteCarloResults {
2029 ranges,
2030 impact_velocities,
2031 impact_positions,
2032 })
2033}
2034
2035pub fn calculate_zero_angle(
2037 inputs: BallisticInputs,
2038 target_distance: f64,
2039 target_height: f64,
2040) -> Result<f64, BallisticsError> {
2041 calculate_zero_angle_with_conditions(
2042 inputs,
2043 target_distance,
2044 target_height,
2045 WindConditions::default(),
2046 AtmosphericConditions::default(),
2047 )
2048}
2049
2050pub fn calculate_zero_angle_with_conditions(
2051 inputs: BallisticInputs,
2052 target_distance: f64,
2053 target_height: f64,
2054 wind: WindConditions,
2055 atmosphere: AtmosphericConditions,
2056) -> Result<f64, BallisticsError> {
2057 let get_height_at_angle = |angle: f64| -> Result<Option<f64>, BallisticsError> {
2059 let mut test_inputs = inputs.clone();
2060 test_inputs.muzzle_angle = angle;
2061 test_inputs.enable_aerodynamic_jump = false;
2066
2067 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
2068 solver.set_max_range(target_distance * 2.0);
2069 solver.set_time_step(0.001);
2070 let result = solver.solve()?;
2071
2072 for i in 0..result.points.len() {
2074 if result.points[i].position.x >= target_distance {
2075 if i > 0 {
2076 let p1 = &result.points[i - 1];
2077 let p2 = &result.points[i];
2078 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
2079 return Ok(Some(p1.position.y + t * (p2.position.y - p1.position.y)));
2080 } else {
2081 return Ok(Some(result.points[i].position.y));
2082 }
2083 }
2084 }
2085 Ok(None)
2086 };
2087
2088 let mut low_angle = 0.0; let mut high_angle = 0.2; let tolerance = 0.00001; let max_iterations = 50;
2094
2095 let low_height = get_height_at_angle(low_angle)?;
2098 let high_height = get_height_at_angle(high_angle)?;
2099
2100 match (low_height, high_height) {
2101 (Some(lh), Some(hh)) => {
2102 let low_error = lh - target_height;
2103 let high_error = hh - target_height;
2104
2105 if low_error > 0.0 && high_error > 0.0 {
2108 } else if low_error < 0.0 && high_error < 0.0 {
2113 let mut expanded = false;
2116 for multiplier in [2.0, 3.0, 4.0] {
2117 let new_high = (high_angle * multiplier).min(0.785);
2118 if let Ok(Some(h)) = get_height_at_angle(new_high) {
2119 if h - target_height > 0.0 {
2120 high_angle = new_high;
2121 expanded = true;
2122 break;
2123 }
2124 }
2125 if new_high >= 0.785 {
2126 break;
2127 }
2128 }
2129 if !expanded {
2130 return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
2131 }
2132 }
2133 }
2135 (None, Some(_hh)) => {
2136 }
2139 (Some(_lh), None) => {
2140 return Err(
2142 "Cannot find zero angle: high angle trajectory doesn't reach target distance"
2143 .into(),
2144 );
2145 }
2146 (None, None) => {
2147 return Err(
2149 "Cannot find zero angle: trajectory cannot reach target distance at any angle"
2150 .into(),
2151 );
2152 }
2153 }
2154
2155 for _iteration in 0..max_iterations {
2156 let mid_angle = (low_angle + high_angle) / 2.0;
2157
2158 let mut test_inputs = inputs.clone();
2159 test_inputs.muzzle_angle = mid_angle;
2160 test_inputs.enable_aerodynamic_jump = false;
2162
2163 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
2164 solver.set_max_range(target_distance * 2.0);
2166 solver.set_time_step(0.001);
2167 let result = solver.solve()?;
2168
2169 let mut height_at_target = None;
2171 for i in 0..result.points.len() {
2172 if result.points[i].position.x >= target_distance {
2173 if i > 0 {
2174 let p1 = &result.points[i - 1];
2176 let p2 = &result.points[i];
2177 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
2178 height_at_target = Some(p1.position.y + t * (p2.position.y - p1.position.y));
2179 } else {
2180 height_at_target = Some(result.points[i].position.y);
2181 }
2182 break;
2183 }
2184 }
2185
2186 match height_at_target {
2187 Some(height) => {
2188 let error = height - target_height;
2189 if error.abs() < 0.001 {
2192 return Ok(mid_angle);
2193 }
2194
2195 if (high_angle - low_angle).abs() < tolerance {
2199 if error.abs() < 0.01 {
2200 return Ok(mid_angle);
2202 }
2203 return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
2208 }
2209
2210 if error > 0.0 {
2211 high_angle = mid_angle;
2212 } else {
2213 low_angle = mid_angle;
2214 }
2215 }
2216 None => {
2217 low_angle = mid_angle;
2219
2220 if (high_angle - low_angle).abs() < tolerance {
2222 return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
2223 }
2224 }
2225 }
2226 }
2227
2228 Err("Failed to find zero angle".into())
2229}
2230
2231pub fn estimate_bc_from_trajectory(
2233 velocity: f64,
2234 mass: f64,
2235 diameter: f64,
2236 points: &[(f64, f64)], ) -> Result<f64, BallisticsError> {
2238 let mut best_bc = 0.5;
2240 let mut best_error = f64::MAX;
2241 let mut found_valid = false;
2242
2243 for bc in (100..1000).step_by(10) {
2245 let bc_value = bc as f64 / 1000.0;
2246
2247 let inputs = BallisticInputs {
2248 muzzle_velocity: velocity,
2249 bc_value,
2250 bullet_mass: mass,
2251 bullet_diameter: diameter,
2252 ..Default::default()
2253 };
2254
2255 let mut solver = TrajectorySolver::new(inputs, Default::default(), Default::default());
2256 solver.set_max_range(points.last().map(|(d, _)| *d * 1.5).unwrap_or(1000.0));
2258
2259 let result = match solver.solve() {
2260 Ok(r) => r,
2261 Err(_) => continue, };
2263
2264 let mut total_error = 0.0;
2266 for (target_dist, target_drop) in points {
2267 let mut calculated_drop = None;
2269 for i in 0..result.points.len() {
2270 if result.points[i].position.x >= *target_dist {
2271 if i > 0 {
2272 let p1 = &result.points[i - 1];
2274 let p2 = &result.points[i];
2275 let t = (target_dist - p1.position.x) / (p2.position.x - p1.position.x);
2276 calculated_drop =
2277 Some(-(p1.position.y + t * (p2.position.y - p1.position.y)));
2278 } else {
2279 calculated_drop = Some(-result.points[i].position.y);
2280 }
2281 break;
2282 }
2283 }
2284
2285 if let Some(drop) = calculated_drop {
2286 let error = (drop - target_drop).abs();
2287 total_error += error * error;
2288 }
2289 }
2290
2291 if total_error < best_error {
2292 best_error = total_error;
2293 best_bc = bc_value;
2294 found_valid = true;
2295 }
2296 }
2297
2298 if !found_valid {
2299 return Err(BallisticsError::from("Unable to estimate BC from provided data. Check that drop values are in correct units.".to_string()));
2300 }
2301
2302 Ok(best_bc)
2303}
2304
2305use rand;
2307use rand_distr;
2308
2309#[cfg(test)]
2310mod ground_termination_tests {
2311 use super::*;
2312
2313 #[test]
2318 fn rk4_and_rk45_descend_to_ground_threshold() {
2319 for adaptive in [false, true] {
2320 let mut inputs = BallisticInputs::default();
2321 inputs.muzzle_angle = 0.1; inputs.use_rk4 = true;
2323 inputs.use_adaptive_rk45 = adaptive;
2324 assert_eq!(
2325 inputs.ground_threshold, -100.0,
2326 "default ground_threshold is -100 m"
2327 );
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}