1use crate::DragModel;
3use crate::wind_shear::{WindShearProfile, WindShearModel, WindLayer};
4use crate::transonic_drag::{transonic_correction, get_projectile_shape, ProjectileShape};
5use crate::trajectory_sampling::{sample_trajectory, TrajectoryData, TrajectoryOutputs, TrajectorySample};
6use crate::pitch_damping::{calculate_pitch_damping_coefficient, PitchDampingCoefficients};
7use crate::precession_nutation::{AngularState, PrecessionNutationParams, calculate_combined_angular_motion};
8use crate::cluster_bc::ClusterBCDegradation;
9use nalgebra::Vector3;
10use std::error::Error;
11use std::fmt;
12
13#[derive(Debug, Clone, Copy, PartialEq)]
15pub enum UnitSystem {
16 Imperial,
17 Metric,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq)]
22pub enum OutputFormat {
23 Table,
24 Json,
25 Csv,
26}
27
28#[derive(Debug)]
30pub struct BallisticsError {
31 message: String,
32}
33
34impl fmt::Display for BallisticsError {
35 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
36 write!(f, "{}", self.message)
37 }
38}
39
40impl Error for BallisticsError {}
41
42impl From<String> for BallisticsError {
43 fn from(msg: String) -> Self {
44 BallisticsError { message: msg }
45 }
46}
47
48impl From<&str> for BallisticsError {
49 fn from(msg: &str) -> Self {
50 BallisticsError { message: msg.to_string() }
51 }
52}
53
54#[derive(Debug, Clone)]
58pub struct BallisticInputs {
59 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,
104 pub use_powder_sensitivity: bool,
105 pub powder_temp_sensitivity: f64,
106 pub powder_temp: f64, pub tipoff_yaw: f64, pub tipoff_decay_distance: f64, pub use_bc_segments: bool,
110 pub bc_segments: Option<Vec<(f64, f64)>>, pub bc_segments_data: Option<Vec<crate::BCSegmentData>>, pub use_enhanced_spin_drift: bool,
113 pub use_form_factor: bool,
114 pub enable_wind_shear: bool,
115 pub wind_shear_model: String,
116 pub enable_trajectory_sampling: bool,
117 pub sample_interval: f64, pub enable_pitch_damping: bool,
119 pub enable_precession_nutation: bool,
120 pub use_cluster_bc: bool, pub custom_drag_table: Option<crate::drag::DragTable>,
124
125 pub bc_type_str: Option<String>,
127}
128
129impl Default for BallisticInputs {
130 fn default() -> Self {
131 let mass_kg = 0.01;
132 let diameter_m = 0.00762;
133 let bc = 0.5;
134 let muzzle_angle_rad = 0.0;
135 let bc_type = DragModel::G1;
136
137 Self {
138 bc_value: bc,
140 bc_type,
141 bullet_mass: mass_kg,
142 muzzle_velocity: 800.0,
143 bullet_diameter: diameter_m,
144 bullet_length: diameter_m * 4.0, muzzle_angle: muzzle_angle_rad,
148 target_distance: 100.0,
149 azimuth_angle: 0.0,
150 shooting_angle: 0.0,
151 sight_height: 0.05,
152 muzzle_height: 0.0, target_height: 0.0, ground_threshold: -100.0, altitude: 0.0,
158 temperature: 15.0,
159 pressure: 1013.25, humidity: 0.5, latitude: None,
162
163 wind_speed: 0.0,
165 wind_angle: 0.0,
166
167 twist_rate: 12.0, is_twist_right: true,
170 caliber_inches: diameter_m / 0.0254, weight_grains: mass_kg / 0.00006479891, manufacturer: None,
173 bullet_model: None,
174 bullet_id: None,
175 bullet_cluster: None,
176
177 use_rk4: true, use_adaptive_rk45: true, enable_advanced_effects: false,
183 use_powder_sensitivity: false,
184 powder_temp_sensitivity: 0.0,
185 powder_temp: 15.0,
186 tipoff_yaw: 0.0,
187 tipoff_decay_distance: 50.0,
188 use_bc_segments: false,
189 bc_segments: None,
190 bc_segments_data: None,
191 use_enhanced_spin_drift: false,
192 use_form_factor: false,
193 enable_wind_shear: false,
194 wind_shear_model: "none".to_string(),
195 enable_trajectory_sampling: false,
196 sample_interval: 10.0, enable_pitch_damping: false,
198 enable_precession_nutation: false,
199 use_cluster_bc: false, custom_drag_table: None,
203
204 bc_type_str: None,
206 }
207 }
208}
209
210#[derive(Debug, Clone)]
212pub struct WindConditions {
213 pub speed: f64, pub direction: f64, }
216
217impl Default for WindConditions {
218 fn default() -> Self {
219 Self {
220 speed: 0.0,
221 direction: 0.0,
222 }
223 }
224}
225
226#[derive(Debug, Clone)]
228pub struct AtmosphericConditions {
229 pub temperature: f64, pub pressure: f64, pub humidity: f64, pub altitude: f64, }
234
235impl Default for AtmosphericConditions {
236 fn default() -> Self {
237 Self {
238 temperature: 15.0,
239 pressure: 1013.25,
240 humidity: 50.0,
241 altitude: 0.0,
242 }
243 }
244}
245
246#[derive(Debug, Clone)]
248pub struct TrajectoryPoint {
249 pub time: f64,
250 pub position: Vector3<f64>,
251 pub velocity_magnitude: f64,
252 pub kinetic_energy: f64,
253}
254
255#[derive(Debug, Clone)]
257pub struct TrajectoryResult {
258 pub max_range: f64,
259 pub max_height: f64,
260 pub time_of_flight: f64,
261 pub impact_velocity: f64,
262 pub impact_energy: f64,
263 pub points: Vec<TrajectoryPoint>,
264 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>, }
271
272impl TrajectoryResult {
273 pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
277 if self.points.is_empty() {
278 return None;
279 }
280
281 for i in 0..self.points.len() - 1 {
283 let p1 = &self.points[i];
284 let p2 = &self.points[i + 1];
285
286 if p1.position.z <= target_range && p2.position.z >= target_range {
288 let dz = p2.position.z - p1.position.z;
290 if dz.abs() < 1e-10 {
291 return Some(p1.position);
292 }
293 let t = (target_range - p1.position.z) / dz;
294
295 return Some(Vector3::new(
297 p1.position.x + t * (p2.position.x - p1.position.x),
298 p1.position.y + t * (p2.position.y - p1.position.y),
299 target_range,
300 ));
301 }
302 }
303
304 self.points.last().map(|p| p.position)
306 }
307}
308
309pub struct TrajectorySolver {
311 inputs: BallisticInputs,
312 wind: WindConditions,
313 atmosphere: AtmosphericConditions,
314 max_range: f64,
315 time_step: f64,
316 cluster_bc: Option<ClusterBCDegradation>,
317}
318
319impl TrajectorySolver {
320 pub fn new(mut inputs: BallisticInputs, wind: WindConditions, atmosphere: AtmosphericConditions) -> Self {
321 inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
323 inputs.weight_grains = inputs.bullet_mass / 0.00006479891;
324
325 let cluster_bc = if inputs.use_cluster_bc {
327 Some(ClusterBCDegradation::new())
328 } else {
329 None
330 };
331
332 Self {
333 inputs,
334 wind,
335 atmosphere,
336 max_range: 1000.0,
337 time_step: 0.001,
338 cluster_bc,
339 }
340 }
341
342 pub fn set_max_range(&mut self, range: f64) {
343 self.max_range = range;
344 }
345
346 pub fn set_time_step(&mut self, step: f64) {
347 self.time_step = step;
348 }
349
350 fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
351 let profile = WindShearProfile {
353 model: if self.inputs.wind_shear_model == "logarithmic" {
354 WindShearModel::Logarithmic
355 } else if self.inputs.wind_shear_model == "power" {
356 WindShearModel::PowerLaw
357 } else {
358 WindShearModel::PowerLaw },
360 surface_wind: WindLayer {
361 altitude_m: 0.0,
362 speed_mps: self.wind.speed,
363 direction_deg: self.wind.direction.to_degrees(),
364 },
365 reference_height: 10.0, roughness_length: 0.03, power_exponent: 1.0 / 7.0, geostrophic_wind: None,
369 custom_layers: Vec::new(),
370 };
371
372 profile.get_wind_at_altitude(altitude_m)
373 }
374
375 pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
376 if self.inputs.use_rk4 {
377 if self.inputs.use_adaptive_rk45 {
378 self.solve_rk45()
379 } else {
380 self.solve_rk4()
381 }
382 } else {
383 self.solve_euler()
384 }
385 }
386
387 fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
388 let mut time = 0.0;
390 let mut position = Vector3::new(0.0, self.inputs.sight_height + self.inputs.muzzle_height, 0.0);
391 let horizontal_velocity = self.inputs.muzzle_velocity * self.inputs.muzzle_angle.cos();
394 let mut velocity = Vector3::new(
395 horizontal_velocity * self.inputs.azimuth_angle.sin(), self.inputs.muzzle_velocity * self.inputs.muzzle_angle.sin(), horizontal_velocity * self.inputs.azimuth_angle.cos(), );
399
400 let mut points = Vec::new();
401 let mut max_height = position.y;
402 let mut min_pitch_damping = 1.0; let mut transonic_mach = None; let mut angular_state = if self.inputs.enable_precession_nutation {
407 Some(AngularState {
408 pitch_angle: 0.001, yaw_angle: 0.001,
410 pitch_rate: 0.0,
411 yaw_rate: 0.0,
412 precession_angle: 0.0,
413 nutation_phase: 0.0,
414 })
415 } else {
416 None
417 };
418 let mut max_yaw_angle = 0.0;
419 let mut max_precession_angle = 0.0;
420
421 let air_density = calculate_air_density(&self.atmosphere);
423
424 let wind_vector = Vector3::new(
426 self.wind.speed * self.wind.direction.sin(), 0.0,
428 self.wind.speed * self.wind.direction.cos(), );
430
431 while position.z < self.max_range && position.y >= 0.0 && time < 100.0 {
433 let velocity_magnitude = velocity.magnitude();
435 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
436
437 points.push(TrajectoryPoint {
438 time,
439 position: position.clone(),
440 velocity_magnitude,
441 kinetic_energy,
442 });
443
444 if points.len() == 1 || points.len() % 100 == 0 {
447 eprintln!("Trajectory point {}: time={:.3}s, lateral={:.2}m, vertical={:.2}m, downrange={:.2}m, vel={:.1}m/s",
448 points.len(), time, position.x, position.y, position.z, velocity_magnitude);
449 }
450
451 if position.y > max_height {
453 max_height = position.y;
454 }
455
456 if self.inputs.enable_pitch_damping {
458 let temp_c = self.atmosphere.temperature;
459 let temp_k = temp_c + 273.15;
460 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
461 let mach = velocity_magnitude / speed_of_sound;
462
463 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
465 transonic_mach = Some(mach);
466 }
467
468 let bullet_type = if let Some(ref model) = self.inputs.bullet_model {
470 model.as_str()
471 } else {
472 "default"
473 };
474 let coeffs = PitchDampingCoefficients::from_bullet_type(bullet_type);
475 let pitch_damping = calculate_pitch_damping_coefficient(mach, &coeffs);
476
477 if pitch_damping < min_pitch_damping {
479 min_pitch_damping = pitch_damping;
480 }
481 }
482
483 if self.inputs.enable_precession_nutation {
485 if let Some(ref mut state) = angular_state {
486 let velocity_magnitude = velocity.magnitude();
487 let temp_c = self.atmosphere.temperature;
488 let temp_k = temp_c + 273.15;
489 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
490 let mach = velocity_magnitude / speed_of_sound;
491
492 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
494 let velocity_fps = velocity_magnitude * 3.28084;
495 let twist_rate_ft = self.inputs.twist_rate / 12.0;
496 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
497 } else {
498 0.0
499 };
500
501 let params = PrecessionNutationParams {
503 mass_kg: self.inputs.bullet_mass,
504 caliber_m: self.inputs.bullet_diameter,
505 length_m: self.inputs.bullet_length,
506 spin_rate_rad_s,
507 spin_inertia: 6.94e-8, transverse_inertia: 9.13e-7, velocity_mps: velocity_magnitude,
510 air_density_kg_m3: air_density,
511 mach,
512 pitch_damping_coeff: -0.8,
513 nutation_damping_factor: 0.05,
514 };
515
516 *state = calculate_combined_angular_motion(
518 ¶ms,
519 state,
520 time,
521 self.time_step,
522 0.001, );
524
525 if state.yaw_angle.abs() > max_yaw_angle {
527 max_yaw_angle = state.yaw_angle.abs();
528 }
529 if state.precession_angle.abs() > max_precession_angle {
530 max_precession_angle = state.precession_angle.abs();
531 }
532 }
533 }
534
535 let actual_wind = if self.inputs.enable_wind_shear {
537 self.get_wind_at_altitude(position.y)
538 } else {
539 wind_vector.clone()
540 };
541 let velocity_rel = velocity - actual_wind;
542 let velocity_rel_mag = velocity_rel.magnitude();
543 let drag_coefficient = self.calculate_drag_coefficient(velocity_rel_mag);
544
545 let drag_force = 0.5 * air_density * drag_coefficient *
547 self.inputs.bullet_diameter * self.inputs.bullet_diameter *
548 std::f64::consts::PI / 4.0 * velocity_rel_mag * velocity_rel_mag;
549
550 let drag_acceleration = -drag_force / self.inputs.bullet_mass;
552 let acceleration = Vector3::new(
553 drag_acceleration * velocity_rel.x / velocity_rel_mag,
554 drag_acceleration * velocity_rel.y / velocity_rel_mag - 9.80665,
555 drag_acceleration * velocity_rel.z / velocity_rel_mag,
556 );
557
558 velocity += acceleration * self.time_step;
560 position += velocity * self.time_step;
561 time += self.time_step;
562 }
563
564 let last_point = points.last().ok_or("No trajectory points generated")?;
566
567 let sampled_points = if self.inputs.enable_trajectory_sampling {
569 let trajectory_data = TrajectoryData {
570 times: points.iter().map(|p| p.time).collect(),
571 positions: points.iter().map(|p| p.position.clone()).collect(),
572 velocities: points.iter().map(|p| {
573 Vector3::new(0.0, 0.0, p.velocity_magnitude)
575 }).collect(),
576 transonic_distances: Vec::new(), };
578
579 let outputs = TrajectoryOutputs {
580 target_distance_horiz_m: last_point.position.z, target_vertical_height_m: last_point.position.y,
582 time_of_flight_s: last_point.time,
583 max_ord_dist_horiz_m: max_height,
584 };
585
586 let samples = sample_trajectory(&trajectory_data, &outputs, self.inputs.sample_interval, self.inputs.bullet_mass);
588 Some(samples)
589 } else {
590 None
591 };
592
593 Ok(TrajectoryResult {
594 max_range: last_point.position.z, max_height,
596 time_of_flight: last_point.time,
597 impact_velocity: last_point.velocity_magnitude,
598 impact_energy: last_point.kinetic_energy,
599 points,
600 sampled_points,
601 min_pitch_damping: if self.inputs.enable_pitch_damping { Some(min_pitch_damping) } else { None },
602 transonic_mach: transonic_mach,
603 angular_state: angular_state,
604 max_yaw_angle: if self.inputs.enable_precession_nutation { Some(max_yaw_angle) } else { None },
605 max_precession_angle: if self.inputs.enable_precession_nutation { Some(max_precession_angle) } else { None },
606 })
607 }
608
609 fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
610 let mut time = 0.0;
612 let mut position = Vector3::new(0.0, self.inputs.sight_height + self.inputs.muzzle_height, 0.0);
613
614 let horizontal_velocity = self.inputs.muzzle_velocity * self.inputs.muzzle_angle.cos();
617 let mut velocity = Vector3::new(
618 horizontal_velocity * self.inputs.azimuth_angle.sin(), self.inputs.muzzle_velocity * self.inputs.muzzle_angle.sin(), horizontal_velocity * self.inputs.azimuth_angle.cos(), );
622
623 let mut points = Vec::new();
624 let mut max_height = position.y;
625 let mut min_pitch_damping = 1.0; let mut transonic_mach = None; let mut angular_state = if self.inputs.enable_precession_nutation {
630 Some(AngularState {
631 pitch_angle: 0.001, yaw_angle: 0.001,
633 pitch_rate: 0.0,
634 yaw_rate: 0.0,
635 precession_angle: 0.0,
636 nutation_phase: 0.0,
637 })
638 } else {
639 None
640 };
641 let mut max_yaw_angle = 0.0;
642 let mut max_precession_angle = 0.0;
643
644 let air_density = calculate_air_density(&self.atmosphere);
646
647 let wind_vector = Vector3::new(
649 self.wind.speed * self.wind.direction.sin(), 0.0,
651 self.wind.speed * self.wind.direction.cos(), );
653
654 while position.z < self.max_range && position.y >= 0.0 && time < 100.0 {
656 let velocity_magnitude = velocity.magnitude();
658 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
659
660 points.push(TrajectoryPoint {
661 time,
662 position: position.clone(),
663 velocity_magnitude,
664 kinetic_energy,
665 });
666
667 if position.y > max_height {
668 max_height = position.y;
669 }
670
671 if self.inputs.enable_pitch_damping {
673 let temp_c = self.atmosphere.temperature;
674 let temp_k = temp_c + 273.15;
675 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
676 let mach = velocity_magnitude / speed_of_sound;
677
678 if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
680 transonic_mach = Some(mach);
681 }
682
683 let bullet_type = if let Some(ref model) = self.inputs.bullet_model {
685 model.as_str()
686 } else {
687 "default"
688 };
689 let coeffs = PitchDampingCoefficients::from_bullet_type(bullet_type);
690 let pitch_damping = calculate_pitch_damping_coefficient(mach, &coeffs);
691
692 if pitch_damping < min_pitch_damping {
694 min_pitch_damping = pitch_damping;
695 }
696 }
697
698 if self.inputs.enable_precession_nutation {
700 if let Some(ref mut state) = angular_state {
701 let velocity_magnitude = velocity.magnitude();
702 let temp_c = self.atmosphere.temperature;
703 let temp_k = temp_c + 273.15;
704 let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
705 let mach = velocity_magnitude / speed_of_sound;
706
707 let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
709 let velocity_fps = velocity_magnitude * 3.28084;
710 let twist_rate_ft = self.inputs.twist_rate / 12.0;
711 (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
712 } else {
713 0.0
714 };
715
716 let params = PrecessionNutationParams {
718 mass_kg: self.inputs.bullet_mass,
719 caliber_m: self.inputs.bullet_diameter,
720 length_m: self.inputs.bullet_length,
721 spin_rate_rad_s,
722 spin_inertia: 6.94e-8, transverse_inertia: 9.13e-7, velocity_mps: velocity_magnitude,
725 air_density_kg_m3: air_density,
726 mach,
727 pitch_damping_coeff: -0.8,
728 nutation_damping_factor: 0.05,
729 };
730
731 *state = calculate_combined_angular_motion(
733 ¶ms,
734 state,
735 time,
736 self.time_step,
737 0.001, );
739
740 if state.yaw_angle.abs() > max_yaw_angle {
742 max_yaw_angle = state.yaw_angle.abs();
743 }
744 if state.precession_angle.abs() > max_precession_angle {
745 max_precession_angle = state.precession_angle.abs();
746 }
747 }
748 }
749
750 let dt = self.time_step;
752
753 let acc1 = self.calculate_acceleration(&position, &velocity, air_density, &wind_vector);
755
756 let pos2 = position + velocity * (dt * 0.5);
758 let vel2 = velocity + acc1 * (dt * 0.5);
759 let acc2 = self.calculate_acceleration(&pos2, &vel2, air_density, &wind_vector);
760
761 let pos3 = position + vel2 * (dt * 0.5);
763 let vel3 = velocity + acc2 * (dt * 0.5);
764 let acc3 = self.calculate_acceleration(&pos3, &vel3, air_density, &wind_vector);
765
766 let pos4 = position + vel3 * dt;
768 let vel4 = velocity + acc3 * dt;
769 let acc4 = self.calculate_acceleration(&pos4, &vel4, air_density, &wind_vector);
770
771 position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
773 velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
774 time += dt;
775 }
776
777 let last_point = points.last().ok_or("No trajectory points generated")?;
779
780 let sampled_points = if self.inputs.enable_trajectory_sampling {
782 let trajectory_data = TrajectoryData {
783 times: points.iter().map(|p| p.time).collect(),
784 positions: points.iter().map(|p| p.position.clone()).collect(),
785 velocities: points.iter().map(|p| {
786 Vector3::new(0.0, 0.0, p.velocity_magnitude)
788 }).collect(),
789 transonic_distances: Vec::new(), };
791
792 let outputs = TrajectoryOutputs {
793 target_distance_horiz_m: last_point.position.z, target_vertical_height_m: last_point.position.y,
795 time_of_flight_s: last_point.time,
796 max_ord_dist_horiz_m: max_height,
797 };
798
799 let samples = sample_trajectory(&trajectory_data, &outputs, self.inputs.sample_interval, self.inputs.bullet_mass);
801 Some(samples)
802 } else {
803 None
804 };
805
806 Ok(TrajectoryResult {
807 max_range: last_point.position.z, max_height,
809 time_of_flight: last_point.time,
810 impact_velocity: last_point.velocity_magnitude,
811 impact_energy: last_point.kinetic_energy,
812 points,
813 sampled_points,
814 min_pitch_damping: if self.inputs.enable_pitch_damping { Some(min_pitch_damping) } else { None },
815 transonic_mach: transonic_mach,
816 angular_state: angular_state,
817 max_yaw_angle: if self.inputs.enable_precession_nutation { Some(max_yaw_angle) } else { None },
818 max_precession_angle: if self.inputs.enable_precession_nutation { Some(max_precession_angle) } else { None },
819 })
820 }
821
822 fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
823 let mut time = 0.0;
825 let mut position = Vector3::new(0.0, self.inputs.sight_height + self.inputs.muzzle_height, 0.0);
826
827 let horizontal_velocity = self.inputs.muzzle_velocity * self.inputs.muzzle_angle.cos();
830 let mut velocity = Vector3::new(
831 horizontal_velocity * self.inputs.azimuth_angle.sin(), self.inputs.muzzle_velocity * self.inputs.muzzle_angle.sin(), horizontal_velocity * self.inputs.azimuth_angle.cos(), );
835
836 let mut points = Vec::new();
837 let mut max_height = position.y;
838 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;
846 const MAX_ITERATIONS: usize = 100000;
847
848 while position.z < self.max_range && position.y > self.inputs.ground_threshold && time < 100.0 { iteration_count += 1;
850 if iteration_count > MAX_ITERATIONS {
851 break; }
853
854 let velocity_magnitude = velocity.magnitude();
856 let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
857
858 points.push(TrajectoryPoint {
859 time,
860 position: position.clone(),
861 velocity_magnitude,
862 kinetic_energy,
863 });
864
865 if position.y > max_height {
866 max_height = position.y;
867 }
868
869 let air_density = calculate_air_density(&self.atmosphere);
871 let wind_vector = Vector3::new(
872 self.wind.speed * self.wind.direction.sin(), 0.0,
874 self.wind.speed * self.wind.direction.cos(), );
876
877 let (new_pos, new_vel, new_dt) = self.rk45_step(
879 &position,
880 &velocity,
881 dt,
882 air_density,
883 &wind_vector,
884 tolerance,
885 );
886
887 dt = (safety_factor * new_dt).clamp(min_dt, max_dt);
889
890 position = new_pos;
892 velocity = new_vel;
893 time += dt;
894 }
895
896 if points.is_empty() {
898 return Err(BallisticsError::from("No trajectory points calculated"));
899 }
900
901 let last_point = points.last().unwrap();
902
903 Ok(TrajectoryResult {
904 max_range: last_point.position.z, max_height,
906 time_of_flight: last_point.time,
907 impact_velocity: last_point.velocity_magnitude,
908 impact_energy: last_point.kinetic_energy,
909 points,
910 sampled_points: None, min_pitch_damping: None,
912 transonic_mach: None,
913 angular_state: None,
914 max_yaw_angle: None,
915 max_precession_angle: None,
916 })
917 }
918
919 fn rk45_step(
920 &self,
921 position: &Vector3<f64>,
922 velocity: &Vector3<f64>,
923 dt: f64,
924 air_density: f64,
925 wind_vector: &Vector3<f64>,
926 tolerance: f64,
927 ) -> (Vector3<f64>, Vector3<f64>, f64) {
928 const A21: f64 = 1.0 / 5.0;
930 const A31: f64 = 3.0 / 40.0;
931 const A32: f64 = 9.0 / 40.0;
932 const A41: f64 = 44.0 / 45.0;
933 const A42: f64 = -56.0 / 15.0;
934 const A43: f64 = 32.0 / 9.0;
935 const A51: f64 = 19372.0 / 6561.0;
936 const A52: f64 = -25360.0 / 2187.0;
937 const A53: f64 = 64448.0 / 6561.0;
938 const A54: f64 = -212.0 / 729.0;
939 const A61: f64 = 9017.0 / 3168.0;
940 const A62: f64 = -355.0 / 33.0;
941 const A63: f64 = 46732.0 / 5247.0;
942 const A64: f64 = 49.0 / 176.0;
943 const A65: f64 = -5103.0 / 18656.0;
944 const A71: f64 = 35.0 / 384.0;
945 const A73: f64 = 500.0 / 1113.0;
946 const A74: f64 = 125.0 / 192.0;
947 const A75: f64 = -2187.0 / 6784.0;
948 const A76: f64 = 11.0 / 84.0;
949
950 const B1: f64 = 35.0 / 384.0;
952 const B3: f64 = 500.0 / 1113.0;
953 const B4: f64 = 125.0 / 192.0;
954 const B5: f64 = -2187.0 / 6784.0;
955 const B6: f64 = 11.0 / 84.0;
956
957 const B1_ERR: f64 = 5179.0 / 57600.0;
959 const B3_ERR: f64 = 7571.0 / 16695.0;
960 const B4_ERR: f64 = 393.0 / 640.0;
961 const B5_ERR: f64 = -92097.0 / 339200.0;
962 const B6_ERR: f64 = 187.0 / 2100.0;
963 const B7_ERR: f64 = 1.0 / 40.0;
964
965 let k1_v = self.calculate_acceleration(position, velocity, air_density, wind_vector);
967 let k1_p = velocity.clone();
968
969 let p2 = position + dt * A21 * k1_p;
970 let v2 = velocity + dt * A21 * k1_v;
971 let k2_v = self.calculate_acceleration(&p2, &v2, air_density, wind_vector);
972 let k2_p = v2;
973
974 let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
975 let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
976 let k3_v = self.calculate_acceleration(&p3, &v3, air_density, wind_vector);
977 let k3_p = v3;
978
979 let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
980 let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
981 let k4_v = self.calculate_acceleration(&p4, &v4, air_density, wind_vector);
982 let k4_p = v4;
983
984 let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
985 let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
986 let k5_v = self.calculate_acceleration(&p5, &v5, air_density, wind_vector);
987 let k5_p = v5;
988
989 let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
990 let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
991 let k6_v = self.calculate_acceleration(&p6, &v6, air_density, wind_vector);
992 let k6_p = v6;
993
994 let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
995 let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
996 let k7_v = self.calculate_acceleration(&p7, &v7, air_density, wind_vector);
997 let k7_p = v7;
998
999 let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
1001 let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
1002
1003 let pos_err = position + dt * (B1_ERR * k1_p + B3_ERR * k3_p + B4_ERR * k4_p + B5_ERR * k5_p + B6_ERR * k6_p + B7_ERR * k7_p);
1005 let vel_err = velocity + dt * (B1_ERR * k1_v + B3_ERR * k3_v + B4_ERR * k4_v + B5_ERR * k5_v + B6_ERR * k6_v + B7_ERR * k7_v);
1006
1007 let pos_error = (new_pos - pos_err).magnitude();
1009 let vel_error = (new_vel - vel_err).magnitude();
1010 let error = (pos_error + vel_error) / (1.0 + position.magnitude() + velocity.magnitude());
1011
1012 let dt_new = if error < tolerance {
1014 dt * (tolerance / error).powf(0.2).min(2.0)
1015 } else {
1016 dt * (tolerance / error).powf(0.25).max(0.1)
1017 };
1018
1019 (new_pos, new_vel, dt_new)
1020 }
1021
1022 fn calculate_acceleration(&self, position: &Vector3<f64>, velocity: &Vector3<f64>, air_density: f64, wind_vector: &Vector3<f64>) -> Vector3<f64> {
1023 let actual_wind = if self.inputs.enable_wind_shear {
1025 self.get_wind_at_altitude(position.y)
1026 } else {
1027 wind_vector.clone()
1028 };
1029
1030 let relative_velocity = velocity - &actual_wind;
1031 let velocity_magnitude = relative_velocity.magnitude();
1032
1033 if velocity_magnitude < 0.001 {
1034 return Vector3::new(0.0, -9.81, 0.0);
1035 }
1036
1037 let cd = self.calculate_drag_coefficient(velocity_magnitude);
1039
1040 let effective_bc = if let Some(ref cluster_bc) = self.cluster_bc {
1042 let velocity_fps = velocity_magnitude * 3.28084;
1044 cluster_bc.apply_correction(
1045 self.inputs.bc_value,
1046 self.inputs.caliber_inches * 0.0254, self.inputs.weight_grains,
1048 velocity_fps
1049 )
1050 } else {
1051 self.inputs.bc_value
1052 };
1053
1054 let velocity_fps = velocity_magnitude * 3.28084; let cd_to_retard = 0.000683 * 0.30; let standard_factor = cd * cd_to_retard;
1060 let density_scale = air_density / 1.225; let a_drag_ft_s2 = (velocity_fps * velocity_fps) * standard_factor * density_scale / effective_bc;
1064 let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
1068
1069 drag_acceleration + Vector3::new(0.0, -9.81, 0.0)
1071 }
1072
1073 fn calculate_drag_coefficient(&self, velocity: f64) -> f64 {
1074 let temp_c = self.atmosphere.temperature;
1076 let temp_k = temp_c + 273.15;
1077 let gamma = 1.4; let r_specific = 287.05; let speed_of_sound = (gamma * r_specific * temp_k).sqrt();
1080 let mach = velocity / speed_of_sound;
1081
1082 let base_cd = crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type);
1084
1085 let projectile_shape = if let Some(ref model) = self.inputs.bullet_model {
1087 if model.to_lowercase().contains("boat") || model.to_lowercase().contains("bt") {
1089 ProjectileShape::BoatTail
1090 } else if model.to_lowercase().contains("round") || model.to_lowercase().contains("rn") {
1091 ProjectileShape::RoundNose
1092 } else if model.to_lowercase().contains("flat") || model.to_lowercase().contains("fb") {
1093 ProjectileShape::FlatBase
1094 } else {
1095 get_projectile_shape(
1097 self.inputs.bullet_diameter,
1098 self.inputs.bullet_mass / 0.00006479891, &self.inputs.bc_type.to_string()
1100 )
1101 }
1102 } else {
1103 get_projectile_shape(
1105 self.inputs.bullet_diameter,
1106 self.inputs.bullet_mass / 0.00006479891, &self.inputs.bc_type.to_string()
1108 )
1109 };
1110
1111 let include_wave_drag = self.inputs.enable_advanced_effects;
1114 transonic_correction(mach, base_cd, projectile_shape, include_wave_drag)
1115 }
1116}
1117
1118#[derive(Debug, Clone)]
1120pub struct MonteCarloParams {
1121 pub num_simulations: usize,
1122 pub velocity_std_dev: f64,
1123 pub angle_std_dev: f64,
1124 pub bc_std_dev: f64,
1125 pub wind_speed_std_dev: f64,
1126 pub target_distance: Option<f64>,
1127 pub base_wind_speed: f64,
1128 pub base_wind_direction: f64,
1129 pub azimuth_std_dev: f64, }
1131
1132impl Default for MonteCarloParams {
1133 fn default() -> Self {
1134 Self {
1135 num_simulations: 1000,
1136 velocity_std_dev: 1.0,
1137 angle_std_dev: 0.001,
1138 bc_std_dev: 0.01,
1139 wind_speed_std_dev: 1.0,
1140 target_distance: None,
1141 base_wind_speed: 0.0,
1142 base_wind_direction: 0.0,
1143 azimuth_std_dev: 0.001, }
1145 }
1146}
1147
1148#[derive(Debug, Clone)]
1150pub struct MonteCarloResults {
1151 pub ranges: Vec<f64>,
1152 pub impact_velocities: Vec<f64>,
1153 pub impact_positions: Vec<Vector3<f64>>,
1154}
1155
1156pub fn run_monte_carlo(
1158 base_inputs: BallisticInputs,
1159 params: MonteCarloParams,
1160) -> Result<MonteCarloResults, BallisticsError> {
1161 let base_wind = WindConditions {
1162 speed: params.base_wind_speed,
1163 direction: params.base_wind_direction,
1164 };
1165 run_monte_carlo_with_wind(base_inputs, base_wind, params)
1166}
1167
1168pub fn run_monte_carlo_with_wind(
1170 base_inputs: BallisticInputs,
1171 base_wind: WindConditions,
1172 params: MonteCarloParams,
1173) -> Result<MonteCarloResults, BallisticsError> {
1174 use rand::thread_rng;
1175 use rand_distr::{Distribution, Normal};
1176
1177 let mut rng = thread_rng();
1178 let mut ranges = Vec::new();
1179 let mut impact_velocities = Vec::new();
1180 let mut impact_positions = Vec::new();
1181
1182 let baseline_solver = TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), Default::default());
1184 let baseline_result = baseline_solver.solve()?;
1185
1186 let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
1188
1189 let baseline_at_target = baseline_result.position_at_range(target_distance)
1191 .ok_or("Could not interpolate baseline at target distance")?;
1192
1193 let velocity_dist = Normal::new(base_inputs.muzzle_velocity, params.velocity_std_dev)
1195 .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
1196 let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
1197 .map_err(|e| format!("Invalid angle distribution: {}", e))?;
1198 let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
1199 .map_err(|e| format!("Invalid BC distribution: {}", e))?;
1200 let wind_speed_dist = Normal::new(base_wind.speed, params.wind_speed_std_dev)
1201 .map_err(|e| format!("Invalid wind speed distribution: {}", e))?;
1202 let wind_dir_dist = Normal::new(base_wind.direction, params.wind_speed_std_dev * 0.1) .map_err(|e| format!("Invalid wind direction distribution: {}", e))?;
1204 let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
1205 .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
1206
1207 let pointing_error_dist = Normal::new(0.0, params.angle_std_dev * target_distance)
1209 .map_err(|e| format!("Invalid pointing distribution: {}", e))?;
1210
1211 for _ in 0..params.num_simulations {
1212 let mut inputs = base_inputs.clone();
1214 inputs.muzzle_velocity = velocity_dist.sample(&mut rng).max(0.0);
1215 inputs.muzzle_angle = angle_dist.sample(&mut rng);
1216 inputs.bc_value = bc_dist.sample(&mut rng).max(0.01);
1217 inputs.azimuth_angle = azimuth_dist.sample(&mut rng); let wind = WindConditions {
1221 speed: wind_speed_dist.sample(&mut rng).abs(),
1222 direction: wind_dir_dist.sample(&mut rng),
1223 };
1224
1225 let solver = TrajectorySolver::new(inputs, wind, Default::default());
1227 match solver.solve() {
1228 Ok(result) => {
1229 ranges.push(result.max_range);
1230 impact_velocities.push(result.impact_velocity);
1231
1232 if let Some(pos_at_target) = result.position_at_range(target_distance) {
1234 let mut deviation = Vector3::new(
1237 pos_at_target.x - baseline_at_target.x, pos_at_target.y - baseline_at_target.y, 0.0, );
1241
1242 let pointing_error_y = pointing_error_dist.sample(&mut rng);
1245 deviation.y += pointing_error_y;
1246
1247 impact_positions.push(deviation);
1248 }
1249 },
1250 Err(_) => {
1251 continue;
1253 }
1254 }
1255 }
1256
1257 if ranges.is_empty() {
1258 return Err("No successful simulations".into());
1259 }
1260
1261 Ok(MonteCarloResults {
1262 ranges,
1263 impact_velocities,
1264 impact_positions,
1265 })
1266}
1267
1268pub fn calculate_zero_angle(
1270 inputs: BallisticInputs,
1271 target_distance: f64,
1272 target_height: f64,
1273) -> Result<f64, BallisticsError> {
1274 calculate_zero_angle_with_conditions(
1275 inputs,
1276 target_distance,
1277 target_height,
1278 WindConditions::default(),
1279 AtmosphericConditions::default(),
1280 )
1281}
1282
1283pub fn calculate_zero_angle_with_conditions(
1284 inputs: BallisticInputs,
1285 target_distance: f64,
1286 target_height: f64,
1287 wind: WindConditions,
1288 atmosphere: AtmosphericConditions,
1289) -> Result<f64, BallisticsError> {
1290 let get_height_at_angle = |angle: f64| -> Result<Option<f64>, BallisticsError> {
1292 let mut test_inputs = inputs.clone();
1293 test_inputs.muzzle_angle = angle;
1294
1295 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
1296 solver.set_max_range(target_distance * 2.0);
1297 solver.set_time_step(0.001);
1298 let result = solver.solve()?;
1299
1300 for i in 0..result.points.len() {
1302 if result.points[i].position.z >= target_distance {
1303 if i > 0 {
1304 let p1 = &result.points[i - 1];
1305 let p2 = &result.points[i];
1306 let t = (target_distance - p1.position.z) / (p2.position.z - p1.position.z);
1307 return Ok(Some(p1.position.y + t * (p2.position.y - p1.position.y)));
1308 } else {
1309 return Ok(Some(result.points[i].position.y));
1310 }
1311 }
1312 }
1313 Ok(None)
1314 };
1315
1316 let mut low_angle = 0.0; let mut high_angle = 0.2; let tolerance = 0.00001; let max_iterations = 50;
1322
1323 let low_height = get_height_at_angle(low_angle)?;
1326 let high_height = get_height_at_angle(high_angle)?;
1327
1328 match (low_height, high_height) {
1329 (Some(lh), Some(hh)) => {
1330 let low_error = lh - target_height;
1331 let high_error = hh - target_height;
1332
1333 if low_error > 0.0 && high_error > 0.0 {
1336 } else if low_error < 0.0 && high_error < 0.0 {
1341 let mut expanded = false;
1344 for multiplier in [2.0, 3.0, 4.0] {
1345 let new_high = (high_angle * multiplier).min(0.785);
1346 if let Ok(Some(h)) = get_height_at_angle(new_high) {
1347 if h - target_height > 0.0 {
1348 high_angle = new_high;
1349 expanded = true;
1350 break;
1351 }
1352 }
1353 if new_high >= 0.785 {
1354 break;
1355 }
1356 }
1357 if !expanded {
1358 return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
1359 }
1360 }
1361 },
1363 (None, Some(_hh)) => {
1364 },
1367 (Some(_lh), None) => {
1368 return Err("Cannot find zero angle: high angle trajectory doesn't reach target distance".into());
1370 },
1371 (None, None) => {
1372 return Err("Cannot find zero angle: trajectory cannot reach target distance at any angle".into());
1374 }
1375 }
1376
1377 for _iteration in 0..max_iterations {
1378 let mid_angle = (low_angle + high_angle) / 2.0;
1379
1380 let mut test_inputs = inputs.clone();
1381 test_inputs.muzzle_angle = mid_angle;
1382
1383 let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
1384 solver.set_max_range(target_distance * 2.0);
1386 solver.set_time_step(0.001);
1387 let result = solver.solve()?;
1388
1389 let mut height_at_target = None;
1391 for i in 0..result.points.len() {
1392 if result.points[i].position.z >= target_distance {
1393 if i > 0 {
1394 let p1 = &result.points[i - 1];
1396 let p2 = &result.points[i];
1397 let t = (target_distance - p1.position.z) / (p2.position.z - p1.position.z);
1398 height_at_target = Some(p1.position.y + t * (p2.position.y - p1.position.y));
1399 } else {
1400 height_at_target = Some(result.points[i].position.y);
1401 }
1402 break;
1403 }
1404 }
1405
1406 match height_at_target {
1407 Some(height) => {
1408 let error = height - target_height;
1409 if error.abs() < 0.001 {
1412 return Ok(mid_angle);
1413 }
1414
1415 if (high_angle - low_angle).abs() < tolerance {
1419 if error.abs() < 0.01 {
1420 return Ok(mid_angle);
1422 }
1423 return Ok(mid_angle);
1426 }
1427
1428 if error > 0.0 {
1429 high_angle = mid_angle;
1430 } else {
1431 low_angle = mid_angle;
1432 }
1433 },
1434 None => {
1435 low_angle = mid_angle;
1437
1438 if (high_angle - low_angle).abs() < tolerance {
1440 return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
1441 }
1442 }
1443 }
1444 }
1445
1446 Err("Failed to find zero angle".into())
1447}
1448
1449pub fn estimate_bc_from_trajectory(
1451 velocity: f64,
1452 mass: f64,
1453 diameter: f64,
1454 points: &[(f64, f64)], ) -> Result<f64, BallisticsError> {
1456 let mut best_bc = 0.5;
1458 let mut best_error = f64::MAX;
1459 let mut found_valid = false;
1460
1461 for bc in (100..1000).step_by(10) {
1463 let bc_value = bc as f64 / 1000.0;
1464
1465 let inputs = BallisticInputs {
1466 muzzle_velocity: velocity,
1467 bc_value,
1468 bullet_mass: mass,
1469 bullet_diameter: diameter,
1470 ..Default::default()
1471 };
1472
1473 let mut solver = TrajectorySolver::new(inputs, Default::default(), Default::default());
1474 solver.set_max_range(points.last().map(|(d, _)| *d * 1.5).unwrap_or(1000.0));
1476
1477 let result = match solver.solve() {
1478 Ok(r) => r,
1479 Err(_) => continue, };
1481
1482 let mut total_error = 0.0;
1484 for (target_dist, target_drop) in points {
1485 let mut calculated_drop = None;
1487 for i in 0..result.points.len() {
1488 if result.points[i].position.z >= *target_dist {
1489 if i > 0 {
1490 let p1 = &result.points[i - 1];
1492 let p2 = &result.points[i];
1493 let t = (target_dist - p1.position.z) / (p2.position.z - p1.position.z);
1494 calculated_drop = Some(-(p1.position.y + t * (p2.position.y - p1.position.y)));
1495 } else {
1496 calculated_drop = Some(-result.points[i].position.y);
1497 }
1498 break;
1499 }
1500 }
1501
1502 if let Some(drop) = calculated_drop {
1503 let error = (drop - target_drop).abs();
1504 total_error += error * error;
1505 }
1506 }
1507
1508 if total_error < best_error {
1509 best_error = total_error;
1510 best_bc = bc_value;
1511 found_valid = true;
1512 }
1513 }
1514
1515 if !found_valid {
1516 return Err(BallisticsError::from("Unable to estimate BC from provided data. Check that drop values are in correct units.".to_string()));
1517 }
1518
1519 Ok(best_bc)
1520}
1521
1522fn calculate_air_density(atmosphere: &AtmosphericConditions) -> f64 {
1524 let r_specific = 287.058; let temperature_k = atmosphere.temperature + 273.15;
1528
1529 let pressure_pa = atmosphere.pressure * 100.0;
1531
1532 let density = pressure_pa / (r_specific * temperature_k);
1534
1535 let altitude_factor = (-atmosphere.altitude / 8000.0).exp();
1537
1538 density * altitude_factor
1539}
1540
1541use rand;
1543use rand_distr;