1use crate::cluster_bc::ClusterBCDegradation;
3use crate::pitch_damping::{calculate_pitch_damping_coefficient, PitchDampingCoefficients};
4use crate::precession_nutation::{
5 calculate_combined_angular_motion, AngularState, PrecessionNutationParams,
6};
7use crate::trajectory_sampling::{
8 sample_trajectory, TrajectoryData, TrajectoryOutputs, TrajectorySample,
9};
10use crate::transonic_drag::transonic_correction;
11use crate::wind_shear::WindShearModel;
12use crate::DragModel;
13use nalgebra::Vector3;
14use std::error::Error;
15use std::fmt;
16
17#[derive(Debug, Clone, Copy, PartialEq)]
19pub enum UnitSystem {
20 Imperial,
21 Metric,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq)]
26pub enum OutputFormat {
27 Table,
28 Json,
29 Csv,
30}
31
32#[derive(Debug)]
34pub struct BallisticsError {
35 message: String,
36}
37
38impl fmt::Display for BallisticsError {
39 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
40 write!(f, "{}", self.message)
41 }
42}
43
44impl Error for BallisticsError {}
45
46impl From<String> for BallisticsError {
47 fn from(msg: String) -> Self {
48 BallisticsError { message: msg }
49 }
50}
51
52impl From<&str> for BallisticsError {
53 fn from(msg: &str) -> Self {
54 BallisticsError {
55 message: msg.to_string(),
56 }
57 }
58}
59
60#[derive(Debug, Clone)]
64pub struct BallisticInputs {
65 pub bc_value: f64, pub bc_type: DragModel, pub bullet_mass: f64, pub muzzle_velocity: f64, pub bullet_diameter: f64, pub bullet_length: f64, pub muzzle_angle: f64, pub target_distance: f64, pub azimuth_angle: f64, pub 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, 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,
110 pub enable_magnus: bool, pub enable_coriolis: bool, pub use_powder_sensitivity: bool,
113 pub powder_temp_sensitivity: f64,
114 pub powder_temp: f64, pub tipoff_yaw: f64, pub tipoff_decay_distance: f64, pub use_bc_segments: bool,
118 pub bc_segments: Option<Vec<(f64, f64)>>, pub bc_segments_data: Option<Vec<crate::BCSegmentData>>, pub use_enhanced_spin_drift: bool,
121 pub use_form_factor: bool,
122 pub enable_wind_shear: bool,
123 pub wind_shear_model: String,
124 pub enable_trajectory_sampling: bool,
125 pub sample_interval: f64, pub enable_pitch_damping: bool,
127 pub enable_precession_nutation: bool,
128 pub use_cluster_bc: bool, pub custom_drag_table: Option<crate::drag::DragTable>,
132
133 pub bc_type_str: Option<String>,
135}
136
137impl Default for BallisticInputs {
138 fn default() -> Self {
139 let mass_kg = 0.01;
140 let diameter_m = 0.00762;
141 let bc = 0.5;
142 let muzzle_angle_rad = 0.0;
143 let bc_type = DragModel::G1;
144
145 Self {
146 bc_value: bc,
148 bc_type,
149 bullet_mass: mass_kg,
150 muzzle_velocity: 800.0,
151 bullet_diameter: diameter_m,
152 bullet_length: diameter_m * 4.5, muzzle_angle: muzzle_angle_rad,
156 target_distance: 100.0,
157 azimuth_angle: 0.0,
158 shooting_angle: 0.0,
159 sight_height: 0.05,
160 muzzle_height: 0.0, target_height: 0.0, ground_threshold: -100.0, altitude: 0.0,
166 temperature: 15.0,
167 pressure: 1013.25, humidity: 0.5, latitude: None,
170
171 wind_speed: 0.0,
173 wind_angle: 0.0,
174
175 twist_rate: 12.0, is_twist_right: true,
178 caliber_inches: diameter_m / 0.0254, weight_grains: mass_kg / 0.00006479891, manufacturer: None,
181 bullet_model: None,
182 bullet_id: None,
183 bullet_cluster: None,
184
185 use_rk4: true, use_adaptive_rk45: true, enable_advanced_effects: false,
191 enable_magnus: false,
192 enable_coriolis: false,
193 use_powder_sensitivity: false,
194 powder_temp_sensitivity: 0.0,
195 powder_temp: 15.0,
196 tipoff_yaw: 0.0,
197 tipoff_decay_distance: 50.0,
198 use_bc_segments: false,
199 bc_segments: None,
200 bc_segments_data: None,
201 use_enhanced_spin_drift: false,
202 use_form_factor: false,
203 enable_wind_shear: false,
204 wind_shear_model: "none".to_string(),
205 enable_trajectory_sampling: false,
206 sample_interval: 10.0, enable_pitch_damping: false,
208 enable_precession_nutation: false,
209 use_cluster_bc: false, custom_drag_table: None,
213
214 bc_type_str: None,
216 }
217 }
218}
219
220#[derive(Debug, Clone)]
222pub struct WindConditions {
223 pub speed: f64, pub direction: f64, }
226
227impl Default for WindConditions {
228 fn default() -> Self {
229 Self {
230 speed: 0.0,
231 direction: 0.0,
232 }
233 }
234}
235
236#[derive(Debug, Clone)]
238pub struct AtmosphericConditions {
239 pub temperature: f64, pub pressure: f64, pub humidity: f64, pub altitude: f64, }
244
245impl Default for AtmosphericConditions {
246 fn default() -> Self {
247 Self {
248 temperature: 15.0,
249 pressure: 1013.25,
250 humidity: 50.0,
251 altitude: 0.0,
252 }
253 }
254}
255
256#[derive(Debug, Clone)]
258pub struct TrajectoryPoint {
259 pub time: f64,
260 pub position: Vector3<f64>,
261 pub velocity_magnitude: f64,
262 pub kinetic_energy: f64,
263}
264
265#[derive(Debug, Clone)]
267pub struct TrajectoryResult {
268 pub max_range: f64,
269 pub max_height: f64,
270 pub time_of_flight: f64,
271 pub impact_velocity: f64,
272 pub impact_energy: f64,
273 pub points: Vec<TrajectoryPoint>,
274 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>, }
281
282impl TrajectoryResult {
283 pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
287 if self.points.is_empty() {
288 return None;
289 }
290
291 for i in 0..self.points.len() - 1 {
293 let p1 = &self.points[i];
294 let p2 = &self.points[i + 1];
295
296 if p1.position.x <= target_range && p2.position.x >= target_range {
298 let dx = p2.position.x - p1.position.x;
300 if dx.abs() < 1e-10 {
301 return Some(p1.position);
302 }
303 let t = (target_range - p1.position.x) / dx;
304
305 return Some(Vector3::new(
307 target_range,
308 p1.position.y + t * (p2.position.y - p1.position.y),
309 p1.position.z + t * (p2.position.z - p1.position.z),
310 ));
311 }
312 }
313
314 self.points.last().map(|p| p.position)
316 }
317}
318
319pub struct TrajectorySolver {
321 inputs: BallisticInputs,
322 wind: WindConditions,
323 atmosphere: AtmosphericConditions,
324 max_range: f64,
325 time_step: f64,
326 cluster_bc: Option<ClusterBCDegradation>,
327}
328
329impl TrajectorySolver {
330 pub fn new(
331 mut inputs: BallisticInputs,
332 wind: WindConditions,
333 atmosphere: AtmosphericConditions,
334 ) -> Self {
335 inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
337 inputs.weight_grains = inputs.bullet_mass / 0.00006479891;
338
339 let cluster_bc = if inputs.use_cluster_bc {
341 Some(ClusterBCDegradation::new())
342 } else {
343 None
344 };
345
346 Self {
347 inputs,
348 wind,
349 atmosphere,
350 max_range: 1000.0,
351 time_step: 0.001,
352 cluster_bc,
353 }
354 }
355
356 pub fn set_max_range(&mut self, range: f64) {
357 self.max_range = range;
358 }
359
360 pub fn set_time_step(&mut self, step: f64) {
361 self.time_step = step;
362 }
363
364 fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
365 let model = if self.inputs.wind_shear_model == "logarithmic" {
374 WindShearModel::Logarithmic
375 } else {
376 WindShearModel::PowerLaw };
378 let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
379
380 Vector3::new(
381 self.wind.speed * self.wind.direction.cos() * speed_ratio, 0.0,
383 self.wind.speed * self.wind.direction.sin() * speed_ratio, )
385 }
386
387 pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
388 let mut result = if self.inputs.use_rk4 {
389 if self.inputs.use_adaptive_rk45 {
390 self.solve_rk45()?
391 } else {
392 self.solve_rk4()?
393 }
394 } else {
395 self.solve_euler()?
396 };
397 self.apply_spin_drift(&mut result);
398 Ok(result)
399 }
400
401 fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
407 if !self.inputs.use_enhanced_spin_drift {
408 return;
409 }
410 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 {
414 return;
415 }
416
417 let length_in = if self.inputs.bullet_length > 0.0 {
419 self.inputs.bullet_length / 0.0254
420 } else {
421 4.5 * d_in
422 };
423 let temp_k = self.atmosphere.temperature + 273.15; let press_hpa = self.atmosphere.pressure; let density_correction = if press_hpa > 0.0 && temp_k > 0.0 {
431 (temp_k / 288.15) * (1013.25 / press_hpa)
432 } else {
433 1.0
434 };
435 let sg =
436 crate::spin_drift::miller_stability(d_in, m_gr, twist_in, length_in) * density_correction;
437 let sign = if self.inputs.is_twist_right { 1.0 } else { -1.0 };
438
439 for p in result.points.iter_mut() {
440 if p.time <= 0.0 {
441 continue;
442 }
443 let sd_in = 1.25 * (sg + 1.2) * p.time.powf(1.83); p.position.z += sign * sd_in * 0.0254; }
446
447 if let Some(samples) = result.sampled_points.as_mut() {
451 for s in samples.iter_mut() {
452 if s.time_s <= 0.0 {
453 continue;
454 }
455 let sd_in = 1.25 * (sg + 1.2) * s.time_s.powf(1.83);
456 s.wind_drift_m += sign * sd_in * 0.0254;
457 }
458 }
459 }
460
461 fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
462 let mut time = 0.0;
464 let mut position = Vector3::new(
467 0.0,
468 self.inputs.muzzle_height, 0.0,
470 );
471 let horizontal_velocity = self.inputs.muzzle_velocity * self.inputs.muzzle_angle.cos();
474 let mut velocity = Vector3::new(
475 horizontal_velocity * self.inputs.azimuth_angle.cos(), self.inputs.muzzle_velocity * self.inputs.muzzle_angle.sin(), horizontal_velocity * self.inputs.azimuth_angle.sin(), );
479
480 let mut points = Vec::new();
481 let mut max_height = position.y;
482 let mut min_pitch_damping = 1.0; let mut transonic_mach = None; let mut angular_state = if self.inputs.enable_precession_nutation {
487 Some(AngularState {
488 pitch_angle: 0.001, yaw_angle: 0.001,
490 pitch_rate: 0.0,
491 yaw_rate: 0.0,
492 precession_angle: 0.0,
493 nutation_phase: 0.0,
494 })
495 } else {
496 None
497 };
498 let mut max_yaw_angle = 0.0;
499 let mut max_precession_angle = 0.0;
500
501 let air_density = calculate_air_density(&self.atmosphere);
503
504 let wind_vector = Vector3::new(
506 self.wind.speed * self.wind.direction.cos(), 0.0,
508 self.wind.speed * self.wind.direction.sin(), );
510
511 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
514 self.inputs.bullet_model.as_deref().unwrap_or("default"),
515 );
516
517 while position.x < self.max_range && position.y > self.inputs.ground_threshold && time < 100.0 {
519 let velocity_magnitude = velocity.magnitude();
521 let kinetic_energy =
522 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
523
524 points.push(TrajectoryPoint {
525 time,
526 position: position,
527 velocity_magnitude,
528 kinetic_energy,
529 });
530
531 #[cfg(debug_assertions)]
535 if points.len() == 1 || points.len() % 100 == 0 {
536 eprintln!("Trajectory point {}: time={:.3}s, downrange={:.2}m, vertical={:.2}m, lateral={:.2}m, vel={:.1}m/s",
537 points.len(), time, position.x, position.y, position.z, velocity_magnitude);
538 }
539
540 if position.y > max_height {
542 max_height = position.y;
543 }
544
545 if self.inputs.enable_pitch_damping {
547 let temp_c = self.atmosphere.temperature;
548 let temp_k = temp_c + 273.15;
549 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
550 let mach = velocity_magnitude / speed_of_sound;
551
552 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
554 transonic_mach = Some(mach);
555 }
556
557 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
559
560 if pitch_damping < min_pitch_damping {
562 min_pitch_damping = pitch_damping;
563 }
564 }
565
566 if self.inputs.enable_precession_nutation {
568 if let Some(ref mut state) = angular_state {
569 let velocity_magnitude = velocity.magnitude();
570 let temp_c = self.atmosphere.temperature;
571 let temp_k = temp_c + 273.15;
572 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
573 let mach = velocity_magnitude / speed_of_sound;
574
575 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
577 let velocity_fps = velocity_magnitude * 3.28084;
578 let twist_rate_ft = self.inputs.twist_rate / 12.0;
579 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
580 } else {
581 0.0
582 };
583
584 let params = PrecessionNutationParams {
586 mass_kg: self.inputs.bullet_mass,
587 caliber_m: self.inputs.bullet_diameter,
588 length_m: self.inputs.bullet_length,
589 spin_rate_rad_s,
590 spin_inertia: 6.94e-8, transverse_inertia: 9.13e-7, velocity_mps: velocity_magnitude,
593 air_density_kg_m3: air_density,
594 mach,
595 pitch_damping_coeff: -0.8,
596 nutation_damping_factor: 0.05,
597 };
598
599 *state = calculate_combined_angular_motion(
601 ¶ms,
602 state,
603 time,
604 self.time_step,
605 0.001, );
607
608 if state.yaw_angle.abs() > max_yaw_angle {
610 max_yaw_angle = state.yaw_angle.abs();
611 }
612 if state.precession_angle.abs() > max_precession_angle {
613 max_precession_angle = state.precession_angle.abs();
614 }
615 }
616 }
617
618 let acceleration =
625 self.calculate_acceleration(&position, &velocity, air_density, &wind_vector);
626
627 velocity += acceleration * self.time_step;
629 position += velocity * self.time_step;
630 time += self.time_step;
631 }
632
633 let last_point = points.last().ok_or("No trajectory points generated")?;
635
636 let sampled_points = if self.inputs.enable_trajectory_sampling {
638 let trajectory_data = TrajectoryData {
639 times: points.iter().map(|p| p.time).collect(),
640 positions: points.iter().map(|p| p.position).collect(),
641 velocities: points
642 .iter()
643 .map(|p| {
644 Vector3::new(0.0, 0.0, p.velocity_magnitude)
646 })
647 .collect(),
648 transonic_distances: Vec::new(), };
650
651 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
656 let outputs = TrajectoryOutputs {
657 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
659 time_of_flight_s: last_point.time,
660 max_ord_dist_horiz_m: max_height,
661 sight_height_m: sight_position_m,
662 };
663
664 let samples = sample_trajectory(
666 &trajectory_data,
667 &outputs,
668 self.inputs.sample_interval,
669 self.inputs.bullet_mass,
670 );
671 Some(samples)
672 } else {
673 None
674 };
675
676 Ok(TrajectoryResult {
677 max_range: last_point.position.x, max_height,
679 time_of_flight: last_point.time,
680 impact_velocity: last_point.velocity_magnitude,
681 impact_energy: last_point.kinetic_energy,
682 points,
683 sampled_points,
684 min_pitch_damping: if self.inputs.enable_pitch_damping {
685 Some(min_pitch_damping)
686 } else {
687 None
688 },
689 transonic_mach,
690 angular_state,
691 max_yaw_angle: if self.inputs.enable_precession_nutation {
692 Some(max_yaw_angle)
693 } else {
694 None
695 },
696 max_precession_angle: if self.inputs.enable_precession_nutation {
697 Some(max_precession_angle)
698 } else {
699 None
700 },
701 })
702 }
703
704 fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
705 let mut time = 0.0;
707 let mut position = Vector3::new(
711 0.0,
712 self.inputs.muzzle_height, 0.0,
714 );
715
716 let horizontal_velocity = self.inputs.muzzle_velocity * self.inputs.muzzle_angle.cos();
719 let mut velocity = Vector3::new(
720 horizontal_velocity * self.inputs.azimuth_angle.cos(), self.inputs.muzzle_velocity * self.inputs.muzzle_angle.sin(), horizontal_velocity * self.inputs.azimuth_angle.sin(), );
724
725 let mut points = Vec::new();
726 let mut max_height = position.y;
727 let mut min_pitch_damping = 1.0; let mut transonic_mach = None; let mut angular_state = if self.inputs.enable_precession_nutation {
732 Some(AngularState {
733 pitch_angle: 0.001, yaw_angle: 0.001,
735 pitch_rate: 0.0,
736 yaw_rate: 0.0,
737 precession_angle: 0.0,
738 nutation_phase: 0.0,
739 })
740 } else {
741 None
742 };
743 let mut max_yaw_angle = 0.0;
744 let mut max_precession_angle = 0.0;
745
746 let air_density = calculate_air_density(&self.atmosphere);
748
749 let wind_vector = Vector3::new(
751 self.wind.speed * self.wind.direction.cos(), 0.0,
753 self.wind.speed * self.wind.direction.sin(), );
755
756 let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
759 self.inputs.bullet_model.as_deref().unwrap_or("default"),
760 );
761
762 while position.x < self.max_range && position.y > self.inputs.ground_threshold && time < 100.0 {
764 let velocity_magnitude = velocity.magnitude();
766 let kinetic_energy =
767 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
768
769 points.push(TrajectoryPoint {
770 time,
771 position: position,
772 velocity_magnitude,
773 kinetic_energy,
774 });
775
776 if position.y > max_height {
777 max_height = position.y;
778 }
779
780 if self.inputs.enable_pitch_damping {
782 let temp_c = self.atmosphere.temperature;
783 let temp_k = temp_c + 273.15;
784 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
785 let mach = velocity_magnitude / speed_of_sound;
786
787 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
789 transonic_mach = Some(mach);
790 }
791
792 let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
794
795 if pitch_damping < min_pitch_damping {
797 min_pitch_damping = pitch_damping;
798 }
799 }
800
801 if self.inputs.enable_precession_nutation {
803 if let Some(ref mut state) = angular_state {
804 let velocity_magnitude = velocity.magnitude();
805 let temp_c = self.atmosphere.temperature;
806 let temp_k = temp_c + 273.15;
807 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
808 let mach = velocity_magnitude / speed_of_sound;
809
810 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
812 let velocity_fps = velocity_magnitude * 3.28084;
813 let twist_rate_ft = self.inputs.twist_rate / 12.0;
814 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
815 } else {
816 0.0
817 };
818
819 let params = PrecessionNutationParams {
821 mass_kg: self.inputs.bullet_mass,
822 caliber_m: self.inputs.bullet_diameter,
823 length_m: self.inputs.bullet_length,
824 spin_rate_rad_s,
825 spin_inertia: 6.94e-8, transverse_inertia: 9.13e-7, velocity_mps: velocity_magnitude,
828 air_density_kg_m3: air_density,
829 mach,
830 pitch_damping_coeff: -0.8,
831 nutation_damping_factor: 0.05,
832 };
833
834 *state = calculate_combined_angular_motion(
836 ¶ms,
837 state,
838 time,
839 self.time_step,
840 0.001, );
842
843 if state.yaw_angle.abs() > max_yaw_angle {
845 max_yaw_angle = state.yaw_angle.abs();
846 }
847 if state.precession_angle.abs() > max_precession_angle {
848 max_precession_angle = state.precession_angle.abs();
849 }
850 }
851 }
852
853 let dt = self.time_step;
855
856 let acc1 = self.calculate_acceleration(&position, &velocity, air_density, &wind_vector);
858
859 let pos2 = position + velocity * (dt * 0.5);
861 let vel2 = velocity + acc1 * (dt * 0.5);
862 let acc2 = self.calculate_acceleration(&pos2, &vel2, air_density, &wind_vector);
863
864 let pos3 = position + vel2 * (dt * 0.5);
866 let vel3 = velocity + acc2 * (dt * 0.5);
867 let acc3 = self.calculate_acceleration(&pos3, &vel3, air_density, &wind_vector);
868
869 let pos4 = position + vel3 * dt;
871 let vel4 = velocity + acc3 * dt;
872 let acc4 = self.calculate_acceleration(&pos4, &vel4, air_density, &wind_vector);
873
874 position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
876 velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
877 time += dt;
878 }
879
880 let last_point = points.last().ok_or("No trajectory points generated")?;
882
883 let sampled_points = if self.inputs.enable_trajectory_sampling {
885 let trajectory_data = TrajectoryData {
886 times: points.iter().map(|p| p.time).collect(),
887 positions: points.iter().map(|p| p.position).collect(),
888 velocities: points
889 .iter()
890 .map(|p| {
891 Vector3::new(0.0, 0.0, p.velocity_magnitude)
893 })
894 .collect(),
895 transonic_distances: Vec::new(), };
897
898 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
903 let outputs = TrajectoryOutputs {
904 target_distance_horiz_m: last_point.position.x, target_vertical_height_m: sight_position_m,
906 time_of_flight_s: last_point.time,
907 max_ord_dist_horiz_m: max_height,
908 sight_height_m: sight_position_m,
909 };
910
911 let samples = sample_trajectory(
913 &trajectory_data,
914 &outputs,
915 self.inputs.sample_interval,
916 self.inputs.bullet_mass,
917 );
918 Some(samples)
919 } else {
920 None
921 };
922
923 Ok(TrajectoryResult {
924 max_range: last_point.position.x, max_height,
926 time_of_flight: last_point.time,
927 impact_velocity: last_point.velocity_magnitude,
928 impact_energy: last_point.kinetic_energy,
929 points,
930 sampled_points,
931 min_pitch_damping: if self.inputs.enable_pitch_damping {
932 Some(min_pitch_damping)
933 } else {
934 None
935 },
936 transonic_mach,
937 angular_state,
938 max_yaw_angle: if self.inputs.enable_precession_nutation {
939 Some(max_yaw_angle)
940 } else {
941 None
942 },
943 max_precession_angle: if self.inputs.enable_precession_nutation {
944 Some(max_precession_angle)
945 } else {
946 None
947 },
948 })
949 }
950
951 fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
952 let mut time = 0.0;
954 let mut position = Vector3::new(
957 0.0,
958 self.inputs.muzzle_height, 0.0,
960 );
961
962 let horizontal_velocity = self.inputs.muzzle_velocity * self.inputs.muzzle_angle.cos();
965 let mut velocity = Vector3::new(
966 horizontal_velocity * self.inputs.azimuth_angle.cos(), self.inputs.muzzle_velocity * self.inputs.muzzle_angle.sin(), horizontal_velocity * self.inputs.azimuth_angle.sin(), );
970
971 let mut points = Vec::new();
972 let mut max_height = position.y;
973 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;
981 const MAX_ITERATIONS: usize = 100000;
982
983 let air_density = calculate_air_density(&self.atmosphere);
986 let wind_vector = Vector3::new(
987 self.wind.speed * self.wind.direction.cos(), 0.0,
989 self.wind.speed * self.wind.direction.sin(), );
991
992 while position.x < self.max_range
993 && position.y > self.inputs.ground_threshold
994 && time < 100.0
995 {
996 iteration_count += 1;
998 if iteration_count > MAX_ITERATIONS {
999 break; }
1001
1002 let velocity_magnitude = velocity.magnitude();
1004 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
1005
1006 points.push(TrajectoryPoint {
1007 time,
1008 position: position,
1009 velocity_magnitude,
1010 kinetic_energy,
1011 });
1012
1013 if position.y > max_height {
1014 max_height = position.y;
1015 }
1016
1017 let (new_pos, new_vel, new_dt) = self.rk45_step(
1019 &position,
1020 &velocity,
1021 dt,
1022 air_density,
1023 &wind_vector,
1024 tolerance,
1025 );
1026
1027 position = new_pos;
1032 velocity = new_vel;
1033 time += dt;
1034
1035 dt = (safety_factor * new_dt).clamp(min_dt, max_dt);
1037 }
1038
1039 if points.is_empty() {
1041 return Err(BallisticsError::from("No trajectory points calculated"));
1042 }
1043
1044 let last_point = points.last().unwrap();
1045
1046 let sampled_points = if self.inputs.enable_trajectory_sampling {
1048 let trajectory_data = TrajectoryData {
1050 times: points.iter().map(|p| p.time).collect(),
1051 positions: points.iter().map(|p| p.position).collect(),
1052 velocities: points
1053 .iter()
1054 .map(|p| {
1055 Vector3::new(0.0, 0.0, p.velocity_magnitude)
1057 })
1058 .collect(),
1059 transonic_distances: Vec::new(),
1060 };
1061
1062 let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1067 let outputs = TrajectoryOutputs {
1068 target_distance_horiz_m: last_point.position.x,
1069 target_vertical_height_m: sight_position_m,
1070 time_of_flight_s: last_point.time,
1071 max_ord_dist_horiz_m: max_height,
1072 sight_height_m: sight_position_m,
1073 };
1074
1075 let samples = sample_trajectory(
1076 &trajectory_data,
1077 &outputs,
1078 self.inputs.sample_interval,
1079 self.inputs.bullet_mass,
1080 );
1081 Some(samples)
1082 } else {
1083 None
1084 };
1085
1086 Ok(TrajectoryResult {
1087 max_range: last_point.position.x, max_height,
1089 time_of_flight: last_point.time,
1090 impact_velocity: last_point.velocity_magnitude,
1091 impact_energy: last_point.kinetic_energy,
1092 points,
1093 sampled_points,
1094 min_pitch_damping: None,
1095 transonic_mach: None,
1096 angular_state: None,
1097 max_yaw_angle: None,
1098 max_precession_angle: None,
1099 })
1100 }
1101
1102 fn rk45_step(
1103 &self,
1104 position: &Vector3<f64>,
1105 velocity: &Vector3<f64>,
1106 dt: f64,
1107 air_density: f64,
1108 wind_vector: &Vector3<f64>,
1109 tolerance: f64,
1110 ) -> (Vector3<f64>, Vector3<f64>, f64) {
1111 const A21: f64 = 1.0 / 5.0;
1113 const A31: f64 = 3.0 / 40.0;
1114 const A32: f64 = 9.0 / 40.0;
1115 const A41: f64 = 44.0 / 45.0;
1116 const A42: f64 = -56.0 / 15.0;
1117 const A43: f64 = 32.0 / 9.0;
1118 const A51: f64 = 19372.0 / 6561.0;
1119 const A52: f64 = -25360.0 / 2187.0;
1120 const A53: f64 = 64448.0 / 6561.0;
1121 const A54: f64 = -212.0 / 729.0;
1122 const A61: f64 = 9017.0 / 3168.0;
1123 const A62: f64 = -355.0 / 33.0;
1124 const A63: f64 = 46732.0 / 5247.0;
1125 const A64: f64 = 49.0 / 176.0;
1126 const A65: f64 = -5103.0 / 18656.0;
1127 const A71: f64 = 35.0 / 384.0;
1128 const A73: f64 = 500.0 / 1113.0;
1129 const A74: f64 = 125.0 / 192.0;
1130 const A75: f64 = -2187.0 / 6784.0;
1131 const A76: f64 = 11.0 / 84.0;
1132
1133 const B1: f64 = 35.0 / 384.0;
1135 const B3: f64 = 500.0 / 1113.0;
1136 const B4: f64 = 125.0 / 192.0;
1137 const B5: f64 = -2187.0 / 6784.0;
1138 const B6: f64 = 11.0 / 84.0;
1139
1140 const B1_ERR: f64 = 5179.0 / 57600.0;
1142 const B3_ERR: f64 = 7571.0 / 16695.0;
1143 const B4_ERR: f64 = 393.0 / 640.0;
1144 const B5_ERR: f64 = -92097.0 / 339200.0;
1145 const B6_ERR: f64 = 187.0 / 2100.0;
1146 const B7_ERR: f64 = 1.0 / 40.0;
1147
1148 let k1_v = self.calculate_acceleration(position, velocity, air_density, wind_vector);
1150 let k1_p = *velocity;
1151
1152 let p2 = position + dt * A21 * k1_p;
1153 let v2 = velocity + dt * A21 * k1_v;
1154 let k2_v = self.calculate_acceleration(&p2, &v2, air_density, wind_vector);
1155 let k2_p = v2;
1156
1157 let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
1158 let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
1159 let k3_v = self.calculate_acceleration(&p3, &v3, air_density, wind_vector);
1160 let k3_p = v3;
1161
1162 let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
1163 let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
1164 let k4_v = self.calculate_acceleration(&p4, &v4, air_density, wind_vector);
1165 let k4_p = v4;
1166
1167 let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
1168 let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
1169 let k5_v = self.calculate_acceleration(&p5, &v5, air_density, wind_vector);
1170 let k5_p = v5;
1171
1172 let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
1173 let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
1174 let k6_v = self.calculate_acceleration(&p6, &v6, air_density, wind_vector);
1175 let k6_p = v6;
1176
1177 let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
1178 let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
1179 let k7_v = self.calculate_acceleration(&p7, &v7, air_density, wind_vector);
1180 let k7_p = v7;
1181
1182 let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
1184 let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
1185
1186 let pos_err = position
1188 + dt * (B1_ERR * k1_p
1189 + B3_ERR * k3_p
1190 + B4_ERR * k4_p
1191 + B5_ERR * k5_p
1192 + B6_ERR * k6_p
1193 + B7_ERR * k7_p);
1194 let vel_err = velocity
1195 + dt * (B1_ERR * k1_v
1196 + B3_ERR * k3_v
1197 + B4_ERR * k4_v
1198 + B5_ERR * k5_v
1199 + B6_ERR * k6_v
1200 + B7_ERR * k7_v);
1201
1202 let pos_error = (new_pos - pos_err).magnitude();
1204 let vel_error = (new_vel - vel_err).magnitude();
1205 let error = (pos_error + vel_error) / (1.0 + position.magnitude() + velocity.magnitude());
1206
1207 let dt_new = if error < tolerance {
1209 dt * (tolerance / error).powf(0.2).min(2.0)
1210 } else {
1211 dt * (tolerance / error).powf(0.25).max(0.1)
1212 };
1213
1214 (new_pos, new_vel, dt_new)
1215 }
1216
1217 fn calculate_acceleration(
1218 &self,
1219 position: &Vector3<f64>,
1220 velocity: &Vector3<f64>,
1221 air_density: f64,
1222 wind_vector: &Vector3<f64>,
1223 ) -> Vector3<f64> {
1224 let actual_wind = if self.inputs.enable_wind_shear {
1226 self.get_wind_at_altitude(position.y)
1227 } else {
1228 *wind_vector
1229 };
1230
1231 let relative_velocity = velocity - actual_wind;
1232 let velocity_magnitude = relative_velocity.magnitude();
1233
1234 if velocity_magnitude < 0.001 {
1235 return Vector3::new(0.0, -crate::constants::G_ACCEL_MPS2, 0.0);
1236 }
1237
1238 let cd = self.calculate_drag_coefficient(velocity_magnitude);
1240
1241 let velocity_fps = velocity_magnitude * 3.28084;
1243
1244 let base_bc = if let Some(ref segments) = self.inputs.bc_segments_data {
1246 segments
1248 .iter()
1249 .find(|seg| velocity_fps >= seg.velocity_min && velocity_fps < seg.velocity_max)
1250 .map(|seg| seg.bc_value)
1251 .unwrap_or(self.inputs.bc_value)
1252 } else {
1253 self.inputs.bc_value
1254 };
1255
1256 let effective_bc = if let Some(ref cluster_bc) = self.cluster_bc {
1258 cluster_bc.apply_correction(
1259 base_bc,
1260 self.inputs.caliber_inches, self.inputs.weight_grains,
1262 velocity_fps,
1263 )
1264 } else {
1265 base_bc
1266 };
1267 let effective_bc = effective_bc.max(1e-6);
1271
1272 let cd_to_retard = 0.000683 * 0.30; let standard_factor = cd * cd_to_retard;
1278 let density_scale = air_density / 1.225; let a_drag_ft_s2 =
1282 (velocity_fps * velocity_fps) * standard_factor * density_scale / effective_bc;
1283 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
1287
1288 let mut accel = drag_acceleration + Vector3::new(0.0, -crate::constants::G_ACCEL_MPS2, 0.0);
1290
1291 if self.inputs.enable_coriolis {
1294 if let Some(lat_deg) = self.inputs.latitude {
1295 let omega_earth = 7.2921159e-5_f64; let lat = lat_deg.to_radians();
1297 let az = self.inputs.azimuth_angle;
1298 let omega = Vector3::new(
1304 omega_earth * lat.cos() * az.cos(), omega_earth * lat.sin(), -omega_earth * lat.cos() * az.sin(), );
1308 accel += -2.0 * omega.cross(velocity);
1313 }
1314 }
1315
1316 if self.inputs.enable_magnus
1318 && self.inputs.bullet_diameter > 0.0
1319 && self.inputs.twist_rate > 0.0
1320 {
1321 let (_, spin_rad_s) =
1322 crate::spin_drift::calculate_spin_rate(velocity_magnitude, self.inputs.twist_rate);
1323 let temp_k = self.atmosphere.temperature + 273.15;
1324 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
1325 let mach = velocity_magnitude / speed_of_sound;
1326
1327 let d_in = self.inputs.bullet_diameter / 0.0254;
1329 let m_gr = self.inputs.bullet_mass / 0.00006479891;
1330 let l_in = if self.inputs.bullet_length > 0.0 {
1331 self.inputs.bullet_length / 0.0254
1332 } else {
1333 4.5 * d_in
1334 };
1335 let press_hpa = self.atmosphere.pressure;
1339 let density_correction = if press_hpa > 0.0 && temp_k > 0.0 {
1340 (temp_k / 288.15) * (1013.25 / press_hpa)
1341 } else {
1342 1.0
1343 };
1344 let sg = crate::spin_drift::miller_stability(d_in, m_gr, self.inputs.twist_rate, l_in)
1345 * density_correction;
1346
1347 let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
1349 sg,
1350 velocity_magnitude,
1351 spin_rad_s,
1352 0.0, 0.0, air_density,
1355 d_in,
1356 l_in,
1357 m_gr,
1358 mach,
1359 "match",
1360 false,
1361 );
1362
1363 let diameter_m = self.inputs.bullet_diameter; let spin_param = spin_rad_s * diameter_m / (2.0 * velocity_magnitude);
1366 let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
1367 let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
1368 let magnus_force = 0.5
1369 * air_density
1370 * velocity_magnitude.powi(2)
1371 * area
1372 * c_np
1373 * spin_param
1374 * yaw_rad.sin();
1375
1376 let velocity_unit = relative_velocity / velocity_magnitude;
1379 let up = Vector3::new(0.0, 1.0, 0.0);
1380 let mut dir = velocity_unit.cross(&up);
1381 let dir_norm = dir.norm();
1382 if dir_norm > 1e-12 && magnus_force.abs() > 1e-12 {
1383 dir /= dir_norm;
1384 if !self.inputs.is_twist_right {
1385 dir = -dir;
1386 }
1387 accel += (magnus_force / self.inputs.bullet_mass) * dir;
1388 }
1389 }
1390
1391 accel
1392 }
1393
1394 fn calculate_drag_coefficient(&self, velocity: f64) -> f64 {
1395 let temp_c = self.atmosphere.temperature;
1397 let temp_k = temp_c + 273.15;
1398 let gamma = 1.4; let r_specific = 287.05; let speed_of_sound = (gamma * r_specific * temp_k).sqrt();
1401 let mach = velocity / speed_of_sound;
1402
1403 if let Some(ref table) = self.inputs.custom_drag_table {
1407 return table.interpolate(mach);
1408 }
1409
1410 let base_cd = crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type);
1412
1413 let bc_type_str: &str = match self.inputs.bc_type {
1417 crate::DragModel::G1 => "G1",
1418 crate::DragModel::G2 => "G2",
1419 crate::DragModel::G5 => "G5",
1420 crate::DragModel::G6 => "G6",
1421 crate::DragModel::G7 => "G7",
1422 crate::DragModel::G8 => "G8",
1423 crate::DragModel::GI => "GI",
1424 crate::DragModel::GS => "GS",
1425 };
1426
1427 let projectile_shape = crate::transonic_drag::resolve_projectile_shape(
1430 self.inputs.bullet_model.as_deref(),
1431 self.inputs.caliber_inches,
1432 self.inputs.bullet_mass / 0.00006479891, bc_type_str,
1434 );
1435
1436 let include_wave_drag = false;
1442 let cd = transonic_correction(mach, base_cd, projectile_shape, include_wave_drag);
1443
1444 crate::form_factor::apply_form_factor_to_drag(
1449 cd,
1450 self.inputs.bullet_model.as_deref(),
1451 &self.inputs.bc_type,
1452 self.inputs.use_form_factor,
1453 )
1454 }
1455}
1456
1457#[derive(Debug, Clone)]
1459pub struct MonteCarloParams {
1460 pub num_simulations: usize,
1461 pub velocity_std_dev: f64,
1462 pub angle_std_dev: f64,
1463 pub bc_std_dev: f64,
1464 pub wind_speed_std_dev: f64,
1465 pub target_distance: Option<f64>,
1466 pub base_wind_speed: f64,
1467 pub base_wind_direction: f64,
1468 pub azimuth_std_dev: f64, }
1470
1471impl Default for MonteCarloParams {
1472 fn default() -> Self {
1473 Self {
1474 num_simulations: 1000,
1475 velocity_std_dev: 1.0,
1476 angle_std_dev: 0.001,
1477 bc_std_dev: 0.01,
1478 wind_speed_std_dev: 1.0,
1479 target_distance: None,
1480 base_wind_speed: 0.0,
1481 base_wind_direction: 0.0,
1482 azimuth_std_dev: 0.001, }
1484 }
1485}
1486
1487#[derive(Debug, Clone)]
1489pub struct MonteCarloResults {
1490 pub ranges: Vec<f64>,
1491 pub impact_velocities: Vec<f64>,
1492 pub impact_positions: Vec<Vector3<f64>>,
1493}
1494
1495pub fn run_monte_carlo(
1497 base_inputs: BallisticInputs,
1498 params: MonteCarloParams,
1499) -> Result<MonteCarloResults, BallisticsError> {
1500 let base_wind = WindConditions {
1501 speed: params.base_wind_speed,
1502 direction: params.base_wind_direction,
1503 };
1504 run_monte_carlo_with_wind(base_inputs, base_wind, params)
1505}
1506
1507pub fn run_monte_carlo_with_wind(
1509 base_inputs: BallisticInputs,
1510 base_wind: WindConditions,
1511 params: MonteCarloParams,
1512) -> Result<MonteCarloResults, BallisticsError> {
1513 use rand::thread_rng;
1514 use rand_distr::{Distribution, Normal};
1515
1516 let mut rng = thread_rng();
1517 let mut ranges = Vec::new();
1518 let mut impact_velocities = Vec::new();
1519 let mut impact_positions = Vec::new();
1520
1521 let baseline_solver =
1523 TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), Default::default());
1524 let baseline_result = baseline_solver.solve()?;
1525
1526 let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
1528
1529 let baseline_at_target = baseline_result
1531 .position_at_range(target_distance)
1532 .ok_or("Could not interpolate baseline at target distance")?;
1533
1534 let velocity_dist = Normal::new(base_inputs.muzzle_velocity, params.velocity_std_dev)
1536 .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
1537 let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
1538 .map_err(|e| format!("Invalid angle distribution: {}", e))?;
1539 let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
1540 .map_err(|e| format!("Invalid BC distribution: {}", e))?;
1541 let wind_speed_dist = Normal::new(base_wind.speed, params.wind_speed_std_dev)
1542 .map_err(|e| format!("Invalid wind speed distribution: {}", e))?;
1543 let wind_dir_dist =
1548 Normal::new(base_wind.direction, params.wind_speed_std_dev * 0.1)
1549 .map_err(|e| format!("Invalid wind direction distribution: {}", e))?;
1550 let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
1551 .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
1552
1553 let pointing_error_dist = Normal::new(0.0, params.angle_std_dev * target_distance)
1555 .map_err(|e| format!("Invalid pointing distribution: {}", e))?;
1556
1557 for _ in 0..params.num_simulations {
1558 let mut inputs = base_inputs.clone();
1560 inputs.muzzle_velocity = velocity_dist.sample(&mut rng).max(0.0);
1561 inputs.muzzle_angle = angle_dist.sample(&mut rng);
1562 inputs.bc_value = bc_dist.sample(&mut rng).max(0.01);
1563 inputs.azimuth_angle = azimuth_dist.sample(&mut rng); let wind = WindConditions {
1567 speed: wind_speed_dist.sample(&mut rng).abs(),
1568 direction: wind_dir_dist.sample(&mut rng),
1569 };
1570
1571 let solver = TrajectorySolver::new(inputs, wind, Default::default());
1573 match solver.solve() {
1574 Ok(result) => {
1575 if result.max_range < target_distance {
1583 continue;
1584 }
1585 let pos_at_target = match result.position_at_range(target_distance) {
1589 Some(p) => p,
1590 None => continue,
1591 };
1592
1593 ranges.push(result.max_range);
1594 impact_velocities.push(result.impact_velocity);
1595
1596 let mut deviation = Vector3::new(
1599 0.0, pos_at_target.y - baseline_at_target.y, pos_at_target.z - baseline_at_target.z, );
1603
1604 let pointing_error_y = pointing_error_dist.sample(&mut rng);
1607 deviation.y += pointing_error_y;
1608
1609 impact_positions.push(deviation);
1610 }
1611 Err(_) => {
1612 continue;
1614 }
1615 }
1616 }
1617
1618 if ranges.is_empty() {
1619 return Err("No successful simulations".into());
1620 }
1621
1622 Ok(MonteCarloResults {
1623 ranges,
1624 impact_velocities,
1625 impact_positions,
1626 })
1627}
1628
1629pub fn calculate_zero_angle(
1631 inputs: BallisticInputs,
1632 target_distance: f64,
1633 target_height: f64,
1634) -> Result<f64, BallisticsError> {
1635 calculate_zero_angle_with_conditions(
1636 inputs,
1637 target_distance,
1638 target_height,
1639 WindConditions::default(),
1640 AtmosphericConditions::default(),
1641 )
1642}
1643
1644pub fn calculate_zero_angle_with_conditions(
1645 inputs: BallisticInputs,
1646 target_distance: f64,
1647 target_height: f64,
1648 wind: WindConditions,
1649 atmosphere: AtmosphericConditions,
1650) -> Result<f64, BallisticsError> {
1651 let get_height_at_angle = |angle: f64| -> Result<Option<f64>, BallisticsError> {
1653 let mut test_inputs = inputs.clone();
1654 test_inputs.muzzle_angle = angle;
1655
1656 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
1657 solver.set_max_range(target_distance * 2.0);
1658 solver.set_time_step(0.001);
1659 let result = solver.solve()?;
1660
1661 for i in 0..result.points.len() {
1663 if result.points[i].position.x >= target_distance {
1664 if i > 0 {
1665 let p1 = &result.points[i - 1];
1666 let p2 = &result.points[i];
1667 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
1668 return Ok(Some(p1.position.y + t * (p2.position.y - p1.position.y)));
1669 } else {
1670 return Ok(Some(result.points[i].position.y));
1671 }
1672 }
1673 }
1674 Ok(None)
1675 };
1676
1677 let mut low_angle = 0.0; let mut high_angle = 0.2; let tolerance = 0.00001; let max_iterations = 50;
1683
1684 let low_height = get_height_at_angle(low_angle)?;
1687 let high_height = get_height_at_angle(high_angle)?;
1688
1689 match (low_height, high_height) {
1690 (Some(lh), Some(hh)) => {
1691 let low_error = lh - target_height;
1692 let high_error = hh - target_height;
1693
1694 if low_error > 0.0 && high_error > 0.0 {
1697 } else if low_error < 0.0 && high_error < 0.0 {
1702 let mut expanded = false;
1705 for multiplier in [2.0, 3.0, 4.0] {
1706 let new_high = (high_angle * multiplier).min(0.785);
1707 if let Ok(Some(h)) = get_height_at_angle(new_high) {
1708 if h - target_height > 0.0 {
1709 high_angle = new_high;
1710 expanded = true;
1711 break;
1712 }
1713 }
1714 if new_high >= 0.785 {
1715 break;
1716 }
1717 }
1718 if !expanded {
1719 return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
1720 }
1721 }
1722 }
1724 (None, Some(_hh)) => {
1725 }
1728 (Some(_lh), None) => {
1729 return Err(
1731 "Cannot find zero angle: high angle trajectory doesn't reach target distance"
1732 .into(),
1733 );
1734 }
1735 (None, None) => {
1736 return Err(
1738 "Cannot find zero angle: trajectory cannot reach target distance at any angle"
1739 .into(),
1740 );
1741 }
1742 }
1743
1744 for _iteration in 0..max_iterations {
1745 let mid_angle = (low_angle + high_angle) / 2.0;
1746
1747 let mut test_inputs = inputs.clone();
1748 test_inputs.muzzle_angle = mid_angle;
1749
1750 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
1751 solver.set_max_range(target_distance * 2.0);
1753 solver.set_time_step(0.001);
1754 let result = solver.solve()?;
1755
1756 let mut height_at_target = None;
1758 for i in 0..result.points.len() {
1759 if result.points[i].position.x >= target_distance {
1760 if i > 0 {
1761 let p1 = &result.points[i - 1];
1763 let p2 = &result.points[i];
1764 let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
1765 height_at_target = Some(p1.position.y + t * (p2.position.y - p1.position.y));
1766 } else {
1767 height_at_target = Some(result.points[i].position.y);
1768 }
1769 break;
1770 }
1771 }
1772
1773 match height_at_target {
1774 Some(height) => {
1775 let error = height - target_height;
1776 if error.abs() < 0.001 {
1779 return Ok(mid_angle);
1780 }
1781
1782 if (high_angle - low_angle).abs() < tolerance {
1786 if error.abs() < 0.01 {
1787 return Ok(mid_angle);
1789 }
1790 return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
1795 }
1796
1797 if error > 0.0 {
1798 high_angle = mid_angle;
1799 } else {
1800 low_angle = mid_angle;
1801 }
1802 }
1803 None => {
1804 low_angle = mid_angle;
1806
1807 if (high_angle - low_angle).abs() < tolerance {
1809 return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
1810 }
1811 }
1812 }
1813 }
1814
1815 Err("Failed to find zero angle".into())
1816}
1817
1818pub fn estimate_bc_from_trajectory(
1820 velocity: f64,
1821 mass: f64,
1822 diameter: f64,
1823 points: &[(f64, f64)], ) -> Result<f64, BallisticsError> {
1825 let mut best_bc = 0.5;
1827 let mut best_error = f64::MAX;
1828 let mut found_valid = false;
1829
1830 for bc in (100..1000).step_by(10) {
1832 let bc_value = bc as f64 / 1000.0;
1833
1834 let inputs = BallisticInputs {
1835 muzzle_velocity: velocity,
1836 bc_value,
1837 bullet_mass: mass,
1838 bullet_diameter: diameter,
1839 ..Default::default()
1840 };
1841
1842 let mut solver = TrajectorySolver::new(inputs, Default::default(), Default::default());
1843 solver.set_max_range(points.last().map(|(d, _)| *d * 1.5).unwrap_or(1000.0));
1845
1846 let result = match solver.solve() {
1847 Ok(r) => r,
1848 Err(_) => continue, };
1850
1851 let mut total_error = 0.0;
1853 for (target_dist, target_drop) in points {
1854 let mut calculated_drop = None;
1856 for i in 0..result.points.len() {
1857 if result.points[i].position.x >= *target_dist {
1858 if i > 0 {
1859 let p1 = &result.points[i - 1];
1861 let p2 = &result.points[i];
1862 let t = (target_dist - p1.position.x) / (p2.position.x - p1.position.x);
1863 calculated_drop =
1864 Some(-(p1.position.y + t * (p2.position.y - p1.position.y)));
1865 } else {
1866 calculated_drop = Some(-result.points[i].position.y);
1867 }
1868 break;
1869 }
1870 }
1871
1872 if let Some(drop) = calculated_drop {
1873 let error = (drop - target_drop).abs();
1874 total_error += error * error;
1875 }
1876 }
1877
1878 if total_error < best_error {
1879 best_error = total_error;
1880 best_bc = bc_value;
1881 found_valid = true;
1882 }
1883 }
1884
1885 if !found_valid {
1886 return Err(BallisticsError::from("Unable to estimate BC from provided data. Check that drop values are in correct units.".to_string()));
1887 }
1888
1889 Ok(best_bc)
1890}
1891
1892fn calculate_air_density(atmosphere: &AtmosphericConditions) -> f64 {
1894 crate::atmosphere::calculate_atmosphere(
1902 atmosphere.altitude,
1903 crate::atmosphere::resolve_station_temperature(atmosphere.temperature, atmosphere.altitude),
1904 crate::atmosphere::resolve_station_pressure(atmosphere.pressure, atmosphere.altitude),
1905 atmosphere.humidity,
1906 )
1907 .0
1908}
1909
1910use rand;
1912use rand_distr;
1913
1914#[cfg(test)]
1915mod ground_termination_tests {
1916 use super::*;
1917
1918 #[test]
1923 fn rk4_and_rk45_descend_to_ground_threshold() {
1924 for adaptive in [false, true] {
1925 let mut inputs = BallisticInputs::default();
1926 inputs.muzzle_angle = 0.1; inputs.use_rk4 = true;
1928 inputs.use_adaptive_rk45 = adaptive;
1929 assert_eq!(inputs.ground_threshold, -100.0, "default ground_threshold is -100 m");
1930
1931 let mut solver = TrajectorySolver::new(
1932 inputs,
1933 WindConditions::default(),
1934 AtmosphericConditions::default(),
1935 );
1936 solver.set_max_range(1.0e7);
1938
1939 let result = solver.solve().expect("solve should succeed");
1940 let final_y = result
1941 .points
1942 .last()
1943 .expect("trajectory has points")
1944 .position
1945 .y;
1946 assert!(
1947 final_y < -1.0,
1948 "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
1949 past launch level toward the ground_threshold floor, not stop at y = 0"
1950 );
1951 }
1952 }
1953}