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