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