Skip to main content

ballistics_engine/
cli_api.rs

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