Skip to main content

ballistics_engine/
cli_api.rs

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