Skip to main content

ballistics_engine/
cli_api.rs

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