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 powder_temp_curve: Option<Vec<(f64, f64)>>,
130 pub powder_curve_temp_c: Option<f64>,
134 pub tipoff_yaw: f64, pub tipoff_decay_distance: f64, pub use_bc_segments: bool,
137 pub bc_segments: Option<Vec<(f64, f64)>>, pub bc_segments_data: Option<Vec<crate::BCSegmentData>>, pub use_enhanced_spin_drift: bool,
140 pub use_form_factor: bool,
141 pub enable_wind_shear: bool,
142 pub wind_shear_model: String,
143 pub enable_trajectory_sampling: bool,
144 pub sample_interval: f64, pub enable_pitch_damping: bool,
146 pub enable_precession_nutation: bool,
147 pub enable_aerodynamic_jump: bool,
150 pub use_cluster_bc: bool, pub custom_drag_table: Option<crate::drag::DragTable>,
154
155 pub bc_type_str: Option<String>,
157}
158
159impl BallisticInputs {
160 pub fn humidity_percent(&self) -> f64 {
165 (self.humidity * 100.0).clamp(0.0, 100.0)
166 }
167}
168
169impl Default for BallisticInputs {
170 fn default() -> Self {
171 let mass_kg = 0.01;
172 let diameter_m = 0.00762;
173 let bc = 0.5;
174 let muzzle_angle_rad = 0.0;
175 let bc_type = DragModel::G1;
176
177 Self {
178 bc_value: bc,
180 bc_type,
181 bullet_mass: mass_kg,
182 muzzle_velocity: 800.0,
183 bullet_diameter: diameter_m,
184 bullet_length: diameter_m * 4.5, muzzle_angle: muzzle_angle_rad,
188 target_distance: 100.0,
189 azimuth_angle: 0.0,
190 shot_azimuth: 0.0,
191 shooting_angle: 0.0,
192 sight_height: 0.05,
193 muzzle_height: 0.0, target_height: 0.0, ground_threshold: -100.0, altitude: 0.0,
199 temperature: 15.0,
200 pressure: 1013.25, humidity: 0.5, latitude: None,
203
204 wind_speed: 0.0,
206 wind_angle: 0.0,
207
208 twist_rate: 12.0, is_twist_right: true,
211 caliber_inches: diameter_m / 0.0254, weight_grains: mass_kg / 0.00006479891, manufacturer: None,
214 bullet_model: None,
215 bullet_id: None,
216 bullet_cluster: None,
217
218 use_rk4: true, use_adaptive_rk45: true, enable_advanced_effects: false,
224 enable_magnus: false,
225 enable_coriolis: false,
226 use_powder_sensitivity: false,
227 powder_temp_sensitivity: 0.0,
228 powder_temp: 15.0,
229 powder_temp_curve: None,
230 powder_curve_temp_c: None,
231 tipoff_yaw: 0.0,
232 tipoff_decay_distance: 50.0,
233 use_bc_segments: false,
234 bc_segments: None,
235 bc_segments_data: None,
236 use_enhanced_spin_drift: false,
237 use_form_factor: false,
238 enable_wind_shear: false,
239 wind_shear_model: "none".to_string(),
240 enable_trajectory_sampling: false,
241 sample_interval: 10.0, enable_pitch_damping: false,
243 enable_precession_nutation: false,
244 enable_aerodynamic_jump: false,
245 use_cluster_bc: false, custom_drag_table: None,
249
250 bc_type_str: None,
252 }
253 }
254}
255
256pub fn interpolate_powder_temp_curve(curve: &[(f64, f64)], temp_c: f64) -> f64 {
262 debug_assert!(!curve.is_empty());
263 if curve.is_empty() {
264 return 0.0;
265 }
266 let mut sorted;
269 let pts: &[(f64, f64)] = if curve.windows(2).all(|w| w[0].0 <= w[1].0) {
270 curve
271 } else {
272 sorted = curve.to_vec();
273 sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
274 &sorted
275 };
276 let n = pts.len();
277 if temp_c <= pts[0].0 {
278 return pts[0].1; }
280 if temp_c >= pts[n - 1].0 {
281 return pts[n - 1].1; }
283 for i in 1..n {
284 let (t0, v0) = pts[i - 1];
285 let (t1, v1) = pts[i];
286 if temp_c <= t1 {
287 let span = t1 - t0;
288 if span.abs() < f64::EPSILON {
289 return v1; }
291 let f = (temp_c - t0) / span;
292 return v0 + f * (v1 - v0);
293 }
294 }
295 pts[n - 1].1
296}
297
298#[derive(Debug, Clone)]
300pub struct WindConditions {
301 pub speed: f64, pub direction: f64,
305}
306
307impl Default for WindConditions {
308 fn default() -> Self {
309 Self {
310 speed: 0.0,
311 direction: 0.0,
312 }
313 }
314}
315
316#[derive(Debug, Clone)]
318pub struct AtmosphericConditions {
319 pub temperature: f64, pub pressure: f64, pub humidity: f64,
325 pub altitude: f64, }
327
328impl Default for AtmosphericConditions {
329 fn default() -> Self {
330 Self {
331 temperature: 15.0,
332 pressure: 1013.25,
333 humidity: 50.0,
334 altitude: 0.0,
335 }
336 }
337}
338
339#[derive(Debug, Clone)]
341pub struct TrajectoryPoint {
342 pub time: f64,
343 pub position: Vector3<f64>,
344 pub velocity_magnitude: f64,
345 pub kinetic_energy: f64,
346}
347
348#[derive(Debug, Clone)]
350pub struct TrajectoryResult {
351 pub max_range: f64,
352 pub max_height: f64,
353 pub time_of_flight: f64,
354 pub impact_velocity: f64,
355 pub impact_energy: f64,
356 pub points: Vec<TrajectoryPoint>,
357 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>,
366}
367
368impl TrajectoryResult {
369 pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
373 if self.points.is_empty() {
374 return None;
375 }
376
377 for i in 0..self.points.len() - 1 {
379 let p1 = &self.points[i];
380 let p2 = &self.points[i + 1];
381
382 if p1.position.x <= target_range && p2.position.x >= target_range {
384 let dx = p2.position.x - p1.position.x;
386 if dx.abs() < 1e-10 {
387 return Some(p1.position);
388 }
389 let t = (target_range - p1.position.x) / dx;
390
391 return Some(Vector3::new(
393 target_range,
394 p1.position.y + t * (p2.position.y - p1.position.y),
395 p1.position.z + t * (p2.position.z - p1.position.z),
396 ));
397 }
398 }
399
400 self.points.last().map(|p| p.position)
402 }
403}
404
405pub struct TrajectorySolver {
407 inputs: BallisticInputs,
408 wind: WindConditions,
409 atmosphere: AtmosphericConditions,
410 max_range: f64,
411 time_step: f64,
412 cluster_bc: Option<ClusterBCDegradation>,
413 wind_sock: Option<crate::wind::WindSock>,
418}
419
420impl TrajectorySolver {
421 pub fn new(
422 mut inputs: BallisticInputs,
423 wind: WindConditions,
424 atmosphere: AtmosphericConditions,
425 ) -> Self {
426 inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
428 inputs.weight_grains = inputs.bullet_mass / 0.00006479891;
429
430 if let Some(curve) = inputs.powder_temp_curve.as_ref() {
439 if !curve.is_empty() {
440 let lookup_c = inputs.powder_curve_temp_c.unwrap_or(inputs.temperature);
445 inputs.muzzle_velocity = interpolate_powder_temp_curve(curve, lookup_c);
446 }
447 } else if inputs.use_powder_sensitivity {
448 let temp_delta_c = inputs.temperature - inputs.powder_temp;
449 inputs.muzzle_velocity += inputs.powder_temp_sensitivity * temp_delta_c;
450 }
451
452 let cluster_bc = if inputs.use_cluster_bc {
454 Some(ClusterBCDegradation::new())
455 } else {
456 None
457 };
458
459 Self {
460 inputs,
461 wind,
462 atmosphere,
463 max_range: 1000.0,
464 time_step: 0.001,
465 cluster_bc,
466 wind_sock: None,
467 }
468 }
469
470 pub fn set_max_range(&mut self, range: f64) {
471 self.max_range = range;
472 }
473
474 pub fn set_time_step(&mut self, step: f64) {
475 self.time_step = step;
476 }
477
478 pub fn set_wind_segments(&mut self, segments: Vec<crate::wind::WindSegment>) {
485 self.wind_sock = if segments.is_empty() {
486 None
487 } else {
488 Some(crate::wind::WindSock::new(segments))
489 };
490 }
491
492 fn launch_angles_from(
501 &self,
502 aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
503 ) -> (f64, f64) {
504 let elev = self.inputs.muzzle_angle;
505 let azim = self.inputs.azimuth_angle;
506 match aj {
507 Some(c) => {
508 const MOA_PER_RAD: f64 = 3437.7467707849;
510 (
511 elev + c.vertical_jump_moa / MOA_PER_RAD,
512 azim + c.horizontal_jump_moa / MOA_PER_RAD,
513 )
514 }
515 None => (elev, azim),
516 }
517 }
518
519 fn aerodynamic_jump_components(
527 &self,
528 ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
529 if !self.inputs.enable_aerodynamic_jump {
530 return None;
531 }
532 let diameter_m = self.inputs.bullet_diameter;
536 if !(self.inputs.twist_rate.is_finite() && self.inputs.twist_rate != 0.0)
537 || !(diameter_m.is_finite() && diameter_m > 0.0)
538 || !(self.inputs.bullet_length.is_finite() && self.inputs.bullet_length > 0.0)
539 || !self.inputs.muzzle_velocity.is_finite()
540 {
541 return None;
542 }
543
544 let (_, _, temp_c, pressure_hpa) = self.resolved_atmosphere();
546 let sg = crate::stability::compute_stability_coefficient(
547 &self.inputs,
548 (self.atmosphere.altitude, temp_c, pressure_hpa, 0.0),
549 );
550 if !(sg.is_finite() && sg > 0.0) {
551 return None;
552 }
553 let length_calibers = self.inputs.bullet_length / diameter_m;
554
555 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
561 let crosswind_from_right_mph = self.wind.speed * self.wind.direction.sin() * MS_TO_MPH;
562
563 let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
564 sg,
565 length_calibers,
566 crosswind_from_right_mph,
567 self.inputs.is_twist_right,
568 );
569 if !vertical_jump_moa.is_finite() {
570 return None;
571 }
572
573 const MOA_PER_RAD: f64 = 3437.7467707849;
574 Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
575 vertical_jump_moa,
576 horizontal_jump_moa: 0.0,
578 jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
579 magnus_component_moa: 0.0,
580 yaw_component_moa: 0.0,
581 stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
582 })
583 }
584
585 fn resolved_atmosphere(&self) -> (f64, f64, f64, f64) {
586 let (temp_c, pressure_hpa) = crate::atmosphere::resolve_station_conditions(
587 self.atmosphere.temperature,
588 self.atmosphere.pressure,
589 self.atmosphere.altitude,
590 );
591 let (density, speed_of_sound) = crate::atmosphere::calculate_atmosphere(
592 self.atmosphere.altitude,
593 Some(temp_c),
594 Some(pressure_hpa),
595 self.atmosphere.humidity,
596 );
597 (density, speed_of_sound, temp_c, pressure_hpa)
598 }
599
600 fn gravity_acceleration(&self) -> Vector3<f64> {
601 let theta = self.inputs.shooting_angle;
602 Vector3::new(
603 -crate::constants::G_ACCEL_MPS2 * theta.sin(),
604 -crate::constants::G_ACCEL_MPS2 * theta.cos(),
605 0.0,
606 )
607 }
608
609 fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
610 let model = match self.inputs.wind_shear_model.as_str() {
625 "logarithmic" => WindShearModel::Logarithmic,
626 "power_law" | "powerlaw" | "exponential" => WindShearModel::PowerLaw,
627 "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
628 "custom_layers" | "custom" => WindShearModel::CustomLayers,
629 _ => WindShearModel::PowerLaw,
630 };
631 let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
632
633 Vector3::new(
636 -self.wind.speed * self.wind.direction.cos() * speed_ratio, 0.0,
638 -self.wind.speed * self.wind.direction.sin() * speed_ratio, )
640 }
641
642 pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
643 let mut result = if self.inputs.use_rk4 {
644 if self.inputs.use_adaptive_rk45 {
645 self.solve_rk45()?
646 } else {
647 self.solve_rk4()?
648 }
649 } else {
650 self.solve_euler()?
651 };
652 self.apply_spin_drift(&mut result);
653 Ok(result)
654 }
655
656 fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
662 if !self.inputs.use_enhanced_spin_drift {
663 return;
664 }
665 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 {
669 return;
670 }
671
672 let length_in = if self.inputs.bullet_length > 0.0 {
674 self.inputs.bullet_length / 0.0254
675 } else {
676 4.5 * d_in
677 };
678 let (_, _, temp_c, press_hpa) = self.resolved_atmosphere();
684 let temp_k = temp_c + 273.15; let density_correction = if press_hpa > 0.0 && temp_k > 0.0 {
686 (temp_k / 288.15) * (1013.25 / press_hpa)
687 } else {
688 1.0
689 };
690 let sg = crate::spin_drift::miller_stability(d_in, m_gr, twist_in, length_in)
691 * density_correction;
692 let sign = if self.inputs.is_twist_right {
693 1.0
694 } else {
695 -1.0
696 };
697
698 for p in result.points.iter_mut() {
699 if p.time <= 0.0 {
700 continue;
701 }
702 let sd_in = 1.25 * (sg + 1.2) * p.time.powf(1.83); p.position.z += sign * sd_in * 0.0254; }
705
706 if let Some(samples) = result.sampled_points.as_mut() {
710 for s in samples.iter_mut() {
711 if s.time_s <= 0.0 {
712 continue;
713 }
714 let sd_in = 1.25 * (sg + 1.2) * s.time_s.powf(1.83);
715 s.wind_drift_m += sign * sd_in * 0.0254;
716 }
717 }
718 }
719
720 fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
721 let mut time = 0.0;
723 let mut position = Vector3::new(
726 0.0,
727 self.inputs.muzzle_height, 0.0,
729 );
730 let aj_components = self.aerodynamic_jump_components();
736 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
737 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
738 let mut velocity = Vector3::new(
739 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
743
744 let mut points = Vec::new();
745 let mut max_height = position.y;
746 let mut min_pitch_damping = 1.0; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
752 let mut crossed_transonic = false;
753 let mut crossed_subsonic = false;
754
755 let mut angular_state = if self.inputs.enable_precession_nutation {
757 Some(AngularState {
758 pitch_angle: 0.001, yaw_angle: 0.001,
760 pitch_rate: 0.0,
761 yaw_rate: 0.0,
762 precession_angle: 0.0,
763 nutation_phase: 0.0,
764 })
765 } else {
766 None
767 };
768 let mut max_yaw_angle = 0.0;
769 let mut max_precession_angle = 0.0;
770
771 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) = self.resolved_atmosphere();
773
774 let wind_vector = Vector3::new(
778 -self.wind.speed * self.wind.direction.cos(), 0.0,
780 -self.wind.speed * self.wind.direction.sin(), );
782
783 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
786 self.inputs.bullet_model.as_deref().unwrap_or("default"),
787 );
788
789 while position.x < self.max_range
791 && position.y > self.inputs.ground_threshold
792 && time < 100.0
793 {
794 let velocity_magnitude = velocity.magnitude();
796 let kinetic_energy =
797 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
798
799 points.push(TrajectoryPoint {
800 time,
801 position: position,
802 velocity_magnitude,
803 kinetic_energy,
804 });
805
806 {
809 let mach_here = if speed_of_sound > 0.0 {
810 velocity_magnitude / speed_of_sound
811 } else {
812 0.0
813 };
814 if !crossed_transonic && mach_here < 1.2 {
815 crossed_transonic = true;
816 transonic_distances.push(position.x);
817 }
818 if !crossed_subsonic && mach_here < 1.0 {
819 crossed_subsonic = true;
820 transonic_distances.push(position.x);
821 }
822 }
823
824 #[cfg(debug_assertions)]
828 if points.len() == 1 || points.len() % 100 == 0 {
829 eprintln!("Trajectory point {}: time={:.3}s, downrange={:.2}m, vertical={:.2}m, lateral={:.2}m, vel={:.1}m/s",
830 points.len(), time, position.x, position.y, position.z, velocity_magnitude);
831 }
832
833 if position.y > max_height {
835 max_height = position.y;
836 }
837
838 if self.inputs.enable_pitch_damping {
840 let mach = velocity_magnitude / speed_of_sound;
841
842 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
844 transonic_mach = Some(mach);
845 }
846
847 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
849
850 if pitch_damping < min_pitch_damping {
852 min_pitch_damping = pitch_damping;
853 }
854 }
855
856 if self.inputs.enable_precession_nutation {
858 if let Some(ref mut state) = angular_state {
859 let velocity_magnitude = velocity.magnitude();
860 let mach = velocity_magnitude / speed_of_sound;
861
862 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
864 let velocity_fps = velocity_magnitude * 3.28084;
865 let twist_rate_ft = self.inputs.twist_rate / 12.0;
866 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
867 } else {
868 0.0
869 };
870
871 let params = PrecessionNutationParams {
873 mass_kg: self.inputs.bullet_mass,
874 caliber_m: self.inputs.bullet_diameter,
875 length_m: self.inputs.bullet_length,
876 spin_rate_rad_s,
877 spin_inertia: 6.94e-8, transverse_inertia: 9.13e-7, velocity_mps: velocity_magnitude,
880 air_density_kg_m3: air_density,
881 mach,
882 pitch_damping_coeff: -0.8,
883 nutation_damping_factor: 0.05,
884 };
885
886 *state = calculate_combined_angular_motion(
888 ¶ms,
889 state,
890 time,
891 self.time_step,
892 0.001, );
894
895 if state.yaw_angle.abs() > max_yaw_angle {
897 max_yaw_angle = state.yaw_angle.abs();
898 }
899 if state.precession_angle.abs() > max_precession_angle {
900 max_precession_angle = state.precession_angle.abs();
901 }
902 }
903 }
904
905 let acceleration =
912 self.calculate_acceleration(&position, &velocity, air_density,
913 &wind_vector,
914 (speed_of_sound, resolved_temp_c, resolved_press_hpa),
915 );
916
917 velocity += acceleration * self.time_step;
919 position += velocity * self.time_step;
920 time += self.time_step;
921 }
922
923 let last_point = points.last().ok_or("No trajectory points generated")?;
925
926 let sampled_points = if self.inputs.enable_trajectory_sampling {
928 let trajectory_data = TrajectoryData {
929 times: points.iter().map(|p| p.time).collect(),
930 positions: points.iter().map(|p| p.position).collect(),
931 velocities: points
932 .iter()
933 .map(|p| {
934 Vector3::new(0.0, 0.0, p.velocity_magnitude)
936 })
937 .collect(),
938 transonic_distances, };
940
941 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
946 let outputs = TrajectoryOutputs {
947 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
949 time_of_flight_s: last_point.time,
950 max_ord_dist_horiz_m: max_height,
951 sight_height_m: sight_position_m,
952 };
953
954 let samples = sample_trajectory(
956 &trajectory_data,
957 &outputs,
958 self.inputs.sample_interval,
959 self.inputs.bullet_mass,
960 );
961 Some(samples)
962 } else {
963 None
964 };
965
966 Ok(TrajectoryResult {
967 max_range: last_point.position.x, max_height,
969 time_of_flight: last_point.time,
970 impact_velocity: last_point.velocity_magnitude,
971 impact_energy: last_point.kinetic_energy,
972 points,
973 sampled_points,
974 min_pitch_damping: if self.inputs.enable_pitch_damping {
975 Some(min_pitch_damping)
976 } else {
977 None
978 },
979 transonic_mach,
980 angular_state,
981 max_yaw_angle: if self.inputs.enable_precession_nutation {
982 Some(max_yaw_angle)
983 } else {
984 None
985 },
986 max_precession_angle: if self.inputs.enable_precession_nutation {
987 Some(max_precession_angle)
988 } else {
989 None
990 },
991 aerodynamic_jump: aj_components,
992 })
993 }
994
995 fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
996 let mut time = 0.0;
998 let mut position = Vector3::new(
1002 0.0,
1003 self.inputs.muzzle_height, 0.0,
1005 );
1006
1007 let aj_components = self.aerodynamic_jump_components();
1013 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1014 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1015 let mut velocity = Vector3::new(
1016 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1020
1021 let mut points = Vec::new();
1022 let mut max_height = position.y;
1023 let mut min_pitch_damping = 1.0; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
1029 let mut crossed_transonic = false;
1030 let mut crossed_subsonic = false;
1031
1032 let mut angular_state = if self.inputs.enable_precession_nutation {
1034 Some(AngularState {
1035 pitch_angle: 0.001, yaw_angle: 0.001,
1037 pitch_rate: 0.0,
1038 yaw_rate: 0.0,
1039 precession_angle: 0.0,
1040 nutation_phase: 0.0,
1041 })
1042 } else {
1043 None
1044 };
1045 let mut max_yaw_angle = 0.0;
1046 let mut max_precession_angle = 0.0;
1047
1048 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) = self.resolved_atmosphere();
1050
1051 let wind_vector = Vector3::new(
1055 -self.wind.speed * self.wind.direction.cos(), 0.0,
1057 -self.wind.speed * self.wind.direction.sin(), );
1059
1060 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1063 self.inputs.bullet_model.as_deref().unwrap_or("default"),
1064 );
1065
1066 while position.x < self.max_range
1068 && position.y > self.inputs.ground_threshold
1069 && time < 100.0
1070 {
1071 let velocity_magnitude = velocity.magnitude();
1073 let kinetic_energy =
1074 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1075
1076 points.push(TrajectoryPoint {
1077 time,
1078 position: position,
1079 velocity_magnitude,
1080 kinetic_energy,
1081 });
1082
1083 {
1086 let mach_here = if speed_of_sound > 0.0 {
1087 velocity_magnitude / speed_of_sound
1088 } else {
1089 0.0
1090 };
1091 if !crossed_transonic && mach_here < 1.2 {
1092 crossed_transonic = true;
1093 transonic_distances.push(position.x);
1094 }
1095 if !crossed_subsonic && mach_here < 1.0 {
1096 crossed_subsonic = true;
1097 transonic_distances.push(position.x);
1098 }
1099 }
1100
1101 if position.y > max_height {
1102 max_height = position.y;
1103 }
1104
1105 if self.inputs.enable_pitch_damping {
1107 let mach = velocity_magnitude / speed_of_sound;
1108
1109 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1111 transonic_mach = Some(mach);
1112 }
1113
1114 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1116
1117 if pitch_damping < min_pitch_damping {
1119 min_pitch_damping = pitch_damping;
1120 }
1121 }
1122
1123 if self.inputs.enable_precession_nutation {
1125 if let Some(ref mut state) = angular_state {
1126 let velocity_magnitude = velocity.magnitude();
1127 let mach = velocity_magnitude / speed_of_sound;
1128
1129 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
1131 let velocity_fps = velocity_magnitude * 3.28084;
1132 let twist_rate_ft = self.inputs.twist_rate / 12.0;
1133 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
1134 } else {
1135 0.0
1136 };
1137
1138 let params = PrecessionNutationParams {
1140 mass_kg: self.inputs.bullet_mass,
1141 caliber_m: self.inputs.bullet_diameter,
1142 length_m: self.inputs.bullet_length,
1143 spin_rate_rad_s,
1144 spin_inertia: 6.94e-8, transverse_inertia: 9.13e-7, velocity_mps: velocity_magnitude,
1147 air_density_kg_m3: air_density,
1148 mach,
1149 pitch_damping_coeff: -0.8,
1150 nutation_damping_factor: 0.05,
1151 };
1152
1153 *state = calculate_combined_angular_motion(
1155 ¶ms,
1156 state,
1157 time,
1158 self.time_step,
1159 0.001, );
1161
1162 if state.yaw_angle.abs() > max_yaw_angle {
1164 max_yaw_angle = state.yaw_angle.abs();
1165 }
1166 if state.precession_angle.abs() > max_precession_angle {
1167 max_precession_angle = state.precession_angle.abs();
1168 }
1169 }
1170 }
1171
1172 let dt = self.time_step;
1174
1175 let acc1 = self.calculate_acceleration(&position, &velocity, air_density, &wind_vector, (speed_of_sound, resolved_temp_c, resolved_press_hpa));
1177
1178 let pos2 = position + velocity * (dt * 0.5);
1180 let vel2 = velocity + acc1 * (dt * 0.5);
1181 let acc2 = self.calculate_acceleration(&pos2, &vel2, air_density, &wind_vector, (speed_of_sound, resolved_temp_c, resolved_press_hpa));
1182
1183 let pos3 = position + vel2 * (dt * 0.5);
1185 let vel3 = velocity + acc2 * (dt * 0.5);
1186 let acc3 = self.calculate_acceleration(&pos3, &vel3, air_density, &wind_vector, (speed_of_sound, resolved_temp_c, resolved_press_hpa));
1187
1188 let pos4 = position + vel3 * dt;
1190 let vel4 = velocity + acc3 * dt;
1191 let acc4 = self.calculate_acceleration(&pos4, &vel4, air_density, &wind_vector, (speed_of_sound, resolved_temp_c, resolved_press_hpa));
1192
1193 position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
1195 velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
1196 time += dt;
1197 }
1198
1199 let last_point = points.last().ok_or("No trajectory points generated")?;
1201
1202 let sampled_points = if self.inputs.enable_trajectory_sampling {
1204 let trajectory_data = TrajectoryData {
1205 times: points.iter().map(|p| p.time).collect(),
1206 positions: points.iter().map(|p| p.position).collect(),
1207 velocities: points
1208 .iter()
1209 .map(|p| {
1210 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1212 })
1213 .collect(),
1214 transonic_distances, };
1216
1217 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1222 let outputs = TrajectoryOutputs {
1223 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
1225 time_of_flight_s: last_point.time,
1226 max_ord_dist_horiz_m: max_height,
1227 sight_height_m: sight_position_m,
1228 };
1229
1230 let samples = sample_trajectory(
1232 &trajectory_data,
1233 &outputs,
1234 self.inputs.sample_interval,
1235 self.inputs.bullet_mass,
1236 );
1237 Some(samples)
1238 } else {
1239 None
1240 };
1241
1242 Ok(TrajectoryResult {
1243 max_range: last_point.position.x, max_height,
1245 time_of_flight: last_point.time,
1246 impact_velocity: last_point.velocity_magnitude,
1247 impact_energy: last_point.kinetic_energy,
1248 points,
1249 sampled_points,
1250 min_pitch_damping: if self.inputs.enable_pitch_damping {
1251 Some(min_pitch_damping)
1252 } else {
1253 None
1254 },
1255 transonic_mach,
1256 angular_state,
1257 max_yaw_angle: if self.inputs.enable_precession_nutation {
1258 Some(max_yaw_angle)
1259 } else {
1260 None
1261 },
1262 max_precession_angle: if self.inputs.enable_precession_nutation {
1263 Some(max_precession_angle)
1264 } else {
1265 None
1266 },
1267 aerodynamic_jump: aj_components,
1268 })
1269 }
1270
1271 fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
1272 let mut time = 0.0;
1274 let mut position = Vector3::new(
1277 0.0,
1278 self.inputs.muzzle_height, 0.0,
1280 );
1281
1282 let aj_components = self.aerodynamic_jump_components();
1288 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1289 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1290 let mut velocity = Vector3::new(
1291 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1295
1296 let mut points = Vec::new();
1297 let mut max_height = position.y;
1298 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;
1306 const MAX_ITERATIONS: usize = 100000;
1307
1308 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) = self.resolved_atmosphere();
1311 let wind_vector = Vector3::new(
1314 -self.wind.speed * self.wind.direction.cos(), 0.0,
1316 -self.wind.speed * self.wind.direction.sin(), );
1318
1319 let mut transonic_distances: Vec<f64> = Vec::new();
1321 let mut crossed_transonic = false;
1322 let mut crossed_subsonic = false;
1323
1324 let mut min_pitch_damping = 1.0;
1329 let mut transonic_mach: Option<f64> = None;
1330 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1331 self.inputs.bullet_model.as_deref().unwrap_or("default"),
1332 );
1333 let mut angular_state = if self.inputs.enable_precession_nutation {
1334 Some(AngularState {
1335 pitch_angle: 0.001,
1336 yaw_angle: 0.001,
1337 pitch_rate: 0.0,
1338 yaw_rate: 0.0,
1339 precession_angle: 0.0,
1340 nutation_phase: 0.0,
1341 })
1342 } else {
1343 None
1344 };
1345 let mut max_yaw_angle = 0.0;
1346 let mut max_precession_angle = 0.0;
1347
1348 while position.x < self.max_range
1349 && position.y > self.inputs.ground_threshold
1350 && time < 100.0
1351 {
1352 iteration_count += 1;
1354 if iteration_count > MAX_ITERATIONS {
1355 break; }
1357
1358 let velocity_magnitude = velocity.magnitude();
1360 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
1361
1362 points.push(TrajectoryPoint {
1363 time,
1364 position: position,
1365 velocity_magnitude,
1366 kinetic_energy,
1367 });
1368
1369 {
1372 let mach_here = if speed_of_sound > 0.0 {
1373 velocity_magnitude / speed_of_sound
1374 } else {
1375 0.0
1376 };
1377 if !crossed_transonic && mach_here < 1.2 {
1378 crossed_transonic = true;
1379 transonic_distances.push(position.x);
1380 }
1381 if !crossed_subsonic && mach_here < 1.0 {
1382 crossed_subsonic = true;
1383 transonic_distances.push(position.x);
1384 }
1385 }
1386
1387 if position.y > max_height {
1388 max_height = position.y;
1389 }
1390
1391 if self.inputs.enable_pitch_damping {
1394 let mach = velocity_magnitude / speed_of_sound;
1395 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1396 transonic_mach = Some(mach);
1397 }
1398 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1399 if pitch_damping < min_pitch_damping {
1400 min_pitch_damping = pitch_damping;
1401 }
1402 }
1403
1404 if self.inputs.enable_precession_nutation {
1408 if let Some(ref mut state) = angular_state {
1409 let mach = velocity_magnitude / speed_of_sound;
1410
1411 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
1412 let velocity_fps = velocity_magnitude * 3.28084;
1413 let twist_rate_ft = self.inputs.twist_rate / 12.0;
1414 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
1415 } else {
1416 0.0
1417 };
1418
1419 let params = PrecessionNutationParams {
1420 mass_kg: self.inputs.bullet_mass,
1421 caliber_m: self.inputs.bullet_diameter,
1422 length_m: self.inputs.bullet_length,
1423 spin_rate_rad_s,
1424 spin_inertia: 6.94e-8,
1425 transverse_inertia: 9.13e-7,
1426 velocity_mps: velocity_magnitude,
1427 air_density_kg_m3: air_density,
1428 mach,
1429 pitch_damping_coeff: -0.8,
1430 nutation_damping_factor: 0.05,
1431 };
1432
1433 *state = calculate_combined_angular_motion(¶ms, state, time, dt, 0.001);
1434
1435 if state.yaw_angle.abs() > max_yaw_angle {
1436 max_yaw_angle = state.yaw_angle.abs();
1437 }
1438 if state.precession_angle.abs() > max_precession_angle {
1439 max_precession_angle = state.precession_angle.abs();
1440 }
1441 }
1442 }
1443
1444 let (new_pos, new_vel, new_dt) = self.rk45_step(
1446 &position,
1447 &velocity,
1448 dt,
1449 air_density,
1450 &wind_vector,
1451 tolerance,
1452 (speed_of_sound, resolved_temp_c, resolved_press_hpa),
1453 );
1454
1455 position = new_pos;
1460 velocity = new_vel;
1461 time += dt;
1462
1463 dt = (safety_factor * new_dt).clamp(min_dt, max_dt);
1465 }
1466
1467 if points.is_empty() {
1469 return Err(BallisticsError::from("No trajectory points calculated"));
1470 }
1471
1472 {
1481 let prev = points.last().unwrap().clone();
1482 let overshoot_x = position.x;
1483 let crossed_range = overshoot_x >= self.max_range && prev.position.x < self.max_range;
1484 if crossed_range {
1485 let span = overshoot_x - prev.position.x;
1486 if span > 1e-9 {
1487 let frac = (self.max_range - prev.position.x) / span;
1488 let interp_pos = prev.position + (position - prev.position) * frac;
1489 let interp_vel_mag = prev.velocity_magnitude
1490 + (velocity.magnitude() - prev.velocity_magnitude) * frac;
1491 let interp_time = prev.time + (time - prev.time) * frac;
1492 let interp_ke = 0.5 * self.inputs.bullet_mass * interp_vel_mag * interp_vel_mag;
1493 points.push(TrajectoryPoint {
1494 time: interp_time,
1495 position: interp_pos,
1496 velocity_magnitude: interp_vel_mag,
1497 kinetic_energy: interp_ke,
1498 });
1499 if interp_pos.y > max_height {
1500 max_height = interp_pos.y;
1501 }
1502 }
1503 }
1504 }
1505
1506 let last_point = points.last().unwrap();
1507
1508 let sampled_points = if self.inputs.enable_trajectory_sampling {
1510 let trajectory_data = TrajectoryData {
1512 times: points.iter().map(|p| p.time).collect(),
1513 positions: points.iter().map(|p| p.position).collect(),
1514 velocities: points
1515 .iter()
1516 .map(|p| {
1517 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1519 })
1520 .collect(),
1521 transonic_distances, };
1523
1524 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1529 let outputs = TrajectoryOutputs {
1530 target_distance_horiz_m: last_point.position.x,
1531 target_vertical_height_m: sight_position_m,
1532 time_of_flight_s: last_point.time,
1533 max_ord_dist_horiz_m: max_height,
1534 sight_height_m: sight_position_m,
1535 };
1536
1537 let samples = sample_trajectory(
1538 &trajectory_data,
1539 &outputs,
1540 self.inputs.sample_interval,
1541 self.inputs.bullet_mass,
1542 );
1543 Some(samples)
1544 } else {
1545 None
1546 };
1547
1548 Ok(TrajectoryResult {
1549 max_range: last_point.position.x, max_height,
1551 time_of_flight: last_point.time,
1552 impact_velocity: last_point.velocity_magnitude,
1553 impact_energy: last_point.kinetic_energy,
1554 points,
1555 sampled_points,
1556 min_pitch_damping: if self.inputs.enable_pitch_damping {
1557 Some(min_pitch_damping)
1558 } else {
1559 None
1560 },
1561 transonic_mach,
1562 angular_state,
1563 max_yaw_angle: if self.inputs.enable_precession_nutation {
1564 Some(max_yaw_angle)
1565 } else {
1566 None
1567 },
1568 max_precession_angle: if self.inputs.enable_precession_nutation {
1569 Some(max_precession_angle)
1570 } else {
1571 None
1572 },
1573 aerodynamic_jump: aj_components,
1574 })
1575 }
1576
1577 fn rk45_step(
1578 &self,
1579 position: &Vector3<f64>,
1580 velocity: &Vector3<f64>,
1581 dt: f64,
1582 air_density: f64,
1583 wind_vector: &Vector3<f64>,
1584 tolerance: f64,
1585 resolved_atmo: (f64, f64, f64), ) -> (Vector3<f64>, Vector3<f64>, f64) {
1587 const A21: f64 = 1.0 / 5.0;
1589 const A31: f64 = 3.0 / 40.0;
1590 const A32: f64 = 9.0 / 40.0;
1591 const A41: f64 = 44.0 / 45.0;
1592 const A42: f64 = -56.0 / 15.0;
1593 const A43: f64 = 32.0 / 9.0;
1594 const A51: f64 = 19372.0 / 6561.0;
1595 const A52: f64 = -25360.0 / 2187.0;
1596 const A53: f64 = 64448.0 / 6561.0;
1597 const A54: f64 = -212.0 / 729.0;
1598 const A61: f64 = 9017.0 / 3168.0;
1599 const A62: f64 = -355.0 / 33.0;
1600 const A63: f64 = 46732.0 / 5247.0;
1601 const A64: f64 = 49.0 / 176.0;
1602 const A65: f64 = -5103.0 / 18656.0;
1603 const A71: f64 = 35.0 / 384.0;
1604 const A73: f64 = 500.0 / 1113.0;
1605 const A74: f64 = 125.0 / 192.0;
1606 const A75: f64 = -2187.0 / 6784.0;
1607 const A76: f64 = 11.0 / 84.0;
1608
1609 const B1: f64 = 35.0 / 384.0;
1611 const B3: f64 = 500.0 / 1113.0;
1612 const B4: f64 = 125.0 / 192.0;
1613 const B5: f64 = -2187.0 / 6784.0;
1614 const B6: f64 = 11.0 / 84.0;
1615
1616 const B1_ERR: f64 = 5179.0 / 57600.0;
1618 const B3_ERR: f64 = 7571.0 / 16695.0;
1619 const B4_ERR: f64 = 393.0 / 640.0;
1620 const B5_ERR: f64 = -92097.0 / 339200.0;
1621 const B6_ERR: f64 = 187.0 / 2100.0;
1622 const B7_ERR: f64 = 1.0 / 40.0;
1623
1624 let k1_v = self.calculate_acceleration(position, velocity, air_density, wind_vector, resolved_atmo);
1626 let k1_p = *velocity;
1627
1628 let p2 = position + dt * A21 * k1_p;
1629 let v2 = velocity + dt * A21 * k1_v;
1630 let k2_v = self.calculate_acceleration(&p2, &v2, air_density, wind_vector, resolved_atmo);
1631 let k2_p = v2;
1632
1633 let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
1634 let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
1635 let k3_v = self.calculate_acceleration(&p3, &v3, air_density, wind_vector, resolved_atmo);
1636 let k3_p = v3;
1637
1638 let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
1639 let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
1640 let k4_v = self.calculate_acceleration(&p4, &v4, air_density, wind_vector, resolved_atmo);
1641 let k4_p = v4;
1642
1643 let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
1644 let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
1645 let k5_v = self.calculate_acceleration(&p5, &v5, air_density, wind_vector, resolved_atmo);
1646 let k5_p = v5;
1647
1648 let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
1649 let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
1650 let k6_v = self.calculate_acceleration(&p6, &v6, air_density, wind_vector, resolved_atmo);
1651 let k6_p = v6;
1652
1653 let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
1654 let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
1655 let k7_v = self.calculate_acceleration(&p7, &v7, air_density, wind_vector, resolved_atmo);
1656 let k7_p = v7;
1657
1658 let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
1660 let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
1661
1662 let pos_err = position
1664 + dt * (B1_ERR * k1_p
1665 + B3_ERR * k3_p
1666 + B4_ERR * k4_p
1667 + B5_ERR * k5_p
1668 + B6_ERR * k6_p
1669 + B7_ERR * k7_p);
1670 let vel_err = velocity
1671 + dt * (B1_ERR * k1_v
1672 + B3_ERR * k3_v
1673 + B4_ERR * k4_v
1674 + B5_ERR * k5_v
1675 + B6_ERR * k6_v
1676 + B7_ERR * k7_v);
1677
1678 let pos_error = (new_pos - pos_err).magnitude();
1680 let vel_error = (new_vel - vel_err).magnitude();
1681 let error = (pos_error + vel_error) / (1.0 + position.magnitude() + velocity.magnitude());
1682
1683 let dt_new = if error < tolerance {
1685 dt * (tolerance / error).powf(0.2).min(2.0)
1686 } else {
1687 dt * (tolerance / error).powf(0.25).max(0.1)
1688 };
1689
1690 (new_pos, new_vel, dt_new)
1691 }
1692
1693 fn calculate_acceleration(
1694 &self,
1695 position: &Vector3<f64>,
1696 velocity: &Vector3<f64>,
1697 air_density: f64,
1698 wind_vector: &Vector3<f64>,
1699 resolved_atmo: (f64, f64, f64), ) -> Vector3<f64> {
1701 let actual_wind = if let Some(ref sock) = self.wind_sock {
1707 sock.vector_for_range_stateless(position.x)
1708 } else if self.inputs.enable_wind_shear {
1709 self.get_wind_at_altitude(position.y)
1710 } else {
1711 *wind_vector
1712 };
1713
1714 let relative_velocity = velocity - actual_wind;
1715 let velocity_magnitude = relative_velocity.magnitude();
1716
1717 if velocity_magnitude < 0.001 {
1718 return self.gravity_acceleration();
1719 }
1720
1721 let cd = self.calculate_drag_coefficient(velocity_magnitude, resolved_atmo.0);
1723
1724 let velocity_fps = velocity_magnitude * 3.28084;
1726
1727 let base_bc = if let Some(ref segments) = self.inputs.bc_segments_data {
1729 segments
1731 .iter()
1732 .find(|seg| velocity_fps >= seg.velocity_min && velocity_fps < seg.velocity_max)
1733 .map(|seg| seg.bc_value)
1734 .unwrap_or(self.inputs.bc_value)
1735 } else {
1736 self.inputs.bc_value
1737 };
1738
1739 let effective_bc = if let Some(ref cluster_bc) = self.cluster_bc {
1741 cluster_bc.apply_correction(
1742 base_bc,
1743 self.inputs.caliber_inches, self.inputs.weight_grains,
1745 velocity_fps,
1746 )
1747 } else {
1748 base_bc
1749 };
1750 let effective_bc = effective_bc.max(1e-6);
1754
1755 let cd_to_retard = crate::constants::CD_TO_RETARD;
1760 let standard_factor = cd * cd_to_retard;
1761 let density_scale = air_density / 1.225; let a_drag_ft_s2 =
1765 (velocity_fps * velocity_fps) * standard_factor * density_scale / effective_bc;
1766 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
1770
1771 let mut accel = drag_acceleration + self.gravity_acceleration();
1774
1775 if self.inputs.enable_coriolis {
1778 if let Some(lat_deg) = self.inputs.latitude {
1779 let omega_earth = 7.2921159e-5_f64; let lat = lat_deg.to_radians();
1781 let az = self.inputs.shot_azimuth; let omega = Vector3::new(
1788 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), );
1792 accel += -2.0 * omega.cross(velocity);
1797 }
1798 }
1799
1800 if self.inputs.enable_magnus
1802 && self.inputs.bullet_diameter > 0.0
1803 && self.inputs.twist_rate > 0.0
1804 {
1805 let (_, spin_rad_s) =
1806 crate::spin_drift::calculate_spin_rate(velocity_magnitude, self.inputs.twist_rate);
1807 let (speed_of_sound, temp_c, press_hpa) = resolved_atmo;
1808 let temp_k = temp_c + 273.15;
1809 let mach = velocity_magnitude / speed_of_sound;
1810
1811 let d_in = self.inputs.bullet_diameter / 0.0254;
1813 let m_gr = self.inputs.bullet_mass / 0.00006479891;
1814 let l_in = if self.inputs.bullet_length > 0.0 {
1815 self.inputs.bullet_length / 0.0254
1816 } else {
1817 4.5 * d_in
1818 };
1819 let density_correction = if press_hpa > 0.0 && temp_k > 0.0 {
1823 (temp_k / 288.15) * (1013.25 / press_hpa)
1824 } else {
1825 1.0
1826 };
1827 let sg = crate::spin_drift::miller_stability(d_in, m_gr, self.inputs.twist_rate, l_in)
1828 * density_correction;
1829
1830 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
1832 sg,
1833 velocity_magnitude,
1834 spin_rad_s,
1835 0.0, 0.0, air_density,
1838 d_in,
1839 l_in,
1840 m_gr,
1841 mach,
1842 "match",
1843 false,
1844 );
1845
1846 let diameter_m = self.inputs.bullet_diameter; let spin_param = spin_rad_s * diameter_m / (2.0 * velocity_magnitude);
1849 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
1850 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
1851 let magnus_force = 0.5
1852 * air_density
1853 * velocity_magnitude.powi(2)
1854 * area
1855 * c_np
1856 * spin_param
1857 * yaw_rad.sin();
1858
1859 let velocity_unit = relative_velocity / velocity_magnitude;
1862 let up = Vector3::new(0.0, 1.0, 0.0);
1863 let mut dir = velocity_unit.cross(&up);
1864 let dir_norm = dir.norm();
1865 if dir_norm > 1e-12 && magnus_force.abs() > 1e-12 {
1866 dir /= dir_norm;
1867 if !self.inputs.is_twist_right {
1868 dir = -dir;
1869 }
1870 accel += (magnus_force / self.inputs.bullet_mass) * dir;
1871 }
1872 }
1873
1874 accel
1875 }
1876
1877 fn calculate_drag_coefficient(&self, velocity: f64, speed_of_sound: f64) -> f64 {
1878 let mach = velocity / speed_of_sound;
1879
1880 if let Some(ref table) = self.inputs.custom_drag_table {
1884 return table.interpolate(mach);
1885 }
1886
1887 let base_cd = crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type);
1889
1890 crate::form_factor::apply_form_factor_to_drag(
1895 base_cd,
1896 self.inputs.bullet_model.as_deref(),
1897 &self.inputs.bc_type,
1898 self.inputs.use_form_factor,
1899 )
1900 }
1901}
1902
1903#[derive(Debug, Clone)]
1905pub struct MonteCarloParams {
1906 pub num_simulations: usize,
1907 pub velocity_std_dev: f64,
1908 pub angle_std_dev: f64,
1909 pub bc_std_dev: f64,
1910 pub wind_speed_std_dev: f64,
1911 pub target_distance: Option<f64>,
1912 pub base_wind_speed: f64,
1913 pub base_wind_direction: f64,
1914 pub azimuth_std_dev: f64, }
1916
1917impl Default for MonteCarloParams {
1918 fn default() -> Self {
1919 Self {
1920 num_simulations: 1000,
1921 velocity_std_dev: 1.0,
1922 angle_std_dev: 0.001,
1923 bc_std_dev: 0.01,
1924 wind_speed_std_dev: 1.0,
1925 target_distance: None,
1926 base_wind_speed: 0.0,
1927 base_wind_direction: 0.0,
1928 azimuth_std_dev: 0.001, }
1930 }
1931}
1932
1933#[derive(Debug, Clone)]
1935pub struct MonteCarloResults {
1936 pub ranges: Vec<f64>,
1937 pub impact_velocities: Vec<f64>,
1938 pub impact_positions: Vec<Vector3<f64>>,
1939}
1940
1941pub const DEFAULT_HIT_RADIUS_M: f64 = 0.3;
1944
1945impl MonteCarloResults {
1946 pub fn hit_probability(&self, hit_radius_m: f64) -> f64 {
1955 if self.impact_positions.is_empty() {
1956 return 0.0;
1957 }
1958 let hits = self
1959 .impact_positions
1960 .iter()
1961 .filter(|p| p.norm() < hit_radius_m)
1962 .count();
1963 hits as f64 / self.impact_positions.len() as f64
1964 }
1965}
1966
1967pub fn run_monte_carlo(
1969 base_inputs: BallisticInputs,
1970 params: MonteCarloParams,
1971) -> Result<MonteCarloResults, BallisticsError> {
1972 let base_wind = WindConditions {
1973 speed: params.base_wind_speed,
1974 direction: params.base_wind_direction,
1975 };
1976 run_monte_carlo_with_wind(base_inputs, base_wind, params)
1977}
1978
1979pub fn run_monte_carlo_with_wind(
1981 base_inputs: BallisticInputs,
1982 base_wind: WindConditions,
1983 params: MonteCarloParams,
1984) -> Result<MonteCarloResults, BallisticsError> {
1985 use rand_distr::{Distribution, Normal};
1986
1987 let mut rng = rand::rng();
1988 let mut ranges = Vec::new();
1989 let mut impact_velocities = Vec::new();
1990 let mut impact_positions = Vec::new();
1991
1992 let atmosphere = AtmosphericConditions {
1993 temperature: base_inputs.temperature,
1994 pressure: base_inputs.pressure,
1995 humidity: base_inputs.humidity_percent(),
1996 altitude: base_inputs.altitude,
1997 };
1998 let target_hint = params
1999 .target_distance
2000 .unwrap_or(base_inputs.target_distance);
2001 let solver_max_range = target_hint.max(1000.0) * 2.0;
2002
2003 let mut baseline_solver =
2005 TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), atmosphere.clone());
2006 baseline_solver.set_max_range(solver_max_range);
2007 let baseline_result = baseline_solver.solve()?;
2008
2009 let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
2011
2012 let baseline_at_target = baseline_result
2014 .position_at_range(target_distance)
2015 .ok_or("Could not interpolate baseline at target distance")?;
2016
2017 let velocity_dist = Normal::new(base_inputs.muzzle_velocity, params.velocity_std_dev)
2019 .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
2020 let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
2021 .map_err(|e| format!("Invalid angle distribution: {}", e))?;
2022 let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
2023 .map_err(|e| format!("Invalid BC distribution: {}", e))?;
2024 let wind_speed_dist = Normal::new(base_wind.speed, params.wind_speed_std_dev)
2025 .map_err(|e| format!("Invalid wind speed distribution: {}", e))?;
2026 let wind_dir_dist = Normal::new(base_wind.direction, params.wind_speed_std_dev * 0.1)
2031 .map_err(|e| format!("Invalid wind direction distribution: {}", e))?;
2032 let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
2033 .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
2034
2035 for _ in 0..params.num_simulations {
2036 let mut inputs = base_inputs.clone();
2038 inputs.muzzle_velocity = velocity_dist.sample(&mut rng).max(0.0);
2039 inputs.muzzle_angle = angle_dist.sample(&mut rng);
2040 inputs.bc_value = bc_dist.sample(&mut rng).max(0.01);
2041 inputs.azimuth_angle = azimuth_dist.sample(&mut rng); let wind = WindConditions {
2045 speed: wind_speed_dist.sample(&mut rng).abs(),
2046 direction: wind_dir_dist.sample(&mut rng),
2047 };
2048
2049 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere.clone());
2051 solver.set_max_range(solver_max_range);
2052 match solver.solve() {
2053 Ok(result) => {
2054 let deviation = if result.max_range < target_distance {
2060 Vector3::new(0.0, -1.0e9, 0.0)
2063 } else {
2064 let pos_at_target = match result.position_at_range(target_distance) {
2065 Some(p) => p,
2066 None => continue, };
2068 Vector3::new(
2073 0.0,
2074 pos_at_target.y - baseline_at_target.y,
2075 pos_at_target.z - baseline_at_target.z,
2076 )
2077 };
2078
2079 ranges.push(result.max_range);
2080 impact_velocities.push(result.impact_velocity);
2081 impact_positions.push(deviation);
2082 }
2083 Err(_) => {
2084 continue;
2086 }
2087 }
2088 }
2089
2090 if ranges.is_empty() {
2091 return Err("No successful simulations".into());
2092 }
2093
2094 Ok(MonteCarloResults {
2095 ranges,
2096 impact_velocities,
2097 impact_positions,
2098 })
2099}
2100
2101pub fn calculate_zero_angle(
2103 inputs: BallisticInputs,
2104 target_distance: f64,
2105 target_height: f64,
2106) -> Result<f64, BallisticsError> {
2107 calculate_zero_angle_with_conditions(
2108 inputs,
2109 target_distance,
2110 target_height,
2111 WindConditions::default(),
2112 AtmosphericConditions::default(),
2113 )
2114}
2115
2116pub fn calculate_zero_angle_with_conditions(
2117 inputs: BallisticInputs,
2118 target_distance: f64,
2119 target_height: f64,
2120 wind: WindConditions,
2121 atmosphere: AtmosphericConditions,
2122) -> Result<f64, BallisticsError> {
2123 let get_height_at_angle = |angle: f64| -> Result<Option<f64>, BallisticsError> {
2125 let mut test_inputs = inputs.clone();
2126 test_inputs.muzzle_angle = angle;
2127 test_inputs.enable_aerodynamic_jump = false;
2132
2133 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
2134 solver.set_max_range(target_distance * 2.0);
2135 solver.set_time_step(0.001);
2136 let result = solver.solve()?;
2137
2138 for i in 0..result.points.len() {
2140 if result.points[i].position.x >= target_distance {
2141 if i > 0 {
2142 let p1 = &result.points[i - 1];
2143 let p2 = &result.points[i];
2144 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
2145 return Ok(Some(p1.position.y + t * (p2.position.y - p1.position.y)));
2146 } else {
2147 return Ok(Some(result.points[i].position.y));
2148 }
2149 }
2150 }
2151 Ok(None)
2152 };
2153
2154 let mut low_angle = 0.0; let mut high_angle = 0.2; let tolerance = 0.00001; let max_iterations = 50;
2160
2161 let low_height = get_height_at_angle(low_angle)?;
2164 let high_height = get_height_at_angle(high_angle)?;
2165
2166 match (low_height, high_height) {
2167 (Some(lh), Some(hh)) => {
2168 let low_error = lh - target_height;
2169 let high_error = hh - target_height;
2170
2171 if low_error > 0.0 && high_error > 0.0 {
2174 } else if low_error < 0.0 && high_error < 0.0 {
2179 let mut expanded = false;
2182 for multiplier in [2.0, 3.0, 4.0] {
2183 let new_high = (high_angle * multiplier).min(0.785);
2184 if let Ok(Some(h)) = get_height_at_angle(new_high) {
2185 if h - target_height > 0.0 {
2186 high_angle = new_high;
2187 expanded = true;
2188 break;
2189 }
2190 }
2191 if new_high >= 0.785 {
2192 break;
2193 }
2194 }
2195 if !expanded {
2196 return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
2197 }
2198 }
2199 }
2201 (None, Some(_hh)) => {
2202 }
2205 (Some(_lh), None) => {
2206 return Err(
2208 "Cannot find zero angle: high angle trajectory doesn't reach target distance"
2209 .into(),
2210 );
2211 }
2212 (None, None) => {
2213 return Err(
2215 "Cannot find zero angle: trajectory cannot reach target distance at any angle"
2216 .into(),
2217 );
2218 }
2219 }
2220
2221 for _iteration in 0..max_iterations {
2222 let mid_angle = (low_angle + high_angle) / 2.0;
2223
2224 let mut test_inputs = inputs.clone();
2225 test_inputs.muzzle_angle = mid_angle;
2226 test_inputs.enable_aerodynamic_jump = false;
2228
2229 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
2230 solver.set_max_range(target_distance * 2.0);
2232 solver.set_time_step(0.001);
2233 let result = solver.solve()?;
2234
2235 let mut height_at_target = None;
2237 for i in 0..result.points.len() {
2238 if result.points[i].position.x >= target_distance {
2239 if i > 0 {
2240 let p1 = &result.points[i - 1];
2242 let p2 = &result.points[i];
2243 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
2244 height_at_target = Some(p1.position.y + t * (p2.position.y - p1.position.y));
2245 } else {
2246 height_at_target = Some(result.points[i].position.y);
2247 }
2248 break;
2249 }
2250 }
2251
2252 match height_at_target {
2253 Some(height) => {
2254 let error = height - target_height;
2255 if error.abs() < 0.001 {
2258 return Ok(mid_angle);
2259 }
2260
2261 if (high_angle - low_angle).abs() < tolerance {
2265 if error.abs() < 0.01 {
2266 return Ok(mid_angle);
2268 }
2269 return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
2274 }
2275
2276 if error > 0.0 {
2277 high_angle = mid_angle;
2278 } else {
2279 low_angle = mid_angle;
2280 }
2281 }
2282 None => {
2283 low_angle = mid_angle;
2285
2286 if (high_angle - low_angle).abs() < tolerance {
2288 return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
2289 }
2290 }
2291 }
2292 }
2293
2294 Err("Failed to find zero angle".into())
2295}
2296
2297pub fn estimate_bc_from_trajectory(
2299 velocity: f64,
2300 mass: f64,
2301 diameter: f64,
2302 points: &[(f64, f64)], ) -> Result<f64, BallisticsError> {
2304 let mut best_bc = 0.5;
2306 let mut best_error = f64::MAX;
2307 let mut found_valid = false;
2308
2309 for bc in (100..1000).step_by(10) {
2311 let bc_value = bc as f64 / 1000.0;
2312
2313 let inputs = BallisticInputs {
2314 muzzle_velocity: velocity,
2315 bc_value,
2316 bullet_mass: mass,
2317 bullet_diameter: diameter,
2318 ..Default::default()
2319 };
2320
2321 let mut solver = TrajectorySolver::new(inputs, Default::default(), Default::default());
2322 solver.set_max_range(points.last().map(|(d, _)| *d * 1.5).unwrap_or(1000.0));
2324
2325 let result = match solver.solve() {
2326 Ok(r) => r,
2327 Err(_) => continue, };
2329
2330 let mut total_error = 0.0;
2332 for (target_dist, target_drop) in points {
2333 let mut calculated_drop = None;
2335 for i in 0..result.points.len() {
2336 if result.points[i].position.x >= *target_dist {
2337 if i > 0 {
2338 let p1 = &result.points[i - 1];
2340 let p2 = &result.points[i];
2341 let t = (target_dist - p1.position.x) / (p2.position.x - p1.position.x);
2342 calculated_drop =
2343 Some(-(p1.position.y + t * (p2.position.y - p1.position.y)));
2344 } else {
2345 calculated_drop = Some(-result.points[i].position.y);
2346 }
2347 break;
2348 }
2349 }
2350
2351 if let Some(drop) = calculated_drop {
2352 let error = (drop - target_drop).abs();
2353 total_error += error * error;
2354 }
2355 }
2356
2357 if total_error < best_error {
2358 best_error = total_error;
2359 best_bc = bc_value;
2360 found_valid = true;
2361 }
2362 }
2363
2364 if !found_valid {
2365 return Err(BallisticsError::from("Unable to estimate BC from provided data. Check that drop values are in correct units.".to_string()));
2366 }
2367
2368 Ok(best_bc)
2369}
2370
2371use rand;
2373use rand_distr;
2374
2375#[cfg(test)]
2376mod ground_termination_tests {
2377 use super::*;
2378
2379 #[test]
2384 fn rk4_and_rk45_descend_to_ground_threshold() {
2385 for adaptive in [false, true] {
2386 let mut inputs = BallisticInputs::default();
2387 inputs.muzzle_angle = 0.1; inputs.use_rk4 = true;
2389 inputs.use_adaptive_rk45 = adaptive;
2390 assert_eq!(
2391 inputs.ground_threshold, -100.0,
2392 "default ground_threshold is -100 m"
2393 );
2394
2395 let mut solver = TrajectorySolver::new(
2396 inputs,
2397 WindConditions::default(),
2398 AtmosphericConditions::default(),
2399 );
2400 solver.set_max_range(1.0e7);
2402
2403 let result = solver.solve().expect("solve should succeed");
2404 let final_y = result
2405 .points
2406 .last()
2407 .expect("trajectory has points")
2408 .position
2409 .y;
2410 assert!(
2411 final_y < -1.0,
2412 "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
2413 past launch level toward the ground_threshold floor, not stop at y = 0"
2414 );
2415 }
2416 }
2417}
2418
2419#[cfg(test)]
2420mod coriolis_direction_tests {
2421 use super::*;
2422 use std::f64::consts::FRAC_PI_2;
2423
2424 #[test]
2425 fn transonic_crossing_flags_a_sampled_point() {
2426 use crate::trajectory_sampling::TrajectoryFlag;
2430 let mut inputs = BallisticInputs::default();
2431 inputs.muzzle_velocity = 850.0; inputs.bc_value = 0.2; inputs.bc_type = DragModel::G7;
2434 inputs.muzzle_angle = 0.03;
2435 inputs.enable_trajectory_sampling = true;
2436 inputs.sample_interval = 50.0;
2437 let mut solver = TrajectorySolver::new(
2438 inputs,
2439 WindConditions::default(),
2440 AtmosphericConditions::default(),
2441 );
2442 solver.set_max_range(2000.0);
2443 let r = solver.solve().expect("solve");
2444 let samples = r
2445 .sampled_points
2446 .expect("sampling enabled -> sampled_points present");
2447 assert!(
2448 samples
2449 .iter()
2450 .any(|s| s.flags.contains(&TrajectoryFlag::MachTransition)),
2451 "a shot that crosses Mach 1 must flag at least one Mach-transition sample"
2452 );
2453 }
2454
2455 #[test]
2456 fn humidity_percent_converts_and_clamps() {
2457 let mut i = BallisticInputs::default();
2459 i.humidity = 0.5;
2460 assert!((i.humidity_percent() - 50.0).abs() < 1e-9, "0.5 -> 50%");
2461 i.humidity = 0.0;
2462 assert_eq!(i.humidity_percent(), 0.0);
2463 i.humidity = 1.0;
2464 assert_eq!(i.humidity_percent(), 100.0);
2465 i.humidity = 1.5; assert_eq!(i.humidity_percent(), 100.0);
2467 }
2468
2469 fn vertical_at(shot_azimuth: f64, range_m: f64) -> f64 {
2472 let mut inputs = BallisticInputs::default();
2473 inputs.muzzle_velocity = 800.0;
2474 inputs.bc_value = 0.5;
2475 inputs.bc_type = DragModel::G7;
2476 inputs.muzzle_angle = 0.02; inputs.enable_coriolis = true;
2478 inputs.latitude = Some(45.0);
2479 inputs.shot_azimuth = shot_azimuth;
2480 inputs.ground_threshold = f64::NEG_INFINITY; let mut solver = TrajectorySolver::new(
2482 inputs,
2483 WindConditions::default(),
2484 AtmosphericConditions::default(),
2485 );
2486 solver.set_max_range(range_m + 50.0);
2487 let r = solver.solve().expect("solve");
2488 let pts = &r.points;
2489 for i in 1..pts.len() {
2490 if pts[i].position.x >= range_m {
2491 let p1 = &pts[i - 1];
2492 let p2 = &pts[i];
2493 let t = (range_m - p1.position.x) / (p2.position.x - p1.position.x);
2494 return p1.position.y + t * (p2.position.y - p1.position.y);
2495 }
2496 }
2497 panic!("range {range_m} not reached");
2498 }
2499
2500 #[test]
2505 fn eotvos_east_higher_than_west() {
2506 let range = 600.0;
2507 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!(
2511 east > west,
2512 "east ({east:.5}) must be higher than west ({west:.5}) at {range} m (Eötvös)"
2513 );
2514 assert!(
2515 east > north && north > west,
2516 "north ({north:.5}) must lie between east ({east:.5}) and west ({west:.5})"
2517 );
2518 assert!(
2519 (east - west) > 1e-3,
2520 "E-W vertical separation ({:.6} m) should be physically meaningful, not FP noise",
2521 east - west
2522 );
2523 }
2524}