ballistics_engine/
cli_api.rs

1// CLI API module - provides simplified interfaces for command-line tool
2use 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::{get_projectile_shape, transonic_correction, ProjectileShape};
11use crate::wind_shear::{WindLayer, WindShearModel, WindShearProfile};
12use crate::DragModel;
13use nalgebra::Vector3;
14use std::error::Error;
15use std::fmt;
16
17// Unit system for input/output
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub enum UnitSystem {
20    Imperial,
21    Metric,
22}
23
24// Output format for results
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub enum OutputFormat {
27    Table,
28    Json,
29    Csv,
30}
31
32// Error type for CLI operations
33#[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// Ballistic input parameters - MBA-151 Reconciled Structure
61// Unified structure used by both ballistics-engine and ballistics_rust
62// Duplicates removed, all necessary fields included
63#[derive(Debug, Clone)]
64pub struct BallisticInputs {
65    // Core ballistics parameters (using intuitive names)
66    pub bc_value: f64,        // Ballistic coefficient (G1, G7, etc.)
67    pub bc_type: DragModel,   // Drag model (G1, G7, G8, etc.)
68    pub bullet_mass: f64,     // kg
69    pub muzzle_velocity: f64, // m/s
70    pub bullet_diameter: f64, // meters
71    pub bullet_length: f64,   // meters
72
73    // Targeting and positioning
74    pub muzzle_angle: f64,     // radians (launch angle)
75    pub target_distance: f64,  // meters
76    pub azimuth_angle: f64,    // horizontal aiming angle in radians
77    pub shooting_angle: f64,   // uphill/downhill angle in radians
78    pub sight_height: f64,     // meters above bore
79    pub muzzle_height: f64,    // meters above ground
80    pub target_height: f64,    // meters above ground for zeroing
81    pub ground_threshold: f64, // meters below which to stop
82
83    // Environmental conditions
84    pub altitude: f64,         // meters
85    pub temperature: f64,      // Celsius
86    pub pressure: f64,         // millibars/hPa
87    pub humidity: f64,         // relative humidity (0-1)
88    pub latitude: Option<f64>, // degrees
89
90    // Wind conditions
91    pub wind_speed: f64, // m/s
92    pub wind_angle: f64, // radians (0=headwind, 90=from right)
93
94    // Bullet characteristics
95    pub twist_rate: f64,               // inches per turn
96    pub is_twist_right: bool,          // right-hand twist
97    pub caliber_inches: f64,           // diameter in inches
98    pub weight_grains: f64,            // mass in grains
99    pub manufacturer: Option<String>,  // Bullet manufacturer
100    pub bullet_model: Option<String>,  // Bullet model name
101    pub bullet_id: Option<String>,     // Unique bullet identifier
102    pub bullet_cluster: Option<usize>, // BC cluster ID for cluster_bc module
103
104    // Integration method selection
105    pub use_rk4: bool,           // Use RK4 integration instead of Euler
106    pub use_adaptive_rk45: bool, // Use RK45 adaptive step size integration
107
108    // Advanced effects flags
109    pub enable_advanced_effects: bool,
110    pub use_powder_sensitivity: bool,
111    pub powder_temp_sensitivity: f64,
112    pub powder_temp: f64,           // Celsius
113    pub tipoff_yaw: f64,            // radians
114    pub tipoff_decay_distance: f64, // meters
115    pub use_bc_segments: bool,
116    pub bc_segments: Option<Vec<(f64, f64)>>, // Mach-BC pairs
117    pub bc_segments_data: Option<Vec<crate::BCSegmentData>>, // Velocity-BC segments
118    pub use_enhanced_spin_drift: bool,
119    pub use_form_factor: bool,
120    pub enable_wind_shear: bool,
121    pub wind_shear_model: String,
122    pub enable_trajectory_sampling: bool,
123    pub sample_interval: f64, // meters
124    pub enable_pitch_damping: bool,
125    pub enable_precession_nutation: bool,
126    pub use_cluster_bc: bool, // Use cluster-based BC degradation
127
128    // Custom drag model support
129    pub custom_drag_table: Option<crate::drag::DragTable>,
130
131    // Legacy field for compatibility
132    pub bc_type_str: Option<String>,
133}
134
135impl Default for BallisticInputs {
136    fn default() -> Self {
137        let mass_kg = 0.01;
138        let diameter_m = 0.00762;
139        let bc = 0.5;
140        let muzzle_angle_rad = 0.0;
141        let bc_type = DragModel::G1;
142
143        Self {
144            // Core ballistics parameters
145            bc_value: bc,
146            bc_type,
147            bullet_mass: mass_kg,
148            muzzle_velocity: 800.0,
149            bullet_diameter: diameter_m,
150            bullet_length: diameter_m * 4.0, // Approximate
151
152            // Targeting and positioning
153            muzzle_angle: muzzle_angle_rad,
154            target_distance: 100.0,
155            azimuth_angle: 0.0,
156            shooting_angle: 0.0,
157            sight_height: 0.05,
158            muzzle_height: 0.0,       // Default 0 - height is in sight_height
159            target_height: 0.0,       // Target at ground level by default
160            ground_threshold: -100.0, // Effectively disable ground detection (allow bullet to drop 100m below start)
161
162            // Environmental conditions
163            altitude: 0.0,
164            temperature: 15.0,
165            pressure: 1013.25, // Standard sea level pressure (millibars)
166            humidity: 0.5,     // 50% relative humidity
167            latitude: None,
168
169            // Wind conditions
170            wind_speed: 0.0,
171            wind_angle: 0.0,
172
173            // Bullet characteristics
174            twist_rate: 12.0, // 1:12" typical
175            is_twist_right: true,
176            caliber_inches: diameter_m / 0.0254, // Convert to inches
177            weight_grains: mass_kg / 0.00006479891, // Convert to grains
178            manufacturer: None,
179            bullet_model: None,
180            bullet_id: None,
181            bullet_cluster: None,
182
183            // Integration method selection
184            use_rk4: true,           // Use Runge-Kutta methods by default
185            use_adaptive_rk45: true, // Default to RK45 adaptive for best accuracy
186
187            // Advanced effects (disabled by default)
188            enable_advanced_effects: false,
189            use_powder_sensitivity: false,
190            powder_temp_sensitivity: 0.0,
191            powder_temp: 15.0,
192            tipoff_yaw: 0.0,
193            tipoff_decay_distance: 50.0,
194            use_bc_segments: false,
195            bc_segments: None,
196            bc_segments_data: None,
197            use_enhanced_spin_drift: false,
198            use_form_factor: false,
199            enable_wind_shear: false,
200            wind_shear_model: "none".to_string(),
201            enable_trajectory_sampling: false,
202            sample_interval: 10.0, // Default 10 meter intervals
203            enable_pitch_damping: false,
204            enable_precession_nutation: false,
205            use_cluster_bc: false, // Disabled by default for backward compatibility
206
207            // Custom drag model support
208            custom_drag_table: None,
209
210            // Legacy field for compatibility
211            bc_type_str: None,
212        }
213    }
214}
215
216// Wind conditions
217#[derive(Debug, Clone)]
218pub struct WindConditions {
219    pub speed: f64,     // m/s
220    pub direction: f64, // radians (0 = North, PI/2 = East)
221}
222
223impl Default for WindConditions {
224    fn default() -> Self {
225        Self {
226            speed: 0.0,
227            direction: 0.0,
228        }
229    }
230}
231
232// Atmospheric conditions
233#[derive(Debug, Clone)]
234pub struct AtmosphericConditions {
235    pub temperature: f64, // Celsius
236    pub pressure: f64,    // hPa
237    pub humidity: f64,    // percentage (0-100)
238    pub altitude: f64,    // meters
239}
240
241impl Default for AtmosphericConditions {
242    fn default() -> Self {
243        Self {
244            temperature: 15.0,
245            pressure: 1013.25,
246            humidity: 50.0,
247            altitude: 0.0,
248        }
249    }
250}
251
252// Trajectory point data
253#[derive(Debug, Clone)]
254pub struct TrajectoryPoint {
255    pub time: f64,
256    pub position: Vector3<f64>,
257    pub velocity_magnitude: f64,
258    pub kinetic_energy: f64,
259}
260
261// Trajectory result
262#[derive(Debug, Clone)]
263pub struct TrajectoryResult {
264    pub max_range: f64,
265    pub max_height: f64,
266    pub time_of_flight: f64,
267    pub impact_velocity: f64,
268    pub impact_energy: f64,
269    pub points: Vec<TrajectoryPoint>,
270    pub sampled_points: Option<Vec<TrajectorySample>>, // Trajectory samples at regular intervals
271    pub min_pitch_damping: Option<f64>, // Minimum pitch damping coefficient (for stability warning)
272    pub transonic_mach: Option<f64>,    // Mach number when entering transonic regime
273    pub angular_state: Option<AngularState>, // Final angular state if precession/nutation enabled
274    pub max_yaw_angle: Option<f64>,     // Maximum yaw angle during flight (radians)
275    pub max_precession_angle: Option<f64>, // Maximum precession angle (radians)
276}
277
278impl TrajectoryResult {
279    /// Interpolate position at a given downrange distance (Z coordinate).
280    /// Returns the interpolated (x, y, z) position at that range.
281    /// If the target range exceeds the trajectory, returns the last point.
282    pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
283        if self.points.is_empty() {
284            return None;
285        }
286
287        // Find the two points that bracket the target range
288        for i in 0..self.points.len() - 1 {
289            let p1 = &self.points[i];
290            let p2 = &self.points[i + 1];
291
292            // Check if target range is between these two points
293            if p1.position.z <= target_range && p2.position.z >= target_range {
294                // Linear interpolation factor
295                let dz = p2.position.z - p1.position.z;
296                if dz.abs() < 1e-10 {
297                    return Some(p1.position);
298                }
299                let t = (target_range - p1.position.z) / dz;
300
301                // Interpolate X and Y, use exact target_range for Z
302                return Some(Vector3::new(
303                    p1.position.x + t * (p2.position.x - p1.position.x),
304                    p1.position.y + t * (p2.position.y - p1.position.y),
305                    target_range,
306                ));
307            }
308        }
309
310        // Target range is beyond trajectory - return last point
311        self.points.last().map(|p| p.position)
312    }
313}
314
315// Trajectory solver
316pub struct TrajectorySolver {
317    inputs: BallisticInputs,
318    wind: WindConditions,
319    atmosphere: AtmosphericConditions,
320    max_range: f64,
321    time_step: f64,
322    cluster_bc: Option<ClusterBCDegradation>,
323}
324
325impl TrajectorySolver {
326    pub fn new(
327        mut inputs: BallisticInputs,
328        wind: WindConditions,
329        atmosphere: AtmosphericConditions,
330    ) -> Self {
331        // Compute derived fields from base units
332        inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
333        inputs.weight_grains = inputs.bullet_mass / 0.00006479891;
334
335        // Initialize cluster BC if enabled
336        let cluster_bc = if inputs.use_cluster_bc {
337            Some(ClusterBCDegradation::new())
338        } else {
339            None
340        };
341
342        Self {
343            inputs,
344            wind,
345            atmosphere,
346            max_range: 1000.0,
347            time_step: 0.001,
348            cluster_bc,
349        }
350    }
351
352    pub fn set_max_range(&mut self, range: f64) {
353        self.max_range = range;
354    }
355
356    pub fn set_time_step(&mut self, step: f64) {
357        self.time_step = step;
358    }
359
360    fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
361        // Create wind shear profile based on surface wind
362        let profile = WindShearProfile {
363            model: if self.inputs.wind_shear_model == "logarithmic" {
364                WindShearModel::Logarithmic
365            } else if self.inputs.wind_shear_model == "power" {
366                WindShearModel::PowerLaw
367            } else {
368                WindShearModel::PowerLaw // Default to power law
369            },
370            surface_wind: WindLayer {
371                altitude_m: 0.0,
372                speed_mps: self.wind.speed,
373                direction_deg: self.wind.direction.to_degrees(),
374            },
375            reference_height: 10.0, // Standard meteorological measurement height
376            roughness_length: 0.03, // Short grass
377            power_exponent: 1.0 / 7.0, // Neutral stability
378            geostrophic_wind: None,
379            custom_layers: Vec::new(),
380        };
381
382        profile.get_wind_at_altitude(altitude_m)
383    }
384
385    pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
386        if self.inputs.use_rk4 {
387            if self.inputs.use_adaptive_rk45 {
388                self.solve_rk45()
389            } else {
390                self.solve_rk4()
391            }
392        } else {
393            self.solve_euler()
394        }
395    }
396
397    fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
398        // Simple trajectory integration using Euler method
399        let mut time = 0.0;
400        // Bullet starts at the BORE position, which is muzzle_height above ground
401        // The sight is sight_height ABOVE the bore, so we don't add sight_height here
402        let mut position = Vector3::new(
403            0.0,
404            self.inputs.muzzle_height, // Bore position above ground (NOT + sight_height)
405            0.0,
406        );
407        // Calculate initial velocity components with both elevation and azimuth
408        // Standard ballistics coordinate system: X=lateral, Y=vertical, Z=downrange
409        let horizontal_velocity = self.inputs.muzzle_velocity * self.inputs.muzzle_angle.cos();
410        let mut velocity = Vector3::new(
411            horizontal_velocity * self.inputs.azimuth_angle.sin(), // X: lateral (side deviation)
412            self.inputs.muzzle_velocity * self.inputs.muzzle_angle.sin(), // Y: vertical component
413            horizontal_velocity * self.inputs.azimuth_angle.cos(), // Z: downrange (forward)
414        );
415
416        let mut points = Vec::new();
417        let mut max_height = position.y;
418        let mut min_pitch_damping = 1.0; // Track minimum pitch damping coefficient
419        let mut transonic_mach = None; // Track when we enter transonic
420
421        // Initialize angular state for precession/nutation tracking
422        let mut angular_state = if self.inputs.enable_precession_nutation {
423            Some(AngularState {
424                pitch_angle: 0.001, // Small initial disturbance
425                yaw_angle: 0.001,
426                pitch_rate: 0.0,
427                yaw_rate: 0.0,
428                precession_angle: 0.0,
429                nutation_phase: 0.0,
430            })
431        } else {
432            None
433        };
434        let mut max_yaw_angle = 0.0;
435        let mut max_precession_angle = 0.0;
436
437        // Calculate air density
438        let air_density = calculate_air_density(&self.atmosphere);
439
440        // Wind vector: X=lateral (crosswind), Y=0, Z=downrange (head/tail wind)
441        let wind_vector = Vector3::new(
442            self.wind.speed * self.wind.direction.sin(), // X: lateral (crosswind)
443            0.0,
444            self.wind.speed * self.wind.direction.cos(), // Z: downrange (head/tail wind)
445        );
446
447        // Main integration loop (Z is downrange)
448        while position.z < self.max_range && position.y >= 0.0 && time < 100.0 {
449            // Store trajectory point
450            let velocity_magnitude = velocity.magnitude();
451            let kinetic_energy =
452                0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
453
454            points.push(TrajectoryPoint {
455                time,
456                position: position,
457                velocity_magnitude,
458                kinetic_energy,
459            });
460
461            // Debug: Log first and every 100th point
462            // Coordinate system: X=lateral, Y=vertical, Z=downrange
463            if points.len() == 1 || points.len() % 100 == 0 {
464                eprintln!("Trajectory point {}: time={:.3}s, lateral={:.2}m, vertical={:.2}m, downrange={:.2}m, vel={:.1}m/s",
465                    points.len(), time, position.x, position.y, position.z, velocity_magnitude);
466            }
467
468            // Track max height
469            if position.y > max_height {
470                max_height = position.y;
471            }
472
473            // Calculate pitch damping if enabled
474            if self.inputs.enable_pitch_damping {
475                let temp_c = self.atmosphere.temperature;
476                let temp_k = temp_c + 273.15;
477                let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
478                let mach = velocity_magnitude / speed_of_sound;
479
480                // Track when we enter transonic
481                if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
482                    transonic_mach = Some(mach);
483                }
484
485                // Calculate pitch damping coefficient
486                let bullet_type = if let Some(ref model) = self.inputs.bullet_model {
487                    model.as_str()
488                } else {
489                    "default"
490                };
491                let coeffs = PitchDampingCoefficients::from_bullet_type(bullet_type);
492                let pitch_damping = calculate_pitch_damping_coefficient(mach, &coeffs);
493
494                // Track minimum (most critical for stability)
495                if pitch_damping < min_pitch_damping {
496                    min_pitch_damping = pitch_damping;
497                }
498            }
499
500            // Calculate precession/nutation if enabled
501            if self.inputs.enable_precession_nutation {
502                if let Some(ref mut state) = angular_state {
503                    let velocity_magnitude = velocity.magnitude();
504                    let temp_c = self.atmosphere.temperature;
505                    let temp_k = temp_c + 273.15;
506                    let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
507                    let mach = velocity_magnitude / speed_of_sound;
508
509                    // Calculate spin rate from twist rate and velocity
510                    let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
511                        let velocity_fps = velocity_magnitude * 3.28084;
512                        let twist_rate_ft = self.inputs.twist_rate / 12.0;
513                        (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
514                    } else {
515                        0.0
516                    };
517
518                    // Create precession/nutation parameters
519                    let params = PrecessionNutationParams {
520                        mass_kg: self.inputs.bullet_mass,
521                        caliber_m: self.inputs.bullet_diameter,
522                        length_m: self.inputs.bullet_length,
523                        spin_rate_rad_s,
524                        spin_inertia: 6.94e-8,       // Typical value
525                        transverse_inertia: 9.13e-7, // Typical value
526                        velocity_mps: velocity_magnitude,
527                        air_density_kg_m3: air_density,
528                        mach,
529                        pitch_damping_coeff: -0.8,
530                        nutation_damping_factor: 0.05,
531                    };
532
533                    // Update angular state
534                    *state = calculate_combined_angular_motion(
535                        &params,
536                        state,
537                        time,
538                        self.time_step,
539                        0.001, // Initial disturbance
540                    );
541
542                    // Track maximums
543                    if state.yaw_angle.abs() > max_yaw_angle {
544                        max_yaw_angle = state.yaw_angle.abs();
545                    }
546                    if state.precession_angle.abs() > max_precession_angle {
547                        max_precession_angle = state.precession_angle.abs();
548                    }
549                }
550            }
551
552            // Calculate drag with altitude-dependent wind if enabled
553            let actual_wind = if self.inputs.enable_wind_shear {
554                self.get_wind_at_altitude(position.y)
555            } else {
556                wind_vector
557            };
558            let velocity_rel = velocity - actual_wind;
559            let velocity_rel_mag = velocity_rel.magnitude();
560            let drag_coefficient = self.calculate_drag_coefficient(velocity_rel_mag);
561
562            // Calculate drag force
563            let drag_force = 0.5
564                * air_density
565                * drag_coefficient
566                * self.inputs.bullet_diameter
567                * self.inputs.bullet_diameter
568                * std::f64::consts::PI
569                / 4.0
570                * velocity_rel_mag
571                * velocity_rel_mag;
572
573            // Calculate acceleration
574            let drag_acceleration = -drag_force / self.inputs.bullet_mass;
575            let acceleration = Vector3::new(
576                drag_acceleration * velocity_rel.x / velocity_rel_mag,
577                drag_acceleration * velocity_rel.y / velocity_rel_mag - 9.80665,
578                drag_acceleration * velocity_rel.z / velocity_rel_mag,
579            );
580
581            // Update state
582            velocity += acceleration * self.time_step;
583            position += velocity * self.time_step;
584            time += self.time_step;
585        }
586
587        // Get final values
588        let last_point = points.last().ok_or("No trajectory points generated")?;
589
590        // Create trajectory sampling data if enabled
591        let sampled_points = if self.inputs.enable_trajectory_sampling {
592            let trajectory_data = TrajectoryData {
593                times: points.iter().map(|p| p.time).collect(),
594                positions: points.iter().map(|p| p.position).collect(),
595                velocities: points
596                    .iter()
597                    .map(|p| {
598                        // Reconstruct velocity vectors from magnitude (approximate)
599                        Vector3::new(0.0, 0.0, p.velocity_magnitude)
600                    })
601                    .collect(),
602                transonic_distances: Vec::new(), // TODO: Track Mach transitions
603            };
604
605            // For LOS calculation in ground-referenced coordinates:
606            // sight_position_m is the sight's actual y-position above ground
607            // (muzzle_height + sight_height, not just sight_height)
608            // For flat shots, target is at same height as the sight (horizontal LOS)
609            let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
610            let outputs = TrajectoryOutputs {
611                target_distance_horiz_m: last_point.position.z, // Z is downrange
612                target_vertical_height_m: sight_position_m,
613                time_of_flight_s: last_point.time,
614                max_ord_dist_horiz_m: max_height,
615                sight_height_m: sight_position_m,
616            };
617
618            // Sample at specified intervals
619            let samples = sample_trajectory(
620                &trajectory_data,
621                &outputs,
622                self.inputs.sample_interval,
623                self.inputs.bullet_mass,
624            );
625            Some(samples)
626        } else {
627            None
628        };
629
630        Ok(TrajectoryResult {
631            max_range: last_point.position.z, // Z is downrange
632            max_height,
633            time_of_flight: last_point.time,
634            impact_velocity: last_point.velocity_magnitude,
635            impact_energy: last_point.kinetic_energy,
636            points,
637            sampled_points,
638            min_pitch_damping: if self.inputs.enable_pitch_damping {
639                Some(min_pitch_damping)
640            } else {
641                None
642            },
643            transonic_mach,
644            angular_state,
645            max_yaw_angle: if self.inputs.enable_precession_nutation {
646                Some(max_yaw_angle)
647            } else {
648                None
649            },
650            max_precession_angle: if self.inputs.enable_precession_nutation {
651                Some(max_precession_angle)
652            } else {
653                None
654            },
655        })
656    }
657
658    fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
659        // RK4 trajectory integration for better accuracy
660        let mut time = 0.0;
661        // Bullet starts at the BORE position, which is muzzle_height above ground
662        // The sight is sight_height ABOVE the bore, so we don't add sight_height here
663        // The sight_height affects the LOS calculation and zero angle, not the starting position
664        let mut position = Vector3::new(
665            0.0,
666            self.inputs.muzzle_height, // Bore position above ground (NOT + sight_height)
667            0.0,
668        );
669
670        // Calculate initial velocity components with both elevation and azimuth
671        // Standard ballistics coordinate system: X=lateral, Y=vertical, Z=downrange
672        let horizontal_velocity = self.inputs.muzzle_velocity * self.inputs.muzzle_angle.cos();
673        let mut velocity = Vector3::new(
674            horizontal_velocity * self.inputs.azimuth_angle.sin(), // X: lateral (side deviation)
675            self.inputs.muzzle_velocity * self.inputs.muzzle_angle.sin(), // Y: vertical component
676            horizontal_velocity * self.inputs.azimuth_angle.cos(), // Z: downrange (forward)
677        );
678
679        let mut points = Vec::new();
680        let mut max_height = position.y;
681        let mut min_pitch_damping = 1.0; // Track minimum pitch damping coefficient
682        let mut transonic_mach = None; // Track when we enter transonic
683
684        // Initialize angular state for precession/nutation tracking
685        let mut angular_state = if self.inputs.enable_precession_nutation {
686            Some(AngularState {
687                pitch_angle: 0.001, // Small initial disturbance
688                yaw_angle: 0.001,
689                pitch_rate: 0.0,
690                yaw_rate: 0.0,
691                precession_angle: 0.0,
692                nutation_phase: 0.0,
693            })
694        } else {
695            None
696        };
697        let mut max_yaw_angle = 0.0;
698        let mut max_precession_angle = 0.0;
699
700        // Calculate air density
701        let air_density = calculate_air_density(&self.atmosphere);
702
703        // Wind vector: X=lateral (crosswind), Y=0, Z=downrange (head/tail wind)
704        let wind_vector = Vector3::new(
705            self.wind.speed * self.wind.direction.sin(), // X: lateral (crosswind)
706            0.0,
707            self.wind.speed * self.wind.direction.cos(), // Z: downrange (head/tail wind)
708        );
709
710        // Main RK4 integration loop (Z is downrange)
711        while position.z < self.max_range && position.y >= 0.0 && time < 100.0 {
712            // Store trajectory point
713            let velocity_magnitude = velocity.magnitude();
714            let kinetic_energy =
715                0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
716
717            points.push(TrajectoryPoint {
718                time,
719                position: position,
720                velocity_magnitude,
721                kinetic_energy,
722            });
723
724            if position.y > max_height {
725                max_height = position.y;
726            }
727
728            // Calculate pitch damping if enabled (RK4 solver)
729            if self.inputs.enable_pitch_damping {
730                let temp_c = self.atmosphere.temperature;
731                let temp_k = temp_c + 273.15;
732                let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
733                let mach = velocity_magnitude / speed_of_sound;
734
735                // Track when we enter transonic
736                if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
737                    transonic_mach = Some(mach);
738                }
739
740                // Calculate pitch damping coefficient
741                let bullet_type = if let Some(ref model) = self.inputs.bullet_model {
742                    model.as_str()
743                } else {
744                    "default"
745                };
746                let coeffs = PitchDampingCoefficients::from_bullet_type(bullet_type);
747                let pitch_damping = calculate_pitch_damping_coefficient(mach, &coeffs);
748
749                // Track minimum (most critical for stability)
750                if pitch_damping < min_pitch_damping {
751                    min_pitch_damping = pitch_damping;
752                }
753            }
754
755            // Calculate precession/nutation if enabled (RK4 solver)
756            if self.inputs.enable_precession_nutation {
757                if let Some(ref mut state) = angular_state {
758                    let velocity_magnitude = velocity.magnitude();
759                    let temp_c = self.atmosphere.temperature;
760                    let temp_k = temp_c + 273.15;
761                    let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
762                    let mach = velocity_magnitude / speed_of_sound;
763
764                    // Calculate spin rate from twist rate and velocity
765                    let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
766                        let velocity_fps = velocity_magnitude * 3.28084;
767                        let twist_rate_ft = self.inputs.twist_rate / 12.0;
768                        (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
769                    } else {
770                        0.0
771                    };
772
773                    // Create precession/nutation parameters
774                    let params = PrecessionNutationParams {
775                        mass_kg: self.inputs.bullet_mass,
776                        caliber_m: self.inputs.bullet_diameter,
777                        length_m: self.inputs.bullet_length,
778                        spin_rate_rad_s,
779                        spin_inertia: 6.94e-8,       // Typical value
780                        transverse_inertia: 9.13e-7, // Typical value
781                        velocity_mps: velocity_magnitude,
782                        air_density_kg_m3: air_density,
783                        mach,
784                        pitch_damping_coeff: -0.8,
785                        nutation_damping_factor: 0.05,
786                    };
787
788                    // Update angular state
789                    *state = calculate_combined_angular_motion(
790                        &params,
791                        state,
792                        time,
793                        self.time_step,
794                        0.001, // Initial disturbance
795                    );
796
797                    // Track maximums
798                    if state.yaw_angle.abs() > max_yaw_angle {
799                        max_yaw_angle = state.yaw_angle.abs();
800                    }
801                    if state.precession_angle.abs() > max_precession_angle {
802                        max_precession_angle = state.precession_angle.abs();
803                    }
804                }
805            }
806
807            // RK4 method
808            let dt = self.time_step;
809
810            // k1
811            let acc1 = self.calculate_acceleration(&position, &velocity, air_density, &wind_vector);
812
813            // k2
814            let pos2 = position + velocity * (dt * 0.5);
815            let vel2 = velocity + acc1 * (dt * 0.5);
816            let acc2 = self.calculate_acceleration(&pos2, &vel2, air_density, &wind_vector);
817
818            // k3
819            let pos3 = position + vel2 * (dt * 0.5);
820            let vel3 = velocity + acc2 * (dt * 0.5);
821            let acc3 = self.calculate_acceleration(&pos3, &vel3, air_density, &wind_vector);
822
823            // k4
824            let pos4 = position + vel3 * dt;
825            let vel4 = velocity + acc3 * dt;
826            let acc4 = self.calculate_acceleration(&pos4, &vel4, air_density, &wind_vector);
827
828            // Update position and velocity
829            position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
830            velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
831            time += dt;
832        }
833
834        // Get final values
835        let last_point = points.last().ok_or("No trajectory points generated")?;
836
837        // Create trajectory sampling data if enabled
838        let sampled_points = if self.inputs.enable_trajectory_sampling {
839            let trajectory_data = TrajectoryData {
840                times: points.iter().map(|p| p.time).collect(),
841                positions: points.iter().map(|p| p.position).collect(),
842                velocities: points
843                    .iter()
844                    .map(|p| {
845                        // Reconstruct velocity vectors from magnitude (approximate)
846                        Vector3::new(0.0, 0.0, p.velocity_magnitude)
847                    })
848                    .collect(),
849                transonic_distances: Vec::new(), // TODO: Track Mach transitions
850            };
851
852            // For LOS calculation in ground-referenced coordinates:
853            // sight_position_m is the sight's actual y-position above ground
854            // (muzzle_height + sight_height, not just sight_height)
855            // For flat shots, target is at same height as the sight (horizontal LOS)
856            let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
857            let outputs = TrajectoryOutputs {
858                target_distance_horiz_m: last_point.position.z, // Z is downrange
859                target_vertical_height_m: sight_position_m,
860                time_of_flight_s: last_point.time,
861                max_ord_dist_horiz_m: max_height,
862                sight_height_m: sight_position_m,
863            };
864
865            // Sample at specified intervals
866            let samples = sample_trajectory(
867                &trajectory_data,
868                &outputs,
869                self.inputs.sample_interval,
870                self.inputs.bullet_mass,
871            );
872            Some(samples)
873        } else {
874            None
875        };
876
877        Ok(TrajectoryResult {
878            max_range: last_point.position.z, // Z is downrange
879            max_height,
880            time_of_flight: last_point.time,
881            impact_velocity: last_point.velocity_magnitude,
882            impact_energy: last_point.kinetic_energy,
883            points,
884            sampled_points,
885            min_pitch_damping: if self.inputs.enable_pitch_damping {
886                Some(min_pitch_damping)
887            } else {
888                None
889            },
890            transonic_mach,
891            angular_state,
892            max_yaw_angle: if self.inputs.enable_precession_nutation {
893                Some(max_yaw_angle)
894            } else {
895                None
896            },
897            max_precession_angle: if self.inputs.enable_precession_nutation {
898                Some(max_precession_angle)
899            } else {
900                None
901            },
902        })
903    }
904
905    fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
906        // RK45 adaptive step size integration (Dormand-Prince method)
907        let mut time = 0.0;
908        // Bullet starts at the BORE position, which is muzzle_height above ground
909        // The sight is sight_height ABOVE the bore, so we don't add sight_height here
910        let mut position = Vector3::new(
911            0.0,
912            self.inputs.muzzle_height, // Bore position above ground (NOT + sight_height)
913            0.0,
914        );
915
916        // Calculate initial velocity components
917        // Standard ballistics coordinate system: X=lateral, Y=vertical, Z=downrange
918        let horizontal_velocity = self.inputs.muzzle_velocity * self.inputs.muzzle_angle.cos();
919        let mut velocity = Vector3::new(
920            horizontal_velocity * self.inputs.azimuth_angle.sin(), // X: lateral (side deviation)
921            self.inputs.muzzle_velocity * self.inputs.muzzle_angle.sin(), // Y: vertical component
922            horizontal_velocity * self.inputs.azimuth_angle.cos(), // Z: downrange (forward)
923        );
924
925        let mut points = Vec::new();
926        let mut max_height = position.y;
927        let mut dt = 0.001; // Initial step size
928        let tolerance = 1e-6; // Error tolerance
929        let safety_factor = 0.9; // Safety factor for step size adjustment
930        let max_dt = 0.01; // Maximum step size
931        let min_dt = 1e-6; // Minimum step size
932
933        // Add a point counter to debug
934        let mut iteration_count = 0;
935        const MAX_ITERATIONS: usize = 100000;
936
937        while position.z < self.max_range
938            && position.y > self.inputs.ground_threshold
939            && time < 100.0
940        {
941            // Z is downrange
942            iteration_count += 1;
943            if iteration_count > MAX_ITERATIONS {
944                break; // Prevent infinite loop
945            }
946
947            // Store current point
948            let velocity_magnitude = velocity.magnitude();
949            let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
950
951            points.push(TrajectoryPoint {
952                time,
953                position: position,
954                velocity_magnitude,
955                kinetic_energy,
956            });
957
958            if position.y > max_height {
959                max_height = position.y;
960            }
961
962            // Get atmospheric conditions and wind: X=lateral (crosswind), Y=0, Z=downrange (head/tail wind)
963            let air_density = calculate_air_density(&self.atmosphere);
964            let wind_vector = Vector3::new(
965                self.wind.speed * self.wind.direction.sin(), // X: lateral (crosswind)
966                0.0,
967                self.wind.speed * self.wind.direction.cos(), // Z: downrange (head/tail wind)
968            );
969
970            // RK45 step with adaptive step size
971            let (new_pos, new_vel, new_dt) = self.rk45_step(
972                &position,
973                &velocity,
974                dt,
975                air_density,
976                &wind_vector,
977                tolerance,
978            );
979
980            // Update step size with safety factor and bounds
981            dt = (safety_factor * new_dt).clamp(min_dt, max_dt);
982
983            // Update state
984            position = new_pos;
985            velocity = new_vel;
986            time += dt;
987        }
988
989        // Ensure we have at least one point
990        if points.is_empty() {
991            return Err(BallisticsError::from("No trajectory points calculated"));
992        }
993
994        let last_point = points.last().unwrap();
995
996        // Generate sampled trajectory points if enabled
997        let sampled_points = if self.inputs.enable_trajectory_sampling {
998            // Build trajectory data for sampling
999            let trajectory_data = TrajectoryData {
1000                times: points.iter().map(|p| p.time).collect(),
1001                positions: points.iter().map(|p| p.position).collect(),
1002                velocities: points
1003                    .iter()
1004                    .map(|p| {
1005                        // Approximate velocity direction from position changes
1006                        Vector3::new(0.0, 0.0, p.velocity_magnitude)
1007                    })
1008                    .collect(),
1009                transonic_distances: Vec::new(),
1010            };
1011
1012            // For LOS calculation in ground-referenced coordinates:
1013            // sight_position_m is the sight's actual y-position above ground
1014            // (muzzle_height + sight_height, not just sight_height)
1015            // For flat shots, target is at same height as the sight (horizontal LOS)
1016            let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1017            let outputs = TrajectoryOutputs {
1018                target_distance_horiz_m: last_point.position.z,
1019                target_vertical_height_m: sight_position_m,
1020                time_of_flight_s: last_point.time,
1021                max_ord_dist_horiz_m: max_height,
1022                sight_height_m: sight_position_m,
1023            };
1024
1025            let samples = sample_trajectory(
1026                &trajectory_data,
1027                &outputs,
1028                self.inputs.sample_interval,
1029                self.inputs.bullet_mass,
1030            );
1031            Some(samples)
1032        } else {
1033            None
1034        };
1035
1036        Ok(TrajectoryResult {
1037            max_range: last_point.position.z, // Z is downrange
1038            max_height,
1039            time_of_flight: last_point.time,
1040            impact_velocity: last_point.velocity_magnitude,
1041            impact_energy: last_point.kinetic_energy,
1042            points,
1043            sampled_points,
1044            min_pitch_damping: None,
1045            transonic_mach: None,
1046            angular_state: None,
1047            max_yaw_angle: None,
1048            max_precession_angle: None,
1049        })
1050    }
1051
1052    fn rk45_step(
1053        &self,
1054        position: &Vector3<f64>,
1055        velocity: &Vector3<f64>,
1056        dt: f64,
1057        air_density: f64,
1058        wind_vector: &Vector3<f64>,
1059        tolerance: f64,
1060    ) -> (Vector3<f64>, Vector3<f64>, f64) {
1061        // Dormand-Prince coefficients
1062        const A21: f64 = 1.0 / 5.0;
1063        const A31: f64 = 3.0 / 40.0;
1064        const A32: f64 = 9.0 / 40.0;
1065        const A41: f64 = 44.0 / 45.0;
1066        const A42: f64 = -56.0 / 15.0;
1067        const A43: f64 = 32.0 / 9.0;
1068        const A51: f64 = 19372.0 / 6561.0;
1069        const A52: f64 = -25360.0 / 2187.0;
1070        const A53: f64 = 64448.0 / 6561.0;
1071        const A54: f64 = -212.0 / 729.0;
1072        const A61: f64 = 9017.0 / 3168.0;
1073        const A62: f64 = -355.0 / 33.0;
1074        const A63: f64 = 46732.0 / 5247.0;
1075        const A64: f64 = 49.0 / 176.0;
1076        const A65: f64 = -5103.0 / 18656.0;
1077        const A71: f64 = 35.0 / 384.0;
1078        const A73: f64 = 500.0 / 1113.0;
1079        const A74: f64 = 125.0 / 192.0;
1080        const A75: f64 = -2187.0 / 6784.0;
1081        const A76: f64 = 11.0 / 84.0;
1082
1083        // 5th order coefficients
1084        const B1: f64 = 35.0 / 384.0;
1085        const B3: f64 = 500.0 / 1113.0;
1086        const B4: f64 = 125.0 / 192.0;
1087        const B5: f64 = -2187.0 / 6784.0;
1088        const B6: f64 = 11.0 / 84.0;
1089
1090        // 4th order coefficients for error estimation
1091        const B1_ERR: f64 = 5179.0 / 57600.0;
1092        const B3_ERR: f64 = 7571.0 / 16695.0;
1093        const B4_ERR: f64 = 393.0 / 640.0;
1094        const B5_ERR: f64 = -92097.0 / 339200.0;
1095        const B6_ERR: f64 = 187.0 / 2100.0;
1096        const B7_ERR: f64 = 1.0 / 40.0;
1097
1098        // Compute RK45 stages
1099        let k1_v = self.calculate_acceleration(position, velocity, air_density, wind_vector);
1100        let k1_p = *velocity;
1101
1102        let p2 = position + dt * A21 * k1_p;
1103        let v2 = velocity + dt * A21 * k1_v;
1104        let k2_v = self.calculate_acceleration(&p2, &v2, air_density, wind_vector);
1105        let k2_p = v2;
1106
1107        let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
1108        let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
1109        let k3_v = self.calculate_acceleration(&p3, &v3, air_density, wind_vector);
1110        let k3_p = v3;
1111
1112        let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
1113        let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
1114        let k4_v = self.calculate_acceleration(&p4, &v4, air_density, wind_vector);
1115        let k4_p = v4;
1116
1117        let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
1118        let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
1119        let k5_v = self.calculate_acceleration(&p5, &v5, air_density, wind_vector);
1120        let k5_p = v5;
1121
1122        let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
1123        let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
1124        let k6_v = self.calculate_acceleration(&p6, &v6, air_density, wind_vector);
1125        let k6_p = v6;
1126
1127        let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
1128        let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
1129        let k7_v = self.calculate_acceleration(&p7, &v7, air_density, wind_vector);
1130        let k7_p = v7;
1131
1132        // 5th order solution
1133        let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
1134        let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
1135
1136        // 4th order solution for error estimate
1137        let pos_err = position
1138            + dt * (B1_ERR * k1_p
1139                + B3_ERR * k3_p
1140                + B4_ERR * k4_p
1141                + B5_ERR * k5_p
1142                + B6_ERR * k6_p
1143                + B7_ERR * k7_p);
1144        let vel_err = velocity
1145            + dt * (B1_ERR * k1_v
1146                + B3_ERR * k3_v
1147                + B4_ERR * k4_v
1148                + B5_ERR * k5_v
1149                + B6_ERR * k6_v
1150                + B7_ERR * k7_v);
1151
1152        // Estimate error
1153        let pos_error = (new_pos - pos_err).magnitude();
1154        let vel_error = (new_vel - vel_err).magnitude();
1155        let error = (pos_error + vel_error) / (1.0 + position.magnitude() + velocity.magnitude());
1156
1157        // Calculate new step size
1158        let dt_new = if error < tolerance {
1159            dt * (tolerance / error).powf(0.2).min(2.0)
1160        } else {
1161            dt * (tolerance / error).powf(0.25).max(0.1)
1162        };
1163
1164        (new_pos, new_vel, dt_new)
1165    }
1166
1167    fn calculate_acceleration(
1168        &self,
1169        position: &Vector3<f64>,
1170        velocity: &Vector3<f64>,
1171        air_density: f64,
1172        wind_vector: &Vector3<f64>,
1173    ) -> Vector3<f64> {
1174        // Calculate altitude-dependent wind if wind shear is enabled
1175        let actual_wind = if self.inputs.enable_wind_shear {
1176            self.get_wind_at_altitude(position.y)
1177        } else {
1178            *wind_vector
1179        };
1180
1181        let relative_velocity = velocity - actual_wind;
1182        let velocity_magnitude = relative_velocity.magnitude();
1183
1184        if velocity_magnitude < 0.001 {
1185            return Vector3::new(0.0, -9.81, 0.0);
1186        }
1187
1188        // Get drag coefficient from drag model (Mach-indexed from drag tables)
1189        let cd = self.calculate_drag_coefficient(velocity_magnitude);
1190
1191        // Apply cluster BC correction if enabled
1192        let effective_bc = if let Some(ref cluster_bc) = self.cluster_bc {
1193            // Convert velocity to fps for cluster BC calculation
1194            let velocity_fps = velocity_magnitude * 3.28084;
1195            cluster_bc.apply_correction(
1196                self.inputs.bc_value,
1197                self.inputs.caliber_inches * 0.0254, // Convert back to meters for consistency
1198                self.inputs.weight_grains,
1199                velocity_fps,
1200            )
1201        } else {
1202            self.inputs.bc_value
1203        };
1204
1205        // Use proper ballistics retardation formula
1206        // This matches the proven formula from fast_trajectory.rs
1207        // The standard retardation factor converts Cd to drag deceleration
1208        let velocity_fps = velocity_magnitude * 3.28084; // m/s to fps
1209        let cd_to_retard = 0.000683 * 0.30; // Standard ballistics constant
1210        let standard_factor = cd * cd_to_retard;
1211        let density_scale = air_density / 1.225; // Scale relative to standard air (1.225 kg/m³)
1212
1213        // Drag acceleration in ft/s² then convert to m/s²
1214        let a_drag_ft_s2 =
1215            (velocity_fps * velocity_fps) * standard_factor * density_scale / effective_bc;
1216        let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; // ft/s² to m/s²
1217
1218        // Apply drag opposite to velocity direction
1219        let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
1220
1221        // Total acceleration = drag + gravity
1222        drag_acceleration + Vector3::new(0.0, -9.81, 0.0)
1223    }
1224
1225    fn calculate_drag_coefficient(&self, velocity: f64) -> f64 {
1226        // Calculate speed of sound based on atmospheric temperature
1227        let temp_c = self.atmosphere.temperature;
1228        let temp_k = temp_c + 273.15;
1229        let gamma = 1.4; // Ratio of specific heats for air
1230        let r_specific = 287.05; // Specific gas constant for air (J/kg·K)
1231        let speed_of_sound = (gamma * r_specific * temp_k).sqrt();
1232        let mach = velocity / speed_of_sound;
1233
1234        // Get drag coefficient from the drag tables (Mach-indexed)
1235        let base_cd = crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type);
1236
1237        // Determine projectile shape for transonic corrections
1238        let projectile_shape = if let Some(ref model) = self.inputs.bullet_model {
1239            // Try to determine shape from bullet model string
1240            if model.to_lowercase().contains("boat") || model.to_lowercase().contains("bt") {
1241                ProjectileShape::BoatTail
1242            } else if model.to_lowercase().contains("round") || model.to_lowercase().contains("rn")
1243            {
1244                ProjectileShape::RoundNose
1245            } else if model.to_lowercase().contains("flat") || model.to_lowercase().contains("fb") {
1246                ProjectileShape::FlatBase
1247            } else {
1248                // Use heuristic based on caliber, weight, and drag model
1249                get_projectile_shape(
1250                    self.inputs.bullet_diameter,
1251                    self.inputs.bullet_mass / 0.00006479891, // Convert kg to grains
1252                    &self.inputs.bc_type.to_string(),
1253                )
1254            }
1255        } else {
1256            // Use heuristic based on caliber, weight, and drag model
1257            get_projectile_shape(
1258                self.inputs.bullet_diameter,
1259                self.inputs.bullet_mass / 0.00006479891, // Convert kg to grains
1260                &self.inputs.bc_type.to_string(),
1261            )
1262        };
1263
1264        // Apply transonic corrections
1265        // Note: Wave drag is disabled because G7/G1 drag functions already include
1266        // transonic effects. Adding wave drag on top would double-count the drag rise.
1267        // Wave drag should only be enabled for custom drag functions that don't
1268        // include transonic behavior.
1269        let include_wave_drag = false;
1270        transonic_correction(mach, base_cd, projectile_shape, include_wave_drag)
1271    }
1272}
1273
1274// Monte Carlo parameters
1275#[derive(Debug, Clone)]
1276pub struct MonteCarloParams {
1277    pub num_simulations: usize,
1278    pub velocity_std_dev: f64,
1279    pub angle_std_dev: f64,
1280    pub bc_std_dev: f64,
1281    pub wind_speed_std_dev: f64,
1282    pub target_distance: Option<f64>,
1283    pub base_wind_speed: f64,
1284    pub base_wind_direction: f64,
1285    pub azimuth_std_dev: f64, // Horizontal aiming variation in radians
1286}
1287
1288impl Default for MonteCarloParams {
1289    fn default() -> Self {
1290        Self {
1291            num_simulations: 1000,
1292            velocity_std_dev: 1.0,
1293            angle_std_dev: 0.001,
1294            bc_std_dev: 0.01,
1295            wind_speed_std_dev: 1.0,
1296            target_distance: None,
1297            base_wind_speed: 0.0,
1298            base_wind_direction: 0.0,
1299            azimuth_std_dev: 0.001, // Default horizontal spread ~0.057 degrees
1300        }
1301    }
1302}
1303
1304// Monte Carlo results
1305#[derive(Debug, Clone)]
1306pub struct MonteCarloResults {
1307    pub ranges: Vec<f64>,
1308    pub impact_velocities: Vec<f64>,
1309    pub impact_positions: Vec<Vector3<f64>>,
1310}
1311
1312// Run Monte Carlo simulation (backwards compatibility)
1313pub fn run_monte_carlo(
1314    base_inputs: BallisticInputs,
1315    params: MonteCarloParams,
1316) -> Result<MonteCarloResults, BallisticsError> {
1317    let base_wind = WindConditions {
1318        speed: params.base_wind_speed,
1319        direction: params.base_wind_direction,
1320    };
1321    run_monte_carlo_with_wind(base_inputs, base_wind, params)
1322}
1323
1324// Run Monte Carlo simulation with wind
1325pub fn run_monte_carlo_with_wind(
1326    base_inputs: BallisticInputs,
1327    base_wind: WindConditions,
1328    params: MonteCarloParams,
1329) -> Result<MonteCarloResults, BallisticsError> {
1330    use rand::thread_rng;
1331    use rand_distr::{Distribution, Normal};
1332
1333    let mut rng = thread_rng();
1334    let mut ranges = Vec::new();
1335    let mut impact_velocities = Vec::new();
1336    let mut impact_positions = Vec::new();
1337
1338    // First, calculate baseline trajectory with no variations
1339    let baseline_solver =
1340        TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), Default::default());
1341    let baseline_result = baseline_solver.solve()?;
1342
1343    // Determine target distance: use explicit target or baseline max range
1344    let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
1345
1346    // Get baseline position at target distance (interpolated)
1347    let baseline_at_target = baseline_result
1348        .position_at_range(target_distance)
1349        .ok_or("Could not interpolate baseline at target distance")?;
1350
1351    // Create normal distributions for variations
1352    let velocity_dist = Normal::new(base_inputs.muzzle_velocity, params.velocity_std_dev)
1353        .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
1354    let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
1355        .map_err(|e| format!("Invalid angle distribution: {}", e))?;
1356    let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
1357        .map_err(|e| format!("Invalid BC distribution: {}", e))?;
1358    let wind_speed_dist = Normal::new(base_wind.speed, params.wind_speed_std_dev)
1359        .map_err(|e| format!("Invalid wind speed distribution: {}", e))?;
1360    let wind_dir_dist =
1361        Normal::new(base_wind.direction, params.wind_speed_std_dev * 0.1) // Small variation in direction
1362            .map_err(|e| format!("Invalid wind direction distribution: {}", e))?;
1363    let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
1364        .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
1365
1366    // Create distribution for pointing errors (simulates shooter's aiming consistency)
1367    let pointing_error_dist = Normal::new(0.0, params.angle_std_dev * target_distance)
1368        .map_err(|e| format!("Invalid pointing distribution: {}", e))?;
1369
1370    for _ in 0..params.num_simulations {
1371        // Create varied inputs
1372        let mut inputs = base_inputs.clone();
1373        inputs.muzzle_velocity = velocity_dist.sample(&mut rng).max(0.0);
1374        inputs.muzzle_angle = angle_dist.sample(&mut rng);
1375        inputs.bc_value = bc_dist.sample(&mut rng).max(0.01);
1376        inputs.azimuth_angle = azimuth_dist.sample(&mut rng); // Add horizontal variation
1377
1378        // Create varied wind (now based on base wind conditions)
1379        let wind = WindConditions {
1380            speed: wind_speed_dist.sample(&mut rng).abs(),
1381            direction: wind_dir_dist.sample(&mut rng),
1382        };
1383
1384        // Run trajectory
1385        let solver = TrajectorySolver::new(inputs, wind, Default::default());
1386        match solver.solve() {
1387            Ok(result) => {
1388                ranges.push(result.max_range);
1389                impact_velocities.push(result.impact_velocity);
1390
1391                // Interpolate position at target distance (not ground impact)
1392                if let Some(pos_at_target) = result.position_at_range(target_distance) {
1393                    // Calculate deviation from baseline at the SAME target distance
1394                    // X = lateral deviation (windage), Y = vertical deviation (elevation)
1395                    let mut deviation = Vector3::new(
1396                        pos_at_target.x - baseline_at_target.x, // Lateral deviation
1397                        pos_at_target.y - baseline_at_target.y, // Vertical deviation
1398                        0.0, // Z deviation is 0 since we're comparing at same range
1399                    );
1400
1401                    // Add additional pointing error to simulate realistic group sizes
1402                    // This represents the shooter's ability to aim consistently
1403                    let pointing_error_y = pointing_error_dist.sample(&mut rng);
1404                    deviation.y += pointing_error_y;
1405
1406                    impact_positions.push(deviation);
1407                }
1408            }
1409            Err(_) => {
1410                // Skip failed simulations
1411                continue;
1412            }
1413        }
1414    }
1415
1416    if ranges.is_empty() {
1417        return Err("No successful simulations".into());
1418    }
1419
1420    Ok(MonteCarloResults {
1421        ranges,
1422        impact_velocities,
1423        impact_positions,
1424    })
1425}
1426
1427// Calculate zero angle for a target
1428pub fn calculate_zero_angle(
1429    inputs: BallisticInputs,
1430    target_distance: f64,
1431    target_height: f64,
1432) -> Result<f64, BallisticsError> {
1433    calculate_zero_angle_with_conditions(
1434        inputs,
1435        target_distance,
1436        target_height,
1437        WindConditions::default(),
1438        AtmosphericConditions::default(),
1439    )
1440}
1441
1442pub fn calculate_zero_angle_with_conditions(
1443    inputs: BallisticInputs,
1444    target_distance: f64,
1445    target_height: f64,
1446    wind: WindConditions,
1447    atmosphere: AtmosphericConditions,
1448) -> Result<f64, BallisticsError> {
1449    // Helper function to get height at target distance for a given angle
1450    let get_height_at_angle = |angle: f64| -> Result<Option<f64>, BallisticsError> {
1451        let mut test_inputs = inputs.clone();
1452        test_inputs.muzzle_angle = angle;
1453
1454        let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
1455        solver.set_max_range(target_distance * 2.0);
1456        solver.set_time_step(0.001);
1457        let result = solver.solve()?;
1458
1459        // Z is downrange in standard ballistics coordinates
1460        for i in 0..result.points.len() {
1461            if result.points[i].position.z >= target_distance {
1462                if i > 0 {
1463                    let p1 = &result.points[i - 1];
1464                    let p2 = &result.points[i];
1465                    let t = (target_distance - p1.position.z) / (p2.position.z - p1.position.z);
1466                    return Ok(Some(p1.position.y + t * (p2.position.y - p1.position.y)));
1467                } else {
1468                    return Ok(Some(result.points[i].position.y));
1469                }
1470            }
1471        }
1472        Ok(None)
1473    };
1474
1475    // Binary search for the angle that hits the target
1476    // Use only positive angles to ensure proper ballistic arc (upward trajectory)
1477    let mut low_angle = 0.0; // radians (horizontal)
1478    let mut high_angle = 0.2; // radians (about 11 degrees)
1479    let tolerance = 0.00001; // radians
1480    let max_iterations = 50;
1481
1482    // MBA-194: Validate bracketing before starting binary search
1483    // Check that the target height is actually between low and high angle trajectories
1484    let low_height = get_height_at_angle(low_angle)?;
1485    let high_height = get_height_at_angle(high_angle)?;
1486
1487    match (low_height, high_height) {
1488        (Some(lh), Some(hh)) => {
1489            let low_error = lh - target_height;
1490            let high_error = hh - target_height;
1491
1492            // For proper bracketing, low angle should undershoot (negative error)
1493            // and high angle should overshoot (positive error)
1494            if low_error > 0.0 && high_error > 0.0 {
1495                // Both angles overshoot - target is too close or height too low
1496                // This shouldn't happen for typical zeroing, but handle gracefully
1497                // Try to find a valid bracket by reducing low_angle (can't go negative)
1498                // Since we can't go below 0, just proceed and let binary search find best
1499            } else if low_error < 0.0 && high_error < 0.0 {
1500                // Both angles undershoot - target is beyond effective range
1501                // Try expanding high_angle up to 45 degrees (0.785 rad)
1502                let mut expanded = false;
1503                for multiplier in [2.0, 3.0, 4.0] {
1504                    let new_high = (high_angle * multiplier).min(0.785);
1505                    if let Ok(Some(h)) = get_height_at_angle(new_high) {
1506                        if h - target_height > 0.0 {
1507                            high_angle = new_high;
1508                            expanded = true;
1509                            break;
1510                        }
1511                    }
1512                    if new_high >= 0.785 {
1513                        break;
1514                    }
1515                }
1516                if !expanded {
1517                    return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
1518                }
1519            }
1520            // If signs are opposite, we have valid bracketing - proceed
1521        }
1522        (None, Some(_hh)) => {
1523            // Low angle doesn't reach target, high does - this is fine
1524            // Binary search will increase low_angle until trajectory reaches
1525        }
1526        (Some(_lh), None) => {
1527            // High angle doesn't reach target - shouldn't happen
1528            return Err(
1529                "Cannot find zero angle: high angle trajectory doesn't reach target distance"
1530                    .into(),
1531            );
1532        }
1533        (None, None) => {
1534            // Neither reaches target - target too far
1535            return Err(
1536                "Cannot find zero angle: trajectory cannot reach target distance at any angle"
1537                    .into(),
1538            );
1539        }
1540    }
1541
1542    for _iteration in 0..max_iterations {
1543        let mid_angle = (low_angle + high_angle) / 2.0;
1544
1545        let mut test_inputs = inputs.clone();
1546        test_inputs.muzzle_angle = mid_angle;
1547
1548        let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
1549        // Make sure we calculate far enough to reach the target
1550        solver.set_max_range(target_distance * 2.0);
1551        solver.set_time_step(0.001);
1552        let result = solver.solve()?;
1553
1554        // Find the height at target distance (Z is downrange)
1555        let mut height_at_target = None;
1556        for i in 0..result.points.len() {
1557            if result.points[i].position.z >= target_distance {
1558                if i > 0 {
1559                    // Linear interpolation
1560                    let p1 = &result.points[i - 1];
1561                    let p2 = &result.points[i];
1562                    let t = (target_distance - p1.position.z) / (p2.position.z - p1.position.z);
1563                    height_at_target = Some(p1.position.y + t * (p2.position.y - p1.position.y));
1564                } else {
1565                    height_at_target = Some(result.points[i].position.y);
1566                }
1567                break;
1568            }
1569        }
1570
1571        match height_at_target {
1572            Some(height) => {
1573                let error = height - target_height;
1574                // MBA-193: Check height error FIRST (primary convergence criterion)
1575                // Height accuracy is what matters for zeroing - angle tolerance is secondary
1576                if error.abs() < 0.001 {
1577                    return Ok(mid_angle);
1578                }
1579
1580                // Only use angle tolerance as convergence criterion if we have
1581                // exhausted angle precision AND height error is still acceptable
1582                // (within 10mm which is reasonable for long range)
1583                if (high_angle - low_angle).abs() < tolerance {
1584                    if error.abs() < 0.01 {
1585                        // Height error within 10mm - acceptable for practical use
1586                        return Ok(mid_angle);
1587                    }
1588                    // Angle converged but height error too large - this shouldn't happen
1589                    // with proper tolerance values, but return best effort
1590                    return Ok(mid_angle);
1591                }
1592
1593                if error > 0.0 {
1594                    high_angle = mid_angle;
1595                } else {
1596                    low_angle = mid_angle;
1597                }
1598            }
1599            None => {
1600                // Trajectory didn't reach target distance, increase angle
1601                low_angle = mid_angle;
1602
1603                // MBA-193: Check angle tolerance for None case too
1604                if (high_angle - low_angle).abs() < tolerance {
1605                    return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
1606                }
1607            }
1608        }
1609    }
1610
1611    Err("Failed to find zero angle".into())
1612}
1613
1614// Estimate BC from trajectory data
1615pub fn estimate_bc_from_trajectory(
1616    velocity: f64,
1617    mass: f64,
1618    diameter: f64,
1619    points: &[(f64, f64)], // (distance, drop) pairs
1620) -> Result<f64, BallisticsError> {
1621    // Simple BC estimation using least squares
1622    let mut best_bc = 0.5;
1623    let mut best_error = f64::MAX;
1624    let mut found_valid = false;
1625
1626    // Try different BC values
1627    for bc in (100..1000).step_by(10) {
1628        let bc_value = bc as f64 / 1000.0;
1629
1630        let inputs = BallisticInputs {
1631            muzzle_velocity: velocity,
1632            bc_value,
1633            bullet_mass: mass,
1634            bullet_diameter: diameter,
1635            ..Default::default()
1636        };
1637
1638        let mut solver = TrajectorySolver::new(inputs, Default::default(), Default::default());
1639        // Set max range for BC estimation
1640        solver.set_max_range(points.last().map(|(d, _)| *d * 1.5).unwrap_or(1000.0));
1641
1642        let result = match solver.solve() {
1643            Ok(r) => r,
1644            Err(_) => continue, // Skip this BC value if solve fails
1645        };
1646
1647        // Calculate error
1648        let mut total_error = 0.0;
1649        for (target_dist, target_drop) in points {
1650            // Find drop at this distance
1651            let mut calculated_drop = None;
1652            for i in 0..result.points.len() {
1653                if result.points[i].position.z >= *target_dist {
1654                    if i > 0 {
1655                        // Linear interpolation
1656                        let p1 = &result.points[i - 1];
1657                        let p2 = &result.points[i];
1658                        let t = (target_dist - p1.position.z) / (p2.position.z - p1.position.z);
1659                        calculated_drop =
1660                            Some(-(p1.position.y + t * (p2.position.y - p1.position.y)));
1661                    } else {
1662                        calculated_drop = Some(-result.points[i].position.y);
1663                    }
1664                    break;
1665                }
1666            }
1667
1668            if let Some(drop) = calculated_drop {
1669                let error = (drop - target_drop).abs();
1670                total_error += error * error;
1671            }
1672        }
1673
1674        if total_error < best_error {
1675            best_error = total_error;
1676            best_bc = bc_value;
1677            found_valid = true;
1678        }
1679    }
1680
1681    if !found_valid {
1682        return Err(BallisticsError::from("Unable to estimate BC from provided data. Check that drop values are in correct units.".to_string()));
1683    }
1684
1685    Ok(best_bc)
1686}
1687
1688// Helper function to calculate air density
1689fn calculate_air_density(atmosphere: &AtmosphericConditions) -> f64 {
1690    // Simplified air density calculation
1691    // P / (R * T) where R is specific gas constant for dry air
1692    let r_specific = 287.058; // J/(kg·K)
1693    let temperature_k = atmosphere.temperature + 273.15;
1694
1695    // Convert pressure from hPa to Pa
1696    let pressure_pa = atmosphere.pressure * 100.0;
1697
1698    // Basic density calculation
1699    let density = pressure_pa / (r_specific * temperature_k);
1700
1701    // Altitude correction (simplified)
1702    let altitude_factor = (-atmosphere.altitude / 8000.0).exp();
1703
1704    density * altitude_factor
1705}
1706
1707// Add rand dependencies for Monte Carlo
1708use rand;
1709use rand_distr;