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::transonic_correction;
11use crate::wind_shear::WindShearModel;
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    // MBA-959: apply aerodynamic jump as a muzzle launch-angle perturbation.
129    // EXPERIMENTAL — the underlying model is heuristic and not yet validated; default OFF.
130    pub enable_aerodynamic_jump: bool,
131    pub use_cluster_bc: bool, // Use cluster-based BC degradation
132
133    // Custom drag model support
134    pub custom_drag_table: Option<crate::drag::DragTable>,
135
136    // Legacy field for compatibility
137    pub bc_type_str: Option<String>,
138}
139
140impl Default for BallisticInputs {
141    fn default() -> Self {
142        let mass_kg = 0.01;
143        let diameter_m = 0.00762;
144        let bc = 0.5;
145        let muzzle_angle_rad = 0.0;
146        let bc_type = DragModel::G1;
147
148        Self {
149            // Core ballistics parameters
150            bc_value: bc,
151            bc_type,
152            bullet_mass: mass_kg,
153            muzzle_velocity: 800.0,
154            bullet_diameter: diameter_m,
155            bullet_length: diameter_m * 4.5, // Approximate (match the CLI's 4.5-caliber heuristic)
156
157            // Targeting and positioning
158            muzzle_angle: muzzle_angle_rad,
159            target_distance: 100.0,
160            azimuth_angle: 0.0,
161            shooting_angle: 0.0,
162            sight_height: 0.05,
163            muzzle_height: 0.0,       // Default 0 - height is in sight_height
164            target_height: 0.0,       // Target at ground level by default
165            ground_threshold: -100.0, // Effectively disable ground detection (allow bullet to drop 100m below start)
166
167            // Environmental conditions
168            altitude: 0.0,
169            temperature: 15.0,
170            pressure: 1013.25, // Standard sea level pressure (millibars)
171            humidity: 0.5,     // 50% relative humidity
172            latitude: None,
173
174            // Wind conditions
175            wind_speed: 0.0,
176            wind_angle: 0.0,
177
178            // Bullet characteristics
179            twist_rate: 12.0, // 1:12" typical
180            is_twist_right: true,
181            caliber_inches: diameter_m / 0.0254, // Convert to inches
182            weight_grains: mass_kg / 0.00006479891, // Convert to grains
183            manufacturer: None,
184            bullet_model: None,
185            bullet_id: None,
186            bullet_cluster: None,
187
188            // Integration method selection
189            use_rk4: true,           // Use Runge-Kutta methods by default
190            use_adaptive_rk45: true, // Default to RK45 adaptive for best accuracy
191
192            // Advanced effects (disabled by default)
193            enable_advanced_effects: false,
194            enable_magnus: false,
195            enable_coriolis: false,
196            use_powder_sensitivity: false,
197            powder_temp_sensitivity: 0.0,
198            powder_temp: 15.0,
199            tipoff_yaw: 0.0,
200            tipoff_decay_distance: 50.0,
201            use_bc_segments: false,
202            bc_segments: None,
203            bc_segments_data: None,
204            use_enhanced_spin_drift: false,
205            use_form_factor: false,
206            enable_wind_shear: false,
207            wind_shear_model: "none".to_string(),
208            enable_trajectory_sampling: false,
209            sample_interval: 10.0, // Default 10 meter intervals
210            enable_pitch_damping: false,
211            enable_precession_nutation: false,
212            enable_aerodynamic_jump: false,
213            use_cluster_bc: false, // Disabled by default for backward compatibility
214
215            // Custom drag model support
216            custom_drag_table: None,
217
218            // Legacy field for compatibility
219            bc_type_str: None,
220        }
221    }
222}
223
224// Wind conditions
225#[derive(Debug, Clone)]
226pub struct WindConditions {
227    pub speed: f64,     // m/s
228    pub direction: f64, // radians (0 = North, PI/2 = East)
229}
230
231impl Default for WindConditions {
232    fn default() -> Self {
233        Self {
234            speed: 0.0,
235            direction: 0.0,
236        }
237    }
238}
239
240// Atmospheric conditions
241#[derive(Debug, Clone)]
242pub struct AtmosphericConditions {
243    pub temperature: f64, // Celsius
244    pub pressure: f64,    // hPa
245    pub humidity: f64,    // percentage (0-100)
246    pub altitude: f64,    // meters
247}
248
249impl Default for AtmosphericConditions {
250    fn default() -> Self {
251        Self {
252            temperature: 15.0,
253            pressure: 1013.25,
254            humidity: 50.0,
255            altitude: 0.0,
256        }
257    }
258}
259
260// Trajectory point data
261#[derive(Debug, Clone)]
262pub struct TrajectoryPoint {
263    pub time: f64,
264    pub position: Vector3<f64>,
265    pub velocity_magnitude: f64,
266    pub kinetic_energy: f64,
267}
268
269// Trajectory result
270#[derive(Debug, Clone)]
271pub struct TrajectoryResult {
272    pub max_range: f64,
273    pub max_height: f64,
274    pub time_of_flight: f64,
275    pub impact_velocity: f64,
276    pub impact_energy: f64,
277    pub points: Vec<TrajectoryPoint>,
278    pub sampled_points: Option<Vec<TrajectorySample>>, // Trajectory samples at regular intervals
279    pub min_pitch_damping: Option<f64>, // Minimum pitch damping coefficient (for stability warning)
280    pub transonic_mach: Option<f64>,    // Mach number when entering transonic regime
281    pub angular_state: Option<AngularState>, // Final angular state if precession/nutation enabled
282    pub max_yaw_angle: Option<f64>,     // Maximum yaw angle during flight (radians)
283    pub max_precession_angle: Option<f64>, // Maximum precession angle (radians)
284    // MBA-959: aerodynamic-jump components applied at the muzzle (None unless
285    // enable_aerodynamic_jump). EXPERIMENTAL.
286    pub aerodynamic_jump: Option<crate::aerodynamic_jump::AerodynamicJumpComponents>,
287}
288
289impl TrajectoryResult {
290    /// Interpolate position at a given downrange distance (X coordinate, McCoy).
291    /// Returns the interpolated (x, y, z) position at that range.
292    /// If the target range exceeds the trajectory, returns the last point.
293    pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
294        if self.points.is_empty() {
295            return None;
296        }
297
298        // Find the two points that bracket the target range
299        for i in 0..self.points.len() - 1 {
300            let p1 = &self.points[i];
301            let p2 = &self.points[i + 1];
302
303            // Check if target range is between these two points (X is downrange)
304            if p1.position.x <= target_range && p2.position.x >= target_range {
305                // Linear interpolation factor
306                let dx = p2.position.x - p1.position.x;
307                if dx.abs() < 1e-10 {
308                    return Some(p1.position);
309                }
310                let t = (target_range - p1.position.x) / dx;
311
312                // Interpolate Y and Z, use exact target_range for X
313                return Some(Vector3::new(
314                    target_range,
315                    p1.position.y + t * (p2.position.y - p1.position.y),
316                    p1.position.z + t * (p2.position.z - p1.position.z),
317                ));
318            }
319        }
320
321        // Target range is beyond trajectory - return last point
322        self.points.last().map(|p| p.position)
323    }
324}
325
326// Trajectory solver
327pub struct TrajectorySolver {
328    inputs: BallisticInputs,
329    wind: WindConditions,
330    atmosphere: AtmosphericConditions,
331    max_range: f64,
332    time_step: f64,
333    cluster_bc: Option<ClusterBCDegradation>,
334}
335
336impl TrajectorySolver {
337    pub fn new(
338        mut inputs: BallisticInputs,
339        wind: WindConditions,
340        atmosphere: AtmosphericConditions,
341    ) -> Self {
342        // Compute derived fields from base units
343        inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
344        inputs.weight_grains = inputs.bullet_mass / 0.00006479891;
345
346        // Initialize cluster BC if enabled
347        let cluster_bc = if inputs.use_cluster_bc {
348            Some(ClusterBCDegradation::new())
349        } else {
350            None
351        };
352
353        Self {
354            inputs,
355            wind,
356            atmosphere,
357            max_range: 1000.0,
358            time_step: 0.001,
359            cluster_bc,
360        }
361    }
362
363    pub fn set_max_range(&mut self, range: f64) {
364        self.max_range = range;
365    }
366
367    pub fn set_time_step(&mut self, step: f64) {
368        self.time_step = step;
369    }
370
371    /// Effective initial launch direction `(elevation, azimuth)` in radians, including
372    /// the aerodynamic-jump muzzle perturbation when `enable_aerodynamic_jump` is set.
373    ///
374    /// Aerodynamic jump is the fixed angular departure imparted as the projectile
375    /// transitions from the constrained bore to free flight; applying it as an initial
376    /// launch-angle offset is the physically correct integration point. Returns the bare
377    /// `(muzzle_angle, azimuth_angle)` when the flag is off, so a default solve is
378    /// numerically identical to pre-feature behavior. (MBA-959)
379    fn launch_angles_from(
380        &self,
381        aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
382    ) -> (f64, f64) {
383        let elev = self.inputs.muzzle_angle;
384        let azim = self.inputs.azimuth_angle;
385        match aj {
386            Some(c) => {
387                // vertical_/horizontal_jump_moa ARE the jump angles expressed in MOA.
388                const MOA_PER_RAD: f64 = 3437.7467707849;
389                (
390                    elev + c.vertical_jump_moa / MOA_PER_RAD,
391                    azim + c.horizontal_jump_moa / MOA_PER_RAD,
392                )
393            }
394            None => (elev, azim),
395        }
396    }
397
398    /// Compute the aerodynamic-jump components for the current inputs, or `None` when the
399    /// feature is disabled / inputs are degenerate.
400    ///
401    /// Uses Bryan Litz's crosswind aerodynamic-jump estimator
402    /// (`Y = 0.01*Sg - 0.0024*L + 0.032` MOA/mph) fed by the engine's own Miller Sg.
403    /// Aerodynamic jump is a vertical effect, so only the elevation is perturbed.
404    /// The estimator is a regression best near Sg ~ 1.75 — see MBA-959.
405    fn aerodynamic_jump_components(
406        &self,
407    ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
408        if !self.inputs.enable_aerodynamic_jump {
409            return None;
410        }
411        // Reject degenerate/non-finite inputs before they can reach the launch angle.
412        // A bare `<= 0.0` test lets NaN through (NaN comparisons are always false), and a
413        // NaN/Inf here would poison the muzzle angle and collapse the whole trajectory.
414        let diameter_m = self.inputs.bullet_diameter;
415        if !(self.inputs.twist_rate.is_finite() && self.inputs.twist_rate != 0.0)
416            || !(diameter_m.is_finite() && diameter_m > 0.0)
417            || !(self.inputs.bullet_length.is_finite() && self.inputs.bullet_length > 0.0)
418            || !self.inputs.muzzle_velocity.is_finite()
419        {
420            return None;
421        }
422
423        // Engine's own gyroscopic (Miller) stability factor — same Sg shown elsewhere.
424        let sg = crate::stability::compute_stability_coefficient(
425            &self.inputs,
426            (
427                self.atmosphere.altitude,
428                self.atmosphere.temperature,
429                self.atmosphere.pressure,
430                0.0,
431            ),
432        );
433        if !(sg.is_finite() && sg > 0.0) {
434            return None;
435        }
436        let length_calibers = self.inputs.bullet_length / diameter_m;
437
438        // Crosswind: the solver's lateral (McCoy +Z = right) wind component is the velocity
439        // the integrator applies; a wind FROM the right is the negative of that (it pushes
440        // the bullet to the left). Litz's estimator wants the from-the-right component.
441        const MS_TO_MPH: f64 = 2.236_936_292_054_4;
442        let crosswind_z_mps = self.wind.speed * self.wind.direction.sin();
443        let crosswind_from_right_mph = -crosswind_z_mps * MS_TO_MPH;
444
445        let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
446            sg,
447            length_calibers,
448            crosswind_from_right_mph,
449            self.inputs.is_twist_right,
450        );
451        if !vertical_jump_moa.is_finite() {
452            return None;
453        }
454
455        const MOA_PER_RAD: f64 = 3437.7467707849;
456        Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
457            vertical_jump_moa,
458            // Aerodynamic jump is a vertical effect; the Litz estimator has no horizontal term.
459            horizontal_jump_moa: 0.0,
460            jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
461            magnus_component_moa: 0.0,
462            yaw_component_moa: 0.0,
463            stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
464        })
465    }
466
467    fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
468        // Scale the operative surface wind by the boundary-layer multiplier. `altitude_m` is the
469        // bullet's height relative to the muzzle (McCoy Y). The multiplier is floored at 1.0, so
470        // flat-fire trajectories keep ~full wind and only high-arcing shots see increased wind.
471        //
472        // We build the vector with THIS solver's non-shear sign convention (X=+cos, Z=+sin; see
473        // the `wind_vector` used in solve_rk4/solve_euler) and scale it, so that "shear on" equals
474        // "shear off" * ratio (ratio == 1.0 for flat fire). The previous code both attenuated the
475        // wind near the line of sight and flipped its sign relative to the non-shear path.
476        let model = if self.inputs.wind_shear_model == "logarithmic" {
477            WindShearModel::Logarithmic
478        } else {
479            WindShearModel::PowerLaw // default to power law
480        };
481        let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
482
483        Vector3::new(
484            self.wind.speed * self.wind.direction.cos() * speed_ratio, // X: downrange head/tail
485            0.0,
486            self.wind.speed * self.wind.direction.sin() * speed_ratio, // Z: lateral crosswind
487        )
488    }
489
490    pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
491        let mut result = if self.inputs.use_rk4 {
492            if self.inputs.use_adaptive_rk45 {
493                self.solve_rk45()?
494            } else {
495                self.solve_rk4()?
496            }
497        } else {
498            self.solve_euler()?
499        };
500        self.apply_spin_drift(&mut result);
501        Ok(result)
502    }
503
504    /// Gyroscopic spin drift via the empirical Litz model, applied in the engine
505    /// (not the WASM formatter) so it covers Euler/RK4/RK45 and all consumers.
506    /// Uses the canonical SI fields and converts to grains/inches correctly,
507    /// avoiding the kg/m-vs-grains/in unit bug in `calculate_enhanced_spin_drift`.
508    /// Frame (McCoy): Z = lateral (windage), so drift adds to `position.z`.
509    fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
510        if !self.inputs.use_enhanced_spin_drift {
511            return;
512        }
513        let d_in = self.inputs.bullet_diameter / 0.0254; // m -> in
514        let m_gr = self.inputs.bullet_mass / 0.00006479891; // kg -> grains
515        let twist_in = self.inputs.twist_rate; // inches/turn
516        if d_in <= 0.0 || m_gr <= 0.0 || twist_in <= 0.0 {
517            return;
518        }
519
520        // Real length when available, else 4.5 cal (typical match bullet).
521        let length_in = if self.inputs.bullet_length > 0.0 {
522            self.inputs.bullet_length / 0.0254
523        } else {
524            4.5 * d_in
525        };
526        // MBA-942: apply the canonical Miller atmospheric correction (LINEAR in density ratio,
527        // = rho0/rho via ideal gas: (T/T0)*(P0/P)), matching stability.rs and py_ballisticcalc.
528        // miller_stability returns the bare geometric Sg with no density dependence, so without
529        // this the spin drift under-predicts at altitude (Sg should rise as the air thins). At
530        // standard sea level (15 C, 1013.25 hPa) the factor is exactly 1.0 — a no-op there.
531        let temp_k = self.atmosphere.temperature + 273.15; // Celsius -> Kelvin
532        let press_hpa = self.atmosphere.pressure; // hPa
533        let density_correction = if press_hpa > 0.0 && temp_k > 0.0 {
534            (temp_k / 288.15) * (1013.25 / press_hpa)
535        } else {
536            1.0
537        };
538        let sg =
539            crate::spin_drift::miller_stability(d_in, m_gr, twist_in, length_in) * density_correction;
540        let sign = if self.inputs.is_twist_right { 1.0 } else { -1.0 };
541
542        for p in result.points.iter_mut() {
543            if p.time <= 0.0 {
544                continue;
545            }
546            let sd_in = 1.25 * (sg + 1.2) * p.time.powf(1.83); // Litz drift, inches
547            p.position.z += sign * sd_in * 0.0254; // in -> m, Z = lateral
548        }
549
550        // sampled_points are snapshotted from the PRE-drift trajectory inside each solver, so the
551        // sampled wind_drift_m column would omit the spin drift that result.points carry. Apply
552        // the same Litz drift to keep the two user-facing outputs consistent.
553        if let Some(samples) = result.sampled_points.as_mut() {
554            for s in samples.iter_mut() {
555                if s.time_s <= 0.0 {
556                    continue;
557                }
558                let sd_in = 1.25 * (sg + 1.2) * s.time_s.powf(1.83);
559                s.wind_drift_m += sign * sd_in * 0.0254;
560            }
561        }
562    }
563
564    fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
565        // Simple trajectory integration using Euler method
566        let mut time = 0.0;
567        // Bullet starts at the BORE position, which is muzzle_height above ground
568        // The sight is sight_height ABOVE the bore, so we don't add sight_height here
569        let mut position = Vector3::new(
570            0.0,
571            self.inputs.muzzle_height, // Bore position above ground (NOT + sight_height)
572            0.0,
573        );
574        // Calculate initial velocity components with both elevation and azimuth
575        // McCoy coordinate system: X=downrange, Y=vertical, Z=lateral (right)
576        // Launch direction includes the aerodynamic-jump muzzle perturbation when enabled
577        // (a no-op returning the bare muzzle/azimuth angles otherwise). MBA-959. Computed
578        // once here and reused for the result so it isn't evaluated twice per solve.
579        let aj_components = self.aerodynamic_jump_components();
580        let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
581        let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
582        let mut velocity = Vector3::new(
583            horizontal_velocity * launch_azim.cos(), // X: downrange (forward)
584            self.inputs.muzzle_velocity * launch_elev.sin(), // Y: vertical component
585            horizontal_velocity * launch_azim.sin(), // Z: lateral (side deviation)
586        );
587
588        let mut points = Vec::new();
589        let mut max_height = position.y;
590        let mut min_pitch_damping = 1.0; // Track minimum pitch damping coefficient
591        let mut transonic_mach = None; // Track when we enter transonic
592
593        // Initialize angular state for precession/nutation tracking
594        let mut angular_state = if self.inputs.enable_precession_nutation {
595            Some(AngularState {
596                pitch_angle: 0.001, // Small initial disturbance
597                yaw_angle: 0.001,
598                pitch_rate: 0.0,
599                yaw_rate: 0.0,
600                precession_angle: 0.0,
601                nutation_phase: 0.0,
602            })
603        } else {
604            None
605        };
606        let mut max_yaw_angle = 0.0;
607        let mut max_precession_angle = 0.0;
608
609        // Calculate air density
610        let air_density = calculate_air_density(&self.atmosphere);
611
612        // Wind vector (McCoy): X=downrange (head/tail wind), Y=0, Z=lateral (crosswind)
613        let wind_vector = Vector3::new(
614            self.wind.speed * self.wind.direction.cos(), // X: downrange (head/tail wind)
615            0.0,
616            self.wind.speed * self.wind.direction.sin(), // Z: lateral (crosswind)
617        );
618
619        // Pitch-damping coefficients depend only on the (constant) bullet_model; compute once
620        // instead of re-deriving them (with a to_lowercase alloc) every integration step.
621        let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
622            self.inputs.bullet_model.as_deref().unwrap_or("default"),
623        );
624
625        // Main integration loop (X is downrange)
626        while position.x < self.max_range && position.y > self.inputs.ground_threshold && time < 100.0 {
627            // Store trajectory point
628            let velocity_magnitude = velocity.magnitude();
629            let kinetic_energy =
630                0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
631
632            points.push(TrajectoryPoint {
633                time,
634                position: position,
635                velocity_magnitude,
636                kinetic_energy,
637            });
638
639            // Debug: log first and every 100th point. Debug builds only — this was ungated and
640            // polluted release/WASM stderr on the --use-euler path (the other solvers have none).
641            // McCoy coordinate system: X=downrange, Y=vertical, Z=lateral
642            #[cfg(debug_assertions)]
643            if points.len() == 1 || points.len() % 100 == 0 {
644                eprintln!("Trajectory point {}: time={:.3}s, downrange={:.2}m, vertical={:.2}m, lateral={:.2}m, vel={:.1}m/s",
645                    points.len(), time, position.x, position.y, position.z, velocity_magnitude);
646            }
647
648            // Track max height
649            if position.y > max_height {
650                max_height = position.y;
651            }
652
653            // Calculate pitch damping if enabled
654            if self.inputs.enable_pitch_damping {
655                let temp_c = self.atmosphere.temperature;
656                let temp_k = temp_c + 273.15;
657                let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
658                let mach = velocity_magnitude / speed_of_sound;
659
660                // Track when we enter transonic
661                if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
662                    transonic_mach = Some(mach);
663                }
664
665                // Calculate pitch damping coefficient
666                let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
667
668                // Track minimum (most critical for stability)
669                if pitch_damping < min_pitch_damping {
670                    min_pitch_damping = pitch_damping;
671                }
672            }
673
674            // Calculate precession/nutation if enabled
675            if self.inputs.enable_precession_nutation {
676                if let Some(ref mut state) = angular_state {
677                    let velocity_magnitude = velocity.magnitude();
678                    let temp_c = self.atmosphere.temperature;
679                    let temp_k = temp_c + 273.15;
680                    let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
681                    let mach = velocity_magnitude / speed_of_sound;
682
683                    // Calculate spin rate from twist rate and velocity
684                    let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
685                        let velocity_fps = velocity_magnitude * 3.28084;
686                        let twist_rate_ft = self.inputs.twist_rate / 12.0;
687                        (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
688                    } else {
689                        0.0
690                    };
691
692                    // Create precession/nutation parameters
693                    let params = PrecessionNutationParams {
694                        mass_kg: self.inputs.bullet_mass,
695                        caliber_m: self.inputs.bullet_diameter,
696                        length_m: self.inputs.bullet_length,
697                        spin_rate_rad_s,
698                        spin_inertia: 6.94e-8,       // Typical value
699                        transverse_inertia: 9.13e-7, // Typical value
700                        velocity_mps: velocity_magnitude,
701                        air_density_kg_m3: air_density,
702                        mach,
703                        pitch_damping_coeff: -0.8,
704                        nutation_damping_factor: 0.05,
705                    };
706
707                    // Update angular state
708                    *state = calculate_combined_angular_motion(
709                        &params,
710                        state,
711                        time,
712                        self.time_step,
713                        0.001, // Initial disturbance
714                    );
715
716                    // Track maximums
717                    if state.yaw_angle.abs() > max_yaw_angle {
718                        max_yaw_angle = state.yaw_angle.abs();
719                    }
720                    if state.precession_angle.abs() > max_precession_angle {
721                        max_precession_angle = state.precession_angle.abs();
722                    }
723                }
724            }
725
726            // Use the same acceleration kernel as RK4/RK45 so all three solvers share ONE drag
727            // model. solve_euler previously used a bespoke frontal-area drag (0.5*rho*Cd*A*v^2/m)
728            // that IGNORED the ballistic coefficient entirely (diverging up to ~2.3x from the
729            // BC-retardation RK4/RK45 path), and also omitted the Magnus/Coriolis terms.
730            // calculate_acceleration applies BC-retardation drag, gravity, Coriolis, Magnus, wind
731            // shear, and the zero-relative-velocity gravity-only guard.
732            let acceleration =
733                self.calculate_acceleration(&position, &velocity, air_density, &wind_vector);
734
735            // Update state
736            velocity += acceleration * self.time_step;
737            position += velocity * self.time_step;
738            time += self.time_step;
739        }
740
741        // Get final values
742        let last_point = points.last().ok_or("No trajectory points generated")?;
743
744        // Create trajectory sampling data if enabled
745        let sampled_points = if self.inputs.enable_trajectory_sampling {
746            let trajectory_data = TrajectoryData {
747                times: points.iter().map(|p| p.time).collect(),
748                positions: points.iter().map(|p| p.position).collect(),
749                velocities: points
750                    .iter()
751                    .map(|p| {
752                        // Reconstruct velocity vectors from magnitude (approximate)
753                        Vector3::new(0.0, 0.0, p.velocity_magnitude)
754                    })
755                    .collect(),
756                transonic_distances: Vec::new(), // TODO: Track Mach transitions
757            };
758
759            // For LOS calculation in ground-referenced coordinates:
760            // sight_position_m is the sight's actual y-position above ground
761            // (muzzle_height + sight_height, not just sight_height)
762            // For flat shots, target is at same height as the sight (horizontal LOS)
763            let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
764            let outputs = TrajectoryOutputs {
765                target_distance_horiz_m: last_point.position.x, // X is downrange
766                target_vertical_height_m: sight_position_m,
767                time_of_flight_s: last_point.time,
768                max_ord_dist_horiz_m: max_height,
769                sight_height_m: sight_position_m,
770            };
771
772            // Sample at specified intervals
773            let samples = sample_trajectory(
774                &trajectory_data,
775                &outputs,
776                self.inputs.sample_interval,
777                self.inputs.bullet_mass,
778            );
779            Some(samples)
780        } else {
781            None
782        };
783
784        Ok(TrajectoryResult {
785            max_range: last_point.position.x, // X is downrange
786            max_height,
787            time_of_flight: last_point.time,
788            impact_velocity: last_point.velocity_magnitude,
789            impact_energy: last_point.kinetic_energy,
790            points,
791            sampled_points,
792            min_pitch_damping: if self.inputs.enable_pitch_damping {
793                Some(min_pitch_damping)
794            } else {
795                None
796            },
797            transonic_mach,
798            angular_state,
799            max_yaw_angle: if self.inputs.enable_precession_nutation {
800                Some(max_yaw_angle)
801            } else {
802                None
803            },
804            max_precession_angle: if self.inputs.enable_precession_nutation {
805                Some(max_precession_angle)
806            } else {
807                None
808            },
809            aerodynamic_jump: aj_components,
810        })
811    }
812
813    fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
814        // RK4 trajectory integration for better accuracy
815        let mut time = 0.0;
816        // Bullet starts at the BORE position, which is muzzle_height above ground
817        // The sight is sight_height ABOVE the bore, so we don't add sight_height here
818        // The sight_height affects the LOS calculation and zero angle, not the starting position
819        let mut position = Vector3::new(
820            0.0,
821            self.inputs.muzzle_height, // Bore position above ground (NOT + sight_height)
822            0.0,
823        );
824
825        // Calculate initial velocity components with both elevation and azimuth
826        // McCoy coordinate system: X=downrange, Y=vertical, Z=lateral (right)
827        // Launch direction includes the aerodynamic-jump muzzle perturbation when enabled
828        // (a no-op returning the bare muzzle/azimuth angles otherwise). MBA-959. Computed
829        // once here and reused for the result so it isn't evaluated twice per solve.
830        let aj_components = self.aerodynamic_jump_components();
831        let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
832        let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
833        let mut velocity = Vector3::new(
834            horizontal_velocity * launch_azim.cos(), // X: downrange (forward)
835            self.inputs.muzzle_velocity * launch_elev.sin(), // Y: vertical component
836            horizontal_velocity * launch_azim.sin(), // Z: lateral (side deviation)
837        );
838
839        let mut points = Vec::new();
840        let mut max_height = position.y;
841        let mut min_pitch_damping = 1.0; // Track minimum pitch damping coefficient
842        let mut transonic_mach = None; // Track when we enter transonic
843
844        // Initialize angular state for precession/nutation tracking
845        let mut angular_state = if self.inputs.enable_precession_nutation {
846            Some(AngularState {
847                pitch_angle: 0.001, // Small initial disturbance
848                yaw_angle: 0.001,
849                pitch_rate: 0.0,
850                yaw_rate: 0.0,
851                precession_angle: 0.0,
852                nutation_phase: 0.0,
853            })
854        } else {
855            None
856        };
857        let mut max_yaw_angle = 0.0;
858        let mut max_precession_angle = 0.0;
859
860        // Calculate air density
861        let air_density = calculate_air_density(&self.atmosphere);
862
863        // Wind vector (McCoy): X=downrange (head/tail wind), Y=0, Z=lateral (crosswind)
864        let wind_vector = Vector3::new(
865            self.wind.speed * self.wind.direction.cos(), // X: downrange (head/tail wind)
866            0.0,
867            self.wind.speed * self.wind.direction.sin(), // Z: lateral (crosswind)
868        );
869
870        // Pitch-damping coefficients depend only on the (constant) bullet_model; compute once
871        // instead of re-deriving them (with a to_lowercase alloc) every integration step.
872        let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
873            self.inputs.bullet_model.as_deref().unwrap_or("default"),
874        );
875
876        // Main RK4 integration loop (X is downrange)
877        while position.x < self.max_range && position.y > self.inputs.ground_threshold && time < 100.0 {
878            // Store trajectory point
879            let velocity_magnitude = velocity.magnitude();
880            let kinetic_energy =
881                0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
882
883            points.push(TrajectoryPoint {
884                time,
885                position: position,
886                velocity_magnitude,
887                kinetic_energy,
888            });
889
890            if position.y > max_height {
891                max_height = position.y;
892            }
893
894            // Calculate pitch damping if enabled (RK4 solver)
895            if self.inputs.enable_pitch_damping {
896                let temp_c = self.atmosphere.temperature;
897                let temp_k = temp_c + 273.15;
898                let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
899                let mach = velocity_magnitude / speed_of_sound;
900
901                // Track when we enter transonic
902                if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
903                    transonic_mach = Some(mach);
904                }
905
906                // Calculate pitch damping coefficient
907                let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
908
909                // Track minimum (most critical for stability)
910                if pitch_damping < min_pitch_damping {
911                    min_pitch_damping = pitch_damping;
912                }
913            }
914
915            // Calculate precession/nutation if enabled (RK4 solver)
916            if self.inputs.enable_precession_nutation {
917                if let Some(ref mut state) = angular_state {
918                    let velocity_magnitude = velocity.magnitude();
919                    let temp_c = self.atmosphere.temperature;
920                    let temp_k = temp_c + 273.15;
921                    let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
922                    let mach = velocity_magnitude / speed_of_sound;
923
924                    // Calculate spin rate from twist rate and velocity
925                    let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
926                        let velocity_fps = velocity_magnitude * 3.28084;
927                        let twist_rate_ft = self.inputs.twist_rate / 12.0;
928                        (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
929                    } else {
930                        0.0
931                    };
932
933                    // Create precession/nutation parameters
934                    let params = PrecessionNutationParams {
935                        mass_kg: self.inputs.bullet_mass,
936                        caliber_m: self.inputs.bullet_diameter,
937                        length_m: self.inputs.bullet_length,
938                        spin_rate_rad_s,
939                        spin_inertia: 6.94e-8,       // Typical value
940                        transverse_inertia: 9.13e-7, // Typical value
941                        velocity_mps: velocity_magnitude,
942                        air_density_kg_m3: air_density,
943                        mach,
944                        pitch_damping_coeff: -0.8,
945                        nutation_damping_factor: 0.05,
946                    };
947
948                    // Update angular state
949                    *state = calculate_combined_angular_motion(
950                        &params,
951                        state,
952                        time,
953                        self.time_step,
954                        0.001, // Initial disturbance
955                    );
956
957                    // Track maximums
958                    if state.yaw_angle.abs() > max_yaw_angle {
959                        max_yaw_angle = state.yaw_angle.abs();
960                    }
961                    if state.precession_angle.abs() > max_precession_angle {
962                        max_precession_angle = state.precession_angle.abs();
963                    }
964                }
965            }
966
967            // RK4 method
968            let dt = self.time_step;
969
970            // k1
971            let acc1 = self.calculate_acceleration(&position, &velocity, air_density, &wind_vector);
972
973            // k2
974            let pos2 = position + velocity * (dt * 0.5);
975            let vel2 = velocity + acc1 * (dt * 0.5);
976            let acc2 = self.calculate_acceleration(&pos2, &vel2, air_density, &wind_vector);
977
978            // k3
979            let pos3 = position + vel2 * (dt * 0.5);
980            let vel3 = velocity + acc2 * (dt * 0.5);
981            let acc3 = self.calculate_acceleration(&pos3, &vel3, air_density, &wind_vector);
982
983            // k4
984            let pos4 = position + vel3 * dt;
985            let vel4 = velocity + acc3 * dt;
986            let acc4 = self.calculate_acceleration(&pos4, &vel4, air_density, &wind_vector);
987
988            // Update position and velocity
989            position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
990            velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
991            time += dt;
992        }
993
994        // Get final values
995        let last_point = points.last().ok_or("No trajectory points generated")?;
996
997        // Create trajectory sampling data if enabled
998        let sampled_points = if self.inputs.enable_trajectory_sampling {
999            let trajectory_data = TrajectoryData {
1000                times: points.iter().map(|p| p.time).collect(),
1001                positions: points.iter().map(|p| p.position).collect(),
1002                velocities: points
1003                    .iter()
1004                    .map(|p| {
1005                        // Reconstruct velocity vectors from magnitude (approximate)
1006                        Vector3::new(0.0, 0.0, p.velocity_magnitude)
1007                    })
1008                    .collect(),
1009                transonic_distances: Vec::new(), // TODO: Track Mach transitions
1010            };
1011
1012            // For LOS calculation in ground-referenced coordinates:
1013            // sight_position_m is the sight's actual y-position above ground
1014            // (muzzle_height + sight_height, not just sight_height)
1015            // For flat shots, target is at same height as the sight (horizontal LOS)
1016            let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1017            let outputs = TrajectoryOutputs {
1018                target_distance_horiz_m: last_point.position.x, // X is downrange
1019                target_vertical_height_m: sight_position_m,
1020                time_of_flight_s: last_point.time,
1021                max_ord_dist_horiz_m: max_height,
1022                sight_height_m: sight_position_m,
1023            };
1024
1025            // Sample at specified intervals
1026            let samples = sample_trajectory(
1027                &trajectory_data,
1028                &outputs,
1029                self.inputs.sample_interval,
1030                self.inputs.bullet_mass,
1031            );
1032            Some(samples)
1033        } else {
1034            None
1035        };
1036
1037        Ok(TrajectoryResult {
1038            max_range: last_point.position.x, // X is downrange
1039            max_height,
1040            time_of_flight: last_point.time,
1041            impact_velocity: last_point.velocity_magnitude,
1042            impact_energy: last_point.kinetic_energy,
1043            points,
1044            sampled_points,
1045            min_pitch_damping: if self.inputs.enable_pitch_damping {
1046                Some(min_pitch_damping)
1047            } else {
1048                None
1049            },
1050            transonic_mach,
1051            angular_state,
1052            max_yaw_angle: if self.inputs.enable_precession_nutation {
1053                Some(max_yaw_angle)
1054            } else {
1055                None
1056            },
1057            max_precession_angle: if self.inputs.enable_precession_nutation {
1058                Some(max_precession_angle)
1059            } else {
1060                None
1061            },
1062            aerodynamic_jump: aj_components,
1063        })
1064    }
1065
1066    fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
1067        // RK45 adaptive step size integration (Dormand-Prince method)
1068        let mut time = 0.0;
1069        // Bullet starts at the BORE position, which is muzzle_height above ground
1070        // The sight is sight_height ABOVE the bore, so we don't add sight_height here
1071        let mut position = Vector3::new(
1072            0.0,
1073            self.inputs.muzzle_height, // Bore position above ground (NOT + sight_height)
1074            0.0,
1075        );
1076
1077        // Calculate initial velocity components
1078        // McCoy coordinate system: X=downrange, Y=vertical, Z=lateral (right)
1079        // Launch direction includes the aerodynamic-jump muzzle perturbation when enabled
1080        // (a no-op returning the bare muzzle/azimuth angles otherwise). MBA-959. Computed
1081        // once here and reused for the result so it isn't evaluated twice per solve.
1082        let aj_components = self.aerodynamic_jump_components();
1083        let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1084        let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1085        let mut velocity = Vector3::new(
1086            horizontal_velocity * launch_azim.cos(), // X: downrange (forward)
1087            self.inputs.muzzle_velocity * launch_elev.sin(), // Y: vertical component
1088            horizontal_velocity * launch_azim.sin(), // Z: lateral (side deviation)
1089        );
1090
1091        let mut points = Vec::new();
1092        let mut max_height = position.y;
1093        let mut dt = 0.001; // Initial step size
1094        let tolerance = 1e-6; // Error tolerance
1095        let safety_factor = 0.9; // Safety factor for step size adjustment
1096        let max_dt = 0.01; // Maximum step size
1097        let min_dt = 1e-6; // Minimum step size
1098
1099        // Add a point counter to debug
1100        let mut iteration_count = 0;
1101        const MAX_ITERATIONS: usize = 100000;
1102
1103        // Air density and wind are constant for the whole solve (self.atmosphere / self.wind
1104        // are immutable); compute once instead of every iteration (mirrors solve_rk4).
1105        let air_density = calculate_air_density(&self.atmosphere);
1106        let wind_vector = Vector3::new(
1107            self.wind.speed * self.wind.direction.cos(), // X: downrange (head/tail wind)
1108            0.0,
1109            self.wind.speed * self.wind.direction.sin(), // Z: lateral (crosswind)
1110        );
1111
1112        while position.x < self.max_range
1113            && position.y > self.inputs.ground_threshold
1114            && time < 100.0
1115        {
1116            // X is downrange
1117            iteration_count += 1;
1118            if iteration_count > MAX_ITERATIONS {
1119                break; // Prevent infinite loop
1120            }
1121
1122            // Store current point
1123            let velocity_magnitude = velocity.magnitude();
1124            let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
1125
1126            points.push(TrajectoryPoint {
1127                time,
1128                position: position,
1129                velocity_magnitude,
1130                kinetic_energy,
1131            });
1132
1133            if position.y > max_height {
1134                max_height = position.y;
1135            }
1136
1137            // RK45 step with adaptive step size (air_density / wind_vector hoisted above)
1138            let (new_pos, new_vel, new_dt) = self.rk45_step(
1139                &position,
1140                &velocity,
1141                dt,
1142                air_density,
1143                &wind_vector,
1144                tolerance,
1145            );
1146
1147            // Advance state and time by the dt actually used for THIS step. (Previously dt
1148            // was overwritten with the adapted next-step size BEFORE `time += dt`, so every
1149            // reported time advanced by the NEXT step's dt — desyncing time from state and
1150            // corrupting time_of_flight and per-point / sampled times.)
1151            position = new_pos;
1152            velocity = new_vel;
1153            time += dt;
1154
1155            // Adapt the step size for the NEXT iteration.
1156            dt = (safety_factor * new_dt).clamp(min_dt, max_dt);
1157        }
1158
1159        // Ensure we have at least one point
1160        if points.is_empty() {
1161            return Err(BallisticsError::from("No trajectory points calculated"));
1162        }
1163
1164        let last_point = points.last().unwrap();
1165
1166        // Generate sampled trajectory points if enabled
1167        let sampled_points = if self.inputs.enable_trajectory_sampling {
1168            // Build trajectory data for sampling
1169            let trajectory_data = TrajectoryData {
1170                times: points.iter().map(|p| p.time).collect(),
1171                positions: points.iter().map(|p| p.position).collect(),
1172                velocities: points
1173                    .iter()
1174                    .map(|p| {
1175                        // Approximate velocity direction from position changes
1176                        Vector3::new(0.0, 0.0, p.velocity_magnitude)
1177                    })
1178                    .collect(),
1179                transonic_distances: Vec::new(),
1180            };
1181
1182            // For LOS calculation in ground-referenced coordinates:
1183            // sight_position_m is the sight's actual y-position above ground
1184            // (muzzle_height + sight_height, not just sight_height)
1185            // For flat shots, target is at same height as the sight (horizontal LOS)
1186            let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1187            let outputs = TrajectoryOutputs {
1188                target_distance_horiz_m: last_point.position.x,
1189                target_vertical_height_m: sight_position_m,
1190                time_of_flight_s: last_point.time,
1191                max_ord_dist_horiz_m: max_height,
1192                sight_height_m: sight_position_m,
1193            };
1194
1195            let samples = sample_trajectory(
1196                &trajectory_data,
1197                &outputs,
1198                self.inputs.sample_interval,
1199                self.inputs.bullet_mass,
1200            );
1201            Some(samples)
1202        } else {
1203            None
1204        };
1205
1206        Ok(TrajectoryResult {
1207            max_range: last_point.position.x, // X is downrange
1208            max_height,
1209            time_of_flight: last_point.time,
1210            impact_velocity: last_point.velocity_magnitude,
1211            impact_energy: last_point.kinetic_energy,
1212            points,
1213            sampled_points,
1214            min_pitch_damping: None,
1215            transonic_mach: None,
1216            angular_state: None,
1217            max_yaw_angle: None,
1218            max_precession_angle: None,
1219            aerodynamic_jump: aj_components,
1220        })
1221    }
1222
1223    fn rk45_step(
1224        &self,
1225        position: &Vector3<f64>,
1226        velocity: &Vector3<f64>,
1227        dt: f64,
1228        air_density: f64,
1229        wind_vector: &Vector3<f64>,
1230        tolerance: f64,
1231    ) -> (Vector3<f64>, Vector3<f64>, f64) {
1232        // Dormand-Prince coefficients
1233        const A21: f64 = 1.0 / 5.0;
1234        const A31: f64 = 3.0 / 40.0;
1235        const A32: f64 = 9.0 / 40.0;
1236        const A41: f64 = 44.0 / 45.0;
1237        const A42: f64 = -56.0 / 15.0;
1238        const A43: f64 = 32.0 / 9.0;
1239        const A51: f64 = 19372.0 / 6561.0;
1240        const A52: f64 = -25360.0 / 2187.0;
1241        const A53: f64 = 64448.0 / 6561.0;
1242        const A54: f64 = -212.0 / 729.0;
1243        const A61: f64 = 9017.0 / 3168.0;
1244        const A62: f64 = -355.0 / 33.0;
1245        const A63: f64 = 46732.0 / 5247.0;
1246        const A64: f64 = 49.0 / 176.0;
1247        const A65: f64 = -5103.0 / 18656.0;
1248        const A71: f64 = 35.0 / 384.0;
1249        const A73: f64 = 500.0 / 1113.0;
1250        const A74: f64 = 125.0 / 192.0;
1251        const A75: f64 = -2187.0 / 6784.0;
1252        const A76: f64 = 11.0 / 84.0;
1253
1254        // 5th order coefficients
1255        const B1: f64 = 35.0 / 384.0;
1256        const B3: f64 = 500.0 / 1113.0;
1257        const B4: f64 = 125.0 / 192.0;
1258        const B5: f64 = -2187.0 / 6784.0;
1259        const B6: f64 = 11.0 / 84.0;
1260
1261        // 4th order coefficients for error estimation
1262        const B1_ERR: f64 = 5179.0 / 57600.0;
1263        const B3_ERR: f64 = 7571.0 / 16695.0;
1264        const B4_ERR: f64 = 393.0 / 640.0;
1265        const B5_ERR: f64 = -92097.0 / 339200.0;
1266        const B6_ERR: f64 = 187.0 / 2100.0;
1267        const B7_ERR: f64 = 1.0 / 40.0;
1268
1269        // Compute RK45 stages
1270        let k1_v = self.calculate_acceleration(position, velocity, air_density, wind_vector);
1271        let k1_p = *velocity;
1272
1273        let p2 = position + dt * A21 * k1_p;
1274        let v2 = velocity + dt * A21 * k1_v;
1275        let k2_v = self.calculate_acceleration(&p2, &v2, air_density, wind_vector);
1276        let k2_p = v2;
1277
1278        let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
1279        let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
1280        let k3_v = self.calculate_acceleration(&p3, &v3, air_density, wind_vector);
1281        let k3_p = v3;
1282
1283        let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
1284        let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
1285        let k4_v = self.calculate_acceleration(&p4, &v4, air_density, wind_vector);
1286        let k4_p = v4;
1287
1288        let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
1289        let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
1290        let k5_v = self.calculate_acceleration(&p5, &v5, air_density, wind_vector);
1291        let k5_p = v5;
1292
1293        let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
1294        let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
1295        let k6_v = self.calculate_acceleration(&p6, &v6, air_density, wind_vector);
1296        let k6_p = v6;
1297
1298        let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
1299        let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
1300        let k7_v = self.calculate_acceleration(&p7, &v7, air_density, wind_vector);
1301        let k7_p = v7;
1302
1303        // 5th order solution
1304        let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
1305        let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
1306
1307        // 4th order solution for error estimate
1308        let pos_err = position
1309            + dt * (B1_ERR * k1_p
1310                + B3_ERR * k3_p
1311                + B4_ERR * k4_p
1312                + B5_ERR * k5_p
1313                + B6_ERR * k6_p
1314                + B7_ERR * k7_p);
1315        let vel_err = velocity
1316            + dt * (B1_ERR * k1_v
1317                + B3_ERR * k3_v
1318                + B4_ERR * k4_v
1319                + B5_ERR * k5_v
1320                + B6_ERR * k6_v
1321                + B7_ERR * k7_v);
1322
1323        // Estimate error
1324        let pos_error = (new_pos - pos_err).magnitude();
1325        let vel_error = (new_vel - vel_err).magnitude();
1326        let error = (pos_error + vel_error) / (1.0 + position.magnitude() + velocity.magnitude());
1327
1328        // Calculate new step size
1329        let dt_new = if error < tolerance {
1330            dt * (tolerance / error).powf(0.2).min(2.0)
1331        } else {
1332            dt * (tolerance / error).powf(0.25).max(0.1)
1333        };
1334
1335        (new_pos, new_vel, dt_new)
1336    }
1337
1338    fn calculate_acceleration(
1339        &self,
1340        position: &Vector3<f64>,
1341        velocity: &Vector3<f64>,
1342        air_density: f64,
1343        wind_vector: &Vector3<f64>,
1344    ) -> Vector3<f64> {
1345        // Calculate altitude-dependent wind if wind shear is enabled
1346        let actual_wind = if self.inputs.enable_wind_shear {
1347            self.get_wind_at_altitude(position.y)
1348        } else {
1349            *wind_vector
1350        };
1351
1352        let relative_velocity = velocity - actual_wind;
1353        let velocity_magnitude = relative_velocity.magnitude();
1354
1355        if velocity_magnitude < 0.001 {
1356            return Vector3::new(0.0, -crate::constants::G_ACCEL_MPS2, 0.0);
1357        }
1358
1359        // Get drag coefficient from drag model (Mach-indexed from drag tables)
1360        let cd = self.calculate_drag_coefficient(velocity_magnitude);
1361
1362        // Convert velocity to fps for BC lookups
1363        let velocity_fps = velocity_magnitude * 3.28084;
1364
1365        // Look up BC from segments if available (highest priority - most accurate)
1366        let base_bc = if let Some(ref segments) = self.inputs.bc_segments_data {
1367            // Find matching segment for current velocity
1368            segments
1369                .iter()
1370                .find(|seg| velocity_fps >= seg.velocity_min && velocity_fps < seg.velocity_max)
1371                .map(|seg| seg.bc_value)
1372                .unwrap_or(self.inputs.bc_value)
1373        } else {
1374            self.inputs.bc_value
1375        };
1376
1377        // Apply cluster BC correction if enabled (on top of segment BC)
1378        let effective_bc = if let Some(ref cluster_bc) = self.cluster_bc {
1379            cluster_bc.apply_correction(
1380                base_bc,
1381                self.inputs.caliber_inches, // predict_cluster normalizes against an inches range
1382                self.inputs.weight_grains,
1383                velocity_fps,
1384            )
1385        } else {
1386            base_bc
1387        };
1388        // Guard bc_value == 0 (allowed on the FFI/WASM surfaces, which lack the CLI's 0.001
1389        // lower bound): dividing by effective_bc below would be Inf -> NaN. Inert for valid
1390        // BCs (>= 0.001).
1391        let effective_bc = effective_bc.max(1e-6);
1392
1393        // Use proper ballistics retardation formula
1394        // This matches the proven formula from fast_trajectory.rs
1395        // The standard retardation factor converts Cd to drag deceleration
1396        // Note: velocity_fps already calculated above for BC segment lookup
1397        let cd_to_retard = 0.000683 * 0.30; // Standard ballistics constant
1398        let standard_factor = cd * cd_to_retard;
1399        let density_scale = air_density / 1.225; // Scale relative to standard air (1.225 kg/m³)
1400
1401        // Drag acceleration in ft/s² then convert to m/s²
1402        let a_drag_ft_s2 =
1403            (velocity_fps * velocity_fps) * standard_factor * density_scale / effective_bc;
1404        let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; // ft/s² to m/s²
1405
1406        // Apply drag opposite to velocity direction
1407        let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
1408
1409        // Total acceleration = drag + gravity
1410        let mut accel = drag_acceleration + Vector3::new(0.0, -crate::constants::G_ACCEL_MPS2, 0.0);
1411
1412        // Coriolis (Earth rotation). McCoy frame: X=downrange, Y=vertical, Z=lateral,
1413        // azimuth 0 = North. McCoy frame: X=downrange, Y=vertical, Z=lateral.
1414        if self.inputs.enable_coriolis {
1415            if let Some(lat_deg) = self.inputs.latitude {
1416                let omega_earth = 7.2921159e-5_f64; // rad/s
1417                let lat = lat_deg.to_radians();
1418                let az = self.inputs.azimuth_angle;
1419                // Earth's angular velocity in the shot frame (X=downrange, Y=up,
1420                // Z=lateral). Projecting Omega=(0, Ω cosφ, Ω sinφ) [local E,N,U] onto
1421                // the azimuth-rotated shot axes gives a NEGATIVE lateral component:
1422                // lateral = downrange × up points East for a North shot, and
1423                // Omega·East = -Ω cosφ sin(az). The previous code dropped that sign.
1424                let omega = Vector3::new(
1425                    omega_earth * lat.cos() * az.cos(),  // X: downrange
1426                    omega_earth * lat.sin(),             // Y: vertical
1427                    -omega_earth * lat.cos() * az.sin(), // Z: lateral (MBA-938: corrected sign)
1428                );
1429                // Coriolis acceleration is the physical -2 Ω×v (MBA-938). The old +2 with
1430                // an "output-preserving relabel" justification produced left-ward drift for
1431                // a North shot in the Northern hemisphere; first principles (and the +Eötvös
1432                // lift for East shots) require -2 with the corrected omega above.
1433                accel += -2.0 * omega.cross(velocity);
1434            }
1435        }
1436
1437        // Magnus side force (spinning projectile). SI units in this solver.
1438        if self.inputs.enable_magnus
1439            && self.inputs.bullet_diameter > 0.0
1440            && self.inputs.twist_rate > 0.0
1441        {
1442            let (_, spin_rad_s) =
1443                crate::spin_drift::calculate_spin_rate(velocity_magnitude, self.inputs.twist_rate);
1444            let temp_k = self.atmosphere.temperature + 273.15;
1445            let speed_of_sound = (1.4 * 287.05 * temp_k).sqrt();
1446            let mach = velocity_magnitude / speed_of_sound;
1447
1448            // Imperial conversions for the stability / yaw-of-repose helpers.
1449            let d_in = self.inputs.bullet_diameter / 0.0254;
1450            let m_gr = self.inputs.bullet_mass / 0.00006479891;
1451            let l_in = if self.inputs.bullet_length > 0.0 {
1452                self.inputs.bullet_length / 0.0254
1453            } else {
1454                4.5 * d_in
1455            };
1456            // MBA-958: apply the canonical linear Miller density correction (T/T0)*(P0/P) to the
1457            // Magnus/yaw-of-repose Sg too, matching the spin-drift Sg (MBA-942) and stability.rs.
1458            // No-op at sea-level standard (15 C, 1013.25 hPa -> factor 1.0).
1459            let press_hpa = self.atmosphere.pressure;
1460            let density_correction = if press_hpa > 0.0 && temp_k > 0.0 {
1461                (temp_k / 288.15) * (1013.25 / press_hpa)
1462            } else {
1463                1.0
1464            };
1465            let sg = crate::spin_drift::miller_stability(d_in, m_gr, self.inputs.twist_rate, l_in)
1466                * density_correction;
1467
1468            // Yaw of repose (radians); zero for unstable bullets (Sg <= 1).
1469            let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
1470                sg,
1471                velocity_magnitude,
1472                spin_rad_s,
1473                0.0, // crosswind handled elsewhere
1474                0.0, // pitch rate not tracked
1475                air_density,
1476                d_in,
1477                l_in,
1478                m_gr,
1479                mach,
1480                "match",
1481                false,
1482            );
1483
1484            // Proper McCoy Magnus FORCE: F = q S C_Npa (pd/2V) sin(alpha_R).
1485            let diameter_m = self.inputs.bullet_diameter; // already meters
1486            let spin_param = spin_rad_s * diameter_m / (2.0 * velocity_magnitude);
1487            let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
1488            let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
1489            let magnus_force = 0.5
1490                * air_density
1491                * velocity_magnitude.powi(2)
1492                * area
1493                * c_np
1494                * spin_param
1495                * yaw_rad.sin();
1496
1497            // Horizontal direction perpendicular to velocity. In McCoy (RH) frame,
1498            // v_unit × up = +Z (right) for a downrange shot, matching spin-drift sign.
1499            let velocity_unit = relative_velocity / velocity_magnitude;
1500            let up = Vector3::new(0.0, 1.0, 0.0);
1501            let mut dir = velocity_unit.cross(&up);
1502            let dir_norm = dir.norm();
1503            if dir_norm > 1e-12 && magnus_force.abs() > 1e-12 {
1504                dir /= dir_norm;
1505                if !self.inputs.is_twist_right {
1506                    dir = -dir;
1507                }
1508                accel += (magnus_force / self.inputs.bullet_mass) * dir;
1509            }
1510        }
1511
1512        accel
1513    }
1514
1515    fn calculate_drag_coefficient(&self, velocity: f64) -> f64 {
1516        // Calculate speed of sound based on atmospheric temperature
1517        let temp_c = self.atmosphere.temperature;
1518        let temp_k = temp_c + 273.15;
1519        let gamma = 1.4; // Ratio of specific heats for air
1520        let r_specific = 287.05; // Specific gas constant for air (J/kg·K)
1521        let speed_of_sound = (gamma * r_specific * temp_k).sqrt();
1522        let mach = velocity / speed_of_sound;
1523
1524        // MBA-940: a user-supplied custom drag table is the final Cd, used as-is — no G-model
1525        // lookup, no transonic shape correction, no form factor. The supplied curve already
1526        // encodes the projectile's true drag, so applying those would distort/double-count it.
1527        if let Some(ref table) = self.inputs.custom_drag_table {
1528            return table.interpolate(mach);
1529        }
1530
1531        // Get drag coefficient from the drag tables (Mach-indexed)
1532        let base_cd = crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type);
1533
1534        // Borrowed &'static str for the drag-model name. bc_type.to_string() goes through
1535        // Debug and heap-allocates a String on every call; this match is bit-identical
1536        // (Display == Debug == variant name) with no per-step allocation.
1537        let bc_type_str: &str = match self.inputs.bc_type {
1538            crate::DragModel::G1 => "G1",
1539            crate::DragModel::G2 => "G2",
1540            crate::DragModel::G5 => "G5",
1541            crate::DragModel::G6 => "G6",
1542            crate::DragModel::G7 => "G7",
1543            crate::DragModel::G8 => "G8",
1544            crate::DragModel::GI => "GI",
1545            crate::DragModel::GS => "GS",
1546        };
1547
1548        // Determine projectile shape for transonic corrections (MBA-949: shared resolver so
1549        // derivatives/fast_trajectory honor named shapes too; caliber in INCHES, weight in grains).
1550        let projectile_shape = crate::transonic_drag::resolve_projectile_shape(
1551            self.inputs.bullet_model.as_deref(),
1552            self.inputs.caliber_inches,
1553            self.inputs.bullet_mass / 0.00006479891, // kg -> grains
1554            bc_type_str,
1555        );
1556
1557        // Apply transonic corrections
1558        // Note: Wave drag is disabled because G7/G1 drag functions already include
1559        // transonic effects. Adding wave drag on top would double-count the drag rise.
1560        // Wave drag should only be enabled for custom drag functions that don't
1561        // include transonic behavior.
1562        let include_wave_drag = false;
1563        let cd = transonic_correction(mach, base_cd, projectile_shape, include_wave_drag);
1564
1565        // MBA-948: honor use_form_factor here too — previously only derivatives.rs applied it,
1566        // so cli_api and fast_trajectory silently ignored the flag. apply_form_factor_to_drag
1567        // short-circuits when the flag is false, so this is a no-op for every current consumer
1568        // (the flag is false on all CLI/FFI/WASM/binding surfaces and defaults false).
1569        crate::form_factor::apply_form_factor_to_drag(
1570            cd,
1571            self.inputs.bullet_model.as_deref(),
1572            &self.inputs.bc_type,
1573            self.inputs.use_form_factor,
1574        )
1575    }
1576}
1577
1578// Monte Carlo parameters
1579#[derive(Debug, Clone)]
1580pub struct MonteCarloParams {
1581    pub num_simulations: usize,
1582    pub velocity_std_dev: f64,
1583    pub angle_std_dev: f64,
1584    pub bc_std_dev: f64,
1585    pub wind_speed_std_dev: f64,
1586    pub target_distance: Option<f64>,
1587    pub base_wind_speed: f64,
1588    pub base_wind_direction: f64,
1589    pub azimuth_std_dev: f64, // Horizontal aiming variation in radians
1590}
1591
1592impl Default for MonteCarloParams {
1593    fn default() -> Self {
1594        Self {
1595            num_simulations: 1000,
1596            velocity_std_dev: 1.0,
1597            angle_std_dev: 0.001,
1598            bc_std_dev: 0.01,
1599            wind_speed_std_dev: 1.0,
1600            target_distance: None,
1601            base_wind_speed: 0.0,
1602            base_wind_direction: 0.0,
1603            azimuth_std_dev: 0.001, // Default horizontal spread ~0.057 degrees
1604        }
1605    }
1606}
1607
1608// Monte Carlo results
1609#[derive(Debug, Clone)]
1610pub struct MonteCarloResults {
1611    pub ranges: Vec<f64>,
1612    pub impact_velocities: Vec<f64>,
1613    pub impact_positions: Vec<Vector3<f64>>,
1614}
1615
1616// Run Monte Carlo simulation (backwards compatibility)
1617pub fn run_monte_carlo(
1618    base_inputs: BallisticInputs,
1619    params: MonteCarloParams,
1620) -> Result<MonteCarloResults, BallisticsError> {
1621    let base_wind = WindConditions {
1622        speed: params.base_wind_speed,
1623        direction: params.base_wind_direction,
1624    };
1625    run_monte_carlo_with_wind(base_inputs, base_wind, params)
1626}
1627
1628// Run Monte Carlo simulation with wind
1629pub fn run_monte_carlo_with_wind(
1630    base_inputs: BallisticInputs,
1631    base_wind: WindConditions,
1632    params: MonteCarloParams,
1633) -> Result<MonteCarloResults, BallisticsError> {
1634    use rand::thread_rng;
1635    use rand_distr::{Distribution, Normal};
1636
1637    let mut rng = thread_rng();
1638    let mut ranges = Vec::new();
1639    let mut impact_velocities = Vec::new();
1640    let mut impact_positions = Vec::new();
1641
1642    // First, calculate baseline trajectory with no variations
1643    let baseline_solver =
1644        TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), Default::default());
1645    let baseline_result = baseline_solver.solve()?;
1646
1647    // Determine target distance: use explicit target or baseline max range
1648    let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
1649
1650    // Get baseline position at target distance (interpolated)
1651    let baseline_at_target = baseline_result
1652        .position_at_range(target_distance)
1653        .ok_or("Could not interpolate baseline at target distance")?;
1654
1655    // Create normal distributions for variations
1656    let velocity_dist = Normal::new(base_inputs.muzzle_velocity, params.velocity_std_dev)
1657        .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
1658    let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
1659        .map_err(|e| format!("Invalid angle distribution: {}", e))?;
1660    let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
1661        .map_err(|e| format!("Invalid BC distribution: {}", e))?;
1662    let wind_speed_dist = Normal::new(base_wind.speed, params.wind_speed_std_dev)
1663        .map_err(|e| format!("Invalid wind speed distribution: {}", e))?;
1664    // MBA-952: wind-direction spread is APPROXIMATED from the wind-SPEED std dev (×0.1), a unit
1665    // conflation (m/s scaled as radians) — there is no dedicated wind_direction_std_dev field yet.
1666    // The dead WASM `--wind-dir-std` setter was removed (it set nothing). A proper fix is an
1667    // API-breaking wind_direction_std_dev on MonteCarloParams plumbed through WASM/FFI/main.
1668    let wind_dir_dist =
1669        Normal::new(base_wind.direction, params.wind_speed_std_dev * 0.1)
1670            .map_err(|e| format!("Invalid wind direction distribution: {}", e))?;
1671    let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
1672        .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
1673
1674    // Create distribution for pointing errors (simulates shooter's aiming consistency)
1675    let pointing_error_dist = Normal::new(0.0, params.angle_std_dev * target_distance)
1676        .map_err(|e| format!("Invalid pointing distribution: {}", e))?;
1677
1678    for _ in 0..params.num_simulations {
1679        // Create varied inputs
1680        let mut inputs = base_inputs.clone();
1681        inputs.muzzle_velocity = velocity_dist.sample(&mut rng).max(0.0);
1682        inputs.muzzle_angle = angle_dist.sample(&mut rng);
1683        inputs.bc_value = bc_dist.sample(&mut rng).max(0.01);
1684        inputs.azimuth_angle = azimuth_dist.sample(&mut rng); // Add horizontal variation
1685
1686        // Create varied wind (now based on base wind conditions)
1687        let wind = WindConditions {
1688            speed: wind_speed_dist.sample(&mut rng).abs(),
1689            direction: wind_dir_dist.sample(&mut rng),
1690        };
1691
1692        // Run trajectory
1693        let solver = TrajectorySolver::new(inputs, wind, Default::default());
1694        match solver.solve() {
1695            Ok(result) => {
1696                // Skip samples that fell short of the target (e.g. a low muzzle-velocity draw):
1697                // position_at_range would clamp to the ground-impact point (a large spurious
1698                // deviation). Exclude such samples from ALL THREE result vectors so they stay
1699                // equal-length and per-sample aligned — the FFI exposes them under ONE count
1700                // (ranges.len()), so a shorter impact_positions would leave uninitialized tail
1701                // slots read across the C ABI. The common case (all samples reach the target) is
1702                // unaffected; range/velocity stats stay consistent with the dispersion stats.
1703                if result.max_range < target_distance {
1704                    continue;
1705                }
1706                // Position at target distance (not ground impact). Always Some here since
1707                // max_range >= target_distance and an Ok result has a non-empty trajectory;
1708                // skip defensively (keeping the vectors aligned) if it ever returns None.
1709                let pos_at_target = match result.position_at_range(target_distance) {
1710                    Some(p) => p,
1711                    None => continue,
1712                };
1713
1714                ranges.push(result.max_range);
1715                impact_velocities.push(result.impact_velocity);
1716
1717                // Calculate deviation from baseline at the SAME target distance (McCoy)
1718                // X = downrange (0 here), Y = vertical (elevation), Z = lateral (windage)
1719                let mut deviation = Vector3::new(
1720                    0.0, // X downrange deviation is 0 since we compare at same range
1721                    pos_at_target.y - baseline_at_target.y, // Vertical deviation
1722                    pos_at_target.z - baseline_at_target.z, // Lateral deviation (windage)
1723                );
1724
1725                // Add additional pointing error to simulate realistic group sizes
1726                // This represents the shooter's ability to aim consistently
1727                let pointing_error_y = pointing_error_dist.sample(&mut rng);
1728                deviation.y += pointing_error_y;
1729
1730                impact_positions.push(deviation);
1731            }
1732            Err(_) => {
1733                // Skip failed simulations
1734                continue;
1735            }
1736        }
1737    }
1738
1739    if ranges.is_empty() {
1740        return Err("No successful simulations".into());
1741    }
1742
1743    Ok(MonteCarloResults {
1744        ranges,
1745        impact_velocities,
1746        impact_positions,
1747    })
1748}
1749
1750// Calculate zero angle for a target
1751pub fn calculate_zero_angle(
1752    inputs: BallisticInputs,
1753    target_distance: f64,
1754    target_height: f64,
1755) -> Result<f64, BallisticsError> {
1756    calculate_zero_angle_with_conditions(
1757        inputs,
1758        target_distance,
1759        target_height,
1760        WindConditions::default(),
1761        AtmosphericConditions::default(),
1762    )
1763}
1764
1765pub fn calculate_zero_angle_with_conditions(
1766    inputs: BallisticInputs,
1767    target_distance: f64,
1768    target_height: f64,
1769    wind: WindConditions,
1770    atmosphere: AtmosphericConditions,
1771) -> Result<f64, BallisticsError> {
1772    // Helper function to get height at target distance for a given angle
1773    let get_height_at_angle = |angle: f64| -> Result<Option<f64>, BallisticsError> {
1774        let mut test_inputs = inputs.clone();
1775        test_inputs.muzzle_angle = angle;
1776        // MBA-959: zero on the bare bore. Aerodynamic jump is a constant elevation
1777        // offset, so leaving it on here would let the zero search silently absorb the
1778        // vertical jump. Disabling it makes AJ an additive POI shift relative to the
1779        // no-jump zero, regardless of the conditions the caller zeroes in.
1780        test_inputs.enable_aerodynamic_jump = false;
1781
1782        let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
1783        solver.set_max_range(target_distance * 2.0);
1784        solver.set_time_step(0.001);
1785        let result = solver.solve()?;
1786
1787        // X is downrange in McCoy coordinates
1788        for i in 0..result.points.len() {
1789            if result.points[i].position.x >= target_distance {
1790                if i > 0 {
1791                    let p1 = &result.points[i - 1];
1792                    let p2 = &result.points[i];
1793                    let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
1794                    return Ok(Some(p1.position.y + t * (p2.position.y - p1.position.y)));
1795                } else {
1796                    return Ok(Some(result.points[i].position.y));
1797                }
1798            }
1799        }
1800        Ok(None)
1801    };
1802
1803    // Binary search for the angle that hits the target
1804    // Use only positive angles to ensure proper ballistic arc (upward trajectory)
1805    let mut low_angle = 0.0; // radians (horizontal)
1806    let mut high_angle = 0.2; // radians (about 11 degrees)
1807    let tolerance = 0.00001; // radians
1808    let max_iterations = 50;
1809
1810    // MBA-194: Validate bracketing before starting binary search
1811    // Check that the target height is actually between low and high angle trajectories
1812    let low_height = get_height_at_angle(low_angle)?;
1813    let high_height = get_height_at_angle(high_angle)?;
1814
1815    match (low_height, high_height) {
1816        (Some(lh), Some(hh)) => {
1817            let low_error = lh - target_height;
1818            let high_error = hh - target_height;
1819
1820            // For proper bracketing, low angle should undershoot (negative error)
1821            // and high angle should overshoot (positive error)
1822            if low_error > 0.0 && high_error > 0.0 {
1823                // Both angles overshoot - target is too close or height too low
1824                // This shouldn't happen for typical zeroing, but handle gracefully
1825                // Try to find a valid bracket by reducing low_angle (can't go negative)
1826                // Since we can't go below 0, just proceed and let binary search find best
1827            } else if low_error < 0.0 && high_error < 0.0 {
1828                // Both angles undershoot - target is beyond effective range
1829                // Try expanding high_angle up to 45 degrees (0.785 rad)
1830                let mut expanded = false;
1831                for multiplier in [2.0, 3.0, 4.0] {
1832                    let new_high = (high_angle * multiplier).min(0.785);
1833                    if let Ok(Some(h)) = get_height_at_angle(new_high) {
1834                        if h - target_height > 0.0 {
1835                            high_angle = new_high;
1836                            expanded = true;
1837                            break;
1838                        }
1839                    }
1840                    if new_high >= 0.785 {
1841                        break;
1842                    }
1843                }
1844                if !expanded {
1845                    return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
1846                }
1847            }
1848            // If signs are opposite, we have valid bracketing - proceed
1849        }
1850        (None, Some(_hh)) => {
1851            // Low angle doesn't reach target, high does - this is fine
1852            // Binary search will increase low_angle until trajectory reaches
1853        }
1854        (Some(_lh), None) => {
1855            // High angle doesn't reach target - shouldn't happen
1856            return Err(
1857                "Cannot find zero angle: high angle trajectory doesn't reach target distance"
1858                    .into(),
1859            );
1860        }
1861        (None, None) => {
1862            // Neither reaches target - target too far
1863            return Err(
1864                "Cannot find zero angle: trajectory cannot reach target distance at any angle"
1865                    .into(),
1866            );
1867        }
1868    }
1869
1870    for _iteration in 0..max_iterations {
1871        let mid_angle = (low_angle + high_angle) / 2.0;
1872
1873        let mut test_inputs = inputs.clone();
1874        test_inputs.muzzle_angle = mid_angle;
1875        // MBA-959: zero on the bare bore so aerodynamic jump is not absorbed (see above).
1876        test_inputs.enable_aerodynamic_jump = false;
1877
1878        let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
1879        // Make sure we calculate far enough to reach the target
1880        solver.set_max_range(target_distance * 2.0);
1881        solver.set_time_step(0.001);
1882        let result = solver.solve()?;
1883
1884        // Find the height at target distance (X is downrange)
1885        let mut height_at_target = None;
1886        for i in 0..result.points.len() {
1887            if result.points[i].position.x >= target_distance {
1888                if i > 0 {
1889                    // Linear interpolation
1890                    let p1 = &result.points[i - 1];
1891                    let p2 = &result.points[i];
1892                    let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
1893                    height_at_target = Some(p1.position.y + t * (p2.position.y - p1.position.y));
1894                } else {
1895                    height_at_target = Some(result.points[i].position.y);
1896                }
1897                break;
1898            }
1899        }
1900
1901        match height_at_target {
1902            Some(height) => {
1903                let error = height - target_height;
1904                // MBA-193: Check height error FIRST (primary convergence criterion)
1905                // Height accuracy is what matters for zeroing - angle tolerance is secondary
1906                if error.abs() < 0.001 {
1907                    return Ok(mid_angle);
1908                }
1909
1910                // Only use angle tolerance as convergence criterion if we have
1911                // exhausted angle precision AND height error is still acceptable
1912                // (within 10mm which is reasonable for long range)
1913                if (high_angle - low_angle).abs() < tolerance {
1914                    if error.abs() < 0.01 {
1915                        // Height error within 10mm - acceptable for practical use
1916                        return Ok(mid_angle);
1917                    }
1918                    // Angle bracket collapsed but the height error is still too large: the
1919                    // target is not actually reachable / was never bracketed. Returning
1920                    // Ok(mid_angle) here reported a NOT-zeroed angle as success (callers use
1921                    // it directly as muzzle_angle); surface it as an error instead.
1922                    return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
1923                }
1924
1925                if error > 0.0 {
1926                    high_angle = mid_angle;
1927                } else {
1928                    low_angle = mid_angle;
1929                }
1930            }
1931            None => {
1932                // Trajectory didn't reach target distance, increase angle
1933                low_angle = mid_angle;
1934
1935                // MBA-193: Check angle tolerance for None case too
1936                if (high_angle - low_angle).abs() < tolerance {
1937                    return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
1938                }
1939            }
1940        }
1941    }
1942
1943    Err("Failed to find zero angle".into())
1944}
1945
1946// Estimate BC from trajectory data
1947pub fn estimate_bc_from_trajectory(
1948    velocity: f64,
1949    mass: f64,
1950    diameter: f64,
1951    points: &[(f64, f64)], // (distance, drop) pairs
1952) -> Result<f64, BallisticsError> {
1953    // Simple BC estimation using least squares
1954    let mut best_bc = 0.5;
1955    let mut best_error = f64::MAX;
1956    let mut found_valid = false;
1957
1958    // Try different BC values
1959    for bc in (100..1000).step_by(10) {
1960        let bc_value = bc as f64 / 1000.0;
1961
1962        let inputs = BallisticInputs {
1963            muzzle_velocity: velocity,
1964            bc_value,
1965            bullet_mass: mass,
1966            bullet_diameter: diameter,
1967            ..Default::default()
1968        };
1969
1970        let mut solver = TrajectorySolver::new(inputs, Default::default(), Default::default());
1971        // Set max range for BC estimation
1972        solver.set_max_range(points.last().map(|(d, _)| *d * 1.5).unwrap_or(1000.0));
1973
1974        let result = match solver.solve() {
1975            Ok(r) => r,
1976            Err(_) => continue, // Skip this BC value if solve fails
1977        };
1978
1979        // Calculate error
1980        let mut total_error = 0.0;
1981        for (target_dist, target_drop) in points {
1982            // Find drop at this distance
1983            let mut calculated_drop = None;
1984            for i in 0..result.points.len() {
1985                if result.points[i].position.x >= *target_dist {
1986                    if i > 0 {
1987                        // Linear interpolation
1988                        let p1 = &result.points[i - 1];
1989                        let p2 = &result.points[i];
1990                        let t = (target_dist - p1.position.x) / (p2.position.x - p1.position.x);
1991                        calculated_drop =
1992                            Some(-(p1.position.y + t * (p2.position.y - p1.position.y)));
1993                    } else {
1994                        calculated_drop = Some(-result.points[i].position.y);
1995                    }
1996                    break;
1997                }
1998            }
1999
2000            if let Some(drop) = calculated_drop {
2001                let error = (drop - target_drop).abs();
2002                total_error += error * error;
2003            }
2004        }
2005
2006        if total_error < best_error {
2007            best_error = total_error;
2008            best_bc = bc_value;
2009            found_valid = true;
2010        }
2011    }
2012
2013    if !found_valid {
2014        return Err(BallisticsError::from("Unable to estimate BC from provided data. Check that drop values are in correct units.".to_string()));
2015    }
2016
2017    Ok(best_bc)
2018}
2019
2020// Helper function to calculate air density
2021fn calculate_air_density(atmosphere: &AtmosphericConditions) -> f64 {
2022    // Use the shared atmosphere model so ALL solvers agree on air density. An explicitly-set
2023    // pressure/temperature is authoritative; the previous body's extra exp(-altitude/8000) factor
2024    // double-counted altitude and ignored humidity. resolve_station_pressure / _temperature pass
2025    // explicit values through unchanged, but when either is left at its sea-level default they
2026    // return None so altitude drives BOTH the station pressure AND the lapse-rate temperature via
2027    // the ICAO standard — otherwise `--altitude` alone gave sea-level density (and, with only the
2028    // pressure derived, still 15 °C air, ~7% too thin at 3 km). Humidity (0-100) is always applied.
2029    crate::atmosphere::calculate_atmosphere(
2030        atmosphere.altitude,
2031        crate::atmosphere::resolve_station_temperature(atmosphere.temperature, atmosphere.altitude),
2032        crate::atmosphere::resolve_station_pressure(atmosphere.pressure, atmosphere.altitude),
2033        atmosphere.humidity,
2034    )
2035    .0
2036}
2037
2038// Add rand dependencies for Monte Carlo
2039use rand;
2040use rand_distr;
2041
2042#[cfg(test)]
2043mod ground_termination_tests {
2044    use super::*;
2045
2046    // Regression lock for the unified ground termination: solve_euler/solve_rk4/solve_rk45 all
2047    // loop while `position.y > ground_threshold` (default -100.0), so they agree with RK45. A
2048    // lofted shot that returns to launch level before reaching max_range must keep descending to
2049    // the -100 m floor instead of stopping at y = 0 — and RK4-fixed and RK45 must behave the same.
2050    #[test]
2051    fn rk4_and_rk45_descend_to_ground_threshold() {
2052        for adaptive in [false, true] {
2053            let mut inputs = BallisticInputs::default();
2054            inputs.muzzle_angle = 0.1; // ~5.7 deg: arcs up, then descends past launch level
2055            inputs.use_rk4 = true;
2056            inputs.use_adaptive_rk45 = adaptive;
2057            assert_eq!(inputs.ground_threshold, -100.0, "default ground_threshold is -100 m");
2058
2059            let mut solver = TrajectorySolver::new(
2060                inputs,
2061                WindConditions::default(),
2062                AtmosphericConditions::default(),
2063            );
2064            // Huge max range: termination must be driven by ground_threshold, not the range cap.
2065            solver.set_max_range(1.0e7);
2066
2067            let result = solver.solve().expect("solve should succeed");
2068            let final_y = result
2069                .points
2070                .last()
2071                .expect("trajectory has points")
2072                .position
2073                .y;
2074            assert!(
2075                final_y < -1.0,
2076                "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
2077                 past launch level toward the ground_threshold floor, not stop at y = 0"
2078            );
2079        }
2080    }
2081}