ballistics_engine/
cli_api.rs

1// CLI API module - provides simplified interfaces for command-line tool
2use 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// Unit system for input/output
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub enum UnitSystem {
16    Imperial,
17    Metric,
18}
19
20// Output format for results
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub enum OutputFormat {
23    Table,
24    Json,
25    Csv,
26}
27
28// Error type for CLI operations
29#[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// Ballistic input parameters - MBA-151 Reconciled Structure
55// Unified structure used by both ballistics-engine and ballistics_rust
56// Duplicates removed, all necessary fields included
57#[derive(Debug, Clone)]
58pub struct BallisticInputs {
59    // Core ballistics parameters (using intuitive names)
60    pub bc_value: f64,              // Ballistic coefficient (G1, G7, etc.)
61    pub bc_type: DragModel,         // Drag model (G1, G7, G8, etc.)
62    pub bullet_mass: f64,           // kg
63    pub muzzle_velocity: f64,       // m/s
64    pub bullet_diameter: f64,       // meters
65    pub bullet_length: f64,         // meters
66
67    // Targeting and positioning
68    pub muzzle_angle: f64,          // radians (launch angle)
69    pub target_distance: f64,       // meters
70    pub azimuth_angle: f64,         // horizontal aiming angle in radians
71    pub shooting_angle: f64,        // uphill/downhill angle in radians
72    pub sight_height: f64,          // meters above bore
73    pub muzzle_height: f64,         // meters above ground
74    pub target_height: f64,         // meters above ground for zeroing
75    pub ground_threshold: f64,      // meters below which to stop
76
77    // Environmental conditions
78    pub altitude: f64,              // meters
79    pub temperature: f64,           // Celsius
80    pub pressure: f64,              // millibars/hPa
81    pub humidity: f64,              // relative humidity (0-1)
82    pub latitude: Option<f64>,      // degrees
83
84    // Wind conditions
85    pub wind_speed: f64,            // m/s
86    pub wind_angle: f64,            // radians (0=headwind, 90=from right)
87
88    // Bullet characteristics
89    pub twist_rate: f64,            // inches per turn
90    pub is_twist_right: bool,       // right-hand twist
91    pub caliber_inches: f64,        // diameter in inches
92    pub weight_grains: f64,         // mass in grains
93    pub manufacturer: Option<String>, // Bullet manufacturer
94    pub bullet_model: Option<String>, // Bullet model name
95    pub bullet_id: Option<String>,  // Unique bullet identifier
96    pub bullet_cluster: Option<usize>, // BC cluster ID for cluster_bc module
97
98    // Integration method selection
99    pub use_rk4: bool,              // Use RK4 integration instead of Euler
100    pub use_adaptive_rk45: bool,    // Use RK45 adaptive step size integration
101
102    // Advanced effects flags
103    pub enable_advanced_effects: bool,
104    pub use_powder_sensitivity: bool,
105    pub powder_temp_sensitivity: f64,
106    pub powder_temp: f64,           // Celsius
107    pub tipoff_yaw: f64,            // radians
108    pub tipoff_decay_distance: f64, // meters
109    pub use_bc_segments: bool,
110    pub bc_segments: Option<Vec<(f64, f64)>>,  // Mach-BC pairs
111    pub bc_segments_data: Option<Vec<crate::BCSegmentData>>,  // Velocity-BC segments
112    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,       // meters
118    pub enable_pitch_damping: bool,
119    pub enable_precession_nutation: bool,
120    pub use_cluster_bc: bool,       // Use cluster-based BC degradation
121
122    // Custom drag model support
123    pub custom_drag_table: Option<crate::drag::DragTable>,
124
125    // Legacy field for compatibility
126    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            // Core ballistics parameters
139            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,  // Approximate
145
146            // Targeting and positioning
147            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,  // Default 0 - height is in sight_height
153            target_height: 0.0,  // Target at ground level by default
154            ground_threshold: -100.0,  // Effectively disable ground detection (allow bullet to drop 100m below start)
155
156            // Environmental conditions
157            altitude: 0.0,
158            temperature: 15.0,
159            pressure: 1013.25,  // Standard sea level pressure (millibars)
160            humidity: 0.5,  // 50% relative humidity
161            latitude: None,
162
163            // Wind conditions
164            wind_speed: 0.0,
165            wind_angle: 0.0,
166
167            // Bullet characteristics
168            twist_rate: 12.0,  // 1:12" typical
169            is_twist_right: true,
170            caliber_inches: diameter_m / 0.0254,  // Convert to inches
171            weight_grains: mass_kg / 0.00006479891,  // Convert to grains
172            manufacturer: None,
173            bullet_model: None,
174            bullet_id: None,
175            bullet_cluster: None,
176
177            // Integration method selection
178            use_rk4: true,  // Use Runge-Kutta methods by default
179            use_adaptive_rk45: true,  // Default to RK45 adaptive for best accuracy
180
181            // Advanced effects (disabled by default)
182            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,  // Default 10 meter intervals
197            enable_pitch_damping: false,
198            enable_precession_nutation: false,
199            use_cluster_bc: false,  // Disabled by default for backward compatibility
200
201            // Custom drag model support
202            custom_drag_table: None,
203
204            // Legacy field for compatibility
205            bc_type_str: None,
206        }
207    }
208}
209
210// Wind conditions
211#[derive(Debug, Clone)]
212pub struct WindConditions {
213    pub speed: f64,        // m/s
214    pub direction: f64,    // radians (0 = North, PI/2 = East)
215}
216
217impl Default for WindConditions {
218    fn default() -> Self {
219        Self {
220            speed: 0.0,
221            direction: 0.0,
222        }
223    }
224}
225
226// Atmospheric conditions
227#[derive(Debug, Clone)]
228pub struct AtmosphericConditions {
229    pub temperature: f64,  // Celsius
230    pub pressure: f64,     // hPa
231    pub humidity: f64,     // percentage (0-100)
232    pub altitude: f64,     // meters
233}
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// Trajectory point data
247#[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// Trajectory result
256#[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>>,  // Trajectory samples at regular intervals
265    pub min_pitch_damping: Option<f64>,  // Minimum pitch damping coefficient (for stability warning)
266    pub transonic_mach: Option<f64>,      // Mach number when entering transonic regime
267    pub angular_state: Option<AngularState>,  // Final angular state if precession/nutation enabled
268    pub max_yaw_angle: Option<f64>,           // Maximum yaw angle during flight (radians)
269    pub max_precession_angle: Option<f64>,    // Maximum precession angle (radians)
270}
271
272impl TrajectoryResult {
273    /// Interpolate position at a given downrange distance (Z coordinate).
274    /// Returns the interpolated (x, y, z) position at that range.
275    /// If the target range exceeds the trajectory, returns the last point.
276    pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
277        if self.points.is_empty() {
278            return None;
279        }
280
281        // Find the two points that bracket the target range
282        for i in 0..self.points.len() - 1 {
283            let p1 = &self.points[i];
284            let p2 = &self.points[i + 1];
285
286            // Check if target range is between these two points
287            if p1.position.z <= target_range && p2.position.z >= target_range {
288                // Linear interpolation factor
289                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                // Interpolate X and Y, use exact target_range for Z
296                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        // Target range is beyond trajectory - return last point
305        self.points.last().map(|p| p.position)
306    }
307}
308
309// Trajectory solver
310pub 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        // Compute derived fields from base units
322        inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
323        inputs.weight_grains = inputs.bullet_mass / 0.00006479891;
324        
325        // Initialize cluster BC if enabled
326        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        // Create wind shear profile based on surface wind
352        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  // Default to power law
359            },
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,  // Standard meteorological measurement height
366            roughness_length: 0.03,  // Short grass
367            power_exponent: 1.0 / 7.0,  // Neutral stability
368            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        // Simple trajectory integration using Euler method
389        let mut time = 0.0;
390        let mut position = Vector3::new(0.0, self.inputs.sight_height + self.inputs.muzzle_height, 0.0);
391        // Calculate initial velocity components with both elevation and azimuth
392        // Standard ballistics coordinate system: X=lateral, Y=vertical, Z=downrange
393        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(),  // X: lateral (side deviation)
396            self.inputs.muzzle_velocity * self.inputs.muzzle_angle.sin(),  // Y: vertical component
397            horizontal_velocity * self.inputs.azimuth_angle.cos(),  // Z: downrange (forward)
398        );
399
400        let mut points = Vec::new();
401        let mut max_height = position.y;
402        let mut min_pitch_damping = 1.0;  // Track minimum pitch damping coefficient
403        let mut transonic_mach = None;    // Track when we enter transonic
404
405        // Initialize angular state for precession/nutation tracking
406        let mut angular_state = if self.inputs.enable_precession_nutation {
407            Some(AngularState {
408                pitch_angle: 0.001,  // Small initial disturbance
409                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        // Calculate air density
422        let air_density = calculate_air_density(&self.atmosphere);
423
424        // Wind vector: X=lateral (crosswind), Y=0, Z=downrange (head/tail wind)
425        let wind_vector = Vector3::new(
426            self.wind.speed * self.wind.direction.sin(),  // X: lateral (crosswind)
427            0.0,
428            self.wind.speed * self.wind.direction.cos(),  // Z: downrange (head/tail wind)
429        );
430
431        // Main integration loop (Z is downrange)
432        while position.z < self.max_range && position.y >= 0.0 && time < 100.0 {
433            // Store trajectory point
434            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            // Debug: Log first and every 100th point
445            // Coordinate system: X=lateral, Y=vertical, Z=downrange
446            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            // Track max height
452            if position.y > max_height {
453                max_height = position.y;
454            }
455            
456            // Calculate pitch damping if enabled
457            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                // Track when we enter transonic
464                if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
465                    transonic_mach = Some(mach);
466                }
467                
468                // Calculate pitch damping coefficient
469                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                // Track minimum (most critical for stability)
478                if pitch_damping < min_pitch_damping {
479                    min_pitch_damping = pitch_damping;
480                }
481            }
482            
483            // Calculate precession/nutation if enabled
484            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                    // Calculate spin rate from twist rate and velocity
493                    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                    // Create precession/nutation parameters
502                    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,      // Typical value
508                        transverse_inertia: 9.13e-7, // Typical value
509                        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                    // Update angular state
517                    *state = calculate_combined_angular_motion(
518                        &params,
519                        state,
520                        time,
521                        self.time_step,
522                        0.001,  // Initial disturbance
523                    );
524                    
525                    // Track maximums
526                    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            // Calculate drag with altitude-dependent wind if enabled
536            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            // Calculate drag force
546            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            // Calculate acceleration
551            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            // Update state
559            velocity += acceleration * self.time_step;
560            position += velocity * self.time_step;
561            time += self.time_step;
562        }
563        
564        // Get final values
565        let last_point = points.last().ok_or("No trajectory points generated")?;
566        
567        // Create trajectory sampling data if enabled
568        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                    // Reconstruct velocity vectors from magnitude (approximate)
574                    Vector3::new(0.0, 0.0, p.velocity_magnitude)
575                }).collect(),
576                transonic_distances: Vec::new(),  // TODO: Track Mach transitions
577            };
578            
579            let outputs = TrajectoryOutputs {
580                target_distance_horiz_m: last_point.position.z,  // Z is downrange
581                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            // Sample at specified intervals
587            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,  // Z is downrange
595            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        // RK4 trajectory integration for better accuracy
611        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        // Calculate initial velocity components with both elevation and azimuth
615        // Standard ballistics coordinate system: X=lateral, Y=vertical, Z=downrange
616        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(),  // X: lateral (side deviation)
619            self.inputs.muzzle_velocity * self.inputs.muzzle_angle.sin(),  // Y: vertical component
620            horizontal_velocity * self.inputs.azimuth_angle.cos(),  // Z: downrange (forward)
621        );
622
623        let mut points = Vec::new();
624        let mut max_height = position.y;
625        let mut min_pitch_damping = 1.0;  // Track minimum pitch damping coefficient
626        let mut transonic_mach = None;    // Track when we enter transonic
627
628        // Initialize angular state for precession/nutation tracking
629        let mut angular_state = if self.inputs.enable_precession_nutation {
630            Some(AngularState {
631                pitch_angle: 0.001,  // Small initial disturbance
632                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        // Calculate air density
645        let air_density = calculate_air_density(&self.atmosphere);
646
647        // Wind vector: X=lateral (crosswind), Y=0, Z=downrange (head/tail wind)
648        let wind_vector = Vector3::new(
649            self.wind.speed * self.wind.direction.sin(),  // X: lateral (crosswind)
650            0.0,
651            self.wind.speed * self.wind.direction.cos(),  // Z: downrange (head/tail wind)
652        );
653
654        // Main RK4 integration loop (Z is downrange)
655        while position.z < self.max_range && position.y >= 0.0 && time < 100.0 {
656            // Store trajectory point
657            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            // Calculate pitch damping if enabled (RK4 solver)
672            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                // Track when we enter transonic
679                if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
680                    transonic_mach = Some(mach);
681                }
682                
683                // Calculate pitch damping coefficient
684                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                // Track minimum (most critical for stability)
693                if pitch_damping < min_pitch_damping {
694                    min_pitch_damping = pitch_damping;
695                }
696            }
697            
698            // Calculate precession/nutation if enabled (RK4 solver)
699            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                    // Calculate spin rate from twist rate and velocity
708                    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                    // Create precession/nutation parameters
717                    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,      // Typical value
723                        transverse_inertia: 9.13e-7, // Typical value
724                        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                    // Update angular state
732                    *state = calculate_combined_angular_motion(
733                        &params,
734                        state,
735                        time,
736                        self.time_step,
737                        0.001,  // Initial disturbance
738                    );
739                    
740                    // Track maximums
741                    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            // RK4 method
751            let dt = self.time_step;
752            
753            // k1
754            let acc1 = self.calculate_acceleration(&position, &velocity, air_density, &wind_vector);
755            
756            // k2
757            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            // k3
762            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            // k4
767            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            // Update position and velocity
772            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        // Get final values
778        let last_point = points.last().ok_or("No trajectory points generated")?;
779        
780        // Create trajectory sampling data if enabled
781        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                    // Reconstruct velocity vectors from magnitude (approximate)
787                    Vector3::new(0.0, 0.0, p.velocity_magnitude)
788                }).collect(),
789                transonic_distances: Vec::new(),  // TODO: Track Mach transitions
790            };
791            
792            let outputs = TrajectoryOutputs {
793                target_distance_horiz_m: last_point.position.z,  // Z is downrange
794                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            // Sample at specified intervals
800            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,  // Z is downrange
808            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        // RK45 adaptive step size integration (Dormand-Prince method)
824        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        // Calculate initial velocity components
828        // Standard ballistics coordinate system: X=lateral, Y=vertical, Z=downrange
829        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(),  // X: lateral (side deviation)
832            self.inputs.muzzle_velocity * self.inputs.muzzle_angle.sin(),  // Y: vertical component
833            horizontal_velocity * self.inputs.azimuth_angle.cos(),  // Z: downrange (forward)
834        );
835
836        let mut points = Vec::new();
837        let mut max_height = position.y;
838        let mut dt = 0.001;  // Initial step size
839        let tolerance = 1e-6;  // Error tolerance
840        let safety_factor = 0.9;  // Safety factor for step size adjustment
841        let max_dt = 0.01;  // Maximum step size
842        let min_dt = 1e-6;   // Minimum step size
843
844        // Add a point counter to debug
845        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 {  // Z is downrange
849            iteration_count += 1;
850            if iteration_count > MAX_ITERATIONS {
851                break; // Prevent infinite loop
852            }
853
854            // Store current point
855            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            // Get atmospheric conditions and wind: X=lateral (crosswind), Y=0, Z=downrange (head/tail wind)
870            let air_density = calculate_air_density(&self.atmosphere);
871            let wind_vector = Vector3::new(
872                self.wind.speed * self.wind.direction.sin(),  // X: lateral (crosswind)
873                0.0,
874                self.wind.speed * self.wind.direction.cos(),  // Z: downrange (head/tail wind)
875            );
876            
877            // RK45 step with adaptive step size
878            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            // Update step size with safety factor and bounds
888            dt = (safety_factor * new_dt).clamp(min_dt, max_dt);
889            
890            // Update state
891            position = new_pos;
892            velocity = new_vel;
893            time += dt;
894        }
895        
896        // Ensure we have at least one point
897        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,  // Z is downrange
905            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,  // Simplified - no trajectory sampling in RK45 for now
911            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        // Dormand-Prince coefficients
929        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        // 5th order coefficients
951        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        // 4th order coefficients for error estimation
958        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        // Compute RK45 stages
966        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        // 5th order solution
1000        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        // 4th order solution for error estimate
1004        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        // Estimate error
1008        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        // Calculate new step size
1013        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        // Calculate altitude-dependent wind if wind shear is enabled
1024        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        // Get drag coefficient from drag model (Mach-indexed from drag tables)
1038        let cd = self.calculate_drag_coefficient(velocity_magnitude);
1039
1040        // Apply cluster BC correction if enabled
1041        let effective_bc = if let Some(ref cluster_bc) = self.cluster_bc {
1042            // Convert velocity to fps for cluster BC calculation
1043            let velocity_fps = velocity_magnitude * 3.28084;
1044            cluster_bc.apply_correction(
1045                self.inputs.bc_value,
1046                self.inputs.caliber_inches * 0.0254,  // Convert back to meters for consistency
1047                self.inputs.weight_grains,
1048                velocity_fps
1049            )
1050        } else {
1051            self.inputs.bc_value
1052        };
1053
1054        // Use proper ballistics retardation formula
1055        // This matches the proven formula from fast_trajectory.rs
1056        // The standard retardation factor converts Cd to drag deceleration
1057        let velocity_fps = velocity_magnitude * 3.28084;  // m/s to fps
1058        let cd_to_retard = 0.000683 * 0.30;  // Standard ballistics constant
1059        let standard_factor = cd * cd_to_retard;
1060        let density_scale = air_density / 1.225;  // Scale relative to standard air (1.225 kg/m³)
1061
1062        // Drag acceleration in ft/s² then convert to m/s²
1063        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;  // ft/s² to m/s²
1065
1066        // Apply drag opposite to velocity direction
1067        let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
1068
1069        // Total acceleration = drag + gravity
1070        drag_acceleration + Vector3::new(0.0, -9.81, 0.0)
1071    }
1072    
1073    fn calculate_drag_coefficient(&self, velocity: f64) -> f64 {
1074        // Calculate speed of sound based on atmospheric temperature
1075        let temp_c = self.atmosphere.temperature;
1076        let temp_k = temp_c + 273.15;
1077        let gamma = 1.4;  // Ratio of specific heats for air
1078        let r_specific = 287.05;  // Specific gas constant for air (J/kg·K)
1079        let speed_of_sound = (gamma * r_specific * temp_k).sqrt();
1080        let mach = velocity / speed_of_sound;
1081
1082        // Get drag coefficient from the drag tables (Mach-indexed)
1083        let base_cd = crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type);
1084
1085        // Determine projectile shape for transonic corrections
1086        let projectile_shape = if let Some(ref model) = self.inputs.bullet_model {
1087            // Try to determine shape from bullet model string
1088            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                // Use heuristic based on caliber, weight, and drag model
1096                get_projectile_shape(
1097                    self.inputs.bullet_diameter,
1098                    self.inputs.bullet_mass / 0.00006479891,  // Convert kg to grains
1099                    &self.inputs.bc_type.to_string()
1100                )
1101            }
1102        } else {
1103            // Use heuristic based on caliber, weight, and drag model
1104            get_projectile_shape(
1105                self.inputs.bullet_diameter,
1106                self.inputs.bullet_mass / 0.00006479891,  // Convert kg to grains
1107                &self.inputs.bc_type.to_string()
1108            )
1109        };
1110
1111        // Apply transonic corrections
1112        // Enable wave drag if advanced effects are enabled
1113        let include_wave_drag = self.inputs.enable_advanced_effects;
1114        transonic_correction(mach, base_cd, projectile_shape, include_wave_drag)
1115    }
1116}
1117
1118// Monte Carlo parameters
1119#[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,  // Horizontal aiming variation in radians
1130}
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,  // Default horizontal spread ~0.057 degrees
1144        }
1145    }
1146}
1147
1148// Monte Carlo results
1149#[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
1156// Run Monte Carlo simulation (backwards compatibility)
1157pub 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
1168// Run Monte Carlo simulation with wind
1169pub 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    // First, calculate baseline trajectory with no variations
1183    let baseline_solver = TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), Default::default());
1184    let baseline_result = baseline_solver.solve()?;
1185
1186    // Determine target distance: use explicit target or baseline max range
1187    let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
1188
1189    // Get baseline position at target distance (interpolated)
1190    let baseline_at_target = baseline_result.position_at_range(target_distance)
1191        .ok_or("Could not interpolate baseline at target distance")?;
1192
1193    // Create normal distributions for variations
1194    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)  // Small variation in direction
1203        .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    // Create distribution for pointing errors (simulates shooter's aiming consistency)
1208    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        // Create varied inputs
1213        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);  // Add horizontal variation
1218
1219        // Create varied wind (now based on base wind conditions)
1220        let wind = WindConditions {
1221            speed: wind_speed_dist.sample(&mut rng).abs(),
1222            direction: wind_dir_dist.sample(&mut rng),
1223        };
1224
1225        // Run trajectory
1226        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                // Interpolate position at target distance (not ground impact)
1233                if let Some(pos_at_target) = result.position_at_range(target_distance) {
1234                    // Calculate deviation from baseline at the SAME target distance
1235                    // X = lateral deviation (windage), Y = vertical deviation (elevation)
1236                    let mut deviation = Vector3::new(
1237                        pos_at_target.x - baseline_at_target.x,  // Lateral deviation
1238                        pos_at_target.y - baseline_at_target.y,  // Vertical deviation
1239                        0.0,  // Z deviation is 0 since we're comparing at same range
1240                    );
1241
1242                    // Add additional pointing error to simulate realistic group sizes
1243                    // This represents the shooter's ability to aim consistently
1244                    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                // Skip failed simulations
1252                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
1268// Calculate zero angle for a target
1269pub 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    // Helper function to get height at target distance for a given angle
1291    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        // Z is downrange in standard ballistics coordinates
1301        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    // Binary search for the angle that hits the target
1317    // Use only positive angles to ensure proper ballistic arc (upward trajectory)
1318    let mut low_angle = 0.0;   // radians (horizontal)
1319    let mut high_angle = 0.2;  // radians (about 11 degrees)
1320    let tolerance = 0.00001;   // radians
1321    let max_iterations = 50;
1322
1323    // MBA-194: Validate bracketing before starting binary search
1324    // Check that the target height is actually between low and high angle trajectories
1325    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            // For proper bracketing, low angle should undershoot (negative error)
1334            // and high angle should overshoot (positive error)
1335            if low_error > 0.0 && high_error > 0.0 {
1336                // Both angles overshoot - target is too close or height too low
1337                // This shouldn't happen for typical zeroing, but handle gracefully
1338                // Try to find a valid bracket by reducing low_angle (can't go negative)
1339                // Since we can't go below 0, just proceed and let binary search find best
1340            } else if low_error < 0.0 && high_error < 0.0 {
1341                // Both angles undershoot - target is beyond effective range
1342                // Try expanding high_angle up to 45 degrees (0.785 rad)
1343                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            // If signs are opposite, we have valid bracketing - proceed
1362        },
1363        (None, Some(_hh)) => {
1364            // Low angle doesn't reach target, high does - this is fine
1365            // Binary search will increase low_angle until trajectory reaches
1366        },
1367        (Some(_lh), None) => {
1368            // High angle doesn't reach target - shouldn't happen
1369            return Err("Cannot find zero angle: high angle trajectory doesn't reach target distance".into());
1370        },
1371        (None, None) => {
1372            // Neither reaches target - target too far
1373            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        // Make sure we calculate far enough to reach the target
1385        solver.set_max_range(target_distance * 2.0);
1386        solver.set_time_step(0.001);
1387        let result = solver.solve()?;
1388
1389        // Find the height at target distance (Z is downrange)
1390        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                    // Linear interpolation
1395                    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                // MBA-193: Check height error FIRST (primary convergence criterion)
1410                // Height accuracy is what matters for zeroing - angle tolerance is secondary
1411                if error.abs() < 0.001 {
1412                    return Ok(mid_angle);
1413                }
1414
1415                // Only use angle tolerance as convergence criterion if we have
1416                // exhausted angle precision AND height error is still acceptable
1417                // (within 10mm which is reasonable for long range)
1418                if (high_angle - low_angle).abs() < tolerance {
1419                    if error.abs() < 0.01 {
1420                        // Height error within 10mm - acceptable for practical use
1421                        return Ok(mid_angle);
1422                    }
1423                    // Angle converged but height error too large - this shouldn't happen
1424                    // with proper tolerance values, but return best effort
1425                    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                // Trajectory didn't reach target distance, increase angle
1436                low_angle = mid_angle;
1437
1438                // MBA-193: Check angle tolerance for None case too
1439                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
1449// Estimate BC from trajectory data
1450pub fn estimate_bc_from_trajectory(
1451    velocity: f64,
1452    mass: f64,
1453    diameter: f64,
1454    points: &[(f64, f64)], // (distance, drop) pairs
1455) -> Result<f64, BallisticsError> {
1456    // Simple BC estimation using least squares
1457    let mut best_bc = 0.5;
1458    let mut best_error = f64::MAX;
1459    let mut found_valid = false;
1460    
1461    // Try different BC values
1462    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        // Set max range for BC estimation
1475        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, // Skip this BC value if solve fails
1480        };
1481        
1482        // Calculate error
1483        let mut total_error = 0.0;
1484        for (target_dist, target_drop) in points {
1485            // Find drop at this distance
1486            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                        // Linear interpolation
1491                        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
1522// Helper function to calculate air density
1523fn calculate_air_density(atmosphere: &AtmosphericConditions) -> f64 {
1524    // Simplified air density calculation
1525    // P / (R * T) where R is specific gas constant for dry air
1526    let r_specific = 287.058; // J/(kg·K)
1527    let temperature_k = atmosphere.temperature + 273.15;
1528    
1529    // Convert pressure from hPa to Pa
1530    let pressure_pa = atmosphere.pressure * 100.0;
1531    
1532    // Basic density calculation
1533    let density = pressure_pa / (r_specific * temperature_k);
1534    
1535    // Altitude correction (simplified)
1536    let altitude_factor = (-atmosphere.altitude / 8000.0).exp();
1537    
1538    density * altitude_factor
1539}
1540
1541// Add rand dependencies for Monte Carlo
1542use rand;
1543use rand_distr;