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// Run Monte Carlo simulation (backwards compatibility)
1888pub fn run_monte_carlo(
1889    base_inputs: BallisticInputs,
1890    params: MonteCarloParams,
1891) -> Result<MonteCarloResults, BallisticsError> {
1892    let base_wind = WindConditions {
1893        speed: params.base_wind_speed,
1894        direction: params.base_wind_direction,
1895    };
1896    run_monte_carlo_with_wind(base_inputs, base_wind, params)
1897}
1898
1899// Run Monte Carlo simulation with wind
1900pub fn run_monte_carlo_with_wind(
1901    base_inputs: BallisticInputs,
1902    base_wind: WindConditions,
1903    params: MonteCarloParams,
1904) -> Result<MonteCarloResults, BallisticsError> {
1905    use rand_distr::{Distribution, Normal};
1906
1907    let mut rng = rand::rng();
1908    let mut ranges = Vec::new();
1909    let mut impact_velocities = Vec::new();
1910    let mut impact_positions = Vec::new();
1911
1912    // First, calculate baseline trajectory with no variations
1913    let baseline_solver =
1914        TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), Default::default());
1915    let baseline_result = baseline_solver.solve()?;
1916
1917    // Determine target distance: use explicit target or baseline max range
1918    let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
1919
1920    // Get baseline position at target distance (interpolated)
1921    let baseline_at_target = baseline_result
1922        .position_at_range(target_distance)
1923        .ok_or("Could not interpolate baseline at target distance")?;
1924
1925    // Create normal distributions for variations
1926    let velocity_dist = Normal::new(base_inputs.muzzle_velocity, params.velocity_std_dev)
1927        .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
1928    let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
1929        .map_err(|e| format!("Invalid angle distribution: {}", e))?;
1930    let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
1931        .map_err(|e| format!("Invalid BC distribution: {}", e))?;
1932    let wind_speed_dist = Normal::new(base_wind.speed, params.wind_speed_std_dev)
1933        .map_err(|e| format!("Invalid wind speed distribution: {}", e))?;
1934    // MBA-952: wind-direction spread is APPROXIMATED from the wind-SPEED std dev (×0.1), a unit
1935    // conflation (m/s scaled as radians) — there is no dedicated wind_direction_std_dev field yet.
1936    // The dead WASM `--wind-dir-std` setter was removed (it set nothing). A proper fix is an
1937    // API-breaking wind_direction_std_dev on MonteCarloParams plumbed through WASM/FFI/main.
1938    let wind_dir_dist =
1939        Normal::new(base_wind.direction, params.wind_speed_std_dev * 0.1)
1940            .map_err(|e| format!("Invalid wind direction distribution: {}", e))?;
1941    let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
1942        .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
1943
1944    // Create distribution for pointing errors (simulates shooter's aiming consistency)
1945    let pointing_error_dist = Normal::new(0.0, params.angle_std_dev * target_distance)
1946        .map_err(|e| format!("Invalid pointing distribution: {}", e))?;
1947
1948    for _ in 0..params.num_simulations {
1949        // Create varied inputs
1950        let mut inputs = base_inputs.clone();
1951        inputs.muzzle_velocity = velocity_dist.sample(&mut rng).max(0.0);
1952        inputs.muzzle_angle = angle_dist.sample(&mut rng);
1953        inputs.bc_value = bc_dist.sample(&mut rng).max(0.01);
1954        inputs.azimuth_angle = azimuth_dist.sample(&mut rng); // Add horizontal variation
1955
1956        // Create varied wind (now based on base wind conditions)
1957        let wind = WindConditions {
1958            speed: wind_speed_dist.sample(&mut rng).abs(),
1959            direction: wind_dir_dist.sample(&mut rng),
1960        };
1961
1962        // Run trajectory
1963        let solver = TrajectorySolver::new(inputs, wind, Default::default());
1964        match solver.solve() {
1965            Ok(result) => {
1966                // Skip samples that fell short of the target (e.g. a low muzzle-velocity draw):
1967                // position_at_range would clamp to the ground-impact point (a large spurious
1968                // deviation). Exclude such samples from ALL THREE result vectors so they stay
1969                // equal-length and per-sample aligned — the FFI exposes them under ONE count
1970                // (ranges.len()), so a shorter impact_positions would leave uninitialized tail
1971                // slots read across the C ABI. The common case (all samples reach the target) is
1972                // unaffected; range/velocity stats stay consistent with the dispersion stats.
1973                if result.max_range < target_distance {
1974                    continue;
1975                }
1976                // Position at target distance (not ground impact). Always Some here since
1977                // max_range >= target_distance and an Ok result has a non-empty trajectory;
1978                // skip defensively (keeping the vectors aligned) if it ever returns None.
1979                let pos_at_target = match result.position_at_range(target_distance) {
1980                    Some(p) => p,
1981                    None => continue,
1982                };
1983
1984                ranges.push(result.max_range);
1985                impact_velocities.push(result.impact_velocity);
1986
1987                // Calculate deviation from baseline at the SAME target distance (McCoy)
1988                // X = downrange (0 here), Y = vertical (elevation), Z = lateral (windage)
1989                let mut deviation = Vector3::new(
1990                    0.0, // X downrange deviation is 0 since we compare at same range
1991                    pos_at_target.y - baseline_at_target.y, // Vertical deviation
1992                    pos_at_target.z - baseline_at_target.z, // Lateral deviation (windage)
1993                );
1994
1995                // Add additional pointing error to simulate realistic group sizes
1996                // This represents the shooter's ability to aim consistently
1997                let pointing_error_y = pointing_error_dist.sample(&mut rng);
1998                deviation.y += pointing_error_y;
1999
2000                impact_positions.push(deviation);
2001            }
2002            Err(_) => {
2003                // Skip failed simulations
2004                continue;
2005            }
2006        }
2007    }
2008
2009    if ranges.is_empty() {
2010        return Err("No successful simulations".into());
2011    }
2012
2013    Ok(MonteCarloResults {
2014        ranges,
2015        impact_velocities,
2016        impact_positions,
2017    })
2018}
2019
2020// Calculate zero angle for a target
2021pub fn calculate_zero_angle(
2022    inputs: BallisticInputs,
2023    target_distance: f64,
2024    target_height: f64,
2025) -> Result<f64, BallisticsError> {
2026    calculate_zero_angle_with_conditions(
2027        inputs,
2028        target_distance,
2029        target_height,
2030        WindConditions::default(),
2031        AtmosphericConditions::default(),
2032    )
2033}
2034
2035pub fn calculate_zero_angle_with_conditions(
2036    inputs: BallisticInputs,
2037    target_distance: f64,
2038    target_height: f64,
2039    wind: WindConditions,
2040    atmosphere: AtmosphericConditions,
2041) -> Result<f64, BallisticsError> {
2042    // Helper function to get height at target distance for a given angle
2043    let get_height_at_angle = |angle: f64| -> Result<Option<f64>, BallisticsError> {
2044        let mut test_inputs = inputs.clone();
2045        test_inputs.muzzle_angle = angle;
2046        // MBA-959: zero on the bare bore. Aerodynamic jump is a constant elevation
2047        // offset, so leaving it on here would let the zero search silently absorb the
2048        // vertical jump. Disabling it makes AJ an additive POI shift relative to the
2049        // no-jump zero, regardless of the conditions the caller zeroes in.
2050        test_inputs.enable_aerodynamic_jump = false;
2051
2052        let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
2053        solver.set_max_range(target_distance * 2.0);
2054        solver.set_time_step(0.001);
2055        let result = solver.solve()?;
2056
2057        // X is downrange in McCoy coordinates
2058        for i in 0..result.points.len() {
2059            if result.points[i].position.x >= target_distance {
2060                if i > 0 {
2061                    let p1 = &result.points[i - 1];
2062                    let p2 = &result.points[i];
2063                    let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
2064                    return Ok(Some(p1.position.y + t * (p2.position.y - p1.position.y)));
2065                } else {
2066                    return Ok(Some(result.points[i].position.y));
2067                }
2068            }
2069        }
2070        Ok(None)
2071    };
2072
2073    // Binary search for the angle that hits the target
2074    // Use only positive angles to ensure proper ballistic arc (upward trajectory)
2075    let mut low_angle = 0.0; // radians (horizontal)
2076    let mut high_angle = 0.2; // radians (about 11 degrees)
2077    let tolerance = 0.00001; // radians
2078    let max_iterations = 50;
2079
2080    // MBA-194: Validate bracketing before starting binary search
2081    // Check that the target height is actually between low and high angle trajectories
2082    let low_height = get_height_at_angle(low_angle)?;
2083    let high_height = get_height_at_angle(high_angle)?;
2084
2085    match (low_height, high_height) {
2086        (Some(lh), Some(hh)) => {
2087            let low_error = lh - target_height;
2088            let high_error = hh - target_height;
2089
2090            // For proper bracketing, low angle should undershoot (negative error)
2091            // and high angle should overshoot (positive error)
2092            if low_error > 0.0 && high_error > 0.0 {
2093                // Both angles overshoot - target is too close or height too low
2094                // This shouldn't happen for typical zeroing, but handle gracefully
2095                // Try to find a valid bracket by reducing low_angle (can't go negative)
2096                // Since we can't go below 0, just proceed and let binary search find best
2097            } else if low_error < 0.0 && high_error < 0.0 {
2098                // Both angles undershoot - target is beyond effective range
2099                // Try expanding high_angle up to 45 degrees (0.785 rad)
2100                let mut expanded = false;
2101                for multiplier in [2.0, 3.0, 4.0] {
2102                    let new_high = (high_angle * multiplier).min(0.785);
2103                    if let Ok(Some(h)) = get_height_at_angle(new_high) {
2104                        if h - target_height > 0.0 {
2105                            high_angle = new_high;
2106                            expanded = true;
2107                            break;
2108                        }
2109                    }
2110                    if new_high >= 0.785 {
2111                        break;
2112                    }
2113                }
2114                if !expanded {
2115                    return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
2116                }
2117            }
2118            // If signs are opposite, we have valid bracketing - proceed
2119        }
2120        (None, Some(_hh)) => {
2121            // Low angle doesn't reach target, high does - this is fine
2122            // Binary search will increase low_angle until trajectory reaches
2123        }
2124        (Some(_lh), None) => {
2125            // High angle doesn't reach target - shouldn't happen
2126            return Err(
2127                "Cannot find zero angle: high angle trajectory doesn't reach target distance"
2128                    .into(),
2129            );
2130        }
2131        (None, None) => {
2132            // Neither reaches target - target too far
2133            return Err(
2134                "Cannot find zero angle: trajectory cannot reach target distance at any angle"
2135                    .into(),
2136            );
2137        }
2138    }
2139
2140    for _iteration in 0..max_iterations {
2141        let mid_angle = (low_angle + high_angle) / 2.0;
2142
2143        let mut test_inputs = inputs.clone();
2144        test_inputs.muzzle_angle = mid_angle;
2145        // MBA-959: zero on the bare bore so aerodynamic jump is not absorbed (see above).
2146        test_inputs.enable_aerodynamic_jump = false;
2147
2148        let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
2149        // Make sure we calculate far enough to reach the target
2150        solver.set_max_range(target_distance * 2.0);
2151        solver.set_time_step(0.001);
2152        let result = solver.solve()?;
2153
2154        // Find the height at target distance (X is downrange)
2155        let mut height_at_target = None;
2156        for i in 0..result.points.len() {
2157            if result.points[i].position.x >= target_distance {
2158                if i > 0 {
2159                    // Linear interpolation
2160                    let p1 = &result.points[i - 1];
2161                    let p2 = &result.points[i];
2162                    let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
2163                    height_at_target = Some(p1.position.y + t * (p2.position.y - p1.position.y));
2164                } else {
2165                    height_at_target = Some(result.points[i].position.y);
2166                }
2167                break;
2168            }
2169        }
2170
2171        match height_at_target {
2172            Some(height) => {
2173                let error = height - target_height;
2174                // MBA-193: Check height error FIRST (primary convergence criterion)
2175                // Height accuracy is what matters for zeroing - angle tolerance is secondary
2176                if error.abs() < 0.001 {
2177                    return Ok(mid_angle);
2178                }
2179
2180                // Only use angle tolerance as convergence criterion if we have
2181                // exhausted angle precision AND height error is still acceptable
2182                // (within 10mm which is reasonable for long range)
2183                if (high_angle - low_angle).abs() < tolerance {
2184                    if error.abs() < 0.01 {
2185                        // Height error within 10mm - acceptable for practical use
2186                        return Ok(mid_angle);
2187                    }
2188                    // Angle bracket collapsed but the height error is still too large: the
2189                    // target is not actually reachable / was never bracketed. Returning
2190                    // Ok(mid_angle) here reported a NOT-zeroed angle as success (callers use
2191                    // it directly as muzzle_angle); surface it as an error instead.
2192                    return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
2193                }
2194
2195                if error > 0.0 {
2196                    high_angle = mid_angle;
2197                } else {
2198                    low_angle = mid_angle;
2199                }
2200            }
2201            None => {
2202                // Trajectory didn't reach target distance, increase angle
2203                low_angle = mid_angle;
2204
2205                // MBA-193: Check angle tolerance for None case too
2206                if (high_angle - low_angle).abs() < tolerance {
2207                    return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
2208                }
2209            }
2210        }
2211    }
2212
2213    Err("Failed to find zero angle".into())
2214}
2215
2216// Estimate BC from trajectory data
2217pub fn estimate_bc_from_trajectory(
2218    velocity: f64,
2219    mass: f64,
2220    diameter: f64,
2221    points: &[(f64, f64)], // (distance, drop) pairs
2222) -> Result<f64, BallisticsError> {
2223    // Simple BC estimation using least squares
2224    let mut best_bc = 0.5;
2225    let mut best_error = f64::MAX;
2226    let mut found_valid = false;
2227
2228    // Try different BC values
2229    for bc in (100..1000).step_by(10) {
2230        let bc_value = bc as f64 / 1000.0;
2231
2232        let inputs = BallisticInputs {
2233            muzzle_velocity: velocity,
2234            bc_value,
2235            bullet_mass: mass,
2236            bullet_diameter: diameter,
2237            ..Default::default()
2238        };
2239
2240        let mut solver = TrajectorySolver::new(inputs, Default::default(), Default::default());
2241        // Set max range for BC estimation
2242        solver.set_max_range(points.last().map(|(d, _)| *d * 1.5).unwrap_or(1000.0));
2243
2244        let result = match solver.solve() {
2245            Ok(r) => r,
2246            Err(_) => continue, // Skip this BC value if solve fails
2247        };
2248
2249        // Calculate error
2250        let mut total_error = 0.0;
2251        for (target_dist, target_drop) in points {
2252            // Find drop at this distance
2253            let mut calculated_drop = None;
2254            for i in 0..result.points.len() {
2255                if result.points[i].position.x >= *target_dist {
2256                    if i > 0 {
2257                        // Linear interpolation
2258                        let p1 = &result.points[i - 1];
2259                        let p2 = &result.points[i];
2260                        let t = (target_dist - p1.position.x) / (p2.position.x - p1.position.x);
2261                        calculated_drop =
2262                            Some(-(p1.position.y + t * (p2.position.y - p1.position.y)));
2263                    } else {
2264                        calculated_drop = Some(-result.points[i].position.y);
2265                    }
2266                    break;
2267                }
2268            }
2269
2270            if let Some(drop) = calculated_drop {
2271                let error = (drop - target_drop).abs();
2272                total_error += error * error;
2273            }
2274        }
2275
2276        if total_error < best_error {
2277            best_error = total_error;
2278            best_bc = bc_value;
2279            found_valid = true;
2280        }
2281    }
2282
2283    if !found_valid {
2284        return Err(BallisticsError::from("Unable to estimate BC from provided data. Check that drop values are in correct units.".to_string()));
2285    }
2286
2287    Ok(best_bc)
2288}
2289
2290// Helper function to calculate air density
2291fn calculate_air_density(atmosphere: &AtmosphericConditions) -> f64 {
2292    // Use the shared atmosphere model so ALL solvers agree on air density. An explicitly-set
2293    // pressure/temperature is authoritative; the previous body's extra exp(-altitude/8000) factor
2294    // double-counted altitude and ignored humidity. resolve_station_pressure / _temperature pass
2295    // explicit values through unchanged, but when either is left at its sea-level default they
2296    // return None so altitude drives BOTH the station pressure AND the lapse-rate temperature via
2297    // the ICAO standard — otherwise `--altitude` alone gave sea-level density (and, with only the
2298    // pressure derived, still 15 °C air, ~7% too thin at 3 km). Humidity (0-100) is always applied.
2299    crate::atmosphere::calculate_atmosphere(
2300        atmosphere.altitude,
2301        crate::atmosphere::resolve_station_temperature(atmosphere.temperature, atmosphere.altitude),
2302        crate::atmosphere::resolve_station_pressure(atmosphere.pressure, atmosphere.altitude),
2303        atmosphere.humidity,
2304    )
2305    .0
2306}
2307
2308// Add rand dependencies for Monte Carlo
2309use rand;
2310use rand_distr;
2311
2312#[cfg(test)]
2313mod ground_termination_tests {
2314    use super::*;
2315
2316    // Regression lock for the unified ground termination: solve_euler/solve_rk4/solve_rk45 all
2317    // loop while `position.y > ground_threshold` (default -100.0), so they agree with RK45. A
2318    // lofted shot that returns to launch level before reaching max_range must keep descending to
2319    // the -100 m floor instead of stopping at y = 0 — and RK4-fixed and RK45 must behave the same.
2320    #[test]
2321    fn rk4_and_rk45_descend_to_ground_threshold() {
2322        for adaptive in [false, true] {
2323            let mut inputs = BallisticInputs::default();
2324            inputs.muzzle_angle = 0.1; // ~5.7 deg: arcs up, then descends past launch level
2325            inputs.use_rk4 = true;
2326            inputs.use_adaptive_rk45 = adaptive;
2327            assert_eq!(inputs.ground_threshold, -100.0, "default ground_threshold is -100 m");
2328
2329            let mut solver = TrajectorySolver::new(
2330                inputs,
2331                WindConditions::default(),
2332                AtmosphericConditions::default(),
2333            );
2334            // Huge max range: termination must be driven by ground_threshold, not the range cap.
2335            solver.set_max_range(1.0e7);
2336
2337            let result = solver.solve().expect("solve should succeed");
2338            let final_y = result
2339                .points
2340                .last()
2341                .expect("trajectory has points")
2342                .position
2343                .y;
2344            assert!(
2345                final_y < -1.0,
2346                "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
2347                 past launch level toward the ground_threshold floor, not stop at y = 0"
2348            );
2349        }
2350    }
2351}
2352
2353#[cfg(test)]
2354mod coriolis_direction_tests {
2355    use super::*;
2356    use std::f64::consts::FRAC_PI_2;
2357
2358    #[test]
2359    fn transonic_crossing_flags_a_sampled_point() {
2360        // A supersonic shot that slows past Mach 1 must flag a sampled point as a Mach
2361        // transition. The underlying transonic_distances were a Vec::new() TODO, so this
2362        // flag was NEVER set regardless of trajectory — this is the regression guard.
2363        use crate::trajectory_sampling::TrajectoryFlag;
2364        let mut inputs = BallisticInputs::default();
2365        inputs.muzzle_velocity = 850.0; // ~2790 fps, well supersonic
2366        inputs.bc_value = 0.2; // low BC -> slows past Mach 1 within range
2367        inputs.bc_type = DragModel::G7;
2368        inputs.muzzle_angle = 0.03;
2369        inputs.enable_trajectory_sampling = true;
2370        inputs.sample_interval = 50.0;
2371        let mut solver = TrajectorySolver::new(
2372            inputs,
2373            WindConditions::default(),
2374            AtmosphericConditions::default(),
2375        );
2376        solver.set_max_range(2000.0);
2377        let r = solver.solve().expect("solve");
2378        let samples = r
2379            .sampled_points
2380            .expect("sampling enabled -> sampled_points present");
2381        assert!(
2382            samples
2383                .iter()
2384                .any(|s| s.flags.contains(&TrajectoryFlag::MachTransition)),
2385            "a shot that crosses Mach 1 must flag at least one Mach-transition sample"
2386        );
2387    }
2388
2389    #[test]
2390    fn humidity_percent_converts_and_clamps() {
2391        // MBA-722: BallisticInputs.humidity is a 0-1 fraction; the helper yields 0-100 percent.
2392        let mut i = BallisticInputs::default();
2393        i.humidity = 0.5;
2394        assert!((i.humidity_percent() - 50.0).abs() < 1e-9, "0.5 -> 50%");
2395        i.humidity = 0.0;
2396        assert_eq!(i.humidity_percent(), 0.0);
2397        i.humidity = 1.0;
2398        assert_eq!(i.humidity_percent(), 100.0);
2399        i.humidity = 1.5; // out of range -> clamped, never > 100
2400        assert_eq!(i.humidity_percent(), 100.0);
2401    }
2402
2403    /// Vertical position (m) at a given downrange `range_m`, for a shot fired along
2404    /// compass bearing `shot_azimuth` (radians, 0=N) with Coriolis enabled.
2405    fn vertical_at(shot_azimuth: f64, range_m: f64) -> f64 {
2406        let mut inputs = BallisticInputs::default();
2407        inputs.muzzle_velocity = 800.0;
2408        inputs.bc_value = 0.5;
2409        inputs.bc_type = DragModel::G7;
2410        inputs.muzzle_angle = 0.02; // ~20 mrad so it carries well past range_m
2411        inputs.enable_coriolis = true;
2412        inputs.latitude = Some(45.0);
2413        inputs.shot_azimuth = shot_azimuth;
2414        inputs.ground_threshold = f64::NEG_INFINITY; // never terminate early
2415        let mut solver = TrajectorySolver::new(
2416            inputs,
2417            WindConditions::default(),
2418            AtmosphericConditions::default(),
2419        );
2420        solver.set_max_range(range_m + 50.0);
2421        let r = solver.solve().expect("solve");
2422        let pts = &r.points;
2423        for i in 1..pts.len() {
2424            if pts[i].position.x >= range_m {
2425                let p1 = &pts[i - 1];
2426                let p2 = &pts[i];
2427                let t = (range_m - p1.position.x) / (p2.position.x - p1.position.x);
2428                return p1.position.y + t * (p2.position.y - p1.position.y);
2429            }
2430        }
2431        panic!("range {range_m} not reached");
2432    }
2433
2434    /// Regression for the shot-direction Coriolis bug: the Eötvös vertical term
2435    /// `a_up = +2Ω cosφ v_east` lifts an EAST shot and depresses a WEST shot, so at a
2436    /// common range east must sit HIGHER than west, with north in between. Before the
2437    /// fix, `--shot-direction` never reached the solver and E/W/N were identical.
2438    #[test]
2439    fn eotvos_east_higher_than_west() {
2440        let range = 600.0;
2441        let east = vertical_at(FRAC_PI_2, range); // 90° E
2442        let west = vertical_at(3.0 * FRAC_PI_2, range); // 270° W
2443        let north = vertical_at(0.0, range); // 0° N
2444        assert!(
2445            east > west,
2446            "east ({east:.5}) must be higher than west ({west:.5}) at {range} m (Eötvös)"
2447        );
2448        assert!(
2449            east > north && north > west,
2450            "north ({north:.5}) must lie between east ({east:.5}) and west ({west:.5})"
2451        );
2452        assert!(
2453            (east - west) > 1e-3,
2454            "E-W vertical separation ({:.6} m) should be physically meaningful, not FP noise",
2455            east - west
2456        );
2457    }
2458}