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
272// Trajectory solver
273pub struct TrajectorySolver {
274    inputs: BallisticInputs,
275    wind: WindConditions,
276    atmosphere: AtmosphericConditions,
277    max_range: f64,
278    time_step: f64,
279    cluster_bc: Option<ClusterBCDegradation>,
280}
281
282impl TrajectorySolver {
283    pub fn new(mut inputs: BallisticInputs, wind: WindConditions, atmosphere: AtmosphericConditions) -> Self {
284        // Compute derived fields from base units
285        inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
286        inputs.weight_grains = inputs.bullet_mass / 0.00006479891;
287        
288        // Initialize cluster BC if enabled
289        let cluster_bc = if inputs.use_cluster_bc {
290            Some(ClusterBCDegradation::new())
291        } else {
292            None
293        };
294        
295        Self {
296            inputs,
297            wind,
298            atmosphere,
299            max_range: 1000.0,
300            time_step: 0.001,
301            cluster_bc,
302        }
303    }
304    
305    pub fn set_max_range(&mut self, range: f64) {
306        self.max_range = range;
307    }
308    
309    pub fn set_time_step(&mut self, step: f64) {
310        self.time_step = step;
311    }
312    
313    fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
314        // Create wind shear profile based on surface wind
315        let profile = WindShearProfile {
316            model: if self.inputs.wind_shear_model == "logarithmic" {
317                WindShearModel::Logarithmic
318            } else if self.inputs.wind_shear_model == "power" {
319                WindShearModel::PowerLaw
320            } else {
321                WindShearModel::PowerLaw  // Default to power law
322            },
323            surface_wind: WindLayer {
324                altitude_m: 0.0,
325                speed_mps: self.wind.speed,
326                direction_deg: self.wind.direction.to_degrees(),
327            },
328            reference_height: 10.0,  // Standard meteorological measurement height
329            roughness_length: 0.03,  // Short grass
330            power_exponent: 1.0 / 7.0,  // Neutral stability
331            geostrophic_wind: None,
332            custom_layers: Vec::new(),
333        };
334        
335        profile.get_wind_at_altitude(altitude_m)
336    }
337    
338    pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
339        if self.inputs.use_rk4 {
340            if self.inputs.use_adaptive_rk45 {
341                self.solve_rk45()
342            } else {
343                self.solve_rk4()
344            }
345        } else {
346            self.solve_euler()
347        }
348    }
349    
350    fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
351        // Simple trajectory integration using Euler method
352        let mut time = 0.0;
353        let mut position = Vector3::new(0.0, self.inputs.sight_height + self.inputs.muzzle_height, 0.0);
354        // Calculate initial velocity components with both elevation and azimuth
355        let horizontal_velocity = self.inputs.muzzle_velocity * self.inputs.muzzle_angle.cos();
356        let mut velocity = Vector3::new(
357            horizontal_velocity * self.inputs.azimuth_angle.cos(),  // X: forward component (downrange)
358            self.inputs.muzzle_velocity * self.inputs.muzzle_angle.sin(),  // Y: vertical component
359            horizontal_velocity * self.inputs.azimuth_angle.sin(),  // Z: side deviation (lateral)
360        );
361        
362        let mut points = Vec::new();
363        let mut max_height = position.y;
364        let mut min_pitch_damping = 1.0;  // Track minimum pitch damping coefficient
365        let mut transonic_mach = None;    // Track when we enter transonic
366        
367        // Initialize angular state for precession/nutation tracking
368        let mut angular_state = if self.inputs.enable_precession_nutation {
369            Some(AngularState {
370                pitch_angle: 0.001,  // Small initial disturbance
371                yaw_angle: 0.001,
372                pitch_rate: 0.0,
373                yaw_rate: 0.0,
374                precession_angle: 0.0,
375                nutation_phase: 0.0,
376            })
377        } else {
378            None
379        };
380        let mut max_yaw_angle = 0.0;
381        let mut max_precession_angle = 0.0;
382        
383        // Calculate air density
384        let air_density = calculate_air_density(&self.atmosphere);
385        
386        // Wind vector (X is downrange, Z is lateral)
387        let wind_vector = Vector3::new(
388            self.wind.speed * self.wind.direction.cos(),  // X: downrange (head/tail wind)
389            0.0,
390            self.wind.speed * self.wind.direction.sin(),  // Z: lateral (crosswind)
391        );
392        
393        // Main integration loop (X is downrange)
394        while position.x < self.max_range && position.y >= 0.0 && time < 100.0 {
395            // Store trajectory point
396            let velocity_magnitude = velocity.magnitude();
397            let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
398
399            points.push(TrajectoryPoint {
400                time,
401                position: position.clone(),
402                velocity_magnitude,
403                kinetic_energy,
404            });
405
406            // Debug: Log first and every 100th point
407            if points.len() == 1 || points.len() % 100 == 0 {
408                eprintln!("Trajectory point {}: time={:.3}s, x={:.2}m, y={:.2}m, z={:.2}m, vel={:.1}m/s",
409                    points.len(), time, position.x, position.y, position.z, velocity_magnitude);
410            }
411            
412            // Track max height
413            if position.y > max_height {
414                max_height = position.y;
415            }
416            
417            // Calculate pitch damping if enabled
418            if self.inputs.enable_pitch_damping {
419                let temp_c = self.atmosphere.temperature;
420                let temp_k = temp_c + 273.15;
421                let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
422                let mach = velocity_magnitude / speed_of_sound;
423                
424                // Track when we enter transonic
425                if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
426                    transonic_mach = Some(mach);
427                }
428                
429                // Calculate pitch damping coefficient
430                let bullet_type = if let Some(ref model) = self.inputs.bullet_model {
431                    model.as_str()
432                } else {
433                    "default"
434                };
435                let coeffs = PitchDampingCoefficients::from_bullet_type(bullet_type);
436                let pitch_damping = calculate_pitch_damping_coefficient(mach, &coeffs);
437                
438                // Track minimum (most critical for stability)
439                if pitch_damping < min_pitch_damping {
440                    min_pitch_damping = pitch_damping;
441                }
442            }
443            
444            // Calculate precession/nutation if enabled
445            if self.inputs.enable_precession_nutation {
446                if let Some(ref mut state) = angular_state {
447                    let velocity_magnitude = velocity.magnitude();
448                    let temp_c = self.atmosphere.temperature;
449                    let temp_k = temp_c + 273.15;
450                    let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
451                    let mach = velocity_magnitude / speed_of_sound;
452                    
453                    // Calculate spin rate from twist rate and velocity
454                    let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
455                        let velocity_fps = velocity_magnitude * 3.28084;
456                        let twist_rate_ft = self.inputs.twist_rate / 12.0;
457                        (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
458                    } else {
459                        0.0
460                    };
461                    
462                    // Create precession/nutation parameters
463                    let params = PrecessionNutationParams {
464                        mass_kg: self.inputs.bullet_mass,
465                        caliber_m: self.inputs.bullet_diameter,
466                        length_m: self.inputs.bullet_length,
467                        spin_rate_rad_s,
468                        spin_inertia: 6.94e-8,      // Typical value
469                        transverse_inertia: 9.13e-7, // Typical value
470                        velocity_mps: velocity_magnitude,
471                        air_density_kg_m3: air_density,
472                        mach,
473                        pitch_damping_coeff: -0.8,
474                        nutation_damping_factor: 0.05,
475                    };
476                    
477                    // Update angular state
478                    *state = calculate_combined_angular_motion(
479                        &params,
480                        state,
481                        time,
482                        self.time_step,
483                        0.001,  // Initial disturbance
484                    );
485                    
486                    // Track maximums
487                    if state.yaw_angle.abs() > max_yaw_angle {
488                        max_yaw_angle = state.yaw_angle.abs();
489                    }
490                    if state.precession_angle.abs() > max_precession_angle {
491                        max_precession_angle = state.precession_angle.abs();
492                    }
493                }
494            }
495            
496            // Calculate drag with altitude-dependent wind if enabled
497            let actual_wind = if self.inputs.enable_wind_shear {
498                self.get_wind_at_altitude(position.y)
499            } else {
500                wind_vector.clone()
501            };
502            let velocity_rel = velocity - actual_wind;
503            let velocity_rel_mag = velocity_rel.magnitude();
504            let drag_coefficient = self.calculate_drag_coefficient(velocity_rel_mag);
505            
506            // Calculate drag force
507            let drag_force = 0.5 * air_density * drag_coefficient * 
508                            self.inputs.bullet_diameter * self.inputs.bullet_diameter * 
509                            std::f64::consts::PI / 4.0 * velocity_rel_mag * velocity_rel_mag;
510            
511            // Calculate acceleration
512            let drag_acceleration = -drag_force / self.inputs.bullet_mass;
513            let acceleration = Vector3::new(
514                drag_acceleration * velocity_rel.x / velocity_rel_mag,
515                drag_acceleration * velocity_rel.y / velocity_rel_mag - 9.80665,
516                drag_acceleration * velocity_rel.z / velocity_rel_mag,
517            );
518            
519            // Update state
520            velocity += acceleration * self.time_step;
521            position += velocity * self.time_step;
522            time += self.time_step;
523        }
524        
525        // Get final values
526        let last_point = points.last().ok_or("No trajectory points generated")?;
527        
528        // Create trajectory sampling data if enabled
529        let sampled_points = if self.inputs.enable_trajectory_sampling {
530            let trajectory_data = TrajectoryData {
531                times: points.iter().map(|p| p.time).collect(),
532                positions: points.iter().map(|p| p.position.clone()).collect(),
533                velocities: points.iter().map(|p| {
534                    // Reconstruct velocity vectors from magnitude (approximate)
535                    Vector3::new(0.0, 0.0, p.velocity_magnitude)
536                }).collect(),
537                transonic_distances: Vec::new(),  // TODO: Track Mach transitions
538            };
539            
540            let outputs = TrajectoryOutputs {
541                target_distance_horiz_m: last_point.position.x,  // X is downrange
542                target_vertical_height_m: last_point.position.y,
543                time_of_flight_s: last_point.time,
544                max_ord_dist_horiz_m: max_height,
545            };
546            
547            // Sample at specified intervals
548            let samples = sample_trajectory(&trajectory_data, &outputs, self.inputs.sample_interval, self.inputs.bullet_mass);
549            Some(samples)
550        } else {
551            None
552        };
553        
554        Ok(TrajectoryResult {
555            max_range: last_point.position.x,  // X is downrange
556            max_height,
557            time_of_flight: last_point.time,
558            impact_velocity: last_point.velocity_magnitude,
559            impact_energy: last_point.kinetic_energy,
560            points,
561            sampled_points,
562            min_pitch_damping: if self.inputs.enable_pitch_damping { Some(min_pitch_damping) } else { None },
563            transonic_mach: transonic_mach,
564            angular_state: angular_state,
565            max_yaw_angle: if self.inputs.enable_precession_nutation { Some(max_yaw_angle) } else { None },
566            max_precession_angle: if self.inputs.enable_precession_nutation { Some(max_precession_angle) } else { None },
567        })
568    }
569    
570    fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
571        // RK4 trajectory integration for better accuracy
572        let mut time = 0.0;
573        let mut position = Vector3::new(0.0, self.inputs.sight_height + self.inputs.muzzle_height, 0.0);
574        
575        // Calculate initial velocity components with both elevation and azimuth
576        let horizontal_velocity = self.inputs.muzzle_velocity * self.inputs.muzzle_angle.cos();
577        let mut velocity = Vector3::new(
578            horizontal_velocity * self.inputs.azimuth_angle.cos(),  // X: forward component (downrange)
579            self.inputs.muzzle_velocity * self.inputs.muzzle_angle.sin(),  // Y: vertical component
580            horizontal_velocity * self.inputs.azimuth_angle.sin(),  // Z: side deviation (lateral)
581        );
582
583        let mut points = Vec::new();
584        let mut max_height = position.y;
585        let mut min_pitch_damping = 1.0;  // Track minimum pitch damping coefficient
586        let mut transonic_mach = None;    // Track when we enter transonic
587
588        // Initialize angular state for precession/nutation tracking
589        let mut angular_state = if self.inputs.enable_precession_nutation {
590            Some(AngularState {
591                pitch_angle: 0.001,  // Small initial disturbance
592                yaw_angle: 0.001,
593                pitch_rate: 0.0,
594                yaw_rate: 0.0,
595                precession_angle: 0.0,
596                nutation_phase: 0.0,
597            })
598        } else {
599            None
600        };
601        let mut max_yaw_angle = 0.0;
602        let mut max_precession_angle = 0.0;
603
604        // Calculate air density
605        let air_density = calculate_air_density(&self.atmosphere);
606
607        // Wind vector (X is downrange, Z is lateral)
608        let wind_vector = Vector3::new(
609            self.wind.speed * self.wind.direction.cos(),  // X: downrange (head/tail wind)
610            0.0,
611            self.wind.speed * self.wind.direction.sin(),  // Z: lateral (crosswind)
612        );
613
614        // Main RK4 integration loop (X is downrange)
615        while position.x < self.max_range && position.y >= 0.0 && time < 100.0 {
616            // Store trajectory point
617            let velocity_magnitude = velocity.magnitude();
618            let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
619            
620            points.push(TrajectoryPoint {
621                time,
622                position: position.clone(),
623                velocity_magnitude,
624                kinetic_energy,
625            });
626            
627            if position.y > max_height {
628                max_height = position.y;
629            }
630            
631            // Calculate pitch damping if enabled (RK4 solver)
632            if self.inputs.enable_pitch_damping {
633                let temp_c = self.atmosphere.temperature;
634                let temp_k = temp_c + 273.15;
635                let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
636                let mach = velocity_magnitude / speed_of_sound;
637                
638                // Track when we enter transonic
639                if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
640                    transonic_mach = Some(mach);
641                }
642                
643                // Calculate pitch damping coefficient
644                let bullet_type = if let Some(ref model) = self.inputs.bullet_model {
645                    model.as_str()
646                } else {
647                    "default"
648                };
649                let coeffs = PitchDampingCoefficients::from_bullet_type(bullet_type);
650                let pitch_damping = calculate_pitch_damping_coefficient(mach, &coeffs);
651                
652                // Track minimum (most critical for stability)
653                if pitch_damping < min_pitch_damping {
654                    min_pitch_damping = pitch_damping;
655                }
656            }
657            
658            // Calculate precession/nutation if enabled (RK4 solver)
659            if self.inputs.enable_precession_nutation {
660                if let Some(ref mut state) = angular_state {
661                    let velocity_magnitude = velocity.magnitude();
662                    let temp_c = self.atmosphere.temperature;
663                    let temp_k = temp_c + 273.15;
664                    let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
665                    let mach = velocity_magnitude / speed_of_sound;
666                    
667                    // Calculate spin rate from twist rate and velocity
668                    let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
669                        let velocity_fps = velocity_magnitude * 3.28084;
670                        let twist_rate_ft = self.inputs.twist_rate / 12.0;
671                        (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
672                    } else {
673                        0.0
674                    };
675                    
676                    // Create precession/nutation parameters
677                    let params = PrecessionNutationParams {
678                        mass_kg: self.inputs.bullet_mass,
679                        caliber_m: self.inputs.bullet_diameter,
680                        length_m: self.inputs.bullet_length,
681                        spin_rate_rad_s,
682                        spin_inertia: 6.94e-8,      // Typical value
683                        transverse_inertia: 9.13e-7, // Typical value
684                        velocity_mps: velocity_magnitude,
685                        air_density_kg_m3: air_density,
686                        mach,
687                        pitch_damping_coeff: -0.8,
688                        nutation_damping_factor: 0.05,
689                    };
690                    
691                    // Update angular state
692                    *state = calculate_combined_angular_motion(
693                        &params,
694                        state,
695                        time,
696                        self.time_step,
697                        0.001,  // Initial disturbance
698                    );
699                    
700                    // Track maximums
701                    if state.yaw_angle.abs() > max_yaw_angle {
702                        max_yaw_angle = state.yaw_angle.abs();
703                    }
704                    if state.precession_angle.abs() > max_precession_angle {
705                        max_precession_angle = state.precession_angle.abs();
706                    }
707                }
708            }
709            
710            // RK4 method
711            let dt = self.time_step;
712            
713            // k1
714            let acc1 = self.calculate_acceleration(&position, &velocity, air_density, &wind_vector);
715            
716            // k2
717            let pos2 = position + velocity * (dt * 0.5);
718            let vel2 = velocity + acc1 * (dt * 0.5);
719            let acc2 = self.calculate_acceleration(&pos2, &vel2, air_density, &wind_vector);
720            
721            // k3
722            let pos3 = position + vel2 * (dt * 0.5);
723            let vel3 = velocity + acc2 * (dt * 0.5);
724            let acc3 = self.calculate_acceleration(&pos3, &vel3, air_density, &wind_vector);
725            
726            // k4
727            let pos4 = position + vel3 * dt;
728            let vel4 = velocity + acc3 * dt;
729            let acc4 = self.calculate_acceleration(&pos4, &vel4, air_density, &wind_vector);
730            
731            // Update position and velocity
732            position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
733            velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
734            time += dt;
735        }
736        
737        // Get final values
738        let last_point = points.last().ok_or("No trajectory points generated")?;
739        
740        // Create trajectory sampling data if enabled
741        let sampled_points = if self.inputs.enable_trajectory_sampling {
742            let trajectory_data = TrajectoryData {
743                times: points.iter().map(|p| p.time).collect(),
744                positions: points.iter().map(|p| p.position.clone()).collect(),
745                velocities: points.iter().map(|p| {
746                    // Reconstruct velocity vectors from magnitude (approximate)
747                    Vector3::new(0.0, 0.0, p.velocity_magnitude)
748                }).collect(),
749                transonic_distances: Vec::new(),  // TODO: Track Mach transitions
750            };
751            
752            let outputs = TrajectoryOutputs {
753                target_distance_horiz_m: last_point.position.x,  // X is downrange
754                target_vertical_height_m: last_point.position.y,
755                time_of_flight_s: last_point.time,
756                max_ord_dist_horiz_m: max_height,
757            };
758            
759            // Sample at specified intervals
760            let samples = sample_trajectory(&trajectory_data, &outputs, self.inputs.sample_interval, self.inputs.bullet_mass);
761            Some(samples)
762        } else {
763            None
764        };
765        
766        Ok(TrajectoryResult {
767            max_range: last_point.position.x,  // X is downrange
768            max_height,
769            time_of_flight: last_point.time,
770            impact_velocity: last_point.velocity_magnitude,
771            impact_energy: last_point.kinetic_energy,
772            points,
773            sampled_points,
774            min_pitch_damping: if self.inputs.enable_pitch_damping { Some(min_pitch_damping) } else { None },
775            transonic_mach: transonic_mach,
776            angular_state: angular_state,
777            max_yaw_angle: if self.inputs.enable_precession_nutation { Some(max_yaw_angle) } else { None },
778            max_precession_angle: if self.inputs.enable_precession_nutation { Some(max_precession_angle) } else { None },
779        })
780    }
781    
782    fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
783        // RK45 adaptive step size integration (Dormand-Prince method)
784        let mut time = 0.0;
785        let mut position = Vector3::new(0.0, self.inputs.sight_height + self.inputs.muzzle_height, 0.0);
786        
787        // Calculate initial velocity components (X is downrange, Z is lateral)
788        let horizontal_velocity = self.inputs.muzzle_velocity * self.inputs.muzzle_angle.cos();
789        let mut velocity = Vector3::new(
790            horizontal_velocity * self.inputs.azimuth_angle.cos(),  // X: forward component (downrange)
791            self.inputs.muzzle_velocity * self.inputs.muzzle_angle.sin(),  // Y: vertical component
792            horizontal_velocity * self.inputs.azimuth_angle.sin(),  // Z: side deviation (lateral)
793        );
794        
795        let mut points = Vec::new();
796        let mut max_height = position.y;
797        let mut dt = 0.001;  // Initial step size
798        let tolerance = 1e-6;  // Error tolerance
799        let safety_factor = 0.9;  // Safety factor for step size adjustment
800        let max_dt = 0.01;  // Maximum step size
801        let min_dt = 1e-6;   // Minimum step size
802        
803        // Add a point counter to debug
804        let mut iteration_count = 0;
805        const MAX_ITERATIONS: usize = 100000;
806        
807        while position.x < self.max_range && position.y > self.inputs.ground_threshold && time < 100.0 {  // X is downrange
808            iteration_count += 1;
809            if iteration_count > MAX_ITERATIONS {
810                break; // Prevent infinite loop
811            }
812            
813            // Store current point
814            let velocity_magnitude = velocity.magnitude();
815            let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
816            
817            points.push(TrajectoryPoint {
818                time,
819                position: position.clone(),
820                velocity_magnitude,
821                kinetic_energy,
822            });
823            
824            if position.y > max_height {
825                max_height = position.y;
826            }
827            
828            // Get atmospheric conditions and wind (X is downrange, Z is lateral)
829            let air_density = calculate_air_density(&self.atmosphere);
830            let wind_vector = Vector3::new(
831                self.wind.speed * self.wind.direction.cos(),  // X: downrange (head/tail wind)
832                0.0,
833                self.wind.speed * self.wind.direction.sin(),  // Z: lateral (crosswind)
834            );
835            
836            // RK45 step with adaptive step size
837            let (new_pos, new_vel, new_dt) = self.rk45_step(
838                &position,
839                &velocity,
840                dt,
841                air_density,
842                &wind_vector,
843                tolerance,
844            );
845            
846            // Update step size with safety factor and bounds
847            dt = (safety_factor * new_dt).clamp(min_dt, max_dt);
848            
849            // Update state
850            position = new_pos;
851            velocity = new_vel;
852            time += dt;
853        }
854        
855        // Ensure we have at least one point
856        if points.is_empty() {
857            return Err(BallisticsError::from("No trajectory points calculated"));
858        }
859
860        let last_point = points.last().unwrap();
861
862        Ok(TrajectoryResult {
863            max_range: last_point.position.x,  // X is downrange
864            max_height,
865            time_of_flight: last_point.time,
866            impact_velocity: last_point.velocity_magnitude,
867            impact_energy: last_point.kinetic_energy,
868            points,
869            sampled_points: None,  // Simplified - no trajectory sampling in RK45 for now
870            min_pitch_damping: None,
871            transonic_mach: None,
872            angular_state: None,
873            max_yaw_angle: None,
874            max_precession_angle: None,
875        })
876    }
877    
878    fn rk45_step(
879        &self,
880        position: &Vector3<f64>,
881        velocity: &Vector3<f64>,
882        dt: f64,
883        air_density: f64,
884        wind_vector: &Vector3<f64>,
885        tolerance: f64,
886    ) -> (Vector3<f64>, Vector3<f64>, f64) {
887        // Dormand-Prince coefficients
888        const A21: f64 = 1.0 / 5.0;
889        const A31: f64 = 3.0 / 40.0;
890        const A32: f64 = 9.0 / 40.0;
891        const A41: f64 = 44.0 / 45.0;
892        const A42: f64 = -56.0 / 15.0;
893        const A43: f64 = 32.0 / 9.0;
894        const A51: f64 = 19372.0 / 6561.0;
895        const A52: f64 = -25360.0 / 2187.0;
896        const A53: f64 = 64448.0 / 6561.0;
897        const A54: f64 = -212.0 / 729.0;
898        const A61: f64 = 9017.0 / 3168.0;
899        const A62: f64 = -355.0 / 33.0;
900        const A63: f64 = 46732.0 / 5247.0;
901        const A64: f64 = 49.0 / 176.0;
902        const A65: f64 = -5103.0 / 18656.0;
903        const A71: f64 = 35.0 / 384.0;
904        const A73: f64 = 500.0 / 1113.0;
905        const A74: f64 = 125.0 / 192.0;
906        const A75: f64 = -2187.0 / 6784.0;
907        const A76: f64 = 11.0 / 84.0;
908        
909        // 5th order coefficients
910        const B1: f64 = 35.0 / 384.0;
911        const B3: f64 = 500.0 / 1113.0;
912        const B4: f64 = 125.0 / 192.0;
913        const B5: f64 = -2187.0 / 6784.0;
914        const B6: f64 = 11.0 / 84.0;
915        
916        // 4th order coefficients for error estimation
917        const B1_ERR: f64 = 5179.0 / 57600.0;
918        const B3_ERR: f64 = 7571.0 / 16695.0;
919        const B4_ERR: f64 = 393.0 / 640.0;
920        const B5_ERR: f64 = -92097.0 / 339200.0;
921        const B6_ERR: f64 = 187.0 / 2100.0;
922        const B7_ERR: f64 = 1.0 / 40.0;
923        
924        // Compute RK45 stages
925        let k1_v = self.calculate_acceleration(position, velocity, air_density, wind_vector);
926        let k1_p = velocity.clone();
927        
928        let p2 = position + dt * A21 * k1_p;
929        let v2 = velocity + dt * A21 * k1_v;
930        let k2_v = self.calculate_acceleration(&p2, &v2, air_density, wind_vector);
931        let k2_p = v2;
932        
933        let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
934        let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
935        let k3_v = self.calculate_acceleration(&p3, &v3, air_density, wind_vector);
936        let k3_p = v3;
937        
938        let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
939        let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
940        let k4_v = self.calculate_acceleration(&p4, &v4, air_density, wind_vector);
941        let k4_p = v4;
942        
943        let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
944        let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
945        let k5_v = self.calculate_acceleration(&p5, &v5, air_density, wind_vector);
946        let k5_p = v5;
947        
948        let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
949        let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
950        let k6_v = self.calculate_acceleration(&p6, &v6, air_density, wind_vector);
951        let k6_p = v6;
952        
953        let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
954        let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
955        let k7_v = self.calculate_acceleration(&p7, &v7, air_density, wind_vector);
956        let k7_p = v7;
957        
958        // 5th order solution
959        let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
960        let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
961        
962        // 4th order solution for error estimate
963        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);
964        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);
965        
966        // Estimate error
967        let pos_error = (new_pos - pos_err).magnitude();
968        let vel_error = (new_vel - vel_err).magnitude();
969        let error = (pos_error + vel_error) / (1.0 + position.magnitude() + velocity.magnitude());
970        
971        // Calculate new step size
972        let dt_new = if error < tolerance {
973            dt * (tolerance / error).powf(0.2).min(2.0)
974        } else {
975            dt * (tolerance / error).powf(0.25).max(0.1)
976        };
977        
978        (new_pos, new_vel, dt_new)
979    }
980    
981    fn calculate_acceleration(&self, position: &Vector3<f64>, velocity: &Vector3<f64>, air_density: f64, wind_vector: &Vector3<f64>) -> Vector3<f64> {
982        // Calculate altitude-dependent wind if wind shear is enabled
983        let actual_wind = if self.inputs.enable_wind_shear {
984            self.get_wind_at_altitude(position.y)
985        } else {
986            wind_vector.clone()
987        };
988
989        let relative_velocity = velocity - &actual_wind;
990        let velocity_magnitude = relative_velocity.magnitude();
991
992        if velocity_magnitude < 0.001 {
993            return Vector3::new(0.0, -9.81, 0.0);
994        }
995
996        // Get drag coefficient from drag model (Mach-indexed from drag tables)
997        let cd = self.calculate_drag_coefficient(velocity_magnitude);
998
999        // Apply cluster BC correction if enabled
1000        let effective_bc = if let Some(ref cluster_bc) = self.cluster_bc {
1001            // Convert velocity to fps for cluster BC calculation
1002            let velocity_fps = velocity_magnitude * 3.28084;
1003            cluster_bc.apply_correction(
1004                self.inputs.bc_value,
1005                self.inputs.caliber_inches * 0.0254,  // Convert back to meters for consistency
1006                self.inputs.weight_grains,
1007                velocity_fps
1008            )
1009        } else {
1010            self.inputs.bc_value
1011        };
1012
1013        // Use proper ballistics retardation formula
1014        // This matches the proven formula from fast_trajectory.rs
1015        // The standard retardation factor converts Cd to drag deceleration
1016        let velocity_fps = velocity_magnitude * 3.28084;  // m/s to fps
1017        let cd_to_retard = 0.000683 * 0.30;  // Standard ballistics constant
1018        let standard_factor = cd * cd_to_retard;
1019        let density_scale = air_density / 1.225;  // Scale relative to standard air (1.225 kg/m³)
1020
1021        // Drag acceleration in ft/s² then convert to m/s²
1022        let a_drag_ft_s2 = (velocity_fps * velocity_fps) * standard_factor * density_scale / effective_bc;
1023        let a_drag_m_s2 = a_drag_ft_s2 * 0.3048;  // ft/s² to m/s²
1024
1025        // Apply drag opposite to velocity direction
1026        let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
1027
1028        // Total acceleration = drag + gravity
1029        drag_acceleration + Vector3::new(0.0, -9.81, 0.0)
1030    }
1031    
1032    fn calculate_drag_coefficient(&self, velocity: f64) -> f64 {
1033        // Calculate speed of sound based on atmospheric temperature
1034        let temp_c = self.atmosphere.temperature;
1035        let temp_k = temp_c + 273.15;
1036        let gamma = 1.4;  // Ratio of specific heats for air
1037        let r_specific = 287.05;  // Specific gas constant for air (J/kg·K)
1038        let speed_of_sound = (gamma * r_specific * temp_k).sqrt();
1039        let mach = velocity / speed_of_sound;
1040
1041        // Get drag coefficient from the drag tables (Mach-indexed)
1042        let base_cd = crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type);
1043
1044        // Determine projectile shape for transonic corrections
1045        let projectile_shape = if let Some(ref model) = self.inputs.bullet_model {
1046            // Try to determine shape from bullet model string
1047            if model.to_lowercase().contains("boat") || model.to_lowercase().contains("bt") {
1048                ProjectileShape::BoatTail
1049            } else if model.to_lowercase().contains("round") || model.to_lowercase().contains("rn") {
1050                ProjectileShape::RoundNose
1051            } else if model.to_lowercase().contains("flat") || model.to_lowercase().contains("fb") {
1052                ProjectileShape::FlatBase
1053            } else {
1054                // Use heuristic based on caliber, weight, and drag model
1055                get_projectile_shape(
1056                    self.inputs.bullet_diameter,
1057                    self.inputs.bullet_mass / 0.00006479891,  // Convert kg to grains
1058                    &self.inputs.bc_type.to_string()
1059                )
1060            }
1061        } else {
1062            // Use heuristic based on caliber, weight, and drag model
1063            get_projectile_shape(
1064                self.inputs.bullet_diameter,
1065                self.inputs.bullet_mass / 0.00006479891,  // Convert kg to grains
1066                &self.inputs.bc_type.to_string()
1067            )
1068        };
1069
1070        // Apply transonic corrections
1071        // Enable wave drag if advanced effects are enabled
1072        let include_wave_drag = self.inputs.enable_advanced_effects;
1073        transonic_correction(mach, base_cd, projectile_shape, include_wave_drag)
1074    }
1075}
1076
1077// Monte Carlo parameters
1078#[derive(Debug, Clone)]
1079pub struct MonteCarloParams {
1080    pub num_simulations: usize,
1081    pub velocity_std_dev: f64,
1082    pub angle_std_dev: f64,
1083    pub bc_std_dev: f64,
1084    pub wind_speed_std_dev: f64,
1085    pub target_distance: Option<f64>,
1086    pub base_wind_speed: f64,
1087    pub base_wind_direction: f64,
1088    pub azimuth_std_dev: f64,  // Horizontal aiming variation in radians
1089}
1090
1091impl Default for MonteCarloParams {
1092    fn default() -> Self {
1093        Self {
1094            num_simulations: 1000,
1095            velocity_std_dev: 1.0,
1096            angle_std_dev: 0.001,
1097            bc_std_dev: 0.01,
1098            wind_speed_std_dev: 1.0,
1099            target_distance: None,
1100            base_wind_speed: 0.0,
1101            base_wind_direction: 0.0,
1102            azimuth_std_dev: 0.001,  // Default horizontal spread ~0.057 degrees
1103        }
1104    }
1105}
1106
1107// Monte Carlo results
1108#[derive(Debug, Clone)]
1109pub struct MonteCarloResults {
1110    pub ranges: Vec<f64>,
1111    pub impact_velocities: Vec<f64>,
1112    pub impact_positions: Vec<Vector3<f64>>,
1113}
1114
1115// Run Monte Carlo simulation (backwards compatibility)
1116pub fn run_monte_carlo(
1117    base_inputs: BallisticInputs,
1118    params: MonteCarloParams,
1119) -> Result<MonteCarloResults, BallisticsError> {
1120    let base_wind = WindConditions {
1121        speed: params.base_wind_speed,
1122        direction: params.base_wind_direction,
1123    };
1124    run_monte_carlo_with_wind(base_inputs, base_wind, params)
1125}
1126
1127// Run Monte Carlo simulation with wind
1128pub fn run_monte_carlo_with_wind(
1129    base_inputs: BallisticInputs,
1130    base_wind: WindConditions,
1131    params: MonteCarloParams,
1132) -> Result<MonteCarloResults, BallisticsError> {
1133    use rand::thread_rng;
1134    use rand_distr::{Distribution, Normal};
1135    
1136    let mut rng = thread_rng();
1137    let mut ranges = Vec::new();
1138    let mut impact_velocities = Vec::new();
1139    let mut impact_positions = Vec::new();
1140    
1141    // First, calculate baseline trajectory with no variations
1142    let baseline_solver = TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), Default::default());
1143    let baseline_result = baseline_solver.solve()?;
1144    let baseline_impact = baseline_result.points.last()
1145        .ok_or("No baseline trajectory points")?
1146        .position.clone();
1147    
1148    // Create normal distributions for variations
1149    let velocity_dist = Normal::new(base_inputs.muzzle_velocity, params.velocity_std_dev)
1150        .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
1151    let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
1152        .map_err(|e| format!("Invalid angle distribution: {}", e))?;
1153    let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
1154        .map_err(|e| format!("Invalid BC distribution: {}", e))?;
1155    let wind_speed_dist = Normal::new(base_wind.speed, params.wind_speed_std_dev)
1156        .map_err(|e| format!("Invalid wind speed distribution: {}", e))?;
1157    let wind_dir_dist = Normal::new(base_wind.direction, params.wind_speed_std_dev * 0.1)  // Small variation in direction
1158        .map_err(|e| format!("Invalid wind direction distribution: {}", e))?;
1159    let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
1160        .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
1161    
1162    // Create distribution for pointing errors (simulates shooter's aiming consistency)
1163    let distance = baseline_impact.z;  // Distance to target
1164    let pointing_error_dist = Normal::new(0.0, params.angle_std_dev * distance)
1165        .map_err(|e| format!("Invalid pointing distribution: {}", e))?;
1166    
1167    for _ in 0..params.num_simulations {
1168        // Create varied inputs
1169        let mut inputs = base_inputs.clone();
1170        inputs.muzzle_velocity = velocity_dist.sample(&mut rng).max(0.0);
1171        inputs.muzzle_angle = angle_dist.sample(&mut rng);
1172        inputs.bc_value = bc_dist.sample(&mut rng).max(0.01);
1173        inputs.azimuth_angle = azimuth_dist.sample(&mut rng);  // Add horizontal variation
1174        
1175        // Create varied wind (now based on base wind conditions)
1176        let wind = WindConditions {
1177            speed: wind_speed_dist.sample(&mut rng).abs(),
1178            direction: wind_dir_dist.sample(&mut rng),
1179        };
1180        
1181        // Run trajectory
1182        let solver = TrajectorySolver::new(inputs, wind, Default::default());
1183        match solver.solve() {
1184            Ok(result) => {
1185                ranges.push(result.max_range);
1186                impact_velocities.push(result.impact_velocity);
1187                if let Some(last_point) = result.points.last() {
1188                    // Calculate physical deviation from baseline impact point
1189                    let mut deviation = Vector3::new(
1190                        last_point.position.x - baseline_impact.x,  // Lateral deviation
1191                        last_point.position.y - baseline_impact.y,  // Vertical deviation
1192                        last_point.position.z - baseline_impact.z,  // Range deviation
1193                    );
1194                    
1195                    // Add additional pointing error to simulate realistic group sizes
1196                    // This represents the shooter's ability to aim consistently
1197                    let pointing_error_y = pointing_error_dist.sample(&mut rng);
1198                    deviation.y += pointing_error_y;
1199                    
1200                    impact_positions.push(deviation);
1201                }
1202            },
1203            Err(_) => {
1204                // Skip failed simulations
1205                continue;
1206            }
1207        }
1208    }
1209    
1210    if ranges.is_empty() {
1211        return Err("No successful simulations".into());
1212    }
1213    
1214    Ok(MonteCarloResults {
1215        ranges,
1216        impact_velocities,
1217        impact_positions,
1218    })
1219}
1220
1221// Calculate zero angle for a target
1222pub fn calculate_zero_angle(
1223    inputs: BallisticInputs,
1224    target_distance: f64,
1225    target_height: f64,
1226) -> Result<f64, BallisticsError> {
1227    calculate_zero_angle_with_conditions(
1228        inputs,
1229        target_distance,
1230        target_height,
1231        WindConditions::default(),
1232        AtmosphericConditions::default(),
1233    )
1234}
1235
1236pub fn calculate_zero_angle_with_conditions(
1237    inputs: BallisticInputs,
1238    target_distance: f64,
1239    target_height: f64,
1240    wind: WindConditions,
1241    atmosphere: AtmosphericConditions,
1242) -> Result<f64, BallisticsError> {
1243    // Helper function to get height at target distance for a given angle
1244    let get_height_at_angle = |angle: f64| -> Result<Option<f64>, BallisticsError> {
1245        let mut test_inputs = inputs.clone();
1246        test_inputs.muzzle_angle = angle;
1247
1248        let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
1249        solver.set_max_range(target_distance * 2.0);
1250        solver.set_time_step(0.001);
1251        let result = solver.solve()?;
1252
1253        for i in 0..result.points.len() {
1254            if result.points[i].position.x >= target_distance {
1255                if i > 0 {
1256                    let p1 = &result.points[i - 1];
1257                    let p2 = &result.points[i];
1258                    let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
1259                    return Ok(Some(p1.position.y + t * (p2.position.y - p1.position.y)));
1260                } else {
1261                    return Ok(Some(result.points[i].position.y));
1262                }
1263            }
1264        }
1265        Ok(None)
1266    };
1267
1268    // Binary search for the angle that hits the target
1269    // Use only positive angles to ensure proper ballistic arc (upward trajectory)
1270    let mut low_angle = 0.0;   // radians (horizontal)
1271    let mut high_angle = 0.2;  // radians (about 11 degrees)
1272    let tolerance = 0.00001;   // radians
1273    let max_iterations = 50;
1274
1275    // MBA-194: Validate bracketing before starting binary search
1276    // Check that the target height is actually between low and high angle trajectories
1277    let low_height = get_height_at_angle(low_angle)?;
1278    let high_height = get_height_at_angle(high_angle)?;
1279
1280    match (low_height, high_height) {
1281        (Some(lh), Some(hh)) => {
1282            let low_error = lh - target_height;
1283            let high_error = hh - target_height;
1284
1285            // For proper bracketing, low angle should undershoot (negative error)
1286            // and high angle should overshoot (positive error)
1287            if low_error > 0.0 && high_error > 0.0 {
1288                // Both angles overshoot - target is too close or height too low
1289                // This shouldn't happen for typical zeroing, but handle gracefully
1290                // Try to find a valid bracket by reducing low_angle (can't go negative)
1291                // Since we can't go below 0, just proceed and let binary search find best
1292            } else if low_error < 0.0 && high_error < 0.0 {
1293                // Both angles undershoot - target is beyond effective range
1294                // Try expanding high_angle up to 45 degrees (0.785 rad)
1295                let mut expanded = false;
1296                for multiplier in [2.0, 3.0, 4.0] {
1297                    let new_high = (high_angle * multiplier).min(0.785);
1298                    if let Ok(Some(h)) = get_height_at_angle(new_high) {
1299                        if h - target_height > 0.0 {
1300                            high_angle = new_high;
1301                            expanded = true;
1302                            break;
1303                        }
1304                    }
1305                    if new_high >= 0.785 {
1306                        break;
1307                    }
1308                }
1309                if !expanded {
1310                    return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
1311                }
1312            }
1313            // If signs are opposite, we have valid bracketing - proceed
1314        },
1315        (None, Some(_hh)) => {
1316            // Low angle doesn't reach target, high does - this is fine
1317            // Binary search will increase low_angle until trajectory reaches
1318        },
1319        (Some(_lh), None) => {
1320            // High angle doesn't reach target - shouldn't happen
1321            return Err("Cannot find zero angle: high angle trajectory doesn't reach target distance".into());
1322        },
1323        (None, None) => {
1324            // Neither reaches target - target too far
1325            return Err("Cannot find zero angle: trajectory cannot reach target distance at any angle".into());
1326        }
1327    }
1328
1329    for _iteration in 0..max_iterations {
1330        let mid_angle = (low_angle + high_angle) / 2.0;
1331        
1332        let mut test_inputs = inputs.clone();
1333        test_inputs.muzzle_angle = mid_angle;
1334        
1335        let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
1336        // Make sure we calculate far enough to reach the target
1337        solver.set_max_range(target_distance * 2.0);
1338        solver.set_time_step(0.001);
1339        let result = solver.solve()?;
1340
1341        // Find the height at target distance (X is downrange)
1342        let mut height_at_target = None;
1343        for i in 0..result.points.len() {
1344            if result.points[i].position.x >= target_distance {
1345                if i > 0 {
1346                    // Linear interpolation
1347                    let p1 = &result.points[i - 1];
1348                    let p2 = &result.points[i];
1349                    let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
1350                    height_at_target = Some(p1.position.y + t * (p2.position.y - p1.position.y));
1351                } else {
1352                    height_at_target = Some(result.points[i].position.y);
1353                }
1354                break;
1355            }
1356        }
1357        
1358        match height_at_target {
1359            Some(height) => {
1360                let error = height - target_height;
1361                // MBA-193: Check height error FIRST (primary convergence criterion)
1362                // Height accuracy is what matters for zeroing - angle tolerance is secondary
1363                if error.abs() < 0.001 {
1364                    return Ok(mid_angle);
1365                }
1366
1367                // Only use angle tolerance as convergence criterion if we have
1368                // exhausted angle precision AND height error is still acceptable
1369                // (within 10mm which is reasonable for long range)
1370                if (high_angle - low_angle).abs() < tolerance {
1371                    if error.abs() < 0.01 {
1372                        // Height error within 10mm - acceptable for practical use
1373                        return Ok(mid_angle);
1374                    }
1375                    // Angle converged but height error too large - this shouldn't happen
1376                    // with proper tolerance values, but return best effort
1377                    return Ok(mid_angle);
1378                }
1379
1380                if error > 0.0 {
1381                    high_angle = mid_angle;
1382                } else {
1383                    low_angle = mid_angle;
1384                }
1385            },
1386            None => {
1387                // Trajectory didn't reach target distance, increase angle
1388                low_angle = mid_angle;
1389
1390                // MBA-193: Check angle tolerance for None case too
1391                if (high_angle - low_angle).abs() < tolerance {
1392                    return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
1393                }
1394            }
1395        }
1396    }
1397    
1398    Err("Failed to find zero angle".into())
1399}
1400
1401// Estimate BC from trajectory data
1402pub fn estimate_bc_from_trajectory(
1403    velocity: f64,
1404    mass: f64,
1405    diameter: f64,
1406    points: &[(f64, f64)], // (distance, drop) pairs
1407) -> Result<f64, BallisticsError> {
1408    // Simple BC estimation using least squares
1409    let mut best_bc = 0.5;
1410    let mut best_error = f64::MAX;
1411    let mut found_valid = false;
1412    
1413    // Try different BC values
1414    for bc in (100..1000).step_by(10) {
1415        let bc_value = bc as f64 / 1000.0;
1416        
1417        let inputs = BallisticInputs {
1418            muzzle_velocity: velocity,
1419            bc_value,
1420            bullet_mass: mass,
1421            bullet_diameter: diameter,
1422            ..Default::default()
1423        };
1424        
1425        let mut solver = TrajectorySolver::new(inputs, Default::default(), Default::default());
1426        // Set max range for BC estimation
1427        solver.set_max_range(points.last().map(|(d, _)| *d * 1.5).unwrap_or(1000.0));
1428        
1429        let result = match solver.solve() {
1430            Ok(r) => r,
1431            Err(_) => continue, // Skip this BC value if solve fails
1432        };
1433        
1434        // Calculate error
1435        let mut total_error = 0.0;
1436        for (target_dist, target_drop) in points {
1437            // Find drop at this distance
1438            let mut calculated_drop = None;
1439            for i in 0..result.points.len() {
1440                if result.points[i].position.z >= *target_dist {
1441                    if i > 0 {
1442                        // Linear interpolation
1443                        let p1 = &result.points[i - 1];
1444                        let p2 = &result.points[i];
1445                        let t = (target_dist - p1.position.z) / (p2.position.z - p1.position.z);
1446                        calculated_drop = Some(-(p1.position.y + t * (p2.position.y - p1.position.y)));
1447                    } else {
1448                        calculated_drop = Some(-result.points[i].position.y);
1449                    }
1450                    break;
1451                }
1452            }
1453            
1454            if let Some(drop) = calculated_drop {
1455                let error = (drop - target_drop).abs();
1456                total_error += error * error;
1457            }
1458        }
1459        
1460        if total_error < best_error {
1461            best_error = total_error;
1462            best_bc = bc_value;
1463            found_valid = true;
1464        }
1465    }
1466    
1467    if !found_valid {
1468        return Err(BallisticsError::from("Unable to estimate BC from provided data. Check that drop values are in correct units.".to_string()));
1469    }
1470    
1471    Ok(best_bc)
1472}
1473
1474// Helper function to calculate air density
1475fn calculate_air_density(atmosphere: &AtmosphericConditions) -> f64 {
1476    // Simplified air density calculation
1477    // P / (R * T) where R is specific gas constant for dry air
1478    let r_specific = 287.058; // J/(kg·K)
1479    let temperature_k = atmosphere.temperature + 273.15;
1480    
1481    // Convert pressure from hPa to Pa
1482    let pressure_pa = atmosphere.pressure * 100.0;
1483    
1484    // Basic density calculation
1485    let density = pressure_pa / (r_specific * temperature_k);
1486    
1487    // Altitude correction (simplified)
1488    let altitude_factor = (-atmosphere.altitude / 8000.0).exp();
1489    
1490    density * altitude_factor
1491}
1492
1493// Add rand dependencies for Monte Carlo
1494use rand;
1495use rand_distr;