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