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