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 tipoff_yaw: f64, pub tipoff_decay_distance: f64, pub use_bc_segments: bool,
133 pub bc_segments: Option<Vec<(f64, f64)>>, pub bc_segments_data: Option<Vec<crate::BCSegmentData>>, pub use_enhanced_spin_drift: bool,
136 pub use_form_factor: bool,
137 pub enable_wind_shear: bool,
138 pub wind_shear_model: String,
139 pub enable_trajectory_sampling: bool,
140 pub sample_interval: f64, pub enable_pitch_damping: bool,
142 pub enable_precession_nutation: bool,
143 pub enable_aerodynamic_jump: bool,
146 pub use_cluster_bc: bool, pub custom_drag_table: Option<crate::drag::DragTable>,
150
151 pub bc_type_str: Option<String>,
153}
154
155impl BallisticInputs {
156 pub fn humidity_percent(&self) -> f64 {
161 (self.humidity * 100.0).clamp(0.0, 100.0)
162 }
163}
164
165impl Default for BallisticInputs {
166 fn default() -> Self {
167 let mass_kg = 0.01;
168 let diameter_m = 0.00762;
169 let bc = 0.5;
170 let muzzle_angle_rad = 0.0;
171 let bc_type = DragModel::G1;
172
173 Self {
174 bc_value: bc,
176 bc_type,
177 bullet_mass: mass_kg,
178 muzzle_velocity: 800.0,
179 bullet_diameter: diameter_m,
180 bullet_length: diameter_m * 4.5, muzzle_angle: muzzle_angle_rad,
184 target_distance: 100.0,
185 azimuth_angle: 0.0,
186 shot_azimuth: 0.0,
187 shooting_angle: 0.0,
188 sight_height: 0.05,
189 muzzle_height: 0.0, target_height: 0.0, ground_threshold: -100.0, altitude: 0.0,
195 temperature: 15.0,
196 pressure: 1013.25, humidity: 0.5, latitude: None,
199
200 wind_speed: 0.0,
202 wind_angle: 0.0,
203
204 twist_rate: 12.0, is_twist_right: true,
207 caliber_inches: diameter_m / 0.0254, weight_grains: mass_kg / 0.00006479891, manufacturer: None,
210 bullet_model: None,
211 bullet_id: None,
212 bullet_cluster: None,
213
214 use_rk4: true, use_adaptive_rk45: true, enable_advanced_effects: false,
220 enable_magnus: false,
221 enable_coriolis: false,
222 use_powder_sensitivity: false,
223 powder_temp_sensitivity: 0.0,
224 powder_temp: 15.0,
225 powder_temp_curve: None,
226 tipoff_yaw: 0.0,
227 tipoff_decay_distance: 50.0,
228 use_bc_segments: false,
229 bc_segments: None,
230 bc_segments_data: None,
231 use_enhanced_spin_drift: false,
232 use_form_factor: false,
233 enable_wind_shear: false,
234 wind_shear_model: "none".to_string(),
235 enable_trajectory_sampling: false,
236 sample_interval: 10.0, enable_pitch_damping: false,
238 enable_precession_nutation: false,
239 enable_aerodynamic_jump: false,
240 use_cluster_bc: false, custom_drag_table: None,
244
245 bc_type_str: None,
247 }
248 }
249}
250
251pub fn interpolate_powder_temp_curve(curve: &[(f64, f64)], temp_c: f64) -> f64 {
257 debug_assert!(!curve.is_empty());
258 if curve.is_empty() {
259 return 0.0;
260 }
261 let mut sorted;
264 let pts: &[(f64, f64)] = if curve.windows(2).all(|w| w[0].0 <= w[1].0) {
265 curve
266 } else {
267 sorted = curve.to_vec();
268 sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
269 &sorted
270 };
271 let n = pts.len();
272 if temp_c <= pts[0].0 {
273 return pts[0].1; }
275 if temp_c >= pts[n - 1].0 {
276 return pts[n - 1].1; }
278 for i in 1..n {
279 let (t0, v0) = pts[i - 1];
280 let (t1, v1) = pts[i];
281 if temp_c <= t1 {
282 let span = t1 - t0;
283 if span.abs() < f64::EPSILON {
284 return v1; }
286 let f = (temp_c - t0) / span;
287 return v0 + f * (v1 - v0);
288 }
289 }
290 pts[n - 1].1
291}
292
293#[derive(Debug, Clone)]
295pub struct WindConditions {
296 pub speed: f64, pub direction: f64,
300}
301
302impl Default for WindConditions {
303 fn default() -> Self {
304 Self {
305 speed: 0.0,
306 direction: 0.0,
307 }
308 }
309}
310
311#[derive(Debug, Clone)]
313pub struct AtmosphericConditions {
314 pub temperature: f64, pub pressure: f64, pub humidity: f64,
320 pub altitude: f64, }
322
323impl Default for AtmosphericConditions {
324 fn default() -> Self {
325 Self {
326 temperature: 15.0,
327 pressure: 1013.25,
328 humidity: 50.0,
329 altitude: 0.0,
330 }
331 }
332}
333
334#[derive(Debug, Clone)]
336pub struct TrajectoryPoint {
337 pub time: f64,
338 pub position: Vector3<f64>,
339 pub velocity_magnitude: f64,
340 pub kinetic_energy: f64,
341}
342
343#[derive(Debug, Clone)]
345pub struct TrajectoryResult {
346 pub max_range: f64,
347 pub max_height: f64,
348 pub time_of_flight: f64,
349 pub impact_velocity: f64,
350 pub impact_energy: f64,
351 pub points: Vec<TrajectoryPoint>,
352 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>,
361}
362
363impl TrajectoryResult {
364 pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
368 if self.points.is_empty() {
369 return None;
370 }
371
372 for i in 0..self.points.len() - 1 {
374 let p1 = &self.points[i];
375 let p2 = &self.points[i + 1];
376
377 if p1.position.x <= target_range && p2.position.x >= target_range {
379 let dx = p2.position.x - p1.position.x;
381 if dx.abs() < 1e-10 {
382 return Some(p1.position);
383 }
384 let t = (target_range - p1.position.x) / dx;
385
386 return Some(Vector3::new(
388 target_range,
389 p1.position.y + t * (p2.position.y - p1.position.y),
390 p1.position.z + t * (p2.position.z - p1.position.z),
391 ));
392 }
393 }
394
395 self.points.last().map(|p| p.position)
397 }
398}
399
400pub struct TrajectorySolver {
402 inputs: BallisticInputs,
403 wind: WindConditions,
404 atmosphere: AtmosphericConditions,
405 max_range: f64,
406 time_step: f64,
407 cluster_bc: Option<ClusterBCDegradation>,
408 wind_sock: Option<crate::wind::WindSock>,
413}
414
415impl TrajectorySolver {
416 pub fn new(
417 mut inputs: BallisticInputs,
418 wind: WindConditions,
419 atmosphere: AtmosphericConditions,
420 ) -> Self {
421 inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
423 inputs.weight_grains = inputs.bullet_mass / 0.00006479891;
424
425 if let Some(curve) = inputs.powder_temp_curve.as_ref() {
434 if !curve.is_empty() {
435 inputs.muzzle_velocity = interpolate_powder_temp_curve(curve, inputs.temperature);
437 }
438 } else if inputs.use_powder_sensitivity {
439 let temp_delta_c = inputs.temperature - inputs.powder_temp;
440 inputs.muzzle_velocity += inputs.powder_temp_sensitivity * temp_delta_c;
441 }
442
443 let cluster_bc = if inputs.use_cluster_bc {
445 Some(ClusterBCDegradation::new())
446 } else {
447 None
448 };
449
450 Self {
451 inputs,
452 wind,
453 atmosphere,
454 max_range: 1000.0,
455 time_step: 0.001,
456 cluster_bc,
457 wind_sock: None,
458 }
459 }
460
461 pub fn set_max_range(&mut self, range: f64) {
462 self.max_range = range;
463 }
464
465 pub fn set_time_step(&mut self, step: f64) {
466 self.time_step = step;
467 }
468
469 pub fn set_wind_segments(&mut self, segments: Vec<crate::wind::WindSegment>) {
476 self.wind_sock = if segments.is_empty() {
477 None
478 } else {
479 Some(crate::wind::WindSock::new(segments))
480 };
481 }
482
483 fn launch_angles_from(
492 &self,
493 aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
494 ) -> (f64, f64) {
495 let elev = self.inputs.muzzle_angle;
496 let azim = self.inputs.azimuth_angle;
497 match aj {
498 Some(c) => {
499 const MOA_PER_RAD: f64 = 3437.7467707849;
501 (
502 elev + c.vertical_jump_moa / MOA_PER_RAD,
503 azim + c.horizontal_jump_moa / MOA_PER_RAD,
504 )
505 }
506 None => (elev, azim),
507 }
508 }
509
510 fn aerodynamic_jump_components(
518 &self,
519 ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
520 if !self.inputs.enable_aerodynamic_jump {
521 return None;
522 }
523 let diameter_m = self.inputs.bullet_diameter;
527 if !(self.inputs.twist_rate.is_finite() && self.inputs.twist_rate != 0.0)
528 || !(diameter_m.is_finite() && diameter_m > 0.0)
529 || !(self.inputs.bullet_length.is_finite() && self.inputs.bullet_length > 0.0)
530 || !self.inputs.muzzle_velocity.is_finite()
531 {
532 return None;
533 }
534
535 let (_, _, temp_c, pressure_hpa) = self.resolved_atmosphere();
537 let sg = crate::stability::compute_stability_coefficient(
538 &self.inputs,
539 (self.atmosphere.altitude, temp_c, pressure_hpa, 0.0),
540 );
541 if !(sg.is_finite() && sg > 0.0) {
542 return None;
543 }
544 let length_calibers = self.inputs.bullet_length / diameter_m;
545
546 const MS_TO_MPH: f64 = 2.236_936_292_054_4;
552 let crosswind_from_right_mph = self.wind.speed * self.wind.direction.sin() * MS_TO_MPH;
553
554 let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
555 sg,
556 length_calibers,
557 crosswind_from_right_mph,
558 self.inputs.is_twist_right,
559 );
560 if !vertical_jump_moa.is_finite() {
561 return None;
562 }
563
564 const MOA_PER_RAD: f64 = 3437.7467707849;
565 Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
566 vertical_jump_moa,
567 horizontal_jump_moa: 0.0,
569 jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
570 magnus_component_moa: 0.0,
571 yaw_component_moa: 0.0,
572 stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
573 })
574 }
575
576 fn resolved_atmosphere(&self) -> (f64, f64, f64, f64) {
577 let (temp_c, pressure_hpa) = crate::atmosphere::resolve_station_conditions(
578 self.atmosphere.temperature,
579 self.atmosphere.pressure,
580 self.atmosphere.altitude,
581 );
582 let (density, speed_of_sound) = crate::atmosphere::calculate_atmosphere(
583 self.atmosphere.altitude,
584 Some(temp_c),
585 Some(pressure_hpa),
586 self.atmosphere.humidity,
587 );
588 (density, speed_of_sound, temp_c, pressure_hpa)
589 }
590
591 fn gravity_acceleration(&self) -> Vector3<f64> {
592 let theta = self.inputs.shooting_angle;
593 Vector3::new(
594 -crate::constants::G_ACCEL_MPS2 * theta.sin(),
595 -crate::constants::G_ACCEL_MPS2 * theta.cos(),
596 0.0,
597 )
598 }
599
600 fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
601 let model = match self.inputs.wind_shear_model.as_str() {
616 "logarithmic" => WindShearModel::Logarithmic,
617 "power_law" | "powerlaw" | "exponential" => WindShearModel::PowerLaw,
618 "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
619 "custom_layers" | "custom" => WindShearModel::CustomLayers,
620 _ => WindShearModel::PowerLaw,
621 };
622 let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
623
624 Vector3::new(
627 -self.wind.speed * self.wind.direction.cos() * speed_ratio, 0.0,
629 -self.wind.speed * self.wind.direction.sin() * speed_ratio, )
631 }
632
633 pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
634 let mut result = if self.inputs.use_rk4 {
635 if self.inputs.use_adaptive_rk45 {
636 self.solve_rk45()?
637 } else {
638 self.solve_rk4()?
639 }
640 } else {
641 self.solve_euler()?
642 };
643 self.apply_spin_drift(&mut result);
644 Ok(result)
645 }
646
647 fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
653 if !self.inputs.use_enhanced_spin_drift {
654 return;
655 }
656 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 {
660 return;
661 }
662
663 let length_in = if self.inputs.bullet_length > 0.0 {
665 self.inputs.bullet_length / 0.0254
666 } else {
667 4.5 * d_in
668 };
669 let (_, _, temp_c, press_hpa) = self.resolved_atmosphere();
675 let temp_k = temp_c + 273.15; let density_correction = if press_hpa > 0.0 && temp_k > 0.0 {
677 (temp_k / 288.15) * (1013.25 / press_hpa)
678 } else {
679 1.0
680 };
681 let sg = crate::spin_drift::miller_stability(d_in, m_gr, twist_in, length_in)
682 * density_correction;
683 let sign = if self.inputs.is_twist_right {
684 1.0
685 } else {
686 -1.0
687 };
688
689 for p in result.points.iter_mut() {
690 if p.time <= 0.0 {
691 continue;
692 }
693 let sd_in = 1.25 * (sg + 1.2) * p.time.powf(1.83); p.position.z += sign * sd_in * 0.0254; }
696
697 if let Some(samples) = result.sampled_points.as_mut() {
701 for s in samples.iter_mut() {
702 if s.time_s <= 0.0 {
703 continue;
704 }
705 let sd_in = 1.25 * (sg + 1.2) * s.time_s.powf(1.83);
706 s.wind_drift_m += sign * sd_in * 0.0254;
707 }
708 }
709 }
710
711 fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
712 let mut time = 0.0;
714 let mut position = Vector3::new(
717 0.0,
718 self.inputs.muzzle_height, 0.0,
720 );
721 let aj_components = self.aerodynamic_jump_components();
727 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
728 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
729 let mut velocity = Vector3::new(
730 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
734
735 let mut points = Vec::new();
736 let mut max_height = position.y;
737 let mut min_pitch_damping = 1.0; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
743 let mut crossed_transonic = false;
744 let mut crossed_subsonic = false;
745
746 let mut angular_state = if self.inputs.enable_precession_nutation {
748 Some(AngularState {
749 pitch_angle: 0.001, yaw_angle: 0.001,
751 pitch_rate: 0.0,
752 yaw_rate: 0.0,
753 precession_angle: 0.0,
754 nutation_phase: 0.0,
755 })
756 } else {
757 None
758 };
759 let mut max_yaw_angle = 0.0;
760 let mut max_precession_angle = 0.0;
761
762 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) = self.resolved_atmosphere();
764
765 let wind_vector = Vector3::new(
769 -self.wind.speed * self.wind.direction.cos(), 0.0,
771 -self.wind.speed * self.wind.direction.sin(), );
773
774 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
777 self.inputs.bullet_model.as_deref().unwrap_or("default"),
778 );
779
780 while position.x < self.max_range
782 && position.y > self.inputs.ground_threshold
783 && time < 100.0
784 {
785 let velocity_magnitude = velocity.magnitude();
787 let kinetic_energy =
788 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
789
790 points.push(TrajectoryPoint {
791 time,
792 position: position,
793 velocity_magnitude,
794 kinetic_energy,
795 });
796
797 {
800 let mach_here = if speed_of_sound > 0.0 {
801 velocity_magnitude / speed_of_sound
802 } else {
803 0.0
804 };
805 if !crossed_transonic && mach_here < 1.2 {
806 crossed_transonic = true;
807 transonic_distances.push(position.x);
808 }
809 if !crossed_subsonic && mach_here < 1.0 {
810 crossed_subsonic = true;
811 transonic_distances.push(position.x);
812 }
813 }
814
815 #[cfg(debug_assertions)]
819 if points.len() == 1 || points.len() % 100 == 0 {
820 eprintln!("Trajectory point {}: time={:.3}s, downrange={:.2}m, vertical={:.2}m, lateral={:.2}m, vel={:.1}m/s",
821 points.len(), time, position.x, position.y, position.z, velocity_magnitude);
822 }
823
824 if position.y > max_height {
826 max_height = position.y;
827 }
828
829 if self.inputs.enable_pitch_damping {
831 let mach = velocity_magnitude / speed_of_sound;
832
833 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
835 transonic_mach = Some(mach);
836 }
837
838 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
840
841 if pitch_damping < min_pitch_damping {
843 min_pitch_damping = pitch_damping;
844 }
845 }
846
847 if self.inputs.enable_precession_nutation {
849 if let Some(ref mut state) = angular_state {
850 let velocity_magnitude = velocity.magnitude();
851 let mach = velocity_magnitude / speed_of_sound;
852
853 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
855 let velocity_fps = velocity_magnitude * 3.28084;
856 let twist_rate_ft = self.inputs.twist_rate / 12.0;
857 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
858 } else {
859 0.0
860 };
861
862 let params = PrecessionNutationParams {
864 mass_kg: self.inputs.bullet_mass,
865 caliber_m: self.inputs.bullet_diameter,
866 length_m: self.inputs.bullet_length,
867 spin_rate_rad_s,
868 spin_inertia: 6.94e-8, transverse_inertia: 9.13e-7, velocity_mps: velocity_magnitude,
871 air_density_kg_m3: air_density,
872 mach,
873 pitch_damping_coeff: -0.8,
874 nutation_damping_factor: 0.05,
875 };
876
877 *state = calculate_combined_angular_motion(
879 ¶ms,
880 state,
881 time,
882 self.time_step,
883 0.001, );
885
886 if state.yaw_angle.abs() > max_yaw_angle {
888 max_yaw_angle = state.yaw_angle.abs();
889 }
890 if state.precession_angle.abs() > max_precession_angle {
891 max_precession_angle = state.precession_angle.abs();
892 }
893 }
894 }
895
896 let acceleration =
903 self.calculate_acceleration(&position, &velocity, air_density,
904 &wind_vector,
905 (speed_of_sound, resolved_temp_c, resolved_press_hpa),
906 );
907
908 velocity += acceleration * self.time_step;
910 position += velocity * self.time_step;
911 time += self.time_step;
912 }
913
914 let last_point = points.last().ok_or("No trajectory points generated")?;
916
917 let sampled_points = if self.inputs.enable_trajectory_sampling {
919 let trajectory_data = TrajectoryData {
920 times: points.iter().map(|p| p.time).collect(),
921 positions: points.iter().map(|p| p.position).collect(),
922 velocities: points
923 .iter()
924 .map(|p| {
925 Vector3::new(0.0, 0.0, p.velocity_magnitude)
927 })
928 .collect(),
929 transonic_distances, };
931
932 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
937 let outputs = TrajectoryOutputs {
938 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
940 time_of_flight_s: last_point.time,
941 max_ord_dist_horiz_m: max_height,
942 sight_height_m: sight_position_m,
943 };
944
945 let samples = sample_trajectory(
947 &trajectory_data,
948 &outputs,
949 self.inputs.sample_interval,
950 self.inputs.bullet_mass,
951 );
952 Some(samples)
953 } else {
954 None
955 };
956
957 Ok(TrajectoryResult {
958 max_range: last_point.position.x, max_height,
960 time_of_flight: last_point.time,
961 impact_velocity: last_point.velocity_magnitude,
962 impact_energy: last_point.kinetic_energy,
963 points,
964 sampled_points,
965 min_pitch_damping: if self.inputs.enable_pitch_damping {
966 Some(min_pitch_damping)
967 } else {
968 None
969 },
970 transonic_mach,
971 angular_state,
972 max_yaw_angle: if self.inputs.enable_precession_nutation {
973 Some(max_yaw_angle)
974 } else {
975 None
976 },
977 max_precession_angle: if self.inputs.enable_precession_nutation {
978 Some(max_precession_angle)
979 } else {
980 None
981 },
982 aerodynamic_jump: aj_components,
983 })
984 }
985
986 fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
987 let mut time = 0.0;
989 let mut position = Vector3::new(
993 0.0,
994 self.inputs.muzzle_height, 0.0,
996 );
997
998 let aj_components = self.aerodynamic_jump_components();
1004 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1005 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1006 let mut velocity = Vector3::new(
1007 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1011
1012 let mut points = Vec::new();
1013 let mut max_height = position.y;
1014 let mut min_pitch_damping = 1.0; let mut transonic_mach = None; let mut transonic_distances: Vec<f64> = Vec::new();
1020 let mut crossed_transonic = false;
1021 let mut crossed_subsonic = false;
1022
1023 let mut angular_state = if self.inputs.enable_precession_nutation {
1025 Some(AngularState {
1026 pitch_angle: 0.001, yaw_angle: 0.001,
1028 pitch_rate: 0.0,
1029 yaw_rate: 0.0,
1030 precession_angle: 0.0,
1031 nutation_phase: 0.0,
1032 })
1033 } else {
1034 None
1035 };
1036 let mut max_yaw_angle = 0.0;
1037 let mut max_precession_angle = 0.0;
1038
1039 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) = self.resolved_atmosphere();
1041
1042 let wind_vector = Vector3::new(
1046 -self.wind.speed * self.wind.direction.cos(), 0.0,
1048 -self.wind.speed * self.wind.direction.sin(), );
1050
1051 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1054 self.inputs.bullet_model.as_deref().unwrap_or("default"),
1055 );
1056
1057 while position.x < self.max_range
1059 && position.y > self.inputs.ground_threshold
1060 && time < 100.0
1061 {
1062 let velocity_magnitude = velocity.magnitude();
1064 let kinetic_energy =
1065 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1066
1067 points.push(TrajectoryPoint {
1068 time,
1069 position: position,
1070 velocity_magnitude,
1071 kinetic_energy,
1072 });
1073
1074 {
1077 let mach_here = if speed_of_sound > 0.0 {
1078 velocity_magnitude / speed_of_sound
1079 } else {
1080 0.0
1081 };
1082 if !crossed_transonic && mach_here < 1.2 {
1083 crossed_transonic = true;
1084 transonic_distances.push(position.x);
1085 }
1086 if !crossed_subsonic && mach_here < 1.0 {
1087 crossed_subsonic = true;
1088 transonic_distances.push(position.x);
1089 }
1090 }
1091
1092 if position.y > max_height {
1093 max_height = position.y;
1094 }
1095
1096 if self.inputs.enable_pitch_damping {
1098 let mach = velocity_magnitude / speed_of_sound;
1099
1100 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1102 transonic_mach = Some(mach);
1103 }
1104
1105 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1107
1108 if pitch_damping < min_pitch_damping {
1110 min_pitch_damping = pitch_damping;
1111 }
1112 }
1113
1114 if self.inputs.enable_precession_nutation {
1116 if let Some(ref mut state) = angular_state {
1117 let velocity_magnitude = velocity.magnitude();
1118 let mach = velocity_magnitude / speed_of_sound;
1119
1120 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
1122 let velocity_fps = velocity_magnitude * 3.28084;
1123 let twist_rate_ft = self.inputs.twist_rate / 12.0;
1124 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
1125 } else {
1126 0.0
1127 };
1128
1129 let params = PrecessionNutationParams {
1131 mass_kg: self.inputs.bullet_mass,
1132 caliber_m: self.inputs.bullet_diameter,
1133 length_m: self.inputs.bullet_length,
1134 spin_rate_rad_s,
1135 spin_inertia: 6.94e-8, transverse_inertia: 9.13e-7, velocity_mps: velocity_magnitude,
1138 air_density_kg_m3: air_density,
1139 mach,
1140 pitch_damping_coeff: -0.8,
1141 nutation_damping_factor: 0.05,
1142 };
1143
1144 *state = calculate_combined_angular_motion(
1146 ¶ms,
1147 state,
1148 time,
1149 self.time_step,
1150 0.001, );
1152
1153 if state.yaw_angle.abs() > max_yaw_angle {
1155 max_yaw_angle = state.yaw_angle.abs();
1156 }
1157 if state.precession_angle.abs() > max_precession_angle {
1158 max_precession_angle = state.precession_angle.abs();
1159 }
1160 }
1161 }
1162
1163 let dt = self.time_step;
1165
1166 let acc1 = self.calculate_acceleration(&position, &velocity, air_density, &wind_vector, (speed_of_sound, resolved_temp_c, resolved_press_hpa));
1168
1169 let pos2 = position + velocity * (dt * 0.5);
1171 let vel2 = velocity + acc1 * (dt * 0.5);
1172 let acc2 = self.calculate_acceleration(&pos2, &vel2, air_density, &wind_vector, (speed_of_sound, resolved_temp_c, resolved_press_hpa));
1173
1174 let pos3 = position + vel2 * (dt * 0.5);
1176 let vel3 = velocity + acc2 * (dt * 0.5);
1177 let acc3 = self.calculate_acceleration(&pos3, &vel3, air_density, &wind_vector, (speed_of_sound, resolved_temp_c, resolved_press_hpa));
1178
1179 let pos4 = position + vel3 * dt;
1181 let vel4 = velocity + acc3 * dt;
1182 let acc4 = self.calculate_acceleration(&pos4, &vel4, air_density, &wind_vector, (speed_of_sound, resolved_temp_c, resolved_press_hpa));
1183
1184 position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
1186 velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
1187 time += dt;
1188 }
1189
1190 let last_point = points.last().ok_or("No trajectory points generated")?;
1192
1193 let sampled_points = if self.inputs.enable_trajectory_sampling {
1195 let trajectory_data = TrajectoryData {
1196 times: points.iter().map(|p| p.time).collect(),
1197 positions: points.iter().map(|p| p.position).collect(),
1198 velocities: points
1199 .iter()
1200 .map(|p| {
1201 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1203 })
1204 .collect(),
1205 transonic_distances, };
1207
1208 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1213 let outputs = TrajectoryOutputs {
1214 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
1216 time_of_flight_s: last_point.time,
1217 max_ord_dist_horiz_m: max_height,
1218 sight_height_m: sight_position_m,
1219 };
1220
1221 let samples = sample_trajectory(
1223 &trajectory_data,
1224 &outputs,
1225 self.inputs.sample_interval,
1226 self.inputs.bullet_mass,
1227 );
1228 Some(samples)
1229 } else {
1230 None
1231 };
1232
1233 Ok(TrajectoryResult {
1234 max_range: last_point.position.x, max_height,
1236 time_of_flight: last_point.time,
1237 impact_velocity: last_point.velocity_magnitude,
1238 impact_energy: last_point.kinetic_energy,
1239 points,
1240 sampled_points,
1241 min_pitch_damping: if self.inputs.enable_pitch_damping {
1242 Some(min_pitch_damping)
1243 } else {
1244 None
1245 },
1246 transonic_mach,
1247 angular_state,
1248 max_yaw_angle: if self.inputs.enable_precession_nutation {
1249 Some(max_yaw_angle)
1250 } else {
1251 None
1252 },
1253 max_precession_angle: if self.inputs.enable_precession_nutation {
1254 Some(max_precession_angle)
1255 } else {
1256 None
1257 },
1258 aerodynamic_jump: aj_components,
1259 })
1260 }
1261
1262 fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
1263 let mut time = 0.0;
1265 let mut position = Vector3::new(
1268 0.0,
1269 self.inputs.muzzle_height, 0.0,
1271 );
1272
1273 let aj_components = self.aerodynamic_jump_components();
1279 let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1280 let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1281 let mut velocity = Vector3::new(
1282 horizontal_velocity * launch_azim.cos(), self.inputs.muzzle_velocity * launch_elev.sin(), horizontal_velocity * launch_azim.sin(), );
1286
1287 let mut points = Vec::new();
1288 let mut max_height = position.y;
1289 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;
1297 const MAX_ITERATIONS: usize = 100000;
1298
1299 let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) = self.resolved_atmosphere();
1302 let wind_vector = Vector3::new(
1305 -self.wind.speed * self.wind.direction.cos(), 0.0,
1307 -self.wind.speed * self.wind.direction.sin(), );
1309
1310 let mut transonic_distances: Vec<f64> = Vec::new();
1312 let mut crossed_transonic = false;
1313 let mut crossed_subsonic = false;
1314
1315 let mut min_pitch_damping = 1.0;
1320 let mut transonic_mach: Option<f64> = None;
1321 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1322 self.inputs.bullet_model.as_deref().unwrap_or("default"),
1323 );
1324 let mut angular_state = if self.inputs.enable_precession_nutation {
1325 Some(AngularState {
1326 pitch_angle: 0.001,
1327 yaw_angle: 0.001,
1328 pitch_rate: 0.0,
1329 yaw_rate: 0.0,
1330 precession_angle: 0.0,
1331 nutation_phase: 0.0,
1332 })
1333 } else {
1334 None
1335 };
1336 let mut max_yaw_angle = 0.0;
1337 let mut max_precession_angle = 0.0;
1338
1339 while position.x < self.max_range
1340 && position.y > self.inputs.ground_threshold
1341 && time < 100.0
1342 {
1343 iteration_count += 1;
1345 if iteration_count > MAX_ITERATIONS {
1346 break; }
1348
1349 let velocity_magnitude = velocity.magnitude();
1351 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
1352
1353 points.push(TrajectoryPoint {
1354 time,
1355 position: position,
1356 velocity_magnitude,
1357 kinetic_energy,
1358 });
1359
1360 {
1363 let mach_here = if speed_of_sound > 0.0 {
1364 velocity_magnitude / speed_of_sound
1365 } else {
1366 0.0
1367 };
1368 if !crossed_transonic && mach_here < 1.2 {
1369 crossed_transonic = true;
1370 transonic_distances.push(position.x);
1371 }
1372 if !crossed_subsonic && mach_here < 1.0 {
1373 crossed_subsonic = true;
1374 transonic_distances.push(position.x);
1375 }
1376 }
1377
1378 if position.y > max_height {
1379 max_height = position.y;
1380 }
1381
1382 if self.inputs.enable_pitch_damping {
1385 let mach = velocity_magnitude / speed_of_sound;
1386 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1387 transonic_mach = Some(mach);
1388 }
1389 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1390 if pitch_damping < min_pitch_damping {
1391 min_pitch_damping = pitch_damping;
1392 }
1393 }
1394
1395 if self.inputs.enable_precession_nutation {
1399 if let Some(ref mut state) = angular_state {
1400 let mach = velocity_magnitude / speed_of_sound;
1401
1402 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
1403 let velocity_fps = velocity_magnitude * 3.28084;
1404 let twist_rate_ft = self.inputs.twist_rate / 12.0;
1405 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
1406 } else {
1407 0.0
1408 };
1409
1410 let params = PrecessionNutationParams {
1411 mass_kg: self.inputs.bullet_mass,
1412 caliber_m: self.inputs.bullet_diameter,
1413 length_m: self.inputs.bullet_length,
1414 spin_rate_rad_s,
1415 spin_inertia: 6.94e-8,
1416 transverse_inertia: 9.13e-7,
1417 velocity_mps: velocity_magnitude,
1418 air_density_kg_m3: air_density,
1419 mach,
1420 pitch_damping_coeff: -0.8,
1421 nutation_damping_factor: 0.05,
1422 };
1423
1424 *state = calculate_combined_angular_motion(¶ms, state, time, dt, 0.001);
1425
1426 if state.yaw_angle.abs() > max_yaw_angle {
1427 max_yaw_angle = state.yaw_angle.abs();
1428 }
1429 if state.precession_angle.abs() > max_precession_angle {
1430 max_precession_angle = state.precession_angle.abs();
1431 }
1432 }
1433 }
1434
1435 let (new_pos, new_vel, new_dt) = self.rk45_step(
1437 &position,
1438 &velocity,
1439 dt,
1440 air_density,
1441 &wind_vector,
1442 tolerance,
1443 (speed_of_sound, resolved_temp_c, resolved_press_hpa),
1444 );
1445
1446 position = new_pos;
1451 velocity = new_vel;
1452 time += dt;
1453
1454 dt = (safety_factor * new_dt).clamp(min_dt, max_dt);
1456 }
1457
1458 if points.is_empty() {
1460 return Err(BallisticsError::from("No trajectory points calculated"));
1461 }
1462
1463 {
1472 let prev = points.last().unwrap().clone();
1473 let overshoot_x = position.x;
1474 let crossed_range = overshoot_x >= self.max_range && prev.position.x < self.max_range;
1475 if crossed_range {
1476 let span = overshoot_x - prev.position.x;
1477 if span > 1e-9 {
1478 let frac = (self.max_range - prev.position.x) / span;
1479 let interp_pos = prev.position + (position - prev.position) * frac;
1480 let interp_vel_mag = prev.velocity_magnitude
1481 + (velocity.magnitude() - prev.velocity_magnitude) * frac;
1482 let interp_time = prev.time + (time - prev.time) * frac;
1483 let interp_ke = 0.5 * self.inputs.bullet_mass * interp_vel_mag * interp_vel_mag;
1484 points.push(TrajectoryPoint {
1485 time: interp_time,
1486 position: interp_pos,
1487 velocity_magnitude: interp_vel_mag,
1488 kinetic_energy: interp_ke,
1489 });
1490 if interp_pos.y > max_height {
1491 max_height = interp_pos.y;
1492 }
1493 }
1494 }
1495 }
1496
1497 let last_point = points.last().unwrap();
1498
1499 let sampled_points = if self.inputs.enable_trajectory_sampling {
1501 let trajectory_data = TrajectoryData {
1503 times: points.iter().map(|p| p.time).collect(),
1504 positions: points.iter().map(|p| p.position).collect(),
1505 velocities: points
1506 .iter()
1507 .map(|p| {
1508 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1510 })
1511 .collect(),
1512 transonic_distances, };
1514
1515 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1520 let outputs = TrajectoryOutputs {
1521 target_distance_horiz_m: last_point.position.x,
1522 target_vertical_height_m: sight_position_m,
1523 time_of_flight_s: last_point.time,
1524 max_ord_dist_horiz_m: max_height,
1525 sight_height_m: sight_position_m,
1526 };
1527
1528 let samples = sample_trajectory(
1529 &trajectory_data,
1530 &outputs,
1531 self.inputs.sample_interval,
1532 self.inputs.bullet_mass,
1533 );
1534 Some(samples)
1535 } else {
1536 None
1537 };
1538
1539 Ok(TrajectoryResult {
1540 max_range: last_point.position.x, max_height,
1542 time_of_flight: last_point.time,
1543 impact_velocity: last_point.velocity_magnitude,
1544 impact_energy: last_point.kinetic_energy,
1545 points,
1546 sampled_points,
1547 min_pitch_damping: if self.inputs.enable_pitch_damping {
1548 Some(min_pitch_damping)
1549 } else {
1550 None
1551 },
1552 transonic_mach,
1553 angular_state,
1554 max_yaw_angle: if self.inputs.enable_precession_nutation {
1555 Some(max_yaw_angle)
1556 } else {
1557 None
1558 },
1559 max_precession_angle: if self.inputs.enable_precession_nutation {
1560 Some(max_precession_angle)
1561 } else {
1562 None
1563 },
1564 aerodynamic_jump: aj_components,
1565 })
1566 }
1567
1568 fn rk45_step(
1569 &self,
1570 position: &Vector3<f64>,
1571 velocity: &Vector3<f64>,
1572 dt: f64,
1573 air_density: f64,
1574 wind_vector: &Vector3<f64>,
1575 tolerance: f64,
1576 resolved_atmo: (f64, f64, f64), ) -> (Vector3<f64>, Vector3<f64>, f64) {
1578 const A21: f64 = 1.0 / 5.0;
1580 const A31: f64 = 3.0 / 40.0;
1581 const A32: f64 = 9.0 / 40.0;
1582 const A41: f64 = 44.0 / 45.0;
1583 const A42: f64 = -56.0 / 15.0;
1584 const A43: f64 = 32.0 / 9.0;
1585 const A51: f64 = 19372.0 / 6561.0;
1586 const A52: f64 = -25360.0 / 2187.0;
1587 const A53: f64 = 64448.0 / 6561.0;
1588 const A54: f64 = -212.0 / 729.0;
1589 const A61: f64 = 9017.0 / 3168.0;
1590 const A62: f64 = -355.0 / 33.0;
1591 const A63: f64 = 46732.0 / 5247.0;
1592 const A64: f64 = 49.0 / 176.0;
1593 const A65: f64 = -5103.0 / 18656.0;
1594 const A71: f64 = 35.0 / 384.0;
1595 const A73: f64 = 500.0 / 1113.0;
1596 const A74: f64 = 125.0 / 192.0;
1597 const A75: f64 = -2187.0 / 6784.0;
1598 const A76: f64 = 11.0 / 84.0;
1599
1600 const B1: f64 = 35.0 / 384.0;
1602 const B3: f64 = 500.0 / 1113.0;
1603 const B4: f64 = 125.0 / 192.0;
1604 const B5: f64 = -2187.0 / 6784.0;
1605 const B6: f64 = 11.0 / 84.0;
1606
1607 const B1_ERR: f64 = 5179.0 / 57600.0;
1609 const B3_ERR: f64 = 7571.0 / 16695.0;
1610 const B4_ERR: f64 = 393.0 / 640.0;
1611 const B5_ERR: f64 = -92097.0 / 339200.0;
1612 const B6_ERR: f64 = 187.0 / 2100.0;
1613 const B7_ERR: f64 = 1.0 / 40.0;
1614
1615 let k1_v = self.calculate_acceleration(position, velocity, air_density, wind_vector, resolved_atmo);
1617 let k1_p = *velocity;
1618
1619 let p2 = position + dt * A21 * k1_p;
1620 let v2 = velocity + dt * A21 * k1_v;
1621 let k2_v = self.calculate_acceleration(&p2, &v2, air_density, wind_vector, resolved_atmo);
1622 let k2_p = v2;
1623
1624 let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
1625 let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
1626 let k3_v = self.calculate_acceleration(&p3, &v3, air_density, wind_vector, resolved_atmo);
1627 let k3_p = v3;
1628
1629 let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
1630 let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
1631 let k4_v = self.calculate_acceleration(&p4, &v4, air_density, wind_vector, resolved_atmo);
1632 let k4_p = v4;
1633
1634 let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
1635 let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
1636 let k5_v = self.calculate_acceleration(&p5, &v5, air_density, wind_vector, resolved_atmo);
1637 let k5_p = v5;
1638
1639 let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
1640 let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
1641 let k6_v = self.calculate_acceleration(&p6, &v6, air_density, wind_vector, resolved_atmo);
1642 let k6_p = v6;
1643
1644 let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
1645 let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
1646 let k7_v = self.calculate_acceleration(&p7, &v7, air_density, wind_vector, resolved_atmo);
1647 let k7_p = v7;
1648
1649 let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
1651 let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
1652
1653 let pos_err = position
1655 + dt * (B1_ERR * k1_p
1656 + B3_ERR * k3_p
1657 + B4_ERR * k4_p
1658 + B5_ERR * k5_p
1659 + B6_ERR * k6_p
1660 + B7_ERR * k7_p);
1661 let vel_err = velocity
1662 + dt * (B1_ERR * k1_v
1663 + B3_ERR * k3_v
1664 + B4_ERR * k4_v
1665 + B5_ERR * k5_v
1666 + B6_ERR * k6_v
1667 + B7_ERR * k7_v);
1668
1669 let pos_error = (new_pos - pos_err).magnitude();
1671 let vel_error = (new_vel - vel_err).magnitude();
1672 let error = (pos_error + vel_error) / (1.0 + position.magnitude() + velocity.magnitude());
1673
1674 let dt_new = if error < tolerance {
1676 dt * (tolerance / error).powf(0.2).min(2.0)
1677 } else {
1678 dt * (tolerance / error).powf(0.25).max(0.1)
1679 };
1680
1681 (new_pos, new_vel, dt_new)
1682 }
1683
1684 fn calculate_acceleration(
1685 &self,
1686 position: &Vector3<f64>,
1687 velocity: &Vector3<f64>,
1688 air_density: f64,
1689 wind_vector: &Vector3<f64>,
1690 resolved_atmo: (f64, f64, f64), ) -> Vector3<f64> {
1692 let actual_wind = if let Some(ref sock) = self.wind_sock {
1698 sock.vector_for_range_stateless(position.x)
1699 } else if self.inputs.enable_wind_shear {
1700 self.get_wind_at_altitude(position.y)
1701 } else {
1702 *wind_vector
1703 };
1704
1705 let relative_velocity = velocity - actual_wind;
1706 let velocity_magnitude = relative_velocity.magnitude();
1707
1708 if velocity_magnitude < 0.001 {
1709 return self.gravity_acceleration();
1710 }
1711
1712 let cd = self.calculate_drag_coefficient(velocity_magnitude, resolved_atmo.0);
1714
1715 let velocity_fps = velocity_magnitude * 3.28084;
1717
1718 let base_bc = if let Some(ref segments) = self.inputs.bc_segments_data {
1720 segments
1722 .iter()
1723 .find(|seg| velocity_fps >= seg.velocity_min && velocity_fps < seg.velocity_max)
1724 .map(|seg| seg.bc_value)
1725 .unwrap_or(self.inputs.bc_value)
1726 } else {
1727 self.inputs.bc_value
1728 };
1729
1730 let effective_bc = if let Some(ref cluster_bc) = self.cluster_bc {
1732 cluster_bc.apply_correction(
1733 base_bc,
1734 self.inputs.caliber_inches, self.inputs.weight_grains,
1736 velocity_fps,
1737 )
1738 } else {
1739 base_bc
1740 };
1741 let effective_bc = effective_bc.max(1e-6);
1745
1746 let cd_to_retard = crate::constants::CD_TO_RETARD;
1751 let standard_factor = cd * cd_to_retard;
1752 let density_scale = air_density / 1.225; let a_drag_ft_s2 =
1756 (velocity_fps * velocity_fps) * standard_factor * density_scale / effective_bc;
1757 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
1761
1762 let mut accel = drag_acceleration + self.gravity_acceleration();
1765
1766 if self.inputs.enable_coriolis {
1769 if let Some(lat_deg) = self.inputs.latitude {
1770 let omega_earth = 7.2921159e-5_f64; let lat = lat_deg.to_radians();
1772 let az = self.inputs.shot_azimuth; let omega = Vector3::new(
1779 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), );
1783 accel += -2.0 * omega.cross(velocity);
1788 }
1789 }
1790
1791 if self.inputs.enable_magnus
1793 && self.inputs.bullet_diameter > 0.0
1794 && self.inputs.twist_rate > 0.0
1795 {
1796 let (_, spin_rad_s) =
1797 crate::spin_drift::calculate_spin_rate(velocity_magnitude, self.inputs.twist_rate);
1798 let (speed_of_sound, temp_c, press_hpa) = resolved_atmo;
1799 let temp_k = temp_c + 273.15;
1800 let mach = velocity_magnitude / speed_of_sound;
1801
1802 let d_in = self.inputs.bullet_diameter / 0.0254;
1804 let m_gr = self.inputs.bullet_mass / 0.00006479891;
1805 let l_in = if self.inputs.bullet_length > 0.0 {
1806 self.inputs.bullet_length / 0.0254
1807 } else {
1808 4.5 * d_in
1809 };
1810 let density_correction = if press_hpa > 0.0 && temp_k > 0.0 {
1814 (temp_k / 288.15) * (1013.25 / press_hpa)
1815 } else {
1816 1.0
1817 };
1818 let sg = crate::spin_drift::miller_stability(d_in, m_gr, self.inputs.twist_rate, l_in)
1819 * density_correction;
1820
1821 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
1823 sg,
1824 velocity_magnitude,
1825 spin_rad_s,
1826 0.0, 0.0, air_density,
1829 d_in,
1830 l_in,
1831 m_gr,
1832 mach,
1833 "match",
1834 false,
1835 );
1836
1837 let diameter_m = self.inputs.bullet_diameter; let spin_param = spin_rad_s * diameter_m / (2.0 * velocity_magnitude);
1840 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
1841 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
1842 let magnus_force = 0.5
1843 * air_density
1844 * velocity_magnitude.powi(2)
1845 * area
1846 * c_np
1847 * spin_param
1848 * yaw_rad.sin();
1849
1850 let velocity_unit = relative_velocity / velocity_magnitude;
1853 let up = Vector3::new(0.0, 1.0, 0.0);
1854 let mut dir = velocity_unit.cross(&up);
1855 let dir_norm = dir.norm();
1856 if dir_norm > 1e-12 && magnus_force.abs() > 1e-12 {
1857 dir /= dir_norm;
1858 if !self.inputs.is_twist_right {
1859 dir = -dir;
1860 }
1861 accel += (magnus_force / self.inputs.bullet_mass) * dir;
1862 }
1863 }
1864
1865 accel
1866 }
1867
1868 fn calculate_drag_coefficient(&self, velocity: f64, speed_of_sound: f64) -> f64 {
1869 let mach = velocity / speed_of_sound;
1870
1871 if let Some(ref table) = self.inputs.custom_drag_table {
1875 return table.interpolate(mach);
1876 }
1877
1878 let base_cd = crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type);
1880
1881 crate::form_factor::apply_form_factor_to_drag(
1886 base_cd,
1887 self.inputs.bullet_model.as_deref(),
1888 &self.inputs.bc_type,
1889 self.inputs.use_form_factor,
1890 )
1891 }
1892}
1893
1894#[derive(Debug, Clone)]
1896pub struct MonteCarloParams {
1897 pub num_simulations: usize,
1898 pub velocity_std_dev: f64,
1899 pub angle_std_dev: f64,
1900 pub bc_std_dev: f64,
1901 pub wind_speed_std_dev: f64,
1902 pub target_distance: Option<f64>,
1903 pub base_wind_speed: f64,
1904 pub base_wind_direction: f64,
1905 pub azimuth_std_dev: f64, }
1907
1908impl Default for MonteCarloParams {
1909 fn default() -> Self {
1910 Self {
1911 num_simulations: 1000,
1912 velocity_std_dev: 1.0,
1913 angle_std_dev: 0.001,
1914 bc_std_dev: 0.01,
1915 wind_speed_std_dev: 1.0,
1916 target_distance: None,
1917 base_wind_speed: 0.0,
1918 base_wind_direction: 0.0,
1919 azimuth_std_dev: 0.001, }
1921 }
1922}
1923
1924#[derive(Debug, Clone)]
1926pub struct MonteCarloResults {
1927 pub ranges: Vec<f64>,
1928 pub impact_velocities: Vec<f64>,
1929 pub impact_positions: Vec<Vector3<f64>>,
1930}
1931
1932pub const DEFAULT_HIT_RADIUS_M: f64 = 0.3;
1935
1936impl MonteCarloResults {
1937 pub fn hit_probability(&self, hit_radius_m: f64) -> f64 {
1946 if self.impact_positions.is_empty() {
1947 return 0.0;
1948 }
1949 let hits = self
1950 .impact_positions
1951 .iter()
1952 .filter(|p| p.norm() < hit_radius_m)
1953 .count();
1954 hits as f64 / self.impact_positions.len() as f64
1955 }
1956}
1957
1958pub fn run_monte_carlo(
1960 base_inputs: BallisticInputs,
1961 params: MonteCarloParams,
1962) -> Result<MonteCarloResults, BallisticsError> {
1963 let base_wind = WindConditions {
1964 speed: params.base_wind_speed,
1965 direction: params.base_wind_direction,
1966 };
1967 run_monte_carlo_with_wind(base_inputs, base_wind, params)
1968}
1969
1970pub fn run_monte_carlo_with_wind(
1972 base_inputs: BallisticInputs,
1973 base_wind: WindConditions,
1974 params: MonteCarloParams,
1975) -> Result<MonteCarloResults, BallisticsError> {
1976 use rand_distr::{Distribution, Normal};
1977
1978 let mut rng = rand::rng();
1979 let mut ranges = Vec::new();
1980 let mut impact_velocities = Vec::new();
1981 let mut impact_positions = Vec::new();
1982
1983 let atmosphere = AtmosphericConditions {
1984 temperature: base_inputs.temperature,
1985 pressure: base_inputs.pressure,
1986 humidity: base_inputs.humidity_percent(),
1987 altitude: base_inputs.altitude,
1988 };
1989 let target_hint = params
1990 .target_distance
1991 .unwrap_or(base_inputs.target_distance);
1992 let solver_max_range = target_hint.max(1000.0) * 2.0;
1993
1994 let mut baseline_solver =
1996 TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), atmosphere.clone());
1997 baseline_solver.set_max_range(solver_max_range);
1998 let baseline_result = baseline_solver.solve()?;
1999
2000 let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
2002
2003 let baseline_at_target = baseline_result
2005 .position_at_range(target_distance)
2006 .ok_or("Could not interpolate baseline at target distance")?;
2007
2008 let velocity_dist = Normal::new(base_inputs.muzzle_velocity, params.velocity_std_dev)
2010 .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
2011 let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
2012 .map_err(|e| format!("Invalid angle distribution: {}", e))?;
2013 let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
2014 .map_err(|e| format!("Invalid BC distribution: {}", e))?;
2015 let wind_speed_dist = Normal::new(base_wind.speed, params.wind_speed_std_dev)
2016 .map_err(|e| format!("Invalid wind speed distribution: {}", e))?;
2017 let wind_dir_dist = Normal::new(base_wind.direction, params.wind_speed_std_dev * 0.1)
2022 .map_err(|e| format!("Invalid wind direction distribution: {}", e))?;
2023 let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
2024 .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
2025
2026 for _ in 0..params.num_simulations {
2027 let mut inputs = base_inputs.clone();
2029 inputs.muzzle_velocity = velocity_dist.sample(&mut rng).max(0.0);
2030 inputs.muzzle_angle = angle_dist.sample(&mut rng);
2031 inputs.bc_value = bc_dist.sample(&mut rng).max(0.01);
2032 inputs.azimuth_angle = azimuth_dist.sample(&mut rng); let wind = WindConditions {
2036 speed: wind_speed_dist.sample(&mut rng).abs(),
2037 direction: wind_dir_dist.sample(&mut rng),
2038 };
2039
2040 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere.clone());
2042 solver.set_max_range(solver_max_range);
2043 match solver.solve() {
2044 Ok(result) => {
2045 let deviation = if result.max_range < target_distance {
2051 Vector3::new(0.0, -1.0e9, 0.0)
2054 } else {
2055 let pos_at_target = match result.position_at_range(target_distance) {
2056 Some(p) => p,
2057 None => continue, };
2059 Vector3::new(
2064 0.0,
2065 pos_at_target.y - baseline_at_target.y,
2066 pos_at_target.z - baseline_at_target.z,
2067 )
2068 };
2069
2070 ranges.push(result.max_range);
2071 impact_velocities.push(result.impact_velocity);
2072 impact_positions.push(deviation);
2073 }
2074 Err(_) => {
2075 continue;
2077 }
2078 }
2079 }
2080
2081 if ranges.is_empty() {
2082 return Err("No successful simulations".into());
2083 }
2084
2085 Ok(MonteCarloResults {
2086 ranges,
2087 impact_velocities,
2088 impact_positions,
2089 })
2090}
2091
2092pub fn calculate_zero_angle(
2094 inputs: BallisticInputs,
2095 target_distance: f64,
2096 target_height: f64,
2097) -> Result<f64, BallisticsError> {
2098 calculate_zero_angle_with_conditions(
2099 inputs,
2100 target_distance,
2101 target_height,
2102 WindConditions::default(),
2103 AtmosphericConditions::default(),
2104 )
2105}
2106
2107pub fn calculate_zero_angle_with_conditions(
2108 inputs: BallisticInputs,
2109 target_distance: f64,
2110 target_height: f64,
2111 wind: WindConditions,
2112 atmosphere: AtmosphericConditions,
2113) -> Result<f64, BallisticsError> {
2114 let get_height_at_angle = |angle: f64| -> Result<Option<f64>, BallisticsError> {
2116 let mut test_inputs = inputs.clone();
2117 test_inputs.muzzle_angle = angle;
2118 test_inputs.enable_aerodynamic_jump = false;
2123
2124 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
2125 solver.set_max_range(target_distance * 2.0);
2126 solver.set_time_step(0.001);
2127 let result = solver.solve()?;
2128
2129 for i in 0..result.points.len() {
2131 if result.points[i].position.x >= target_distance {
2132 if i > 0 {
2133 let p1 = &result.points[i - 1];
2134 let p2 = &result.points[i];
2135 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
2136 return Ok(Some(p1.position.y + t * (p2.position.y - p1.position.y)));
2137 } else {
2138 return Ok(Some(result.points[i].position.y));
2139 }
2140 }
2141 }
2142 Ok(None)
2143 };
2144
2145 let mut low_angle = 0.0; let mut high_angle = 0.2; let tolerance = 0.00001; let max_iterations = 50;
2151
2152 let low_height = get_height_at_angle(low_angle)?;
2155 let high_height = get_height_at_angle(high_angle)?;
2156
2157 match (low_height, high_height) {
2158 (Some(lh), Some(hh)) => {
2159 let low_error = lh - target_height;
2160 let high_error = hh - target_height;
2161
2162 if low_error > 0.0 && high_error > 0.0 {
2165 } else if low_error < 0.0 && high_error < 0.0 {
2170 let mut expanded = false;
2173 for multiplier in [2.0, 3.0, 4.0] {
2174 let new_high = (high_angle * multiplier).min(0.785);
2175 if let Ok(Some(h)) = get_height_at_angle(new_high) {
2176 if h - target_height > 0.0 {
2177 high_angle = new_high;
2178 expanded = true;
2179 break;
2180 }
2181 }
2182 if new_high >= 0.785 {
2183 break;
2184 }
2185 }
2186 if !expanded {
2187 return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
2188 }
2189 }
2190 }
2192 (None, Some(_hh)) => {
2193 }
2196 (Some(_lh), None) => {
2197 return Err(
2199 "Cannot find zero angle: high angle trajectory doesn't reach target distance"
2200 .into(),
2201 );
2202 }
2203 (None, None) => {
2204 return Err(
2206 "Cannot find zero angle: trajectory cannot reach target distance at any angle"
2207 .into(),
2208 );
2209 }
2210 }
2211
2212 for _iteration in 0..max_iterations {
2213 let mid_angle = (low_angle + high_angle) / 2.0;
2214
2215 let mut test_inputs = inputs.clone();
2216 test_inputs.muzzle_angle = mid_angle;
2217 test_inputs.enable_aerodynamic_jump = false;
2219
2220 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
2221 solver.set_max_range(target_distance * 2.0);
2223 solver.set_time_step(0.001);
2224 let result = solver.solve()?;
2225
2226 let mut height_at_target = None;
2228 for i in 0..result.points.len() {
2229 if result.points[i].position.x >= target_distance {
2230 if i > 0 {
2231 let p1 = &result.points[i - 1];
2233 let p2 = &result.points[i];
2234 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
2235 height_at_target = Some(p1.position.y + t * (p2.position.y - p1.position.y));
2236 } else {
2237 height_at_target = Some(result.points[i].position.y);
2238 }
2239 break;
2240 }
2241 }
2242
2243 match height_at_target {
2244 Some(height) => {
2245 let error = height - target_height;
2246 if error.abs() < 0.001 {
2249 return Ok(mid_angle);
2250 }
2251
2252 if (high_angle - low_angle).abs() < tolerance {
2256 if error.abs() < 0.01 {
2257 return Ok(mid_angle);
2259 }
2260 return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
2265 }
2266
2267 if error > 0.0 {
2268 high_angle = mid_angle;
2269 } else {
2270 low_angle = mid_angle;
2271 }
2272 }
2273 None => {
2274 low_angle = mid_angle;
2276
2277 if (high_angle - low_angle).abs() < tolerance {
2279 return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
2280 }
2281 }
2282 }
2283 }
2284
2285 Err("Failed to find zero angle".into())
2286}
2287
2288pub fn estimate_bc_from_trajectory(
2290 velocity: f64,
2291 mass: f64,
2292 diameter: f64,
2293 points: &[(f64, f64)], ) -> Result<f64, BallisticsError> {
2295 let mut best_bc = 0.5;
2297 let mut best_error = f64::MAX;
2298 let mut found_valid = false;
2299
2300 for bc in (100..1000).step_by(10) {
2302 let bc_value = bc as f64 / 1000.0;
2303
2304 let inputs = BallisticInputs {
2305 muzzle_velocity: velocity,
2306 bc_value,
2307 bullet_mass: mass,
2308 bullet_diameter: diameter,
2309 ..Default::default()
2310 };
2311
2312 let mut solver = TrajectorySolver::new(inputs, Default::default(), Default::default());
2313 solver.set_max_range(points.last().map(|(d, _)| *d * 1.5).unwrap_or(1000.0));
2315
2316 let result = match solver.solve() {
2317 Ok(r) => r,
2318 Err(_) => continue, };
2320
2321 let mut total_error = 0.0;
2323 for (target_dist, target_drop) in points {
2324 let mut calculated_drop = None;
2326 for i in 0..result.points.len() {
2327 if result.points[i].position.x >= *target_dist {
2328 if i > 0 {
2329 let p1 = &result.points[i - 1];
2331 let p2 = &result.points[i];
2332 let t = (target_dist - p1.position.x) / (p2.position.x - p1.position.x);
2333 calculated_drop =
2334 Some(-(p1.position.y + t * (p2.position.y - p1.position.y)));
2335 } else {
2336 calculated_drop = Some(-result.points[i].position.y);
2337 }
2338 break;
2339 }
2340 }
2341
2342 if let Some(drop) = calculated_drop {
2343 let error = (drop - target_drop).abs();
2344 total_error += error * error;
2345 }
2346 }
2347
2348 if total_error < best_error {
2349 best_error = total_error;
2350 best_bc = bc_value;
2351 found_valid = true;
2352 }
2353 }
2354
2355 if !found_valid {
2356 return Err(BallisticsError::from("Unable to estimate BC from provided data. Check that drop values are in correct units.".to_string()));
2357 }
2358
2359 Ok(best_bc)
2360}
2361
2362use rand;
2364use rand_distr;
2365
2366#[cfg(test)]
2367mod ground_termination_tests {
2368 use super::*;
2369
2370 #[test]
2375 fn rk4_and_rk45_descend_to_ground_threshold() {
2376 for adaptive in [false, true] {
2377 let mut inputs = BallisticInputs::default();
2378 inputs.muzzle_angle = 0.1; inputs.use_rk4 = true;
2380 inputs.use_adaptive_rk45 = adaptive;
2381 assert_eq!(
2382 inputs.ground_threshold, -100.0,
2383 "default ground_threshold is -100 m"
2384 );
2385
2386 let mut solver = TrajectorySolver::new(
2387 inputs,
2388 WindConditions::default(),
2389 AtmosphericConditions::default(),
2390 );
2391 solver.set_max_range(1.0e7);
2393
2394 let result = solver.solve().expect("solve should succeed");
2395 let final_y = result
2396 .points
2397 .last()
2398 .expect("trajectory has points")
2399 .position
2400 .y;
2401 assert!(
2402 final_y < -1.0,
2403 "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
2404 past launch level toward the ground_threshold floor, not stop at y = 0"
2405 );
2406 }
2407 }
2408}
2409
2410#[cfg(test)]
2411mod coriolis_direction_tests {
2412 use super::*;
2413 use std::f64::consts::FRAC_PI_2;
2414
2415 #[test]
2416 fn transonic_crossing_flags_a_sampled_point() {
2417 use crate::trajectory_sampling::TrajectoryFlag;
2421 let mut inputs = BallisticInputs::default();
2422 inputs.muzzle_velocity = 850.0; inputs.bc_value = 0.2; inputs.bc_type = DragModel::G7;
2425 inputs.muzzle_angle = 0.03;
2426 inputs.enable_trajectory_sampling = true;
2427 inputs.sample_interval = 50.0;
2428 let mut solver = TrajectorySolver::new(
2429 inputs,
2430 WindConditions::default(),
2431 AtmosphericConditions::default(),
2432 );
2433 solver.set_max_range(2000.0);
2434 let r = solver.solve().expect("solve");
2435 let samples = r
2436 .sampled_points
2437 .expect("sampling enabled -> sampled_points present");
2438 assert!(
2439 samples
2440 .iter()
2441 .any(|s| s.flags.contains(&TrajectoryFlag::MachTransition)),
2442 "a shot that crosses Mach 1 must flag at least one Mach-transition sample"
2443 );
2444 }
2445
2446 #[test]
2447 fn humidity_percent_converts_and_clamps() {
2448 let mut i = BallisticInputs::default();
2450 i.humidity = 0.5;
2451 assert!((i.humidity_percent() - 50.0).abs() < 1e-9, "0.5 -> 50%");
2452 i.humidity = 0.0;
2453 assert_eq!(i.humidity_percent(), 0.0);
2454 i.humidity = 1.0;
2455 assert_eq!(i.humidity_percent(), 100.0);
2456 i.humidity = 1.5; assert_eq!(i.humidity_percent(), 100.0);
2458 }
2459
2460 fn vertical_at(shot_azimuth: f64, range_m: f64) -> f64 {
2463 let mut inputs = BallisticInputs::default();
2464 inputs.muzzle_velocity = 800.0;
2465 inputs.bc_value = 0.5;
2466 inputs.bc_type = DragModel::G7;
2467 inputs.muzzle_angle = 0.02; inputs.enable_coriolis = true;
2469 inputs.latitude = Some(45.0);
2470 inputs.shot_azimuth = shot_azimuth;
2471 inputs.ground_threshold = f64::NEG_INFINITY; let mut solver = TrajectorySolver::new(
2473 inputs,
2474 WindConditions::default(),
2475 AtmosphericConditions::default(),
2476 );
2477 solver.set_max_range(range_m + 50.0);
2478 let r = solver.solve().expect("solve");
2479 let pts = &r.points;
2480 for i in 1..pts.len() {
2481 if pts[i].position.x >= range_m {
2482 let p1 = &pts[i - 1];
2483 let p2 = &pts[i];
2484 let t = (range_m - p1.position.x) / (p2.position.x - p1.position.x);
2485 return p1.position.y + t * (p2.position.y - p1.position.y);
2486 }
2487 }
2488 panic!("range {range_m} not reached");
2489 }
2490
2491 #[test]
2496 fn eotvos_east_higher_than_west() {
2497 let range = 600.0;
2498 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!(
2502 east > west,
2503 "east ({east:.5}) must be higher than west ({west:.5}) at {range} m (Eötvös)"
2504 );
2505 assert!(
2506 east > north && north > west,
2507 "north ({north:.5}) must lie between east ({east:.5}) and west ({west:.5})"
2508 );
2509 assert!(
2510 (east - west) > 1e-3,
2511 "E-W vertical separation ({:.6} m) should be physically meaningful, not FP noise",
2512 east - west
2513 );
2514 }
2515}