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, projectile_moments_of_inertia, AngularState,
6    PrecessionNutationParams,
7};
8use crate::trajectory_sampling::{
9    sample_trajectory, TrajectoryData, TrajectoryOutputs, TrajectorySample,
10};
11use crate::wind_shear::WindShearModel;
12use crate::DragModel;
13use nalgebra::{Vector3, Vector6};
14use std::error::Error;
15use std::fmt;
16
17// Unit system for input/output
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub enum UnitSystem {
20    Imperial,
21    Metric,
22}
23
24// Output format for results
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub enum OutputFormat {
27    Table,
28    Json,
29    Csv,
30}
31
32// Error type for CLI operations
33#[derive(Debug)]
34pub struct BallisticsError {
35    message: String,
36}
37
38impl fmt::Display for BallisticsError {
39    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
40        write!(f, "{}", self.message)
41    }
42}
43
44impl Error for BallisticsError {}
45
46impl From<String> for BallisticsError {
47    fn from(msg: String) -> Self {
48        BallisticsError { message: msg }
49    }
50}
51
52impl From<&str> for BallisticsError {
53    fn from(msg: &str) -> Self {
54        BallisticsError {
55            message: msg.to_string(),
56        }
57    }
58}
59
60// Ballistic input parameters - MBA-151 Reconciled Structure
61// Unified structure used by both ballistics-engine and ballistics_rust
62// Duplicates removed, all necessary fields included
63#[derive(Debug, Clone)]
64pub struct BallisticInputs {
65    // Core ballistics parameters (using intuitive names)
66    pub bc_value: f64,        // Ballistic coefficient (G1, G7, etc.)
67    pub bc_type: DragModel,   // Drag model (G1, G7, G8, etc.)
68    pub bullet_mass: f64,     // kg
69    pub muzzle_velocity: f64, // m/s
70    pub bullet_diameter: f64, // meters
71    pub bullet_length: f64,   // meters
72
73    // Targeting and positioning
74    pub muzzle_angle: f64,    // radians (launch angle)
75    pub target_distance: f64, // meters
76    pub azimuth_angle: f64, // horizontal aiming angle in radians (small aim offset within the shot frame)
77    /// Compass bearing the shot is fired ALONG, radians, 0 = North, π/2 = East.
78    /// Used only by the Coriolis model (Earth-rotation depends on which way downrange
79    /// points relative to true North). Distinct from `azimuth_angle`, which is the
80    /// small horizontal *aiming* offset and rotates the launch velocity.
81    pub shot_azimuth: f64,
82    pub shooting_angle: f64,   // uphill/downhill angle in radians
83    /// Rifle cant angle in radians about the line of sight — positive = clockwise from the
84    /// shooter's view (top of the scope tips right). Rotates the sight-frame aim offsets
85    /// (`muzzle_angle`, `azimuth_angle`) about the LOS and swings the bore's sight-height
86    /// offset laterally, producing the classic canted-rifle POI error (right-and-low for
87    /// clockwise cant with an upward zero). Zeroing always solves un-canted ("zero level,
88    /// fire canted"). NOTE: treats `muzzle_angle` as a sight-frame offset — the standard
89    /// zero-then-fire usage; a raw gravity-frame launch angle would not rotate physically.
90    /// 0.0 = level rifle (bit-identical to pre-cant behavior). (MBA-1286)
91    pub cant_angle: f64,
92    pub sight_height: f64,     // meters above bore
93    pub muzzle_height: f64,    // meters above ground
94    pub target_height: f64,    // meters above ground for zeroing
95    pub ground_threshold: f64, // meters below which to stop
96
97    // Environmental conditions
98    pub altitude: f64,    // meters
99    pub temperature: f64, // Celsius
100    pub pressure: f64,    // millibars/hPa
101    /// Relative humidity as a FRACTION in `[0, 1]` (e.g. 0.5 = 50%). NOTE the scale
102    /// differs from [`AtmosphericConditions::humidity`], which is a PERCENT in `[0, 100]`.
103    /// The atmosphere helpers (`calculate_air_density_*`) expect percent, so convert via
104    /// [`BallisticInputs::humidity_percent`] before passing this value to them (MBA-722).
105    pub humidity: f64,
106    pub latitude: Option<f64>, // degrees
107
108    // Wind conditions
109    pub wind_speed: f64, // m/s
110    pub wind_angle: f64, // radians (0=headwind, PI/2=from right)
111
112    // Bullet characteristics
113    pub twist_rate: f64,               // inches per turn
114    pub is_twist_right: bool,          // right-hand twist
115    pub caliber_inches: f64,           // diameter in inches
116    pub weight_grains: f64,            // mass in grains
117    pub manufacturer: Option<String>,  // Bullet manufacturer
118    pub bullet_model: Option<String>,  // Bullet model name
119    pub bullet_id: Option<String>,     // Unique bullet identifier
120    pub bullet_cluster: Option<usize>, // BC cluster ID for cluster_bc module
121
122    // Integration method selection
123    pub use_rk4: bool,           // Use RK4 integration instead of Euler
124    pub use_adaptive_rk45: bool, // Use RK45 adaptive step size integration
125
126    // Advanced effects flags
127    pub enable_advanced_effects: bool,
128    pub enable_magnus: bool,   // Magnus force (independent of Coriolis)
129    pub enable_coriolis: bool, // Coriolis deflection (requires latitude)
130    pub use_powder_sensitivity: bool,
131    pub powder_temp_sensitivity: f64, // m/s per degree Celsius
132    pub powder_temp: f64,           // Celsius
133    /// Optional measured powder-temperature -> muzzle-velocity curve, as
134    /// (temperature_celsius, muzzle_velocity_m_s) points sorted ascending by
135    /// temperature. When present it supersedes the linear `powder_temp_sensitivity`
136    /// model: the muzzle velocity is interpolated from this table at the ambient
137    /// `temperature` (clamped to the endpoints — no extrapolation beyond measured
138    /// data). This is the data-driven, non-linear alternative to the constant slope.
139    pub powder_temp_curve: Option<Vec<(f64, f64)>>,
140    /// Temperature (Celsius) at which to interpolate `powder_temp_curve` — the POWDER
141    /// temperature, which may differ from the ambient `temperature` (air). `None` uses
142    /// `temperature`. Decouples the velocity lookup from the air-density temperature.
143    pub powder_curve_temp_c: Option<f64>,
144    pub tipoff_yaw: f64,            // radians
145    pub tipoff_decay_distance: f64, // meters
146    /// Enables velocity-keyed `bc_segments_data`. Explicit Mach-keyed `bc_segments` retain their
147    /// legacy behavior and remain active when this flag is false.
148    pub use_bc_segments: bool,
149    pub bc_segments: Option<Vec<(f64, f64)>>, // Mach-BC pairs
150    pub bc_segments_data: Option<Vec<crate::BCSegmentData>>, // Velocity-BC segments
151    pub use_enhanced_spin_drift: bool,
152    /// Legacy compatibility flag. Name-derived "form factors" are intentionally not multiplied
153    /// into reference Cd when `bc_value` is already the retardation denominator (MBA-1184).
154    pub use_form_factor: bool,
155    pub enable_wind_shear: bool,
156    pub wind_shear_model: String,
157    pub enable_trajectory_sampling: bool,
158    pub sample_interval: f64, // meters
159    pub enable_pitch_damping: bool,
160    pub enable_precession_nutation: bool,
161    // MBA-959: apply aerodynamic jump as a muzzle launch-angle perturbation.
162    // EXPERIMENTAL — the underlying model is heuristic and not yet validated; default OFF.
163    pub enable_aerodynamic_jump: bool,
164    pub use_cluster_bc: bool, // Use cluster-based BC degradation
165
166    // Custom drag model support
167    pub custom_drag_table: Option<crate::drag::DragTable>,
168
169    // Legacy field for compatibility
170    pub bc_type_str: Option<String>,
171}
172
173impl BallisticInputs {
174    /// `humidity` as a PERCENT in `[0, 100]`, clamped — the scale the atmosphere
175    /// density helpers expect. Centralizes the 0–1 → 0–100 conversion so callers don't
176    /// re-derive it (and can't accidentally feed the raw 0–1 fraction as a percentage).
177    /// See the field doc on [`BallisticInputs::humidity`] (MBA-722).
178    pub fn humidity_percent(&self) -> f64 {
179        (self.humidity * 100.0).clamp(0.0, 100.0)
180    }
181
182    /// Sectional density in lb/in²: `weight_grains / 7000 / diameter_in²`.
183    ///
184    /// Derived from the imperial mirror fields (`weight_grains` / `caliber_inches`), falling
185    /// back to the SI `bullet_mass` (kg) / `bullet_diameter` (meters) for SI-only callers
186    /// (mirrors the fallbacks in derivatives.rs). `None` when neither source is usable.
187    pub fn sectional_density_lb_in2(&self) -> Option<f64> {
188        let weight_gr = if self.weight_grains > 0.0 {
189            self.weight_grains
190        } else {
191            self.bullet_mass / 0.00006479891 // kg -> grains
192        };
193        let diameter_in = if self.caliber_inches > 0.0 {
194            self.caliber_inches
195        } else {
196            self.bullet_diameter / 0.0254 // meters -> inches
197        };
198        if weight_gr > 0.0 && diameter_in > 0.0 {
199            Some(weight_gr / 7000.0 / (diameter_in * diameter_in))
200        } else {
201            None
202        }
203    }
204
205    /// Retardation denominator to use when `custom_drag_table` is active.
206    ///
207    /// A custom drag table supplies the projectile's ACTUAL drag coefficient, so the
208    /// point-mass retardation formula must divide it by the projectile's SECTIONAL DENSITY
209    /// (lb/in²), not by a ballistic coefficient: BC = SD / i (form factor i vs the reference
210    /// projectile), and with the projectile's own curve i == 1, so Cd_own / SD == Cd_ref / BC.
211    /// Dividing the curve's Cd by `bc_value` made custom-table trajectories wrongly scale
212    /// with whatever BC happened to be set.
213    ///
214    /// Falls back to `fallback_bc` (with a one-time stderr warning) when mass/diameter are
215    /// unavailable, so degenerate inputs degrade to the old behavior instead of panicking.
216    pub fn custom_drag_denominator(&self, fallback_bc: f64) -> f64 {
217        match self.sectional_density_lb_in2() {
218            Some(sd) => sd,
219            None => {
220                static WARN_ONCE: std::sync::Once = std::sync::Once::new();
221                WARN_ONCE.call_once(|| {
222                    eprintln!(
223                        "Warning: custom drag table active but bullet mass/diameter are \
224                         unavailable; falling back to bc_value for the retardation denominator"
225                    );
226                });
227                fallback_bc
228            }
229        }
230    }
231}
232
233impl Default for BallisticInputs {
234    fn default() -> Self {
235        let mass_kg = 0.01;
236        let diameter_m = 0.00762;
237        let bc = 0.5;
238        let muzzle_angle_rad = 0.0;
239        let bc_type = DragModel::G1;
240
241        Self {
242            // Core ballistics parameters
243            bc_value: bc,
244            bc_type,
245            bullet_mass: mass_kg,
246            muzzle_velocity: 800.0,
247            bullet_diameter: diameter_m,
248            // MBA-1135: mass-based length estimate so the default is self-consistent with the
249            // default mass/diameter (was a mass-blind 4.5-caliber literal). The twist default below
250            // stays a fixed 1:12" per the ticket (a constant is a sensible velocity-agnostic default).
251            bullet_length: crate::stability::estimate_bullet_length_m(diameter_m, mass_kg),
252
253            // Targeting and positioning
254            muzzle_angle: muzzle_angle_rad,
255            target_distance: 100.0,
256            azimuth_angle: 0.0,
257            shot_azimuth: 0.0,
258            shooting_angle: 0.0,
259            cant_angle: 0.0,
260            sight_height: 0.05,
261            muzzle_height: 0.0,       // Default 0 - height is in sight_height
262            target_height: 0.0,       // Target at ground level by default
263            ground_threshold: -100.0, // Effectively disable ground detection (allow bullet to drop 100m below start)
264
265            // Environmental conditions
266            altitude: 0.0,
267            temperature: 15.0,
268            pressure: 1013.25, // Standard sea level pressure (millibars)
269            humidity: 0.5,     // 50% relative humidity
270            latitude: None,
271
272            // Wind conditions
273            wind_speed: 0.0,
274            wind_angle: 0.0,
275
276            // Bullet characteristics
277            twist_rate: 12.0, // 1:12" typical
278            is_twist_right: true,
279            caliber_inches: diameter_m / 0.0254, // Convert to inches
280            weight_grains: mass_kg / 0.00006479891, // Convert to grains
281            manufacturer: None,
282            bullet_model: None,
283            bullet_id: None,
284            bullet_cluster: None,
285
286            // Integration method selection
287            use_rk4: true,           // Use Runge-Kutta methods by default
288            use_adaptive_rk45: true, // Default to RK45 adaptive for best accuracy
289
290            // Advanced effects (disabled by default)
291            enable_advanced_effects: false,
292            enable_magnus: false,
293            enable_coriolis: false,
294            use_powder_sensitivity: false,
295            powder_temp_sensitivity: 0.0,
296            powder_temp: 15.0,
297            powder_temp_curve: None,
298            powder_curve_temp_c: None,
299            tipoff_yaw: 0.0,
300            tipoff_decay_distance: 50.0,
301            use_bc_segments: false,
302            bc_segments: None,
303            bc_segments_data: None,
304            use_enhanced_spin_drift: false,
305            use_form_factor: false,
306            enable_wind_shear: false,
307            wind_shear_model: "none".to_string(),
308            enable_trajectory_sampling: false,
309            sample_interval: 10.0, // Default 10 meter intervals
310            enable_pitch_damping: false,
311            enable_precession_nutation: false,
312            enable_aerodynamic_jump: false,
313            use_cluster_bc: false, // Disabled by default for backward compatibility
314
315            // Custom drag model support
316            custom_drag_table: None,
317
318            // Legacy field for compatibility
319            bc_type_str: None,
320        }
321    }
322}
323
324/// Interpolate a muzzle velocity (m/s) from a measured powder-temperature curve at
325/// `temp_c` (Celsius). `curve` is `(temperature_celsius, velocity_m_s)` points; it is
326/// sorted ascending by temperature before use. Values below the first point or above
327/// the last are CLAMPED to the endpoint velocity (no extrapolation beyond measured
328/// data), and segments are linearly interpolated. A single point yields a constant.
329pub fn interpolate_powder_temp_curve(curve: &[(f64, f64)], temp_c: f64) -> f64 {
330    debug_assert!(!curve.is_empty());
331    if curve.is_empty() {
332        return 0.0;
333    }
334    // Defensive: accept unsorted input by sorting a local copy only when needed.
335    // Callers (CLI/WASM parsers) already sort, so the common path is a no-op scan.
336    let mut sorted;
337    let pts: &[(f64, f64)] = if curve.windows(2).all(|w| w[0].0 <= w[1].0) {
338        curve
339    } else {
340        sorted = curve.to_vec();
341        sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
342        &sorted
343    };
344    let n = pts.len();
345    if temp_c <= pts[0].0 {
346        return pts[0].1; // clamp below the coldest measured point
347    }
348    if temp_c >= pts[n - 1].0 {
349        return pts[n - 1].1; // clamp above the hottest measured point
350    }
351    for i in 1..n {
352        let (t0, v0) = pts[i - 1];
353        let (t1, v1) = pts[i];
354        if temp_c <= t1 {
355            let span = t1 - t0;
356            if span.abs() < f64::EPSILON {
357                return v1; // coincident temps: avoid divide-by-zero, take the upper
358            }
359            let f = (temp_c - t0) / span;
360            return v0 + f * (v1 - v0);
361        }
362    }
363    pts[n - 1].1
364}
365
366// Wind conditions
367#[derive(Debug, Clone)]
368pub struct WindConditions {
369    pub speed: f64, // m/s
370    // radians, wind-FROM convention: 0 = headwind, PI/2 = from the right,
371    // PI = tailwind, 3*PI/2 = from the left (matches WindSock / the bindings).
372    pub direction: f64,
373    /// Vertical wind component, m/s. POSITIVE = UPDRAFT (raises POI downrange); negative =
374    /// downdraft. Default 0.0. Enters the wind vector via [`crate::wind::wind_vector`]'s third
375    /// argument (MBA-728). Boundary-layer shear scales horizontal wind only — vertical passes
376    /// through unscaled wherever shear is applied on top of this. This scalar field (like
377    /// [`WindConditions::speed`]/[`WindConditions::direction`]) is ignored once downrange wind
378    /// segments are set on the solver — each [`crate::wind::WindSegment`] carries its own
379    /// `vertical_mps` instead.
380    pub vertical_speed: f64,
381}
382
383impl Default for WindConditions {
384    fn default() -> Self {
385        Self {
386            speed: 0.0,
387            direction: 0.0,
388            vertical_speed: 0.0,
389        }
390    }
391}
392
393// Atmospheric conditions
394#[derive(Debug, Clone)]
395pub struct AtmosphericConditions {
396    pub temperature: f64, // Celsius
397    pub pressure: f64,    // hPa
398    /// Relative humidity as a PERCENT in `[0, 100]`. NOTE: [`BallisticInputs::humidity`]
399    /// uses a 0–1 FRACTION instead — convert with `BallisticInputs::humidity_percent` when
400    /// crossing between them (MBA-722).
401    pub humidity: f64,
402    pub altitude: f64, // meters
403}
404
405impl Default for AtmosphericConditions {
406    fn default() -> Self {
407        Self {
408            temperature: 15.0,
409            pressure: 1013.25,
410            humidity: 50.0,
411            altitude: 0.0,
412        }
413    }
414}
415
416// Trajectory point data
417#[derive(Debug, Clone)]
418pub struct TrajectoryPoint {
419    pub time: f64,
420    pub position: Vector3<f64>,
421    pub velocity_magnitude: f64,
422    pub kinetic_energy: f64,
423}
424
425// Trajectory result
426#[derive(Debug, Clone)]
427pub struct TrajectoryResult {
428    pub max_range: f64,
429    pub max_height: f64,
430    pub time_of_flight: f64,
431    pub impact_velocity: f64,
432    pub impact_energy: f64,
433    pub points: Vec<TrajectoryPoint>,
434    pub sampled_points: Option<Vec<TrajectorySample>>, // Trajectory samples at regular intervals
435    pub min_pitch_damping: Option<f64>, // Minimum pitch damping coefficient (for stability warning)
436    pub transonic_mach: Option<f64>,    // Mach number when entering transonic regime
437    pub angular_state: Option<AngularState>, // Final angular state if precession/nutation enabled
438    pub max_yaw_angle: Option<f64>,     // Maximum yaw angle during flight (radians)
439    pub max_precession_angle: Option<f64>, // Maximum precession angle (radians)
440    // MBA-959: aerodynamic-jump components applied at the muzzle (None unless
441    // enable_aerodynamic_jump). EXPERIMENTAL.
442    pub aerodynamic_jump: Option<crate::aerodynamic_jump::AerodynamicJumpComponents>,
443}
444
445const RK45_TOLERANCE: f64 = 1e-6;
446const RK45_SAFETY_FACTOR: f64 = 0.9;
447const RK45_MAX_DT: f64 = 0.01;
448const RK45_MIN_DT: f64 = 1e-6;
449
450/// Hard ceiling for points retained by a single [`TrajectorySolver`] result.
451///
452/// The cap applies across Euler, fixed RK4, and adaptive RK45, including an interpolated
453/// max-range endpoint. Solves that would exceed it return [`BallisticsError`] instead of
454/// truncating or growing their point buffer without bound.
455pub const MAX_TRAJECTORY_POINTS: usize = 250_000;
456
457/// Pack the CLI solver's split position/velocity vectors into the shared six-component RK45 norm.
458fn cli_rk45_error_norm(
459    position: &Vector3<f64>,
460    velocity: &Vector3<f64>,
461    fifth_position: &Vector3<f64>,
462    fifth_velocity: &Vector3<f64>,
463    fourth_position: &Vector3<f64>,
464    fourth_velocity: &Vector3<f64>,
465) -> f64 {
466    let pack_state = |position: &Vector3<f64>, velocity: &Vector3<f64>| {
467        Vector6::new(
468            position.x, position.y, position.z, velocity.x, velocity.y, velocity.z,
469        )
470    };
471    let state = pack_state(position, velocity);
472    let fifth_order = pack_state(fifth_position, fifth_velocity);
473    let fourth_order = pack_state(fourth_position, fourth_velocity);
474
475    crate::trajectory_integration::rk45_error_norm(&state, &fifth_order, &fourth_order)
476}
477
478struct Rk45Trial {
479    position: Vector3<f64>,
480    velocity: Vector3<f64>,
481    suggested_dt: f64,
482    error: f64,
483}
484
485struct Rk45AcceptedStep {
486    position: Vector3<f64>,
487    velocity: Vector3<f64>,
488    used_dt: f64,
489    next_dt: f64,
490    error: f64,
491}
492
493#[derive(Default)]
494struct MachTransitionTracker {
495    previous_mach: Option<f64>,
496    crossed_transonic: bool,
497    crossed_subsonic: bool,
498}
499
500impl MachTransitionTracker {
501    fn record_downward_crossings(&mut self, mach: f64, downrange_m: f64, distances: &mut Vec<f64>) {
502        if !mach.is_finite() {
503            self.previous_mach = None;
504            return;
505        }
506
507        if let Some(previous_mach) = self.previous_mach {
508            if !self.crossed_transonic && previous_mach >= 1.2 && mach < 1.2 {
509                self.crossed_transonic = true;
510                distances.push(downrange_m);
511            }
512            if !self.crossed_subsonic && previous_mach >= 1.0 && mach < 1.0 {
513                self.crossed_subsonic = true;
514                distances.push(downrange_m);
515            }
516        }
517        self.previous_mach = Some(mach);
518    }
519}
520
521impl TrajectoryResult {
522    /// Interpolate position at a given downrange distance (X coordinate, McCoy).
523    /// Returns the interpolated (x, y, z) position at that range.
524    /// If the target range exceeds the trajectory, returns the last point.
525    pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
526        if self.points.is_empty() {
527            return None;
528        }
529
530        // Find the two points that bracket the target range
531        for i in 0..self.points.len() - 1 {
532            let p1 = &self.points[i];
533            let p2 = &self.points[i + 1];
534
535            // Check if target range is between these two points (X is downrange)
536            if p1.position.x <= target_range && p2.position.x >= target_range {
537                // Linear interpolation factor
538                let dx = p2.position.x - p1.position.x;
539                if dx.abs() < 1e-10 {
540                    return Some(p1.position);
541                }
542                let t = (target_range - p1.position.x) / dx;
543
544                // Interpolate Y and Z, use exact target_range for X
545                return Some(Vector3::new(
546                    target_range,
547                    p1.position.y + t * (p2.position.y - p1.position.y),
548                    p1.position.z + t * (p2.position.z - p1.position.z),
549                ));
550            }
551        }
552
553        // Target range is beyond trajectory - return last point
554        self.points.last().map(|p| p.position)
555    }
556}
557
558// Trajectory solver
559pub struct TrajectorySolver {
560    inputs: BallisticInputs,
561    wind: WindConditions,
562    atmosphere: AtmosphericConditions,
563    max_range: f64,
564    time_step: f64,
565    max_trajectory_points: usize,
566    cluster_bc: Option<ClusterBCDegradation>,
567    /// Geometry-derived `(longitudinal, transverse)` moments used by angular diagnostics.
568    precession_nutation_inertias: (f64, f64),
569    /// Optional downrange-segmented wind. When `Some`, the per-step wind vector is
570    /// looked up by downrange distance from this `WindSock` and the scalar `wind`
571    /// field is ignored. When `None`, the constant `wind` vector is used (default),
572    /// so a non-segmented solve is numerically identical to pre-feature behavior.
573    wind_sock: Option<crate::wind::WindSock>,
574    /// Optional downrange-segmented atmosphere (MBA-1137). When `Some`, the per-substep local
575    /// atmosphere recompute samples the base (station-referenced) temperature/pressure/humidity by
576    /// downrange distance from this `AtmoSock`, then feeds them through the SAME altitude-lapse
577    /// pipeline as a single-station solve — so the downrange zone and the vertical altitude lapse
578    /// compose without double-counting. When `None` (default), the resolved single-station
579    /// conditions are used.
580    atmo_sock: Option<crate::atmosphere::AtmoSock>,
581}
582
583impl TrajectorySolver {
584    pub fn new(
585        mut inputs: BallisticInputs,
586        wind: WindConditions,
587        atmosphere: AtmosphericConditions,
588    ) -> Self {
589        // Compute derived fields from base units
590        inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
591        inputs.weight_grains = inputs.bullet_mass / 0.00006479891;
592
593        // Resolve the muzzle velocity for the ambient temperature before integration.
594        // A measured powder-temperature -> velocity curve (data-driven, non-linear)
595        // takes precedence when supplied; otherwise fall back to the linear
596        // powder-temperature-sensitivity model (MBA-963). Both operate in canonical
597        // SI (Celsius, m/s) and are applied here so every solver built from these
598        // inputs — the main trajectory AND the zero-angle search — sees the same
599        // temperature-resolved velocity. In particular, when a zero solve passes the
600        // zero-day temperature, the curve automatically yields the zero-day velocity.
601        if let Some(curve) = inputs.powder_temp_curve.as_ref() {
602            if !curve.is_empty() {
603                // Interpolate at the POWDER temperature, which defaults to the ambient
604                // air temperature but can be decoupled (powder warmed/cooled relative to
605                // the air) via powder_curve_temp_c. Air temperature still drives density
606                // separately; this only sets the velocity. Absolute override (idempotent).
607                let lookup_c = inputs.powder_curve_temp_c.unwrap_or(inputs.temperature);
608                inputs.muzzle_velocity = interpolate_powder_temp_curve(curve, lookup_c);
609            }
610        } else if inputs.use_powder_sensitivity {
611            let temp_delta_c = inputs.temperature - inputs.powder_temp;
612            inputs.muzzle_velocity += inputs.powder_temp_sensitivity * temp_delta_c;
613        }
614
615        // Initialize cluster BC if enabled
616        let cluster_bc = if inputs.use_cluster_bc {
617            Some(ClusterBCDegradation::new())
618        } else {
619            None
620        };
621        let precession_nutation_inertias = projectile_moments_of_inertia(
622            inputs.bullet_mass,
623            inputs.bullet_diameter,
624            inputs.bullet_length,
625        );
626
627        Self {
628            inputs,
629            wind,
630            atmosphere,
631            max_range: 1000.0,
632            time_step: 0.001,
633            max_trajectory_points: MAX_TRAJECTORY_POINTS,
634            cluster_bc,
635            precession_nutation_inertias,
636            wind_sock: None,
637            atmo_sock: None,
638        }
639    }
640
641    pub fn set_max_range(&mut self, range: f64) {
642        self.max_range = range;
643    }
644
645    pub fn set_time_step(&mut self, step: f64) {
646        self.time_step = step;
647    }
648
649    /// Reject malformed state before it reaches an integration loop.
650    ///
651    /// `new` resolves powder-temperature velocity overrides and refreshes the imperial mirror
652    /// fields, so validation belongs here: it sees the effective muzzle velocity, covers values
653    /// changed through solver setters, and applies uniformly to Euler, RK4, and RK45.
654    fn validate_for_solve(&self) -> Result<(), BallisticsError> {
655        let require_finite = |name: &str, value: f64| {
656            if value.is_finite() {
657                Ok(())
658            } else {
659                Err(BallisticsError::from(format!("{name} must be finite")))
660            }
661        };
662        let require_positive = |name: &str, value: f64| {
663            if value.is_finite() && value > 0.0 {
664                Ok(())
665            } else {
666                Err(BallisticsError::from(format!(
667                    "{name} must be finite and greater than zero"
668                )))
669            }
670        };
671
672        // These four quantities are required by every point-mass solve. In particular, validate
673        // muzzle_velocity after `new` has applied a measured curve or linear powder correction.
674        // A custom drag table supplies the actual Cd and divides by sectional density, so bc_value
675        // is physically ignored (see custom_drag_denominator). Require it only in the no-table case;
676        // mass + diameter are always required (they drive the SD denominator when a table is set).
677        if self.inputs.custom_drag_table.is_none() {
678            require_positive("bc_value", self.inputs.bc_value)?;
679        }
680        require_positive("bullet_mass", self.inputs.bullet_mass)?;
681        require_positive("bullet_diameter", self.inputs.bullet_diameter)?;
682        require_positive("muzzle_velocity", self.inputs.muzzle_velocity)?;
683
684        require_finite("muzzle_angle", self.inputs.muzzle_angle)?;
685        require_finite("azimuth_angle", self.inputs.azimuth_angle)?;
686        require_finite("shooting_angle", self.inputs.shooting_angle)?;
687        require_finite("cant_angle", self.inputs.cant_angle)?;
688        require_finite("muzzle_height", self.inputs.muzzle_height)?;
689
690        // Negative infinity is the documented ignore-ground sentinel. NaN and positive infinity
691        // make the loop condition meaningless and are rejected.
692        if !(self.inputs.ground_threshold.is_finite()
693            || self.inputs.ground_threshold == f64::NEG_INFINITY)
694        {
695            return Err(BallisticsError::from(
696                "ground_threshold must be finite or negative infinity",
697            ));
698        }
699
700        if self.wind_sock.is_none() {
701            require_finite("wind.speed", self.wind.speed)?;
702            require_finite("wind.direction", self.wind.direction)?;
703            require_finite("wind.vertical_speed", self.wind.vertical_speed)?;
704        }
705
706        require_finite("atmosphere.temperature", self.atmosphere.temperature)?;
707        require_finite("atmosphere.pressure", self.atmosphere.pressure)?;
708        require_finite("atmosphere.humidity", self.atmosphere.humidity)?;
709        require_finite("atmosphere.altitude", self.atmosphere.altitude)?;
710
711        require_positive("max_range", self.max_range)?;
712        // Adaptive RK45 owns its step size; the caller-provided fixed step is used only by Euler
713        // and fixed RK4.
714        if !self.inputs.use_rk4 || !self.inputs.use_adaptive_rk45 {
715            require_positive("time_step", self.time_step)?;
716        }
717
718        if self.inputs.enable_trajectory_sampling {
719            require_finite("sight_height", self.inputs.sight_height)?;
720            require_positive("sample_interval", self.inputs.sample_interval)?;
721        }
722
723        if self.inputs.enable_coriolis {
724            require_finite("shot_azimuth", self.inputs.shot_azimuth)?;
725            if let Some(latitude) = self.inputs.latitude {
726                require_finite("latitude", latitude)?;
727            }
728        }
729
730        Ok(())
731    }
732
733    /// Public solve results must never report success with NaN or infinity. The input gate catches
734    /// malformed scalar state; this postcondition also covers overflow and malformed optional
735    /// tables/segments without imposing arbitrary upper bounds on otherwise finite inputs.
736    fn validate_result_finiteness(&self, result: &TrajectoryResult) -> Result<(), BallisticsError> {
737        let require_finite = |name: &str, value: f64| {
738            if value.is_finite() {
739                Ok(())
740            } else {
741                Err(BallisticsError::from(format!(
742                    "trajectory result contains non-finite {name}"
743                )))
744            }
745        };
746        let require_indexed_finite = |collection: &str, index: usize, field: &str, value: f64| {
747            if value.is_finite() {
748                Ok(())
749            } else {
750                Err(BallisticsError::from(format!(
751                    "trajectory result contains non-finite {collection}[{index}].{field}"
752                )))
753            }
754        };
755
756        require_finite("max_range", result.max_range)?;
757        require_finite("max_height", result.max_height)?;
758        require_finite("time_of_flight", result.time_of_flight)?;
759        require_finite("impact_velocity", result.impact_velocity)?;
760        require_finite("impact_energy", result.impact_energy)?;
761
762        for (index, point) in result.points.iter().enumerate() {
763            require_indexed_finite("points", index, "time", point.time)?;
764            require_indexed_finite("points", index, "position.x", point.position.x)?;
765            require_indexed_finite("points", index, "position.y", point.position.y)?;
766            require_indexed_finite("points", index, "position.z", point.position.z)?;
767            require_indexed_finite(
768                "points",
769                index,
770                "velocity_magnitude",
771                point.velocity_magnitude,
772            )?;
773            require_indexed_finite("points", index, "kinetic_energy", point.kinetic_energy)?;
774        }
775
776        if let Some(samples) = &result.sampled_points {
777            for (index, sample) in samples.iter().enumerate() {
778                require_indexed_finite("sampled_points", index, "distance_m", sample.distance_m)?;
779                require_indexed_finite("sampled_points", index, "drop_m", sample.drop_m)?;
780                require_indexed_finite(
781                    "sampled_points",
782                    index,
783                    "wind_drift_m",
784                    sample.wind_drift_m,
785                )?;
786                require_indexed_finite(
787                    "sampled_points",
788                    index,
789                    "velocity_mps",
790                    sample.velocity_mps,
791                )?;
792                require_indexed_finite("sampled_points", index, "energy_j", sample.energy_j)?;
793                require_indexed_finite("sampled_points", index, "time_s", sample.time_s)?;
794            }
795        }
796
797        for (name, value) in [
798            ("min_pitch_damping", result.min_pitch_damping),
799            ("transonic_mach", result.transonic_mach),
800            ("max_yaw_angle", result.max_yaw_angle),
801            ("max_precession_angle", result.max_precession_angle),
802        ] {
803            if let Some(value) = value {
804                require_finite(name, value)?;
805            }
806        }
807
808        if let Some(state) = result.angular_state {
809            for (name, value) in [
810                ("angular_state.pitch_angle", state.pitch_angle),
811                ("angular_state.yaw_angle", state.yaw_angle),
812                ("angular_state.pitch_rate", state.pitch_rate),
813                ("angular_state.yaw_rate", state.yaw_rate),
814                ("angular_state.precession_angle", state.precession_angle),
815                ("angular_state.nutation_phase", state.nutation_phase),
816            ] {
817                require_finite(name, value)?;
818            }
819        }
820
821        if let Some(jump) = result.aerodynamic_jump {
822            for (name, value) in [
823                ("aerodynamic_jump.vertical_jump_moa", jump.vertical_jump_moa),
824                (
825                    "aerodynamic_jump.horizontal_jump_moa",
826                    jump.horizontal_jump_moa,
827                ),
828                ("aerodynamic_jump.jump_angle_rad", jump.jump_angle_rad),
829                (
830                    "aerodynamic_jump.magnus_component_moa",
831                    jump.magnus_component_moa,
832                ),
833                ("aerodynamic_jump.yaw_component_moa", jump.yaw_component_moa),
834                (
835                    "aerodynamic_jump.stabilization_factor",
836                    jump.stabilization_factor,
837                ),
838            ] {
839                require_finite(name, value)?;
840            }
841        }
842
843        Ok(())
844    }
845
846    /// Integration methods store the pre-step state in `points`. Validate each newly accepted
847    /// state as well, otherwise a poisoned final step could terminate the loop and leave only the
848    /// previous finite point in an apparently successful result.
849    fn validate_integration_state(
850        position: &Vector3<f64>,
851        velocity: &Vector3<f64>,
852        time: f64,
853    ) -> Result<(), BallisticsError> {
854        if position.iter().all(|value| value.is_finite())
855            && velocity.iter().all(|value| value.is_finite())
856            && time.is_finite()
857        {
858            Ok(())
859        } else {
860            Err(BallisticsError::from(
861                "trajectory integration produced a non-finite state",
862            ))
863        }
864    }
865
866    /// Store one public trajectory point without exceeding the per-solve resource budget.
867    fn push_trajectory_point(
868        &self,
869        points: &mut Vec<TrajectoryPoint>,
870        point: TrajectoryPoint,
871    ) -> Result<(), BallisticsError> {
872        if points.len() >= self.max_trajectory_points {
873            return Err(BallisticsError::from(format!(
874                "trajectory point limit of {} exceeded",
875                self.max_trajectory_points
876            )));
877        }
878        points.push(point);
879        Ok(())
880    }
881
882    /// Supply downrange-segmented wind. Each segment is `(speed_kmh, angle_deg,
883    /// until_distance_m)`; the wind for a given downrange distance is the first
884    /// segment whose `until_distance_m` exceeds it (a step function), and wind is
885    /// zero beyond the last segment. An empty list clears segmented wind (reverts
886    /// to the scalar `wind`). The angle convention matches `WindConditions`
887    /// (0 = headwind, 90 = from the right).
888    pub fn set_wind_segments(&mut self, segments: Vec<crate::wind::WindSegment>) {
889        self.wind_sock = if segments.is_empty() {
890            None
891        } else {
892            Some(crate::wind::WindSock::new(segments))
893        };
894    }
895
896    /// Supply downrange-segmented atmosphere (MBA-1137). Each segment is
897    /// `(temp_c, pressure_hpa, humidity_percent, until_distance_m)`, defined at the shooter base
898    /// altitude; the per-substep local-atmosphere recompute selects the active zone by downrange
899    /// distance (first zone whose `until_distance_m` exceeds it; the last zone is held beyond the
900    /// final threshold). The zone's base conditions are composed with the vertical altitude lapse
901    /// via `get_local_atmosphere_humid`, so a steeply-arcing shot still sees the y-lapse on top of
902    /// the zone base. An empty list clears segmented atmosphere (reverts to the resolved
903    /// single-station conditions).
904    pub fn set_atmo_segments(&mut self, segments: Vec<crate::atmosphere::AtmoSegment>) {
905        self.atmo_sock = if segments.is_empty() {
906            None
907        } else {
908            Some(crate::atmosphere::AtmoSock::new(segments))
909        };
910    }
911
912    /// Effective initial launch direction `(elevation, azimuth)` in radians, including
913    /// the aerodynamic-jump muzzle perturbation when `enable_aerodynamic_jump` is set.
914    ///
915    /// Aerodynamic jump is the fixed angular departure imparted as the projectile
916    /// transitions from the constrained bore to free flight; applying it as an initial
917    /// launch-angle offset is the physically correct integration point. Returns the bare
918    /// `(muzzle_angle, azimuth_angle)` when the flag is off, so a default solve is
919    /// numerically identical to pre-feature behavior. (MBA-959)
920    fn launch_angles_from(
921        &self,
922        aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
923    ) -> (f64, f64) {
924        let (mut elev, mut azim) = (self.inputs.muzzle_angle, self.inputs.azimuth_angle);
925        // MBA-1286: cant rotates the sight-frame aim offsets about the line of sight.
926        // Positive = clockwise from the shooter: the upward zero correction leaks right
927        // (+z) and shrinks by cos(cant) -> POI right and low. Exactly-0.0 skips all float
928        // ops so un-canted solves stay bit-identical. Aerodynamic jump is added AFTER the
929        // rotation: it arises at bore exit from crosswind/spin in the ground frame, not
930        // from the rifle's sight geometry.
931        if self.inputs.cant_angle != 0.0 {
932            let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
933            let (e0, a0) = (elev, azim);
934            elev = e0 * cos_c - a0 * sin_c;
935            azim = a0 * cos_c + e0 * sin_c;
936        }
937        match aj {
938            Some(c) => {
939                // vertical_/horizontal_jump_moa ARE the jump angles expressed in MOA.
940                const MOA_PER_RAD: f64 = 3437.7467707849;
941                (
942                    elev + c.vertical_jump_moa / MOA_PER_RAD,
943                    azim + c.horizontal_jump_moa / MOA_PER_RAD,
944                )
945            }
946            None => (elev, azim),
947        }
948    }
949
950    /// Compute the aerodynamic-jump components for the current inputs, or `None` when the
951    /// feature is disabled / inputs are degenerate.
952    ///
953    /// Uses Bryan Litz's crosswind aerodynamic-jump estimator
954    /// (`Y = 0.01*Sg - 0.0024*L + 0.032` MOA/mph) fed by the engine's own Miller Sg.
955    /// Aerodynamic jump is a vertical effect, so only the elevation is perturbed.
956    /// The estimator is a regression best near Sg ~ 1.75 — see MBA-959.
957    fn aerodynamic_jump_components(
958        &self,
959    ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
960        if !self.inputs.enable_aerodynamic_jump {
961            return None;
962        }
963        // Reject degenerate/non-finite inputs before they can reach the launch angle.
964        // A bare `<= 0.0` test lets NaN through (NaN comparisons are always false), and a
965        // NaN/Inf here would poison the muzzle angle and collapse the whole trajectory.
966        let diameter_m = self.inputs.bullet_diameter;
967        if !(self.inputs.twist_rate.is_finite() && self.inputs.twist_rate != 0.0)
968            || !(diameter_m.is_finite() && diameter_m > 0.0)
969            || !(self.inputs.bullet_length.is_finite() && self.inputs.bullet_length > 0.0)
970            || !self.inputs.muzzle_velocity.is_finite()
971        {
972            return None;
973        }
974
975        // Engine's own gyroscopic (Miller) stability factor — same Sg shown elsewhere.
976        let (_, _, temp_c, pressure_hpa) = self.resolved_atmosphere();
977        let sg = crate::stability::compute_stability_coefficient(
978            &self.inputs,
979            (self.atmosphere.altitude, temp_c, pressure_hpa, 0.0),
980        );
981        if !(sg.is_finite() && sg > 0.0) {
982            return None;
983        }
984        let length_calibers = self.inputs.bullet_length / diameter_m;
985
986        // Crosswind-from-the-right (mph) for Litz's estimator. Wind direction uses the
987        // wind-FROM convention (0 = headwind, +90deg = from the right), matching the
988        // fast-integrate path (fast_trajectory::aerodynamic_jump_launch_offset_rad) and
989        // the lateral windage sign, so a from-the-right wind on a right-twist barrel
990        // jumps the impact UP and drifts it left.
991        const MS_TO_MPH: f64 = 2.236_936_292_054_4;
992        let crosswind_from_right_mps = if let Some(sock) = &self.wind_sock {
993            -sock.vector_for_range_stateless(0.0)[2]
994        } else {
995            self.wind.speed * self.wind.direction.sin()
996        };
997        let crosswind_from_right_mph = crosswind_from_right_mps * MS_TO_MPH;
998
999        let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
1000            sg,
1001            length_calibers,
1002            crosswind_from_right_mph,
1003            self.inputs.is_twist_right,
1004        );
1005        if !vertical_jump_moa.is_finite() {
1006            return None;
1007        }
1008
1009        const MOA_PER_RAD: f64 = 3437.7467707849;
1010        Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
1011            vertical_jump_moa,
1012            // Aerodynamic jump is a vertical effect; the Litz estimator has no horizontal term.
1013            horizontal_jump_moa: 0.0,
1014            jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
1015            magnus_component_moa: 0.0,
1016            yaw_component_moa: 0.0,
1017            stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
1018        })
1019    }
1020
1021    fn resolved_atmosphere(&self) -> (f64, f64, f64, f64) {
1022        let (temp_c, pressure_hpa) = crate::atmosphere::resolve_station_conditions(
1023            self.atmosphere.temperature,
1024            self.atmosphere.pressure,
1025            self.atmosphere.altitude,
1026        );
1027        let (density, speed_of_sound) = crate::atmosphere::calculate_atmosphere(
1028            self.atmosphere.altitude,
1029            Some(temp_c),
1030            Some(pressure_hpa),
1031            self.atmosphere.humidity,
1032        );
1033        (density, speed_of_sound, temp_c, pressure_hpa)
1034    }
1035
1036    fn precession_nutation_params(
1037        &self,
1038        velocity_mps: f64,
1039        air_density_kg_m3: f64,
1040        speed_of_sound_mps: f64,
1041    ) -> PrecessionNutationParams {
1042        let (spin_inertia, transverse_inertia) = self.precession_nutation_inertias;
1043        let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
1044            let velocity_fps = velocity_mps * 3.28084;
1045            let twist_rate_ft = self.inputs.twist_rate / 12.0;
1046            (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
1047        } else {
1048            0.0
1049        };
1050
1051        PrecessionNutationParams {
1052            mass_kg: self.inputs.bullet_mass,
1053            caliber_m: self.inputs.bullet_diameter,
1054            length_m: self.inputs.bullet_length,
1055            spin_rate_rad_s,
1056            spin_inertia,
1057            transverse_inertia,
1058            velocity_mps,
1059            air_density_kg_m3,
1060            mach: velocity_mps / speed_of_sound_mps,
1061            pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
1062            nutation_damping_factor: 0.05,
1063        }
1064    }
1065
1066    /// Append the state where the final integration step crossed `max_range`.
1067    ///
1068    /// Each solver stores its pre-step state, so a range crossing otherwise leaves the reported
1069    /// endpoint one step short. Ground- and time-limit exits that do not bracket `max_range` are
1070    /// intentionally left unchanged.
1071    fn append_max_range_endpoint(
1072        &self,
1073        points: &mut Vec<TrajectoryPoint>,
1074        post_position: Vector3<f64>,
1075        post_velocity: Vector3<f64>,
1076        post_time: f64,
1077        max_height: &mut f64,
1078    ) -> Result<(), BallisticsError> {
1079        let Some(previous) = points.last().cloned() else {
1080            return Ok(());
1081        };
1082        if previous.position.x >= self.max_range || post_position.x < self.max_range {
1083            return Ok(());
1084        }
1085
1086        let span = post_position.x - previous.position.x;
1087        if !span.is_finite() || span <= 1e-9 {
1088            return Ok(());
1089        }
1090
1091        let fraction = (self.max_range - previous.position.x) / span;
1092        let mut position = previous.position + (post_position - previous.position) * fraction;
1093        position.x = self.max_range;
1094        let velocity_magnitude = previous.velocity_magnitude
1095            + (post_velocity.magnitude() - previous.velocity_magnitude) * fraction;
1096        let time = previous.time + (post_time - previous.time) * fraction;
1097        let kinetic_energy =
1098            0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1099
1100        if position.y > *max_height {
1101            *max_height = position.y;
1102        }
1103        self.push_trajectory_point(
1104            points,
1105            TrajectoryPoint {
1106                time,
1107                position,
1108                velocity_magnitude,
1109                kinetic_energy,
1110            },
1111        )
1112    }
1113
1114    fn gravity_acceleration(&self) -> Vector3<f64> {
1115        let theta = self.inputs.shooting_angle;
1116        Vector3::new(
1117            -crate::constants::G_ACCEL_MPS2 * theta.sin(),
1118            -crate::constants::G_ACCEL_MPS2 * theta.cos(),
1119            0.0,
1120        )
1121    }
1122
1123    fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
1124        // Scale the operative surface wind by the boundary-layer multiplier. `altitude_m` is the
1125        // bullet's height relative to the muzzle (McCoy Y). The multiplier is floored at 1.0, so
1126        // flat-fire trajectories keep ~full wind and only high-arcing shots see increased wind.
1127        //
1128        // We build the vector with THIS solver's non-shear sign convention (X=-cos, Z=-sin; see
1129        // the `wind_vector` used in solve_rk4/solve_euler, matching WindSock) and scale it, so that
1130        // "shear on" equals "shear off" * ratio (ratio == 1.0 for flat fire). An earlier revision
1131        // attenuated the wind near the line of sight and flipped its sign relative to the non-shear
1132        // path; this keeps them sign-consistent.
1133        // Map the requested model name to the boundary-layer model (MBA-965).
1134        // Names match wind_shear::get_wind_at_position. Unknown strings should
1135        // never reach here (the CLI parses an enum), but default to PowerLaw to
1136        // preserve the historical "exponential" behaviour for any caller that
1137        // forwards an unexpected value.
1138        let model = match self.inputs.wind_shear_model.as_str() {
1139            "logarithmic" => WindShearModel::Logarithmic,
1140            "power_law" | "powerlaw" | "exponential" => WindShearModel::PowerLaw,
1141            "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
1142            "custom_layers" | "custom" => WindShearModel::CustomLayers,
1143            _ => WindShearModel::PowerLaw,
1144        };
1145        let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
1146
1147        // 0deg = headwind, 90deg = from the right (McCoy wind-FROM convention, matching
1148        // WindConditions / WindSock); wind enters drag via velocity - wind.
1149        //
1150        // MBA-728: the horizontal vector is built with vertical=0.0 and scaled by speed_ratio,
1151        // then wind.vertical_speed is added back UNSCALED — boundary-layer shear scales
1152        // horizontal wind only, vertical passes through as-is.
1153        crate::wind::wind_vector(self.wind.speed, self.wind.direction, 0.0) * speed_ratio
1154            + Vector3::new(0.0, self.wind.vertical_speed, 0.0)
1155    }
1156
1157    pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
1158        self.validate_for_solve()?;
1159        let mut result = if self.inputs.use_rk4 {
1160            if self.inputs.use_adaptive_rk45 {
1161                self.solve_rk45()?
1162            } else {
1163                self.solve_rk4()?
1164            }
1165        } else {
1166            self.solve_euler()?
1167        };
1168        self.apply_spin_drift(&mut result);
1169        self.validate_result_finiteness(&result)?;
1170        Ok(result)
1171    }
1172
1173    /// Gyroscopic spin drift via the empirical Litz model, applied in the engine
1174    /// (not the WASM formatter) so it covers Euler/RK4/RK45 and all consumers.
1175    /// Uses the canonical SI fields and converts to grains/inches correctly,
1176    /// avoiding the kg/m-vs-grains/in unit bug in `calculate_enhanced_spin_drift`.
1177    /// Frame (McCoy): Z = lateral (windage), so drift adds to `position.z`.
1178    fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
1179        if !self.inputs.use_enhanced_spin_drift {
1180            return;
1181        }
1182        let d_in = self.inputs.bullet_diameter / 0.0254; // m -> in
1183        let m_gr = self.inputs.bullet_mass / 0.00006479891; // kg -> grains
1184        let twist_in = self.inputs.twist_rate; // inches/turn
1185        if d_in <= 0.0 || m_gr <= 0.0 || twist_in <= 0.0 {
1186            return;
1187        }
1188
1189        // MBA-1134 (rank 31): single source of truth for the muzzle Sg —
1190        // stability::compute_stability_coefficient via spin_drift::effective_sg_from_inputs. This
1191        // ADDS the (v/2800)^(1/3) muzzle-velocity term the bare miller_stability() lacked, so the
1192        // spin-drift Sg now matches the reported SG and the aerodynamic-jump Sg. The linear Miller
1193        // density correction ((T/T0)*(P0/P), a no-op at sea-level standard) and the 4.5-caliber
1194        // length fallback are handled inside effective_sg_from_inputs.
1195        let sg = self.effective_spin_drift_sg();
1196
1197        for p in result.points.iter_mut() {
1198            if p.time <= 0.0 {
1199                continue;
1200            }
1201            // Canonical Litz drift, shared with the fast / Monte-Carlo path (spin_drift::litz_*).
1202            p.position.z +=
1203                crate::spin_drift::litz_drift_meters(sg, p.time, self.inputs.is_twist_right);
1204        }
1205
1206        // sampled_points are snapshotted from the PRE-drift trajectory inside each solver, so the
1207        // sampled wind_drift_m column would omit the spin drift that result.points carry. Apply
1208        // the same canonical Litz drift to keep the two user-facing outputs consistent.
1209        if let Some(samples) = result.sampled_points.as_mut() {
1210            for s in samples.iter_mut() {
1211                if s.time_s <= 0.0 {
1212                    continue;
1213                }
1214                s.wind_drift_m +=
1215                    crate::spin_drift::litz_drift_meters(sg, s.time_s, self.inputs.is_twist_right);
1216            }
1217        }
1218    }
1219
1220    /// Muzzle gyroscopic stability Sg used by the empirical Litz spin-drift post-process
1221    /// (MBA-1134). Extracted so the exact value is unit-testable and provably identical to the Sg
1222    /// the fast / Monte-Carlo path uses — both go through
1223    /// [`crate::spin_drift::effective_sg_from_inputs`] with the resolved muzzle atmosphere.
1224    fn effective_spin_drift_sg(&self) -> f64 {
1225        let (_, _, temp_c, press_hpa) = self.resolved_atmosphere();
1226        crate::spin_drift::effective_sg_from_inputs(&self.inputs, temp_c, press_hpa)
1227    }
1228
1229    /// Bore muzzle position at t=0 (bore-origin frame, `muzzle_height` above ground).
1230    /// With cant the rifle rotates about the LINE OF SIGHT, so the bore — sight_height
1231    /// below the sight — swings laterally by `-sight_height*sin(cant)` (left of the aim
1232    /// plane for clockwise cant) and rises by `sight_height*(1-cos(cant))` toward the
1233    /// pivot. Exactly-0.0 cant returns the historical position (bit-identical). (MBA-1286)
1234    fn initial_position(&self) -> Vector3<f64> {
1235        if self.inputs.cant_angle == 0.0 {
1236            return Vector3::new(0.0, self.inputs.muzzle_height, 0.0);
1237        }
1238        let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
1239        let sh = self.inputs.sight_height;
1240        Vector3::new(
1241            0.0,
1242            self.inputs.muzzle_height + sh * (1.0 - cos_c),
1243            -sh * sin_c,
1244        )
1245    }
1246
1247    fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
1248        // Simple trajectory integration using Euler method
1249        let mut time = 0.0;
1250        // Bullet starts at the BORE position, which is muzzle_height above ground
1251        // The sight is sight_height ABOVE the bore, so we don't add sight_height here
1252        // cant-adjusted via initial_position (MBA-1286)
1253        let mut position = self.initial_position();
1254        // Calculate initial velocity components with both elevation and azimuth
1255        // McCoy coordinate system: X=downrange, Y=vertical, Z=lateral (right)
1256        // Launch direction includes the aerodynamic-jump muzzle perturbation when enabled
1257        // (a no-op returning the bare muzzle/azimuth angles otherwise). MBA-959. Computed
1258        // once here and reused for the result so it isn't evaluated twice per solve.
1259        let aj_components = self.aerodynamic_jump_components();
1260        let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1261        let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1262        let mut velocity = Vector3::new(
1263            horizontal_velocity * launch_azim.cos(), // X: downrange (forward)
1264            self.inputs.muzzle_velocity * launch_elev.sin(), // Y: vertical component
1265            horizontal_velocity * launch_azim.sin(), // Z: lateral (side deviation)
1266        );
1267
1268        let mut points = Vec::new();
1269        let mut max_height = position.y;
1270        let mut min_pitch_damping = f64::INFINITY; // Track minimum pitch damping coefficient
1271        let mut transonic_mach = None; // Track when we enter transonic
1272                                       // Downrange distances where the projectile crosses Mach 1.2 (transonic) then Mach 1.0
1273                                       // (subsonic), so the sampled trajectory output can flag those transitions
1274                                       // (trajectory_sampling::add_trajectory_flags consumes this).
1275        let mut transonic_distances: Vec<f64> = Vec::new();
1276        let mut mach_transitions = MachTransitionTracker::default();
1277
1278        // Initialize angular state for precession/nutation tracking
1279        let mut angular_state = if self.inputs.enable_precession_nutation {
1280            Some(AngularState {
1281                pitch_angle: 0.001, // Small initial disturbance
1282                yaw_angle: 0.001,
1283                pitch_rate: 0.0,
1284                yaw_rate: 0.0,
1285                precession_angle: 0.0,
1286                nutation_phase: 0.0,
1287            })
1288        } else {
1289            None
1290        };
1291        let mut max_yaw_angle = 0.0;
1292        let mut max_precession_angle = 0.0;
1293
1294        // Calculate air density
1295        let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
1296            self.resolved_atmosphere();
1297        // MBA-1136 (rank 30): base density RATIO for the local-altitude atmosphere recompute done
1298        // per-substep inside calculate_acceleration. The `air_density` / `speed_of_sound` above
1299        // stay the frozen station values, still used for the Mach-transition, pitch-damping and
1300        // precession/nutation diagnostics (which are intentionally referenced to station Mach).
1301        let base_ratio = air_density / 1.225;
1302
1303        // Wind vector (McCoy): X=downrange (head/tail wind), Y=0, Z=lateral (crosswind)
1304        // 0deg = headwind, 90deg = from the right (McCoy wind-FROM convention, matching
1305        // WindSock); wind enters drag via velocity - wind. Used when no segmented wind.
1306        // MBA-728: no shear/no segments here, so vertical_speed passes straight through
1307        // (there is no horizontal-only scaling step on this path).
1308        let wind_vector =
1309            crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
1310
1311        // Pitch-damping coefficients depend only on the (constant) bullet_model; compute once
1312        // instead of re-deriving them (with a to_lowercase alloc) every integration step.
1313        let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1314            self.inputs.bullet_model.as_deref().unwrap_or("default"),
1315        );
1316
1317        // Main integration loop (X is downrange)
1318        while position.x < self.max_range
1319            && position.y > self.inputs.ground_threshold
1320            && time < 100.0
1321        {
1322            // Store trajectory point
1323            let velocity_magnitude = velocity.magnitude();
1324            let kinetic_energy =
1325                0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1326
1327            self.push_trajectory_point(
1328                &mut points,
1329                TrajectoryPoint {
1330                    time,
1331                    position,
1332                    velocity_magnitude,
1333                    kinetic_energy,
1334                },
1335            )?;
1336
1337            // Record Mach-transition distances (constant sea-level speed of sound, matching the
1338            // transonic_mach tracking). Each threshold is recorded once, in descending order.
1339            {
1340                let mach_here = if speed_of_sound > 0.0 {
1341                    velocity_magnitude / speed_of_sound
1342                } else {
1343                    0.0
1344                };
1345                mach_transitions.record_downward_crossings(
1346                    mach_here,
1347                    position.x,
1348                    &mut transonic_distances,
1349                );
1350            }
1351
1352            // Debug: log first and every 100th point. Debug builds only — this was ungated and
1353            // polluted release/WASM stderr on the --use-euler path (the other solvers have none).
1354            // McCoy coordinate system: X=downrange, Y=vertical, Z=lateral
1355            #[cfg(debug_assertions)]
1356            if points.len() == 1 || points.len() % 100 == 0 {
1357                eprintln!("Trajectory point {}: time={:.3}s, downrange={:.2}m, vertical={:.2}m, lateral={:.2}m, vel={:.1}m/s",
1358                    points.len(), time, position.x, position.y, position.z, velocity_magnitude);
1359            }
1360
1361            // Track max height
1362            if position.y > max_height {
1363                max_height = position.y;
1364            }
1365
1366            // Calculate pitch damping if enabled
1367            if self.inputs.enable_pitch_damping {
1368                let mach = velocity_magnitude / speed_of_sound;
1369
1370                // Track when we enter transonic
1371                if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1372                    transonic_mach = Some(mach);
1373                }
1374
1375                // Calculate pitch damping coefficient
1376                let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1377
1378                // Track minimum (most critical for stability)
1379                if pitch_damping < min_pitch_damping {
1380                    min_pitch_damping = pitch_damping;
1381                }
1382            }
1383
1384            // Calculate precession/nutation if enabled
1385            if self.inputs.enable_precession_nutation {
1386                if let Some(ref mut state) = angular_state {
1387                    let velocity_magnitude = velocity.magnitude();
1388                    let params = self.precession_nutation_params(
1389                        velocity_magnitude,
1390                        air_density,
1391                        speed_of_sound,
1392                    );
1393
1394                    // Update angular state
1395                    *state = calculate_combined_angular_motion(
1396                        &params,
1397                        state,
1398                        time,
1399                        self.time_step,
1400                        0.001, // Initial disturbance
1401                    );
1402
1403                    // Track maximums
1404                    if state.yaw_angle.abs() > max_yaw_angle {
1405                        max_yaw_angle = state.yaw_angle.abs();
1406                    }
1407                    if state.precession_angle.abs() > max_precession_angle {
1408                        max_precession_angle = state.precession_angle.abs();
1409                    }
1410                }
1411            }
1412
1413            // Use the same acceleration kernel as RK4/RK45 so all three solvers share ONE drag
1414            // model. solve_euler previously used a bespoke frontal-area drag (0.5*rho*Cd*A*v^2/m)
1415            // that IGNORED the ballistic coefficient entirely (diverging up to ~2.3x from the
1416            // BC-retardation RK4/RK45 path), and also omitted the Magnus/Coriolis terms.
1417            // calculate_acceleration applies BC-retardation drag, gravity, Coriolis, Magnus, wind
1418            // shear, and the zero-relative-velocity gravity-only guard.
1419            let acceleration = self.calculate_acceleration(
1420                &position,
1421                &velocity,
1422                &wind_vector,
1423                (resolved_temp_c, resolved_press_hpa, base_ratio),
1424            );
1425
1426            // Update state
1427            velocity += acceleration * self.time_step;
1428            position += velocity * self.time_step;
1429            time += self.time_step;
1430            Self::validate_integration_state(&position, &velocity, time)?;
1431        }
1432
1433        self.append_max_range_endpoint(&mut points, position, velocity, time, &mut max_height)?;
1434
1435        // Get final values
1436        let last_point = points.last().ok_or("No trajectory points generated")?;
1437
1438        // Create trajectory sampling data if enabled
1439        let sampled_points = if self.inputs.enable_trajectory_sampling {
1440            let trajectory_data = TrajectoryData {
1441                times: points.iter().map(|p| p.time).collect(),
1442                positions: points.iter().map(|p| p.position).collect(),
1443                velocities: points
1444                    .iter()
1445                    .map(|p| {
1446                        // Reconstruct velocity vectors from magnitude (approximate)
1447                        Vector3::new(0.0, 0.0, p.velocity_magnitude)
1448                    })
1449                    .collect(),
1450                transonic_distances, // populated above at each Mach-threshold crossing
1451            };
1452
1453            // For LOS calculation in ground-referenced coordinates:
1454            // sight_position_m is the sight's actual y-position above ground
1455            // (muzzle_height + sight_height, not just sight_height)
1456            // For flat shots, target is at same height as the sight (horizontal LOS)
1457            let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1458            let outputs = TrajectoryOutputs {
1459                target_distance_horiz_m: last_point.position.x, // X is downrange
1460                target_vertical_height_m: sight_position_m,
1461                time_of_flight_s: last_point.time,
1462                max_ord_dist_horiz_m: max_height,
1463                sight_height_m: sight_position_m,
1464            };
1465
1466            // Sample at specified intervals
1467            let samples = sample_trajectory(
1468                &trajectory_data,
1469                &outputs,
1470                self.inputs.sample_interval,
1471                self.inputs.bullet_mass,
1472            );
1473            Some(samples)
1474        } else {
1475            None
1476        };
1477
1478        Ok(TrajectoryResult {
1479            max_range: last_point.position.x, // X is downrange
1480            max_height,
1481            time_of_flight: last_point.time,
1482            impact_velocity: last_point.velocity_magnitude,
1483            impact_energy: last_point.kinetic_energy,
1484            points,
1485            sampled_points,
1486            min_pitch_damping: if self.inputs.enable_pitch_damping {
1487                Some(min_pitch_damping)
1488            } else {
1489                None
1490            },
1491            transonic_mach,
1492            angular_state,
1493            max_yaw_angle: if self.inputs.enable_precession_nutation {
1494                Some(max_yaw_angle)
1495            } else {
1496                None
1497            },
1498            max_precession_angle: if self.inputs.enable_precession_nutation {
1499                Some(max_precession_angle)
1500            } else {
1501                None
1502            },
1503            aerodynamic_jump: aj_components,
1504        })
1505    }
1506
1507    fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
1508        // RK4 trajectory integration for better accuracy
1509        let mut time = 0.0;
1510        // Bullet starts at the BORE position, which is muzzle_height above ground
1511        // The sight is sight_height ABOVE the bore, so we don't add sight_height here
1512        // The sight_height affects the LOS calculation and zero angle, not the starting position
1513        // cant-adjusted via initial_position (MBA-1286)
1514        let mut position = self.initial_position();
1515
1516        // Calculate initial velocity components with both elevation and azimuth
1517        // McCoy coordinate system: X=downrange, Y=vertical, Z=lateral (right)
1518        // Launch direction includes the aerodynamic-jump muzzle perturbation when enabled
1519        // (a no-op returning the bare muzzle/azimuth angles otherwise). MBA-959. Computed
1520        // once here and reused for the result so it isn't evaluated twice per solve.
1521        let aj_components = self.aerodynamic_jump_components();
1522        let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1523        let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1524        let mut velocity = Vector3::new(
1525            horizontal_velocity * launch_azim.cos(), // X: downrange (forward)
1526            self.inputs.muzzle_velocity * launch_elev.sin(), // Y: vertical component
1527            horizontal_velocity * launch_azim.sin(), // Z: lateral (side deviation)
1528        );
1529
1530        let mut points = Vec::new();
1531        let mut max_height = position.y;
1532        let mut min_pitch_damping = f64::INFINITY; // Track minimum pitch damping coefficient
1533        let mut transonic_mach = None; // Track when we enter transonic
1534                                       // Downrange distances where the projectile crosses Mach 1.2 (transonic) then Mach 1.0
1535                                       // (subsonic), so the sampled trajectory output can flag those transitions
1536                                       // (trajectory_sampling::add_trajectory_flags consumes this).
1537        let mut transonic_distances: Vec<f64> = Vec::new();
1538        let mut mach_transitions = MachTransitionTracker::default();
1539
1540        // Initialize angular state for precession/nutation tracking
1541        let mut angular_state = if self.inputs.enable_precession_nutation {
1542            Some(AngularState {
1543                pitch_angle: 0.001, // Small initial disturbance
1544                yaw_angle: 0.001,
1545                pitch_rate: 0.0,
1546                yaw_rate: 0.0,
1547                precession_angle: 0.0,
1548                nutation_phase: 0.0,
1549            })
1550        } else {
1551            None
1552        };
1553        let mut max_yaw_angle = 0.0;
1554        let mut max_precession_angle = 0.0;
1555
1556        // Calculate air density
1557        let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
1558            self.resolved_atmosphere();
1559        // MBA-1136 (rank 30): base density RATIO for the local-altitude atmosphere recompute done
1560        // per-substep inside calculate_acceleration. The `air_density` / `speed_of_sound` above
1561        // stay the frozen station values, still used for the Mach-transition, pitch-damping and
1562        // precession/nutation diagnostics (which are intentionally referenced to station Mach).
1563        let base_ratio = air_density / 1.225;
1564
1565        // Wind vector (McCoy): X=downrange (head/tail wind), Y=0, Z=lateral (crosswind)
1566        // 0deg = headwind, 90deg = from the right (McCoy wind-FROM convention, matching
1567        // WindSock); wind enters drag via velocity - wind. Used when no segmented wind.
1568        // MBA-728: no shear/no segments here, so vertical_speed passes straight through
1569        // (there is no horizontal-only scaling step on this path).
1570        let wind_vector =
1571            crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
1572
1573        // Pitch-damping coefficients depend only on the (constant) bullet_model; compute once
1574        // instead of re-deriving them (with a to_lowercase alloc) every integration step.
1575        let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1576            self.inputs.bullet_model.as_deref().unwrap_or("default"),
1577        );
1578
1579        // Main RK4 integration loop (X is downrange)
1580        while position.x < self.max_range
1581            && position.y > self.inputs.ground_threshold
1582            && time < 100.0
1583        {
1584            // Store trajectory point
1585            let velocity_magnitude = velocity.magnitude();
1586            let kinetic_energy =
1587                0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
1588
1589            self.push_trajectory_point(
1590                &mut points,
1591                TrajectoryPoint {
1592                    time,
1593                    position,
1594                    velocity_magnitude,
1595                    kinetic_energy,
1596                },
1597            )?;
1598
1599            // Record Mach-transition distances (constant sea-level speed of sound, matching the
1600            // transonic_mach tracking). Each threshold is recorded once, in descending order.
1601            {
1602                let mach_here = if speed_of_sound > 0.0 {
1603                    velocity_magnitude / speed_of_sound
1604                } else {
1605                    0.0
1606                };
1607                mach_transitions.record_downward_crossings(
1608                    mach_here,
1609                    position.x,
1610                    &mut transonic_distances,
1611                );
1612            }
1613
1614            if position.y > max_height {
1615                max_height = position.y;
1616            }
1617
1618            // Calculate pitch damping if enabled (RK4 solver)
1619            if self.inputs.enable_pitch_damping {
1620                let mach = velocity_magnitude / speed_of_sound;
1621
1622                // Track when we enter transonic
1623                if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1624                    transonic_mach = Some(mach);
1625                }
1626
1627                // Calculate pitch damping coefficient
1628                let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1629
1630                // Track minimum (most critical for stability)
1631                if pitch_damping < min_pitch_damping {
1632                    min_pitch_damping = pitch_damping;
1633                }
1634            }
1635
1636            // Calculate precession/nutation if enabled (RK4 solver)
1637            if self.inputs.enable_precession_nutation {
1638                if let Some(ref mut state) = angular_state {
1639                    let velocity_magnitude = velocity.magnitude();
1640                    let params = self.precession_nutation_params(
1641                        velocity_magnitude,
1642                        air_density,
1643                        speed_of_sound,
1644                    );
1645
1646                    // Update angular state
1647                    *state = calculate_combined_angular_motion(
1648                        &params,
1649                        state,
1650                        time,
1651                        self.time_step,
1652                        0.001, // Initial disturbance
1653                    );
1654
1655                    // Track maximums
1656                    if state.yaw_angle.abs() > max_yaw_angle {
1657                        max_yaw_angle = state.yaw_angle.abs();
1658                    }
1659                    if state.precession_angle.abs() > max_precession_angle {
1660                        max_precession_angle = state.precession_angle.abs();
1661                    }
1662                }
1663            }
1664
1665            // RK4 method
1666            let dt = self.time_step;
1667
1668            // k1
1669            let acc1 = self.calculate_acceleration(
1670                &position,
1671                &velocity,
1672                &wind_vector,
1673                (resolved_temp_c, resolved_press_hpa, base_ratio),
1674            );
1675
1676            // k2
1677            let pos2 = position + velocity * (dt * 0.5);
1678            let vel2 = velocity + acc1 * (dt * 0.5);
1679            let acc2 = self.calculate_acceleration(
1680                &pos2,
1681                &vel2,
1682                &wind_vector,
1683                (resolved_temp_c, resolved_press_hpa, base_ratio),
1684            );
1685
1686            // k3
1687            let pos3 = position + vel2 * (dt * 0.5);
1688            let vel3 = velocity + acc2 * (dt * 0.5);
1689            let acc3 = self.calculate_acceleration(
1690                &pos3,
1691                &vel3,
1692                &wind_vector,
1693                (resolved_temp_c, resolved_press_hpa, base_ratio),
1694            );
1695
1696            // k4
1697            let pos4 = position + vel3 * dt;
1698            let vel4 = velocity + acc3 * dt;
1699            let acc4 = self.calculate_acceleration(
1700                &pos4,
1701                &vel4,
1702                &wind_vector,
1703                (resolved_temp_c, resolved_press_hpa, base_ratio),
1704            );
1705
1706            // Update position and velocity
1707            position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
1708            velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
1709            time += dt;
1710            Self::validate_integration_state(&position, &velocity, time)?;
1711        }
1712
1713        self.append_max_range_endpoint(&mut points, position, velocity, time, &mut max_height)?;
1714
1715        // Get final values
1716        let last_point = points.last().ok_or("No trajectory points generated")?;
1717
1718        // Create trajectory sampling data if enabled
1719        let sampled_points = if self.inputs.enable_trajectory_sampling {
1720            let trajectory_data = TrajectoryData {
1721                times: points.iter().map(|p| p.time).collect(),
1722                positions: points.iter().map(|p| p.position).collect(),
1723                velocities: points
1724                    .iter()
1725                    .map(|p| {
1726                        // Reconstruct velocity vectors from magnitude (approximate)
1727                        Vector3::new(0.0, 0.0, p.velocity_magnitude)
1728                    })
1729                    .collect(),
1730                transonic_distances, // populated above at each Mach-threshold crossing
1731            };
1732
1733            // For LOS calculation in ground-referenced coordinates:
1734            // sight_position_m is the sight's actual y-position above ground
1735            // (muzzle_height + sight_height, not just sight_height)
1736            // For flat shots, target is at same height as the sight (horizontal LOS)
1737            let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1738            let outputs = TrajectoryOutputs {
1739                target_distance_horiz_m: last_point.position.x, // X is downrange
1740                target_vertical_height_m: sight_position_m,
1741                time_of_flight_s: last_point.time,
1742                max_ord_dist_horiz_m: max_height,
1743                sight_height_m: sight_position_m,
1744            };
1745
1746            // Sample at specified intervals
1747            let samples = sample_trajectory(
1748                &trajectory_data,
1749                &outputs,
1750                self.inputs.sample_interval,
1751                self.inputs.bullet_mass,
1752            );
1753            Some(samples)
1754        } else {
1755            None
1756        };
1757
1758        Ok(TrajectoryResult {
1759            max_range: last_point.position.x, // X is downrange
1760            max_height,
1761            time_of_flight: last_point.time,
1762            impact_velocity: last_point.velocity_magnitude,
1763            impact_energy: last_point.kinetic_energy,
1764            points,
1765            sampled_points,
1766            min_pitch_damping: if self.inputs.enable_pitch_damping {
1767                Some(min_pitch_damping)
1768            } else {
1769                None
1770            },
1771            transonic_mach,
1772            angular_state,
1773            max_yaw_angle: if self.inputs.enable_precession_nutation {
1774                Some(max_yaw_angle)
1775            } else {
1776                None
1777            },
1778            max_precession_angle: if self.inputs.enable_precession_nutation {
1779                Some(max_precession_angle)
1780            } else {
1781                None
1782            },
1783            aerodynamic_jump: aj_components,
1784        })
1785    }
1786
1787    fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
1788        // RK45 adaptive step size integration (Dormand-Prince method)
1789        let mut time = 0.0;
1790        // Bullet starts at the BORE position, which is muzzle_height above ground
1791        // The sight is sight_height ABOVE the bore, so we don't add sight_height here
1792        // cant-adjusted via initial_position (MBA-1286)
1793        let mut position = self.initial_position();
1794
1795        // Calculate initial velocity components
1796        // McCoy coordinate system: X=downrange, Y=vertical, Z=lateral (right)
1797        // Launch direction includes the aerodynamic-jump muzzle perturbation when enabled
1798        // (a no-op returning the bare muzzle/azimuth angles otherwise). MBA-959. Computed
1799        // once here and reused for the result so it isn't evaluated twice per solve.
1800        let aj_components = self.aerodynamic_jump_components();
1801        let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
1802        let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
1803        let mut velocity = Vector3::new(
1804            horizontal_velocity * launch_azim.cos(), // X: downrange (forward)
1805            self.inputs.muzzle_velocity * launch_elev.sin(), // Y: vertical component
1806            horizontal_velocity * launch_azim.sin(), // Z: lateral (side deviation)
1807        );
1808
1809        let mut points = Vec::new();
1810        let mut max_height = position.y;
1811        let mut dt = 0.001; // Initial step size
1812
1813        // Air density and wind are constant for the whole solve (self.atmosphere / self.wind
1814        // are immutable); compute once instead of every iteration (mirrors solve_rk4).
1815        let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
1816            self.resolved_atmosphere();
1817        // MBA-1136 (rank 30): base density RATIO for the local-altitude atmosphere recompute done
1818        // per-substep inside calculate_acceleration. The `air_density` / `speed_of_sound` above
1819        // stay the frozen station values, still used for the Mach-transition, pitch-damping and
1820        // precession/nutation diagnostics (which are intentionally referenced to station Mach).
1821        let base_ratio = air_density / 1.225;
1822        // 0deg = headwind, 90deg = from the right (McCoy wind-FROM convention, matching
1823        // WindSock); wind enters drag via velocity - wind. Used when no segmented wind.
1824        // MBA-728: no shear/no segments here, so vertical_speed passes straight through
1825        // (there is no horizontal-only scaling step on this path).
1826        let wind_vector =
1827            crate::wind::wind_vector(self.wind.speed, self.wind.direction, self.wind.vertical_speed);
1828
1829        // Mach-transition distances for the sampled-output flags (see solve_euler/solve_rk4).
1830        let mut transonic_distances: Vec<f64> = Vec::new();
1831        let mut mach_transitions = MachTransitionTracker::default();
1832
1833        // Pitch-damping / precession diagnostics (MBA-966). Previously only the
1834        // Euler and fixed-RK4 solvers tracked these, so the default adaptive
1835        // RK45 path always reported null even with --enable-pitch-damping /
1836        // --enable-precession set. Mirror the RK4 tracking here.
1837        let mut min_pitch_damping = f64::INFINITY;
1838        let mut transonic_mach: Option<f64> = None;
1839        let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
1840            self.inputs.bullet_model.as_deref().unwrap_or("default"),
1841        );
1842        let mut angular_state = if self.inputs.enable_precession_nutation {
1843            Some(AngularState {
1844                pitch_angle: 0.001,
1845                yaw_angle: 0.001,
1846                pitch_rate: 0.0,
1847                yaw_rate: 0.0,
1848                precession_angle: 0.0,
1849                nutation_phase: 0.0,
1850            })
1851        } else {
1852            None
1853        };
1854        let mut max_yaw_angle = 0.0;
1855        let mut max_precession_angle = 0.0;
1856
1857        while position.x < self.max_range
1858            && position.y > self.inputs.ground_threshold
1859            && time < 100.0
1860        {
1861            // Store current point
1862            let velocity_magnitude = velocity.magnitude();
1863            let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
1864
1865            self.push_trajectory_point(
1866                &mut points,
1867                TrajectoryPoint {
1868                    time,
1869                    position,
1870                    velocity_magnitude,
1871                    kinetic_energy,
1872                },
1873            )?;
1874
1875            // Record Mach-transition distances (constant sea-level speed of sound, matching the
1876            // transonic_mach tracking). Each threshold is recorded once, in descending order.
1877            {
1878                let mach_here = if speed_of_sound > 0.0 {
1879                    velocity_magnitude / speed_of_sound
1880                } else {
1881                    0.0
1882                };
1883                mach_transitions.record_downward_crossings(
1884                    mach_here,
1885                    position.x,
1886                    &mut transonic_distances,
1887                );
1888            }
1889
1890            if position.y > max_height {
1891                max_height = position.y;
1892            }
1893
1894            // Pitch damping (RK45 solver) — track the minimum coefficient and the
1895            // Mach at which the projectile enters the transonic band (MBA-966).
1896            if self.inputs.enable_pitch_damping {
1897                let mach = velocity_magnitude / speed_of_sound;
1898                if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
1899                    transonic_mach = Some(mach);
1900                }
1901                let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
1902                if pitch_damping < min_pitch_damping {
1903                    min_pitch_damping = pitch_damping;
1904                }
1905            }
1906
1907            // Retry the same state until the embedded error estimate accepts the
1908            // candidate. No trajectory or angular state advances on rejection.
1909            let accepted_step = self.adaptive_rk45_step(
1910                &position,
1911                &velocity,
1912                dt,
1913                &wind_vector,
1914                (resolved_temp_c, resolved_press_hpa, base_ratio),
1915            );
1916            debug_assert!(
1917                accepted_step.error <= RK45_TOLERANCE || accepted_step.used_dt <= RK45_MIN_DT
1918            );
1919
1920            // Precession / nutation advances only after the translational step
1921            // is accepted, using that accepted interval rather than a rejected
1922            // trial's dt.
1923            if self.inputs.enable_precession_nutation {
1924                if let Some(ref mut state) = angular_state {
1925                    let params = self.precession_nutation_params(
1926                        velocity_magnitude,
1927                        air_density,
1928                        speed_of_sound,
1929                    );
1930
1931                    *state = calculate_combined_angular_motion(
1932                        &params,
1933                        state,
1934                        time,
1935                        accepted_step.used_dt,
1936                        0.001,
1937                    );
1938
1939                    if state.yaw_angle.abs() > max_yaw_angle {
1940                        max_yaw_angle = state.yaw_angle.abs();
1941                    }
1942                    if state.precession_angle.abs() > max_precession_angle {
1943                        max_precession_angle = state.precession_angle.abs();
1944                    }
1945                }
1946            }
1947
1948            position = accepted_step.position;
1949            velocity = accepted_step.velocity;
1950            time += accepted_step.used_dt;
1951            Self::validate_integration_state(&position, &velocity, time)?;
1952
1953            // Adapt the step size for the NEXT iteration.
1954            dt = accepted_step.next_dt;
1955        }
1956
1957        // Ensure we have at least one point
1958        if points.is_empty() {
1959            return Err(BallisticsError::from("No trajectory points calculated"));
1960        }
1961
1962        // Shared MBA-968/MBA-1218 range-crossing interpolation for all solver modes.
1963        self.append_max_range_endpoint(&mut points, position, velocity, time, &mut max_height)?;
1964
1965        let last_point = points.last().unwrap();
1966
1967        // Generate sampled trajectory points if enabled
1968        let sampled_points = if self.inputs.enable_trajectory_sampling {
1969            // Build trajectory data for sampling
1970            let trajectory_data = TrajectoryData {
1971                times: points.iter().map(|p| p.time).collect(),
1972                positions: points.iter().map(|p| p.position).collect(),
1973                velocities: points
1974                    .iter()
1975                    .map(|p| {
1976                        // Approximate velocity direction from position changes
1977                        Vector3::new(0.0, 0.0, p.velocity_magnitude)
1978                    })
1979                    .collect(),
1980                transonic_distances, // populated at each Mach-threshold crossing
1981            };
1982
1983            // For LOS calculation in ground-referenced coordinates:
1984            // sight_position_m is the sight's actual y-position above ground
1985            // (muzzle_height + sight_height, not just sight_height)
1986            // For flat shots, target is at same height as the sight (horizontal LOS)
1987            let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
1988            let outputs = TrajectoryOutputs {
1989                target_distance_horiz_m: last_point.position.x,
1990                target_vertical_height_m: sight_position_m,
1991                time_of_flight_s: last_point.time,
1992                max_ord_dist_horiz_m: max_height,
1993                sight_height_m: sight_position_m,
1994            };
1995
1996            let samples = sample_trajectory(
1997                &trajectory_data,
1998                &outputs,
1999                self.inputs.sample_interval,
2000                self.inputs.bullet_mass,
2001            );
2002            Some(samples)
2003        } else {
2004            None
2005        };
2006
2007        Ok(TrajectoryResult {
2008            max_range: last_point.position.x, // X is downrange
2009            max_height,
2010            time_of_flight: last_point.time,
2011            impact_velocity: last_point.velocity_magnitude,
2012            impact_energy: last_point.kinetic_energy,
2013            points,
2014            sampled_points,
2015            min_pitch_damping: if self.inputs.enable_pitch_damping {
2016                Some(min_pitch_damping)
2017            } else {
2018                None
2019            },
2020            transonic_mach,
2021            angular_state,
2022            max_yaw_angle: if self.inputs.enable_precession_nutation {
2023                Some(max_yaw_angle)
2024            } else {
2025                None
2026            },
2027            max_precession_angle: if self.inputs.enable_precession_nutation {
2028                Some(max_precession_angle)
2029            } else {
2030                None
2031            },
2032            aerodynamic_jump: aj_components,
2033        })
2034    }
2035
2036    fn adaptive_rk45_step(
2037        &self,
2038        position: &Vector3<f64>,
2039        velocity: &Vector3<f64>,
2040        initial_dt: f64,
2041        wind_vector: &Vector3<f64>,
2042        resolved_atmo: (f64, f64, f64),
2043    ) -> Rk45AcceptedStep {
2044        let mut trial_dt = initial_dt;
2045
2046        loop {
2047            let trial = self.rk45_step(
2048                position,
2049                velocity,
2050                trial_dt,
2051                wind_vector,
2052                RK45_TOLERANCE,
2053                resolved_atmo,
2054            );
2055            // A finite-but-extreme input or malformed optional curve can overflow an embedded
2056            // trial. Do not let a NaN suggested step poison `trial_dt` and retry forever: shrink
2057            // to the minimum step, return the non-finite trial there, and let the immediate
2058            // integration-state check turn it into a clean Err.
2059            let next_dt = if trial.suggested_dt.is_finite() {
2060                (RK45_SAFETY_FACTOR * trial.suggested_dt).clamp(RK45_MIN_DT, RK45_MAX_DT)
2061            } else {
2062                RK45_MIN_DT
2063            };
2064
2065            if trial.error <= RK45_TOLERANCE || trial_dt <= RK45_MIN_DT {
2066                return Rk45AcceptedStep {
2067                    position: trial.position,
2068                    velocity: trial.velocity,
2069                    used_dt: trial_dt,
2070                    next_dt,
2071                    error: trial.error,
2072                };
2073            }
2074
2075            trial_dt = next_dt;
2076        }
2077    }
2078
2079    fn rk45_step(
2080        &self,
2081        position: &Vector3<f64>,
2082        velocity: &Vector3<f64>,
2083        dt: f64,
2084        wind_vector: &Vector3<f64>,
2085        tolerance: f64,
2086        resolved_atmo: (f64, f64, f64), // (base_temp_c, base_press_hpa, base_ratio)
2087    ) -> Rk45Trial {
2088        // Dormand-Prince coefficients
2089        const A21: f64 = 1.0 / 5.0;
2090        const A31: f64 = 3.0 / 40.0;
2091        const A32: f64 = 9.0 / 40.0;
2092        const A41: f64 = 44.0 / 45.0;
2093        const A42: f64 = -56.0 / 15.0;
2094        const A43: f64 = 32.0 / 9.0;
2095        const A51: f64 = 19372.0 / 6561.0;
2096        const A52: f64 = -25360.0 / 2187.0;
2097        const A53: f64 = 64448.0 / 6561.0;
2098        const A54: f64 = -212.0 / 729.0;
2099        const A61: f64 = 9017.0 / 3168.0;
2100        const A62: f64 = -355.0 / 33.0;
2101        const A63: f64 = 46732.0 / 5247.0;
2102        const A64: f64 = 49.0 / 176.0;
2103        const A65: f64 = -5103.0 / 18656.0;
2104        const A71: f64 = 35.0 / 384.0;
2105        const A73: f64 = 500.0 / 1113.0;
2106        const A74: f64 = 125.0 / 192.0;
2107        const A75: f64 = -2187.0 / 6784.0;
2108        const A76: f64 = 11.0 / 84.0;
2109
2110        // 5th order coefficients
2111        const B1: f64 = 35.0 / 384.0;
2112        const B3: f64 = 500.0 / 1113.0;
2113        const B4: f64 = 125.0 / 192.0;
2114        const B5: f64 = -2187.0 / 6784.0;
2115        const B6: f64 = 11.0 / 84.0;
2116
2117        // 4th order coefficients for error estimation
2118        const B1_ERR: f64 = 5179.0 / 57600.0;
2119        const B3_ERR: f64 = 7571.0 / 16695.0;
2120        const B4_ERR: f64 = 393.0 / 640.0;
2121        const B5_ERR: f64 = -92097.0 / 339200.0;
2122        const B6_ERR: f64 = 187.0 / 2100.0;
2123        const B7_ERR: f64 = 1.0 / 40.0;
2124
2125        // Compute RK45 stages
2126        let k1_v = self.calculate_acceleration(position, velocity, wind_vector, resolved_atmo);
2127        let k1_p = *velocity;
2128
2129        let p2 = position + dt * A21 * k1_p;
2130        let v2 = velocity + dt * A21 * k1_v;
2131        let k2_v = self.calculate_acceleration(&p2, &v2, wind_vector, resolved_atmo);
2132        let k2_p = v2;
2133
2134        let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
2135        let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
2136        let k3_v = self.calculate_acceleration(&p3, &v3, wind_vector, resolved_atmo);
2137        let k3_p = v3;
2138
2139        let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
2140        let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
2141        let k4_v = self.calculate_acceleration(&p4, &v4, wind_vector, resolved_atmo);
2142        let k4_p = v4;
2143
2144        let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
2145        let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
2146        let k5_v = self.calculate_acceleration(&p5, &v5, wind_vector, resolved_atmo);
2147        let k5_p = v5;
2148
2149        let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
2150        let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
2151        let k6_v = self.calculate_acceleration(&p6, &v6, wind_vector, resolved_atmo);
2152        let k6_p = v6;
2153
2154        let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
2155        let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
2156        let k7_v = self.calculate_acceleration(&p7, &v7, wind_vector, resolved_atmo);
2157        let k7_p = v7;
2158
2159        // 5th order solution
2160        let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
2161        let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
2162
2163        // 4th order solution for error estimate
2164        let pos_err = position
2165            + dt * (B1_ERR * k1_p
2166                + B3_ERR * k3_p
2167                + B4_ERR * k4_p
2168                + B5_ERR * k5_p
2169                + B6_ERR * k6_p
2170                + B7_ERR * k7_p);
2171        let vel_err = velocity
2172            + dt * (B1_ERR * k1_v
2173                + B3_ERR * k3_v
2174                + B4_ERR * k4_v
2175                + B5_ERR * k5_v
2176                + B6_ERR * k6_v
2177                + B7_ERR * k7_v);
2178
2179        // Estimate error
2180        let error = cli_rk45_error_norm(position, velocity, &new_pos, &new_vel, &pos_err, &vel_err);
2181
2182        // Calculate new step size
2183        let dt_new = if error < tolerance {
2184            dt * (tolerance / error).powf(0.2).min(2.0)
2185        } else {
2186            dt * (tolerance / error).powf(0.25).max(0.1)
2187        };
2188
2189        Rk45Trial {
2190            position: new_pos,
2191            velocity: new_vel,
2192            suggested_dt: dt_new,
2193            error,
2194        }
2195    }
2196
2197    fn apply_cluster_bc_correction(&self, base_bc: f64, velocity_fps: f64) -> f64 {
2198        if let Some(ref cluster_bc) = self.cluster_bc {
2199            cluster_bc.apply_correction_for_drag_model(
2200                base_bc,
2201                self.inputs.caliber_inches,
2202                self.inputs.weight_grains,
2203                velocity_fps,
2204                self.inputs.bc_type,
2205            )
2206        } else {
2207            base_bc
2208        }
2209    }
2210
2211    fn calculate_acceleration(
2212        &self,
2213        position: &Vector3<f64>,
2214        velocity: &Vector3<f64>,
2215        wind_vector: &Vector3<f64>,
2216        resolved_atmo: (f64, f64, f64), // (base_temp_c, base_press_hpa, base_ratio) hoisted per-solve
2217    ) -> Vector3<f64> {
2218        // Resolve the wind at this point. Downrange-segmented wind (when supplied)
2219        // takes precedence and is sampled by downrange distance (position.x) per
2220        // step; otherwise altitude-dependent shear (if enabled); otherwise the
2221        // constant `wind_vector`. Segmented wind is not combined with shear (the
2222        // CLI/WASM front-ends reject that combination), so the order is safe.
2223        let actual_wind = if let Some(ref sock) = self.wind_sock {
2224            sock.vector_for_range_stateless(position.x)
2225        } else if self.inputs.enable_wind_shear {
2226            self.get_wind_at_altitude(position.y)
2227        } else {
2228            *wind_vector
2229        };
2230        let actual_wind =
2231            crate::derivatives::level_vector_to_shot_frame(actual_wind, self.inputs.shooting_angle);
2232
2233        let relative_velocity = velocity - actual_wind;
2234        let velocity_magnitude = relative_velocity.magnitude();
2235
2236        if velocity_magnitude < 0.001 {
2237            return self.gravity_acceleration();
2238        }
2239
2240        // MBA-1136 (rank 30): recompute the atmosphere at the LOCAL substep altitude instead of
2241        // holding the frozen station scalars for the whole flight. This mirrors what
2242        // derivatives.rs / fast_trajectory.rs already do, so all three solver families vary air
2243        // density AND speed of sound with altitude (matters on elevated / long-range shots; a
2244        // no-op at the shooter altitude, where the ratio-based density recovers the station value
2245        // exactly). base_* were resolved once per solve via resolved_atmosphere().
2246        //
2247        // `base_temp_c` / `base_press_hpa` are the STATION conditions that seed the local
2248        // atmosphere calculation below. Magnus dynamic stability consumes the resulting local
2249        // density rather than freezing a launch-density correction.
2250        let (base_temp_c, base_press_hpa, station_ratio) = resolved_atmo;
2251
2252        // MBA-1137: downrange-segmented atmosphere. When an AtmoSock is present, swap the BASE
2253        // (station-referenced) T/P/H tuple for the active zone selected by downrange distance
2254        // (position.x), recomputing the per-zone base density ratio via CIPM. That swapped base
2255        // then flows through the SAME altitude-lapse pipeline, so downrange zone selection and the
2256        // world-vertical altitude lapse compose — the zone sets the base density/humidity, and the
2257        // lapse multiplies on top of it (no double-count). When None, this is the resolved
2258        // single-station base.
2259        let (drag_base_temp_c, drag_base_press_hpa, drag_base_ratio, drag_humidity_percent) =
2260            if let Some(ref sock) = self.atmo_sock {
2261                let (zone_temp_c, zone_press_hpa, zone_humidity) = sock.atmo_for_range(position.x);
2262                let zone_base_ratio = crate::atmosphere::calculate_air_density_cimp(
2263                    zone_temp_c,
2264                    zone_press_hpa,
2265                    zone_humidity,
2266                ) / 1.225;
2267                (zone_temp_c, zone_press_hpa, zone_base_ratio, zone_humidity)
2268            } else {
2269                (
2270                    base_temp_c,
2271                    base_press_hpa,
2272                    station_ratio,
2273                    self.atmosphere.humidity,
2274                )
2275            };
2276        let local_alt = crate::atmosphere::shot_frame_altitude(
2277            self.atmosphere.altitude,
2278            position.x,
2279            position.y,
2280            self.inputs.shooting_angle,
2281        );
2282        let (air_density, speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
2283            local_alt,
2284            self.atmosphere.altitude,
2285            drag_base_temp_c,
2286            drag_base_press_hpa,
2287            drag_base_ratio,
2288            drag_humidity_percent,
2289        );
2290
2291        // Get drag coefficient from drag model (Mach-indexed from drag tables)
2292        let cd = self.calculate_drag_coefficient(velocity_magnitude, speed_of_sound);
2293
2294        // Convert velocity to fps for BC lookups
2295        let velocity_fps = velocity_magnitude * 3.28084;
2296
2297        // Match the other solver families' BC precedence: enabled velocity-keyed segments first,
2298        // then legacy Mach-keyed segments, then the scalar BC. `use_bc_segments` gates velocity
2299        // tables, while explicit Mach segments remain active when it is false; derivatives.rs and
2300        // the fast solver preserve that legacy contract for callers that provide a Mach table.
2301        let (base_bc, bc_from_segments) = if let Some(segments) = self
2302            .inputs
2303            .bc_segments_data
2304            .as_ref()
2305            .filter(|segments| self.inputs.use_bc_segments && !segments.is_empty())
2306        {
2307            // Find matching segment for current velocity.
2308            (
2309                crate::bc_estimation::velocity_segment_bc(
2310                    velocity_fps,
2311                    segments,
2312                    self.inputs.bc_value,
2313                ),
2314                true,
2315            )
2316        } else if let Some(segments) = self
2317            .inputs
2318            .bc_segments
2319            .as_ref()
2320            .filter(|segments| !segments.is_empty())
2321        {
2322            (
2323                crate::derivatives::interpolated_bc(
2324                    velocity_magnitude / speed_of_sound,
2325                    segments,
2326                    Some(&self.inputs),
2327                ),
2328                true,
2329            )
2330        } else {
2331            (self.inputs.bc_value, false)
2332        };
2333
2334        // Segment tables already own the velocity-dependent BC shape. Stacking the empirical
2335        // cluster ladder on top would apply that shape twice (MBA-1175). Cluster correction is
2336        // therefore only a fallback for a scalar BC, regardless of which explicit segment
2337        // representation supplied the active value.
2338        let effective_bc = if bc_from_segments {
2339            base_bc
2340        } else {
2341            self.apply_cluster_bc_correction(base_bc, velocity_fps)
2342        };
2343        // The scalar BC is validated at the solve boundary. Retain a small denominator floor for
2344        // explicit segment tables, whose interpolated values are independent caller data.
2345        let effective_bc = effective_bc.max(1e-6);
2346
2347        // When a custom drag table is active, calculate_drag_coefficient returned the
2348        // projectile's ACTUAL Cd, so the retardation denominator must be the sectional
2349        // density (lb/in²), not a BC: Cd_own / SD == Cd_ref / BC
2350        // (see BallisticInputs::custom_drag_denominator).
2351        let retard_denom = if self.inputs.custom_drag_table.is_some() {
2352            self.inputs.custom_drag_denominator(effective_bc)
2353        } else {
2354            effective_bc
2355        };
2356
2357        // Use proper ballistics retardation formula
2358        // This matches the proven formula from fast_trajectory.rs
2359        // The standard retardation factor converts Cd to drag deceleration
2360        // Note: velocity_fps already calculated above for BC segment lookup
2361        let cd_to_retard = crate::constants::CD_TO_RETARD;
2362        let standard_factor = cd * cd_to_retard;
2363        let density_scale = air_density / 1.225; // Scale relative to standard air (1.225 kg/m³)
2364
2365        // Drag acceleration in ft/s² then convert to m/s²
2366        let a_drag_ft_s2 =
2367            (velocity_fps * velocity_fps) * standard_factor * density_scale / retard_denom;
2368        let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; // ft/s² to m/s²
2369
2370        // Apply drag opposite to velocity direction
2371        let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
2372
2373        // Total acceleration = drag + gravity. `shooting_angle` rotates gravity into the shot
2374        // frame for inclined fire; at 0 deg this is the normal vertical-only gravity vector.
2375        let mut accel = drag_acceleration + self.gravity_acceleration();
2376
2377        // Coriolis (Earth rotation). McCoy frame: X=downrange, Y=vertical, Z=lateral,
2378        // azimuth 0 = North. McCoy frame: X=downrange, Y=vertical, Z=lateral.
2379        if self.inputs.enable_coriolis {
2380            if let Some(lat_deg) = self.inputs.latitude {
2381                let omega_earth = 7.2921159e-5_f64; // rad/s
2382                let lat = lat_deg.to_radians();
2383                let az = self.inputs.shot_azimuth; // compass bearing (0=N), NOT the aiming offset
2384                                                   // Earth's angular velocity in level downrange/up/lateral axes.
2385                                                   // Projecting Omega=(0, Ω cosφ, Ω sinφ) [local E,N,U] by azimuth gives
2386                                                   // a NEGATIVE lateral component:
2387                                                   // lateral = downrange × up points East for a North shot, and
2388                                                   // Omega·East = -Ω cosφ sin(az). The previous code dropped that sign.
2389                let omega = Vector3::new(
2390                    omega_earth * lat.cos() * az.cos(),  // X: downrange
2391                    omega_earth * lat.sin(),             // Y: vertical
2392                    -omega_earth * lat.cos() * az.sin(), // Z: lateral (MBA-938: corrected sign)
2393                );
2394                let omega = crate::derivatives::level_vector_to_shot_frame(
2395                    omega,
2396                    self.inputs.shooting_angle,
2397                );
2398                // Coriolis acceleration is the physical -2 Ω×v (MBA-938). The old +2 with
2399                // an "output-preserving relabel" justification produced left-ward drift for
2400                // a North shot in the Northern hemisphere; first principles (and the +Eötvös
2401                // lift for East shots) require -2 with the corrected omega above.
2402                accel += -2.0 * omega.cross(velocity);
2403            }
2404        }
2405
2406        // Magnus force (spinning projectile). SI units in this solver.
2407        // MBA-1134 (rank 35): the canonical empirical Litz spin-drift post-process
2408        // (apply_spin_drift) already captures the gyroscopic yaw-of-repose lateral, so the
2409        // explicit Magnus side force must NOT be added on top of it — otherwise the two lateral
2410        // models stack and double-count the drift. Suppress Magnus whenever Litz spin drift is
2411        // active. (The inverse is intentionally NOT done: Litz is not suppressed when Magnus is on.)
2412        if self.inputs.enable_magnus
2413            && !self.inputs.use_enhanced_spin_drift
2414            && self.inputs.bullet_diameter > 0.0
2415            && self.inputs.twist_rate > 0.0
2416        {
2417            let diameter_m = self.inputs.bullet_diameter;
2418            let (spin_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
2419                self.inputs.muzzle_velocity,
2420                velocity_magnitude,
2421                self.inputs.twist_rate,
2422                diameter_m,
2423            );
2424            // Mach and dynamic stability both use the LOCAL atmosphere recomputed above.
2425            let mach = velocity_magnitude / speed_of_sound;
2426
2427            // Imperial conversions for the stability / yaw-of-repose helpers.
2428            let d_in = self.inputs.bullet_diameter / 0.0254;
2429            let m_gr = self.inputs.bullet_mass / 0.00006479891;
2430            let l_in = if self.inputs.bullet_length > 0.0 {
2431                self.inputs.bullet_length / 0.0254
2432            } else {
2433                // MBA-1135: mass-based length estimate (was a mass-blind 4.5-caliber default).
2434                let est_m = crate::stability::estimate_bullet_length_m(
2435                    self.inputs.bullet_diameter,
2436                    self.inputs.bullet_mass,
2437                );
2438                if est_m > 0.0 {
2439                    est_m / 0.0254
2440                } else {
2441                    4.5 * d_in
2442                }
2443            };
2444            // Use current-flight Sg with the muzzle-set spin. The helper back-calculates the
2445            // effective twist from fixed spin and current airspeed, so Sg and yaw of repose grow
2446            // downrange instead of remaining tied to launch conditions.
2447            let sg = crate::spin_drift::calculate_dynamic_stability(
2448                m_gr,
2449                velocity_magnitude,
2450                spin_rad_s,
2451                d_in,
2452                l_in,
2453                air_density,
2454            );
2455
2456            // Yaw of repose (radians); zero for unstable bullets (Sg <= 1).
2457            let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
2458                sg,
2459                velocity_magnitude,
2460                spin_rad_s,
2461                0.0, // crosswind handled elsewhere
2462                0.0, // pitch rate not tracked
2463                air_density,
2464                d_in,
2465                l_in,
2466                m_gr,
2467                mach,
2468                "match",
2469                false,
2470            );
2471
2472            // Proper McCoy Magnus FORCE: F = q S C_Npa (pd/2V) sin(alpha_R).
2473            let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
2474            let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
2475            let magnus_force = 0.5
2476                * air_density
2477                * velocity_magnitude.powi(2)
2478                * area
2479                * c_np
2480                * spin_param
2481                * yaw_rad.sin();
2482
2483            // The yaw of repose is lateral, so its Magnus force follows gravity projected normal
2484            // to flight (down for right-hand twist). Lateral yaw lift belongs to the separate Litz
2485            // spin-drift model and must not be synthesized from this Magnus magnitude.
2486            if magnus_force.abs() > 1e-12 {
2487                if let Some(dir) = crate::derivatives::yaw_of_repose_magnus_direction(
2488                    relative_velocity,
2489                    self.gravity_acceleration(),
2490                    self.inputs.is_twist_right,
2491                ) {
2492                    accel += (magnus_force / self.inputs.bullet_mass) * dir;
2493                }
2494            }
2495        }
2496
2497        accel
2498    }
2499
2500    fn calculate_drag_coefficient(&self, velocity: f64, speed_of_sound: f64) -> f64 {
2501        let mach = velocity / speed_of_sound;
2502
2503        // MBA-940: a user-supplied custom drag table is the final Cd, used as-is — no G-model
2504        // lookup, no transonic shape correction, no form factor. The supplied curve already
2505        // encodes the projectile's true drag, so applying those would distort/double-count it.
2506        if let Some(ref table) = self.inputs.custom_drag_table {
2507            return table.interpolate(mach);
2508        }
2509
2510        // A published/measured BC already contains the projectile form factor (BC = SD / i).
2511        // Multiplying reference Cd by a second name-derived factor double-counts shape.
2512        crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type)
2513    }
2514}
2515
2516// Monte Carlo parameters
2517#[derive(Debug, Clone)]
2518pub struct MonteCarloParams {
2519    pub num_simulations: usize,
2520    pub velocity_std_dev: f64,
2521    pub angle_std_dev: f64,
2522    pub bc_std_dev: f64,
2523    pub wind_speed_std_dev: f64,
2524    pub target_distance: Option<f64>,
2525    pub base_wind_speed: f64,
2526    pub base_wind_direction: f64,
2527    pub azimuth_std_dev: f64, // Horizontal aiming variation in radians
2528}
2529
2530impl Default for MonteCarloParams {
2531    fn default() -> Self {
2532        Self {
2533            num_simulations: 1000,
2534            velocity_std_dev: 1.0,
2535            angle_std_dev: 0.001,
2536            bc_std_dev: 0.01,
2537            wind_speed_std_dev: 1.0,
2538            target_distance: None,
2539            base_wind_speed: 0.0,
2540            base_wind_direction: 0.0,
2541            azimuth_std_dev: 0.001, // Default horizontal spread ~0.057 degrees
2542        }
2543    }
2544}
2545
2546// Monte Carlo results
2547#[derive(Debug, Clone)]
2548pub struct MonteCarloResults {
2549    pub ranges: Vec<f64>,
2550    pub impact_velocities: Vec<f64>,
2551    /// Deviations from the baseline point of aim at the target plane.
2552    ///
2553    /// A sample that falls short of the plane is encoded as
2554    /// `(0, TARGET_NOT_REACHED_SENTINEL_M, 0)` so it remains aligned with
2555    /// `ranges` and `impact_velocities` and still counts as a miss.
2556    pub impact_positions: Vec<Vector3<f64>>,
2557}
2558
2559/// Default hit-zone radius (meters) around the point of aim at the target plane — a 30 cm
2560/// circle. Shared by the CLI, FFI, and WASM so "hit probability" means the same thing everywhere.
2561pub const DEFAULT_HIT_RADIUS_M: f64 = 0.3;
2562
2563/// Vertical-position marker for a Monte Carlo sample that never reached the target plane.
2564///
2565/// The marker preserves the equal-length result-vector and C-ABI contract. Exclude marked
2566/// positions from target-plane dispersion statistics, but keep them in the denominator for hit
2567/// probability because they are definite misses.
2568pub const TARGET_NOT_REACHED_SENTINEL_M: f64 = -1.0e9;
2569
2570impl MonteCarloResults {
2571    /// Whether an encoded impact position represents a finite arrival at the target plane.
2572    pub fn position_reached_target(position: &Vector3<f64>) -> bool {
2573        position.iter().all(|component| component.is_finite())
2574            && position.y != TARGET_NOT_REACHED_SENTINEL_M
2575    }
2576
2577    /// Number of recorded simulations that reached the target plane.
2578    pub fn target_arrival_count(&self) -> usize {
2579        self.impact_positions
2580            .iter()
2581            .filter(|position| Self::position_reached_target(position))
2582            .count()
2583    }
2584
2585    /// Fraction of recorded simulations that fell short of (or otherwise failed to produce a
2586    /// finite position at) the target plane.
2587    pub fn target_shortfall_fraction(&self) -> f64 {
2588        if self.impact_positions.is_empty() {
2589            return 0.0;
2590        }
2591        (self.impact_positions.len() - self.target_arrival_count()) as f64
2592            / self.impact_positions.len() as f64
2593    }
2594
2595    /// Upper-median radial miss among samples that reached the target plane.
2596    ///
2597    /// This preserves the CLI's historical radial-to-baseline "CEP (approx)" convention while
2598    /// preventing the finite target-shortfall marker from becoming the median (MBA-1159).
2599    /// Returns `None` when no recorded simulation reached the target plane.
2600    pub fn target_plane_cep(&self) -> Option<f64> {
2601        let mut radial_misses: Vec<f64> = self
2602            .impact_positions
2603            .iter()
2604            .filter(|position| Self::position_reached_target(position))
2605            .map(Vector3::norm)
2606            .filter(|miss| miss.is_finite())
2607            .collect();
2608        radial_misses.sort_by(f64::total_cmp);
2609        if radial_misses.is_empty() {
2610            None
2611        } else {
2612            Some(radial_misses[radial_misses.len() / 2])
2613        }
2614    }
2615
2616    /// Fraction of simulations whose impact at the target plane lands within `hit_radius_m`
2617    /// of the point of aim. `impact_positions` are deviations from the baseline at the target
2618    /// plane (the downrange component is 0), so the vector norm is the radial miss distance.
2619    /// Samples that fall short of the target remain in the denominator and count as misses.
2620    /// Returns 0.0 when there are no samples.
2621    ///
2622    /// Single source of truth for hit probability — previously the CLI used a range-precision
2623    /// notion and the FFI a position notion with a redundant clause, so they disagreed.
2624    pub fn hit_probability(&self, hit_radius_m: f64) -> f64 {
2625        if self.impact_positions.is_empty() {
2626            return 0.0;
2627        }
2628        let hits = self
2629            .impact_positions
2630            .iter()
2631            .filter(|position| {
2632                Self::position_reached_target(position) && position.norm() < hit_radius_m
2633            })
2634            .count();
2635        hits as f64 / self.impact_positions.len() as f64
2636    }
2637}
2638
2639fn wind_from_signed_speed_sample(
2640    signed_speed: f64,
2641    sampled_direction: f64,
2642    vertical_speed: f64,
2643) -> WindConditions {
2644    // The base wind's vertical component rides along un-dispersed: vertical wind is a
2645    // systematic input (MBA-728), not a sampled dispersion source. Dropping it here
2646    // would make every per-sample solve disagree with the baseline solve by the whole
2647    // vertical deflection — a phantom bias in the MC statistics.
2648    if signed_speed < 0.0 {
2649        WindConditions {
2650            speed: -signed_speed,
2651            direction: sampled_direction + std::f64::consts::PI,
2652            vertical_speed,
2653        }
2654    } else {
2655        WindConditions {
2656            speed: signed_speed,
2657            direction: sampled_direction,
2658            vertical_speed,
2659        }
2660    }
2661}
2662
2663struct MonteCarloWindSampler {
2664    speed: rand_distr::Normal<f64>,
2665    direction: rand_distr::Normal<f64>,
2666    /// Base wind's vertical component, carried into every sample un-dispersed.
2667    vertical_speed: f64,
2668}
2669
2670impl MonteCarloWindSampler {
2671    fn new(
2672        base_wind: &WindConditions,
2673        wind_speed_std_dev: f64,
2674        wind_direction_std_dev: f64,
2675    ) -> Result<Self, BallisticsError> {
2676        use rand_distr::Normal;
2677
2678        if !wind_direction_std_dev.is_finite() || wind_direction_std_dev < 0.0 {
2679            return Err("Wind direction standard deviation must be finite and non-negative".into());
2680        }
2681
2682        let speed = Normal::new(base_wind.speed, wind_speed_std_dev)
2683            .map_err(|e| format!("Invalid wind speed distribution: {e}"))?;
2684        let direction = Normal::new(base_wind.direction, wind_direction_std_dev)
2685            .map_err(|e| format!("Invalid wind direction distribution: {e}"))?;
2686        Ok(Self { speed, direction, vertical_speed: base_wind.vertical_speed })
2687    }
2688
2689    fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> WindConditions {
2690        use rand_distr::Distribution;
2691
2692        wind_from_signed_speed_sample(
2693            self.speed.sample(rng),
2694            self.direction.sample(rng),
2695            self.vertical_speed,
2696        )
2697    }
2698}
2699
2700// Run Monte Carlo simulation (backwards compatibility)
2701pub fn run_monte_carlo(
2702    base_inputs: BallisticInputs,
2703    params: MonteCarloParams,
2704) -> Result<MonteCarloResults, BallisticsError> {
2705    run_monte_carlo_with_direction_std_dev(base_inputs, params, 0.0)
2706}
2707
2708/// Run Monte Carlo with an independent wind-direction standard deviation in radians.
2709///
2710/// The older [`run_monte_carlo`] entry point remains source compatible and delegates here with
2711/// zero direction uncertainty.
2712pub fn run_monte_carlo_with_direction_std_dev(
2713    base_inputs: BallisticInputs,
2714    params: MonteCarloParams,
2715    wind_direction_std_dev: f64,
2716) -> Result<MonteCarloResults, BallisticsError> {
2717    let base_wind = WindConditions {
2718        speed: params.base_wind_speed,
2719        direction: params.base_wind_direction,
2720        vertical_speed: 0.0,
2721    };
2722    run_monte_carlo_with_wind_and_direction_std_dev(
2723        base_inputs,
2724        base_wind,
2725        params,
2726        wind_direction_std_dev,
2727    )
2728}
2729
2730// Run Monte Carlo simulation with wind
2731pub fn run_monte_carlo_with_wind(
2732    base_inputs: BallisticInputs,
2733    base_wind: WindConditions,
2734    params: MonteCarloParams,
2735) -> Result<MonteCarloResults, BallisticsError> {
2736    run_monte_carlo_with_wind_and_direction_std_dev(base_inputs, base_wind, params, 0.0)
2737}
2738
2739/// Run Monte Carlo with explicit base wind and independent direction uncertainty in radians.
2740///
2741/// The older [`run_monte_carlo_with_wind`] entry point delegates here with zero direction
2742/// uncertainty, preserving its API while removing the former speed-to-angle unit conflation.
2743pub fn run_monte_carlo_with_wind_and_direction_std_dev(
2744    base_inputs: BallisticInputs,
2745    base_wind: WindConditions,
2746    params: MonteCarloParams,
2747    wind_direction_std_dev: f64,
2748) -> Result<MonteCarloResults, BallisticsError> {
2749    use rand_distr::{Distribution, Normal};
2750
2751    let mut rng = rand::rng();
2752    let mut ranges = Vec::new();
2753    let mut impact_velocities = Vec::new();
2754    let mut impact_positions = Vec::new();
2755
2756    let atmosphere = AtmosphericConditions {
2757        temperature: base_inputs.temperature,
2758        pressure: base_inputs.pressure,
2759        humidity: base_inputs.humidity_percent(),
2760        altitude: base_inputs.altitude,
2761    };
2762    let target_hint = params
2763        .target_distance
2764        .unwrap_or(base_inputs.target_distance);
2765    let solver_max_range = target_hint.max(1000.0) * 2.0;
2766
2767    // First, calculate baseline trajectory with no variations
2768    let mut baseline_solver =
2769        TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), atmosphere.clone());
2770    baseline_solver.set_max_range(solver_max_range);
2771    let baseline_result = baseline_solver.solve()?;
2772
2773    // Determine target distance: use explicit target or baseline max range
2774    let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
2775
2776    // Get baseline position at target distance (interpolated)
2777    let baseline_at_target = baseline_result
2778        .position_at_range(target_distance)
2779        .ok_or("Could not interpolate baseline at target distance")?;
2780
2781    // Create normal distributions for variations
2782    // Sample muzzle velocity as a DELTA and apply it after TrajectorySolver::new resolves the
2783    // powder-temperature model. Sampling an absolute value here let a powder curve overwrite
2784    // every draw in the constructor, collapsing the requested dispersion to zero (MBA-1176).
2785    let velocity_delta_dist = Normal::new(0.0, params.velocity_std_dev)
2786        .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
2787    let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
2788        .map_err(|e| format!("Invalid angle distribution: {}", e))?;
2789    let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
2790        .map_err(|e| format!("Invalid BC distribution: {}", e))?;
2791    // Direction uncertainty is an independent angular quantity in radians. Do not derive it from
2792    // wind-speed uncertainty: meters/second cannot supply an angular standard deviation.
2793    let wind_sampler = MonteCarloWindSampler::new(
2794        &base_wind,
2795        params.wind_speed_std_dev,
2796        wind_direction_std_dev,
2797    )?;
2798    let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
2799        .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
2800
2801    for _ in 0..params.num_simulations {
2802        // Create varied inputs
2803        let mut inputs = base_inputs.clone();
2804        let muzzle_velocity_delta = velocity_delta_dist.sample(&mut rng);
2805        inputs.muzzle_angle = angle_dist.sample(&mut rng);
2806        inputs.bc_value = bc_dist.sample(&mut rng).max(0.01);
2807        inputs.azimuth_angle = azimuth_dist.sample(&mut rng); // Add horizontal variation
2808
2809        // Create varied wind (now based on base wind conditions)
2810        let wind = wind_sampler.sample(&mut rng);
2811
2812        // Run trajectory
2813        let mut solver = TrajectorySolver::new(inputs, wind, atmosphere.clone());
2814        solver.inputs.muzzle_velocity =
2815            (solver.inputs.muzzle_velocity + muzzle_velocity_delta).max(0.0);
2816        solver.set_max_range(solver_max_range);
2817        match solver.solve() {
2818            Ok(result) => {
2819                // MBA-967: do NOT skip samples that fall short of the target. range/velocity are
2820                // recorded at GROUND IMPACT for EVERY sample, so "Mean Range" is the ground-impact
2821                // distribution — independent of target_distance and consistent with `trajectory`.
2822                // All three result vectors still grow together per sample, so the equal-length FFI
2823                // ABI (exposed under one count) is preserved.
2824                let deviation = if result.max_range < target_distance {
2825                    // This sample never reached the target plane -> definite miss. Keep the
2826                    // encoded miss finite but far outside any practical target radius.
2827                    Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)
2828                } else {
2829                    let pos_at_target = match result.position_at_range(target_distance) {
2830                        Some(p) => p,
2831                        None => continue, // defensive: skip the whole sample (keeps vectors aligned)
2832                    };
2833                    // Deviation from baseline at the SAME target distance (McCoy): X = downrange
2834                    // (0 here), Y = vertical (elevation), Z = lateral (windage). Muzzle-angle
2835                    // sampling already models vertical pointing dispersion, so do not add a
2836                    // second independent vertical pointing draw here.
2837                    Vector3::new(
2838                        0.0,
2839                        pos_at_target.y - baseline_at_target.y,
2840                        pos_at_target.z - baseline_at_target.z,
2841                    )
2842                };
2843
2844                ranges.push(result.max_range);
2845                impact_velocities.push(result.impact_velocity);
2846                impact_positions.push(deviation);
2847            }
2848            Err(_) => {
2849                // Skip failed simulations
2850                continue;
2851            }
2852        }
2853    }
2854
2855    if ranges.is_empty() {
2856        return Err("No successful simulations".into());
2857    }
2858
2859    Ok(MonteCarloResults {
2860        ranges,
2861        impact_velocities,
2862        impact_positions,
2863    })
2864}
2865
2866// Calculate zero angle for a target
2867pub fn calculate_zero_angle(
2868    inputs: BallisticInputs,
2869    target_distance: f64,
2870    target_height: f64,
2871) -> Result<f64, BallisticsError> {
2872    calculate_zero_angle_with_conditions(
2873        inputs,
2874        target_distance,
2875        target_height,
2876        WindConditions::default(),
2877        AtmosphericConditions::default(),
2878    )
2879}
2880
2881pub fn calculate_zero_angle_with_conditions(
2882    inputs: BallisticInputs,
2883    target_distance: f64,
2884    target_height: f64,
2885    wind: WindConditions,
2886    atmosphere: AtmosphericConditions,
2887) -> Result<f64, BallisticsError> {
2888    // Helper function to get height at target distance for a given angle
2889    let get_height_at_angle = |angle: f64| -> Result<Option<f64>, BallisticsError> {
2890        let mut test_inputs = inputs.clone();
2891        test_inputs.muzzle_angle = angle;
2892        // MBA-959: zero on the bare bore. Aerodynamic jump is a constant elevation
2893        // offset, so leaving it on here would let the zero search silently absorb the
2894        // vertical jump. Disabling it makes AJ an additive POI shift relative to the
2895        // no-jump zero, regardless of the conditions the caller zeroes in.
2896        test_inputs.enable_aerodynamic_jump = false;
2897        // MBA-1286: zero on a LEVEL rifle. The zero angle is a property of the sight
2898        // geometry; canting is applied at fire time, so the classic "zero level, fire
2899        // canted" POI error emerges from the flight, not the zero.
2900        test_inputs.cant_angle = 0.0;
2901
2902        let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
2903        solver.set_max_range(target_distance * 2.0);
2904        solver.set_time_step(0.001);
2905        let result = solver.solve()?;
2906
2907        // X is downrange in McCoy coordinates
2908        for i in 0..result.points.len() {
2909            if result.points[i].position.x >= target_distance {
2910                if i > 0 {
2911                    let p1 = &result.points[i - 1];
2912                    let p2 = &result.points[i];
2913                    let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
2914                    return Ok(Some(p1.position.y + t * (p2.position.y - p1.position.y)));
2915                } else {
2916                    return Ok(Some(result.points[i].position.y));
2917                }
2918            }
2919        }
2920        Ok(None)
2921    };
2922
2923    // Binary search for the angle that hits the target
2924    // Use only positive angles to ensure proper ballistic arc (upward trajectory)
2925    let mut low_angle = 0.0; // radians (horizontal)
2926    let mut high_angle = 0.2; // radians (about 11 degrees)
2927    let tolerance = 1e-7; // radians
2928    let max_iterations = 60;
2929
2930    // MBA-194: Validate bracketing before starting binary search
2931    // Check that the target height is actually between low and high angle trajectories
2932    let low_height = get_height_at_angle(low_angle)?;
2933    let high_height = get_height_at_angle(high_angle)?;
2934
2935    match (low_height, high_height) {
2936        (Some(lh), Some(hh)) => {
2937            let low_error = lh - target_height;
2938            let high_error = hh - target_height;
2939
2940            // For proper bracketing, low angle should undershoot (negative error)
2941            // and high angle should overshoot (positive error)
2942            if low_error > 0.0 && high_error > 0.0 {
2943                // Both angles overshoot - target is too close or height too low
2944                // This shouldn't happen for typical zeroing, but handle gracefully
2945                // Try to find a valid bracket by reducing low_angle (can't go negative)
2946                // Since we can't go below 0, just proceed and let binary search find best
2947            } else if low_error < 0.0 && high_error < 0.0 {
2948                // Both angles undershoot - target is beyond effective range
2949                // Try expanding high_angle up to 45 degrees (0.785 rad)
2950                let mut expanded = false;
2951                for multiplier in [2.0, 3.0, 4.0] {
2952                    let new_high = (high_angle * multiplier).min(0.785);
2953                    if let Ok(Some(h)) = get_height_at_angle(new_high) {
2954                        if h - target_height > 0.0 {
2955                            high_angle = new_high;
2956                            expanded = true;
2957                            break;
2958                        }
2959                    }
2960                    if new_high >= 0.785 {
2961                        break;
2962                    }
2963                }
2964                if !expanded {
2965                    return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
2966                }
2967            }
2968            // If signs are opposite, we have valid bracketing - proceed
2969        }
2970        (None, Some(_hh)) => {
2971            // Low angle doesn't reach target, high does - this is fine
2972            // Binary search will increase low_angle until trajectory reaches
2973        }
2974        (Some(_lh), None) => {
2975            // High angle doesn't reach target - shouldn't happen
2976            return Err(
2977                "Cannot find zero angle: high angle trajectory doesn't reach target distance"
2978                    .into(),
2979            );
2980        }
2981        (None, None) => {
2982            // Neither reaches target - target too far
2983            return Err(
2984                "Cannot find zero angle: trajectory cannot reach target distance at any angle"
2985                    .into(),
2986            );
2987        }
2988    }
2989
2990    for _iteration in 0..max_iterations {
2991        let mid_angle = (low_angle + high_angle) / 2.0;
2992
2993        let mut test_inputs = inputs.clone();
2994        test_inputs.muzzle_angle = mid_angle;
2995        // MBA-959: zero on the bare bore so aerodynamic jump is not absorbed (see above).
2996        test_inputs.enable_aerodynamic_jump = false;
2997        // MBA-1286: zero on a LEVEL rifle. The zero angle is a property of the sight
2998        // geometry; canting is applied at fire time, so the classic "zero level, fire
2999        // canted" POI error emerges from the flight, not the zero.
3000        test_inputs.cant_angle = 0.0;
3001
3002        let mut solver = TrajectorySolver::new(test_inputs, wind.clone(), atmosphere.clone());
3003        // Make sure we calculate far enough to reach the target
3004        solver.set_max_range(target_distance * 2.0);
3005        solver.set_time_step(0.001);
3006        let result = solver.solve()?;
3007
3008        // Find the height at target distance (X is downrange)
3009        let mut height_at_target = None;
3010        for i in 0..result.points.len() {
3011            if result.points[i].position.x >= target_distance {
3012                if i > 0 {
3013                    // Linear interpolation
3014                    let p1 = &result.points[i - 1];
3015                    let p2 = &result.points[i];
3016                    let t = (target_distance - p1.position.x) / (p2.position.x - p1.position.x);
3017                    height_at_target = Some(p1.position.y + t * (p2.position.y - p1.position.y));
3018                } else {
3019                    height_at_target = Some(result.points[i].position.y);
3020                }
3021                break;
3022            }
3023        }
3024
3025        match height_at_target {
3026            Some(height) => {
3027                let error = height - target_height;
3028                // MBA-193: Check height error FIRST (primary convergence criterion)
3029                // Height accuracy is what matters for zeroing - angle tolerance is secondary.
3030                // 0.0001 m (0.1 mm) at the zero distance: fine enough that the (small)
3031                // zero-day atmosphere effect on a short zero still resolves the zero angle
3032                // instead of quantizing two very different atmospheres to an identical angle.
3033                if error.abs() < 0.0001 {
3034                    return Ok(mid_angle);
3035                }
3036
3037                // Only use angle tolerance as convergence criterion if we have
3038                // exhausted angle precision AND height error is still acceptable
3039                // (within 10mm which is reasonable for long range)
3040                if (high_angle - low_angle).abs() < tolerance {
3041                    if error.abs() < 0.01 {
3042                        // Height error within 10mm - acceptable for practical use
3043                        return Ok(mid_angle);
3044                    }
3045                    // Angle bracket collapsed but the height error is still too large: the
3046                    // target is not actually reachable / was never bracketed. Returning
3047                    // Ok(mid_angle) here reported a NOT-zeroed angle as success (callers use
3048                    // it directly as muzzle_angle); surface it as an error instead.
3049                    return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
3050                }
3051
3052                if error > 0.0 {
3053                    high_angle = mid_angle;
3054                } else {
3055                    low_angle = mid_angle;
3056                }
3057            }
3058            None => {
3059                // Trajectory didn't reach target distance, increase angle
3060                low_angle = mid_angle;
3061
3062                // MBA-193: Check angle tolerance for None case too
3063                if (high_angle - low_angle).abs() < tolerance {
3064                    return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
3065                }
3066            }
3067        }
3068    }
3069
3070    Err("Failed to find zero angle".into())
3071}
3072
3073/// What a BC estimate is fit against.
3074#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3075pub enum BcFitMode {
3076    /// Data points are `(distance_m, drop_m)` — the classic drop-curve fit.
3077    Drop,
3078    /// Data points are `(distance_m, velocity_mps)` — a velocity-retention fit,
3079    /// which is immune to zero / sight-height / launch-angle error.
3080    Velocity,
3081}
3082
3083/// The result of a single BC fit (one drag model, one fit basis).
3084#[derive(Debug, Clone, Copy)]
3085pub struct BcEstimate {
3086    /// The estimated ballistic coefficient.
3087    pub bc: f64,
3088    /// RMS residual across the data points, in fit units (meters of drop, or m/s of speed).
3089    pub rms_error: f64,
3090    /// Which standard drag model this BC is referenced to.
3091    pub drag_model: DragModel,
3092    /// Whether the fit was against drop or velocity data.
3093    pub mode: BcFitMode,
3094    /// True if the best fit landed at the edge of the physical BC search range — i.e. the
3095    /// data did not pin down an interior optimum (too sparse/short-range, or wrong units /
3096    /// atmosphere / zero). The reported `bc` is then a floor/ceiling, not a real estimate.
3097    pub at_bound: bool,
3098}
3099
3100/// Interpolate the fitted quantity (drop in meters, or speed in m/s) at a downrange
3101/// distance from a solved trajectory. `None` if the trajectory never reaches `target_dist`.
3102///
3103/// `drop_offset` is subtracted-from convention: for `Drop` the returned value is
3104/// `drop_offset - y`. With `drop_offset = 0` this is bore-referenced drop (flat fire);
3105/// with `drop_offset = sight_height` and a zeroed trajectory it is drop below the
3106/// (horizontal) line of sight — i.e. dope-card drop.
3107fn fit_value_at(
3108    points: &[TrajectoryPoint],
3109    target_dist: f64,
3110    mode: BcFitMode,
3111    drop_offset: f64,
3112) -> Option<f64> {
3113    let val = |p: &TrajectoryPoint| match mode {
3114        BcFitMode::Drop => drop_offset - p.position.y,
3115        BcFitMode::Velocity => p.velocity_magnitude,
3116    };
3117    for i in 0..points.len() {
3118        if points[i].position.x >= target_dist {
3119            if i == 0 {
3120                return Some(val(&points[0]));
3121            }
3122            let p1 = &points[i - 1];
3123            let p2 = &points[i];
3124            let dx = p2.position.x - p1.position.x;
3125            if dx.abs() < 1e-9 {
3126                return Some(val(p2));
3127            }
3128            let t = (target_dist - p1.position.x) / dx;
3129            return Some(val(p1) + t * (val(p2) - val(p1)));
3130        }
3131    }
3132    None
3133}
3134
3135fn fit_residual_sse(
3136    trajectory: &[TrajectoryPoint],
3137    observations: &[(f64, f64)],
3138    mode: BcFitMode,
3139    drop_offset: f64,
3140) -> Option<f64> {
3141    if observations.is_empty() {
3142        return None;
3143    }
3144    let mut total = 0.0;
3145    for (target_dist, target_val) in observations {
3146        // Scores are comparable only when every candidate contains every residual term.
3147        // Reject a trajectory that terminates before even one observation (MBA-1178).
3148        let value = fit_value_at(trajectory, *target_dist, mode, drop_offset)?;
3149        let error = value - target_val;
3150        total += error * error;
3151    }
3152    Some(total)
3153}
3154
3155/// Estimate a BC by fitting a simulated trajectory to measured data, for a chosen drag
3156/// model (G1, G7, …) and fit basis (drop or velocity). Uses a coarse 0.01 sweep over
3157/// plausible BCs followed by a 0.001 local refine around the coarse best.
3158///
3159/// `points` are `(distance_m, value_m_or_mps)` where the second element is drop in meters
3160/// (`BcFitMode::Drop`) or remaining speed in m/s (`BcFitMode::Velocity`).
3161///
3162/// The fit runs under `atmosphere` — BC is only meaningful relative to the air density the
3163/// data was measured at, so this must match the conditions the drop/velocity came from
3164/// (pass ICAO standard for a standard-atmosphere dope card).
3165///
3166/// `zero_range` selects the drop reference frame (ignored for velocity fits):
3167/// - `None` → **bore-referenced**: flat 0° fire, drop below the extended bore axis.
3168/// - `Some(range_m)` → **sight/dope-card-referenced**: the trajectory is zeroed at
3169///   `range_m` (using `sight_height`), and drop is measured below the horizontal line of
3170///   sight — i.e. exactly what a dope card zeroed at that range prints.
3171pub fn estimate_bc_fit(
3172    velocity: f64,
3173    mass: f64,
3174    diameter: f64,
3175    points: &[(f64, f64)],
3176    drag_model: DragModel,
3177    mode: BcFitMode,
3178    atmosphere: AtmosphericConditions,
3179    zero_range: Option<f64>,
3180    sight_height: f64,
3181) -> Result<BcEstimate, BallisticsError> {
3182    if points.is_empty() {
3183        return Err(BallisticsError::from(
3184            "No data points provided for BC estimation.".to_string(),
3185        ));
3186    }
3187    let max_dist = points.iter().map(|(d, _)| *d).fold(0.0_f64, f64::max);
3188    // For a zeroed drop fit, drop is below the horizontal LOS which sits `sight_height`
3189    // above the bore at the muzzle: drop = sight_height - y. Bore-referenced fits use 0.
3190    let drop_offset = if zero_range.is_some() { sight_height } else { 0.0 };
3191
3192    // Sum of squared residuals for a trial BC; None unless the solve reaches ALL data points.
3193    let sse = |bc_value: f64| -> Option<f64> {
3194        let mut inputs = BallisticInputs {
3195            muzzle_velocity: velocity,
3196            bc_value,
3197            bc_type: drag_model,
3198            bullet_mass: mass,
3199            bullet_diameter: diameter,
3200            sight_height,
3201            ..Default::default()
3202        };
3203        // Zeroed fit: tilt the bore so the bullet crosses LOS at the zero range, so the
3204        // downrange drops match a dope card zeroed there. Bore fit leaves muzzle_angle = 0.
3205        if let Some(zr) = zero_range {
3206            // MBA-1130: zero to the LINE OF SIGHT (y = sight_height) at the zero range,
3207            // not the bore line (y = 0). Drop is measured as `drop_offset - y` with
3208            // drop_offset = sight_height, so a bore-referenced zero left drop != 0 at the
3209            // zero range and the drop-fit no longer round-tripped to the true BC. This
3210            // matches how range-table / come-up / dope-card generation zero.
3211            let za = calculate_zero_angle_with_conditions(
3212                inputs.clone(),
3213                zr,
3214                sight_height,
3215                WindConditions::default(),
3216                atmosphere.clone(),
3217            )
3218            .ok()?;
3219            inputs.muzzle_angle = za;
3220        }
3221        let mut solver =
3222            TrajectorySolver::new(inputs, WindConditions::default(), atmosphere.clone());
3223        solver.set_max_range(max_dist * 1.5);
3224        let result = solver.solve().ok()?;
3225        fit_residual_sse(&result.points, points, mode, drop_offset)
3226    };
3227
3228    // Physical BC search range, per drag model. Real G7 BCs top out well under 0.5 (0.7 is
3229    // a generous ceiling); G1 BCs run higher. Keeping G7 out of G1 territory means a fit
3230    // that runs to the ceiling reports a sane bound, not a nonsensical 1.2.
3231    let (bc_min, bc_max) = match drag_model {
3232        DragModel::G7 => (0.05, 0.70),
3233        _ => (0.10, 1.20),
3234    };
3235
3236    // Coarse sweep across the physical range.
3237    let mut best_bc = f64::NAN;
3238    let mut best_sse = f64::MAX;
3239    let mut bc = bc_min;
3240    while bc <= bc_max + 1e-9 {
3241        if let Some(s) = sse(bc) {
3242            if s < best_sse {
3243                best_sse = s;
3244                best_bc = bc;
3245            }
3246        }
3247        bc += 0.01;
3248    }
3249    if !best_bc.is_finite() {
3250        return Err(BallisticsError::from(
3251            "Unable to estimate BC from provided data. Check that the values and units are correct."
3252                .to_string(),
3253        ));
3254    }
3255
3256    // Local refine at 0.001 resolution around the coarse best (kept within the range).
3257    let lo = (best_bc - 0.01).max(bc_min);
3258    let hi = (best_bc + 0.01).min(bc_max);
3259    let mut bc = lo;
3260    while bc <= hi + 1e-9 {
3261        if let Some(s) = sse(bc) {
3262            if s < best_sse {
3263                best_sse = s;
3264                best_bc = bc;
3265            }
3266        }
3267        bc += 0.001;
3268    }
3269
3270    // A solution sitting on the search boundary means the data didn't determine an interior
3271    // optimum — the fit ran to the floor/ceiling. Flag it so callers don't trust the number.
3272    let at_bound = best_bc <= bc_min + 0.011 || best_bc >= bc_max - 0.011;
3273    // fit_residual_sse rejects partial trajectories, so best_sse contains exactly one residual
3274    // per input point and this denominator is also the honest matched-point count.
3275    let rms_error = (best_sse / points.len() as f64).sqrt();
3276    Ok(BcEstimate {
3277        bc: best_bc,
3278        rms_error,
3279        drag_model,
3280        mode,
3281        at_bound,
3282    })
3283}
3284
3285/// Estimate a G1 BC from a drop curve. Back-compatible wrapper over [`estimate_bc_fit`];
3286/// `points` are `(distance_m, drop_m)`.
3287pub fn estimate_bc_from_trajectory(
3288    velocity: f64,
3289    mass: f64,
3290    diameter: f64,
3291    points: &[(f64, f64)], // (distance, drop) pairs
3292) -> Result<f64, BallisticsError> {
3293    estimate_bc_fit(
3294        velocity,
3295        mass,
3296        diameter,
3297        points,
3298        DragModel::G1,
3299        BcFitMode::Drop,
3300        AtmosphericConditions::default(),
3301        None,
3302        0.05,
3303    )
3304    .map(|e| e.bc)
3305}
3306
3307// Add rand dependencies for Monte Carlo
3308use rand;
3309use rand_distr;
3310
3311#[cfg(test)]
3312mod trajectory_point_budget_tests {
3313    use super::*;
3314
3315    fn solver_with_budget(
3316        use_rk4: bool,
3317        use_adaptive_rk45: bool,
3318        point_budget: usize,
3319        max_range: f64,
3320    ) -> TrajectorySolver {
3321        let inputs = BallisticInputs {
3322            use_rk4,
3323            use_adaptive_rk45,
3324            ground_threshold: f64::NEG_INFINITY,
3325            ..BallisticInputs::default()
3326        };
3327        let mut solver = TrajectorySolver::new(
3328            inputs,
3329            WindConditions::default(),
3330            AtmosphericConditions::default(),
3331        );
3332        solver.max_trajectory_points = point_budget;
3333        solver.set_max_range(max_range);
3334        solver.set_time_step(0.001);
3335        solver
3336    }
3337
3338    #[test]
3339    fn mba1283_every_solver_errors_instead_of_exceeding_point_budget() {
3340        for (mode, use_rk4, use_adaptive_rk45) in [
3341            ("Euler", false, false),
3342            ("RK4", true, false),
3343            ("RK45", true, true),
3344        ] {
3345            let error = solver_with_budget(use_rk4, use_adaptive_rk45, 3, 10.0)
3346                .solve()
3347                .expect_err("a solve requiring more than three points must fail");
3348            assert!(
3349                error.to_string().contains("point limit of 3"),
3350                "unexpected {mode} point-budget error: {error}"
3351            );
3352        }
3353    }
3354
3355    #[test]
3356    fn mba1283_interpolated_endpoint_counts_toward_point_budget() {
3357        for (mode, use_rk4, use_adaptive_rk45) in [
3358            ("Euler", false, false),
3359            ("RK4", true, false),
3360            ("RK45", true, true),
3361        ] {
3362            let result = solver_with_budget(use_rk4, use_adaptive_rk45, 2, 0.1)
3363                .solve()
3364                .expect("the initial point plus exact endpoint fit a two-point budget");
3365            assert_eq!(result.points.len(), 2, "unexpected {mode} point count");
3366
3367            let error = solver_with_budget(use_rk4, use_adaptive_rk45, 1, 0.1)
3368                .solve()
3369                .expect_err("the exact endpoint must not exceed a one-point budget");
3370            assert!(
3371                error.to_string().contains("point limit of 1"),
3372                "unexpected {mode} endpoint-budget error: {error}"
3373            );
3374        }
3375    }
3376}
3377
3378#[cfg(test)]
3379mod monte_carlo_result_tests {
3380    use super::*;
3381
3382    fn make_results(impact_positions: Vec<Vector3<f64>>) -> MonteCarloResults {
3383        let count = impact_positions.len();
3384        MonteCarloResults {
3385            ranges: vec![500.0; count],
3386            impact_velocities: vec![300.0; count],
3387            impact_positions,
3388        }
3389    }
3390
3391    #[test]
3392    fn target_plane_cep_excludes_shortfall_markers() {
3393        let mut positions: Vec<Vector3<f64>> = (1..=5)
3394            .map(|radius| Vector3::new(0.0, radius as f64, 0.0))
3395            .collect();
3396        positions.extend(
3397            (0..5).map(|_| Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)),
3398        );
3399        let results = make_results(positions);
3400
3401        assert_eq!(results.target_arrival_count(), 5);
3402        assert_eq!(results.target_shortfall_fraction(), 0.5);
3403        assert_eq!(results.target_plane_cep(), Some(3.0));
3404
3405        let one_shortfall = make_results(vec![
3406            Vector3::new(0.0, 1.0, 0.0),
3407            Vector3::new(0.0, 2.0, 0.0),
3408            Vector3::new(0.0, 3.0, 0.0),
3409            Vector3::new(0.0, 4.0, 0.0),
3410            Vector3::new(0.0, 5.0, 0.0),
3411            Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
3412        ]);
3413        assert_eq!(one_shortfall.target_plane_cep(), Some(3.0));
3414    }
3415
3416    #[test]
3417    fn all_shortfalls_have_no_cep_but_still_count_as_misses() {
3418        let all_shortfalls = make_results(vec![
3419            Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
3420            Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
3421        ]);
3422        assert_eq!(all_shortfalls.target_arrival_count(), 0);
3423        assert_eq!(all_shortfalls.target_shortfall_fraction(), 1.0);
3424        assert_eq!(all_shortfalls.target_plane_cep(), None);
3425        assert_eq!(all_shortfalls.hit_probability(0.3), 0.0);
3426
3427        let one_hit_one_shortfall = make_results(vec![
3428            Vector3::new(0.0, 0.1, 0.0),
3429            Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
3430        ]);
3431        assert_eq!(one_hit_one_shortfall.hit_probability(0.3), 0.5);
3432    }
3433}
3434
3435#[cfg(test)]
3436mod monte_carlo_powder_curve_tests {
3437    use super::*;
3438
3439    #[test]
3440    fn powder_curve_preserves_sampled_muzzle_velocity_dispersion() {
3441        let inputs = BallisticInputs {
3442            muzzle_velocity: 700.0,
3443            powder_temp_curve: Some(vec![(15.0, 800.0)]),
3444            powder_curve_temp_c: Some(15.0),
3445            ..BallisticInputs::default()
3446        };
3447        let params = MonteCarloParams {
3448            num_simulations: 16,
3449            velocity_std_dev: 20.0,
3450            angle_std_dev: 1e-12,
3451            bc_std_dev: 1e-12,
3452            wind_speed_std_dev: 1e-12,
3453            target_distance: Some(100.0),
3454            azimuth_std_dev: 1e-12,
3455            ..MonteCarloParams::default()
3456        };
3457
3458        let results = run_monte_carlo(inputs, params).expect("Monte Carlo solve");
3459        let min_velocity = results
3460            .impact_velocities
3461            .iter()
3462            .copied()
3463            .fold(f64::INFINITY, f64::min);
3464        let max_velocity = results
3465            .impact_velocities
3466            .iter()
3467            .copied()
3468            .fold(f64::NEG_INFINITY, f64::max);
3469
3470        assert!(
3471            max_velocity - min_velocity > 1.0,
3472            "20 m/s muzzle spread collapsed after curve resolution: impact-velocity span={} m/s",
3473            max_velocity - min_velocity
3474        );
3475    }
3476}
3477
3478#[cfg(test)]
3479mod monte_carlo_wind_sampling_tests {
3480    use super::*;
3481    use rand::{rngs::StdRng, SeedableRng};
3482
3483    #[test]
3484    fn wind_speed_sigma_does_not_change_seeded_direction_draws() {
3485        let base_wind = WindConditions {
3486            speed: 100.0,
3487            direction: 0.37,
3488            vertical_speed: 0.0,
3489        };
3490        let narrow_speed = MonteCarloWindSampler::new(&base_wind, 0.5, 0.2).unwrap();
3491        let wide_speed = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
3492        let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
3493        let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
3494        let mut speed_changed = false;
3495
3496        for _ in 0..32 {
3497            let narrow = narrow_speed.sample(&mut narrow_rng);
3498            let wide = wide_speed.sample(&mut wide_rng);
3499            assert!(narrow.speed > 0.0 && wide.speed > 0.0);
3500            assert_eq!(narrow.direction.to_bits(), wide.direction.to_bits());
3501            speed_changed |= narrow.speed.to_bits() != wide.speed.to_bits();
3502        }
3503        assert!(
3504            speed_changed,
3505            "different speed sigmas must still vary speed draws"
3506        );
3507    }
3508
3509    #[test]
3510    fn zero_direction_sigma_has_no_angular_jitter() {
3511        let base_wind = WindConditions {
3512            speed: 100.0,
3513            direction: 0.37,
3514            vertical_speed: 0.0,
3515        };
3516        let sampler = MonteCarloWindSampler::new(&base_wind, 4.0, 0.0).unwrap();
3517        let mut rng = StdRng::seed_from_u64(0x5EED_1223);
3518        let mut speed_changed = false;
3519
3520        for _ in 0..32 {
3521            let wind = sampler.sample(&mut rng);
3522            speed_changed |= wind.speed.to_bits() != base_wind.speed.to_bits();
3523            assert_eq!(wind.direction.to_bits(), base_wind.direction.to_bits());
3524        }
3525        assert!(speed_changed, "speed uncertainty should remain active");
3526    }
3527
3528    #[test]
3529    fn direction_sigma_controls_seeded_angular_spread_in_radians() {
3530        let base_wind = WindConditions {
3531            speed: 100.0,
3532            direction: 0.37,
3533            vertical_speed: 0.0,
3534        };
3535        let narrow = MonteCarloWindSampler::new(&base_wind, 4.0, 0.1).unwrap();
3536        let wide = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
3537        let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
3538        let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
3539        let mut nonzero_direction_draw = false;
3540
3541        for _ in 0..32 {
3542            let narrow_wind = narrow.sample(&mut narrow_rng);
3543            let wide_wind = wide.sample(&mut wide_rng);
3544            assert_eq!(narrow_wind.speed.to_bits(), wide_wind.speed.to_bits());
3545
3546            let narrow_delta = narrow_wind.direction - base_wind.direction;
3547            let wide_delta = wide_wind.direction - base_wind.direction;
3548            assert!((wide_delta - 2.0 * narrow_delta).abs() < 1e-12);
3549            nonzero_direction_draw |= narrow_delta.abs() > 1e-6;
3550        }
3551        assert!(
3552            nonzero_direction_draw,
3553            "positive radians sigma must vary direction"
3554        );
3555    }
3556
3557    #[test]
3558    fn direction_sigma_rejects_negative_or_nonfinite_values() {
3559        let base_wind = WindConditions::default();
3560        for sigma in [-0.1, f64::NAN, f64::INFINITY] {
3561            assert!(MonteCarloWindSampler::new(&base_wind, 1.0, sigma).is_err());
3562        }
3563    }
3564
3565    #[test]
3566    fn base_vertical_wind_rides_into_every_mc_sample() {
3567        // MBA-728: vertical wind is a systematic input, not a dispersion source —
3568        // every sampled wind must carry the base vertical un-dispersed. (Before
3569        // this fix, samples dropped it, biasing the whole MC cloud vs the baseline.)
3570        use rand::SeedableRng;
3571        let base_wind = WindConditions { vertical_speed: 4.2, ..Default::default() };
3572        let sampler = MonteCarloWindSampler::new(&base_wind, 1.0, 0.2).unwrap();
3573        let mut rng = rand::rngs::StdRng::seed_from_u64(7);
3574        for _ in 0..32 {
3575            let w = sampler.sample(&mut rng);
3576            assert_eq!(w.vertical_speed, 4.2);
3577        }
3578    }
3579
3580    #[test]
3581    fn negative_speed_sample_reverses_wind_direction() {
3582        let direction = 0.25;
3583        let signed_speed = -2.5;
3584        let wind = wind_from_signed_speed_sample(signed_speed, direction, 0.0);
3585        let positive_wind = wind_from_signed_speed_sample(2.5, direction, 0.0);
3586
3587        assert_eq!(wind.speed, 2.5);
3588        assert!(
3589            (wind.direction - (direction + std::f64::consts::PI)).abs() < f64::EPSILON,
3590            "negative speed must reverse direction by pi: got {}",
3591            wind.direction
3592        );
3593        assert_eq!(positive_wind.speed, 2.5);
3594        assert_eq!(positive_wind.direction, direction);
3595
3596        let normalized_x = -wind.speed * wind.direction.cos();
3597        let normalized_z = -wind.speed * wind.direction.sin();
3598        let signed_x = -signed_speed * direction.cos();
3599        let signed_z = -signed_speed * direction.sin();
3600        assert!((normalized_x - signed_x).abs() < 1e-12);
3601        assert!((normalized_z - signed_z).abs() < 1e-12);
3602    }
3603}
3604
3605#[cfg(test)]
3606mod bc_fit_objective_tests {
3607    use super::*;
3608
3609    fn velocity_point(range_m: f64, velocity_mps: f64) -> TrajectoryPoint {
3610        TrajectoryPoint {
3611            time: 0.0,
3612            position: Vector3::new(range_m, 0.0, 0.0),
3613            velocity_magnitude: velocity_mps,
3614            kinetic_energy: 0.0,
3615        }
3616    }
3617
3618    #[test]
3619    fn candidate_that_misses_an_observation_has_no_score() {
3620        let trajectory = vec![velocity_point(0.0, 800.0), velocity_point(100.0, 700.0)];
3621        let observations = vec![(50.0, 750.0), (150.0, 600.0)];
3622
3623        assert!(
3624            fit_residual_sse(&trajectory, &observations, BcFitMode::Velocity, 0.0).is_none(),
3625            "a candidate that reaches only one of two observations must not compete on partial SSE"
3626        );
3627
3628        let complete_observations = vec![(50.0, 740.0), (100.0, 680.0)];
3629        assert_eq!(
3630            fit_residual_sse(
3631                &trajectory,
3632                &complete_observations,
3633                BcFitMode::Velocity,
3634                0.0,
3635            ),
3636            Some(500.0)
3637        );
3638    }
3639}
3640
3641#[cfg(test)]
3642mod cluster_bc_reference_space_tests {
3643    use super::*;
3644
3645    fn acceleration_at_1100_fps(inputs: BallisticInputs) -> Vector3<f64> {
3646        let solver = TrajectorySolver::new(
3647            inputs,
3648            WindConditions::default(),
3649            AtmosphericConditions::default(),
3650        );
3651        let position = Vector3::zeros();
3652        let velocity = Vector3::new(1100.0 / 3.28084, 0.0, 0.0);
3653        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
3654        solver.calculate_acceleration(
3655            &position,
3656            &velocity,
3657            &Vector3::zeros(),
3658            (temp_c, pressure_hpa, density / 1.225),
3659        )
3660    }
3661
3662    #[test]
3663    fn solver_passes_g7_reference_model_to_cluster_classifier() {
3664        let mut inputs = BallisticInputs::default();
3665        inputs.bc_value = 0.190;
3666        inputs.bc_type = DragModel::G7;
3667        inputs.bullet_mass = 77.0 * 0.00006479891;
3668        inputs.bullet_diameter = 0.224 * 0.0254;
3669        inputs.use_cluster_bc = true;
3670
3671        let solver = TrajectorySolver::new(
3672            inputs,
3673            WindConditions::default(),
3674            AtmosphericConditions::default(),
3675        );
3676        let corrected = solver.apply_cluster_bc_correction(0.190, 2800.0);
3677
3678        assert!(
3679            (corrected / 0.190 - 1.004).abs() < 1e-12,
3680            "solver selected the wrong G7 cluster multiplier: {}",
3681            corrected / 0.190
3682        );
3683    }
3684
3685    #[test]
3686    fn velocity_bc_segments_are_not_cluster_corrected_twice() {
3687        let segmented_clustered = BallisticInputs {
3688            bc_value: 0.5,
3689            bc_type: DragModel::G7,
3690            use_bc_segments: true,
3691            bc_segments_data: Some(vec![
3692                crate::BCSegmentData {
3693                    velocity_min: 0.0,
3694                    velocity_max: 1_600.0,
3695                    bc_value: 0.4,
3696                },
3697                crate::BCSegmentData {
3698                    velocity_min: 1_600.0,
3699                    velocity_max: 5_000.0,
3700                    bc_value: 0.45,
3701                },
3702            ]),
3703            use_cluster_bc: true,
3704            ..BallisticInputs::default()
3705        };
3706        let mut segmented_only = segmented_clustered.clone();
3707        segmented_only.use_cluster_bc = false;
3708        let mut constant_clustered = segmented_clustered.clone();
3709        constant_clustered.bc_value = 0.4;
3710        constant_clustered.bc_segments_data = None;
3711
3712        let stacked = acceleration_at_1100_fps(segmented_clustered);
3713        let segment_only = acceleration_at_1100_fps(segmented_only);
3714        let cluster_only = acceleration_at_1100_fps(constant_clustered);
3715
3716        assert!(
3717            (stacked.x - segment_only.x).abs() < 1e-12,
3718            "segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
3719            stacked.x,
3720            segment_only.x
3721        );
3722        assert!(
3723            (cluster_only.x - segment_only.x).abs() > 1e-6,
3724            "cluster correction must remain active for a constant BC"
3725        );
3726    }
3727
3728    #[test]
3729    fn mach_bc_segments_are_not_cluster_corrected_twice() {
3730        let mach_segmented_clustered = BallisticInputs {
3731            bc_value: 0.5,
3732            bc_type: DragModel::G7,
3733            use_bc_segments: false,
3734            bc_segments: Some(vec![(0.5, 0.3), (1.5, 0.5)]),
3735            use_cluster_bc: true,
3736            ..BallisticInputs::default()
3737        };
3738        let mut mach_segmented_only = mach_segmented_clustered.clone();
3739        mach_segmented_only.use_cluster_bc = false;
3740
3741        let stacked = acceleration_at_1100_fps(mach_segmented_clustered);
3742        let segment_only = acceleration_at_1100_fps(mach_segmented_only);
3743
3744        assert!(
3745            (stacked.x - segment_only.x).abs() < 1e-12,
3746            "Mach segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
3747            stacked.x,
3748            segment_only.x
3749        );
3750    }
3751}
3752
3753#[cfg(test)]
3754mod velocity_bc_flag_tests {
3755    use super::*;
3756
3757    fn acceleration_at_600_mps(inputs: BallisticInputs) -> Vector3<f64> {
3758        let solver = TrajectorySolver::new(
3759            inputs,
3760            WindConditions::default(),
3761            AtmosphericConditions::default(),
3762        );
3763        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
3764        solver.calculate_acceleration(
3765            &Vector3::zeros(),
3766            &Vector3::new(600.0, 0.0, 0.0),
3767            &Vector3::zeros(),
3768            (temp_c, pressure_hpa, density / 1.225),
3769        )
3770    }
3771
3772    #[test]
3773    fn velocity_bc_data_requires_opt_in_in_trajectory_solver() {
3774        let scalar_inputs = BallisticInputs {
3775            bc_value: 0.5,
3776            bc_type: DragModel::G7,
3777            ..BallisticInputs::default()
3778        };
3779        let mut disabled_inputs = scalar_inputs.clone();
3780        disabled_inputs.bc_segments_data = Some(vec![crate::BCSegmentData {
3781            velocity_min: 0.0,
3782            velocity_max: 4_000.0,
3783            bc_value: 0.46,
3784        }]);
3785        disabled_inputs.use_bc_segments = false;
3786        let mut enabled_inputs = disabled_inputs.clone();
3787        enabled_inputs.use_bc_segments = true;
3788        let mut mach_only_inputs = scalar_inputs.clone();
3789        mach_only_inputs.bc_segments = Some(vec![(0.0, 0.4), (3.0, 0.4)]);
3790        let mut disabled_with_both = mach_only_inputs.clone();
3791        disabled_with_both.bc_segments_data = disabled_inputs.bc_segments_data.clone();
3792
3793        let scalar = acceleration_at_600_mps(scalar_inputs);
3794        let disabled = acceleration_at_600_mps(disabled_inputs);
3795        let enabled = acceleration_at_600_mps(enabled_inputs);
3796        let mach_only = acceleration_at_600_mps(mach_only_inputs);
3797        let disabled_with_both = acceleration_at_600_mps(disabled_with_both);
3798
3799        assert_eq!(
3800            disabled.x.to_bits(),
3801            scalar.x.to_bits(),
3802            "a populated velocity table must not change drag while use_bc_segments is false"
3803        );
3804        assert!(
3805            enabled.x < disabled.x - 1.0,
3806            "enabling the lower BC table must increase drag: disabled ax={} enabled ax={}",
3807            disabled.x,
3808            enabled.x
3809        );
3810        assert_eq!(
3811            disabled_with_both.x.to_bits(),
3812            mach_only.x.to_bits(),
3813            "disabling velocity data must fall through to an explicit Mach table"
3814        );
3815    }
3816}
3817
3818#[cfg(test)]
3819mod mach_bc_segment_tests {
3820    use super::*;
3821
3822    #[test]
3823    fn trajectory_solver_interpolates_explicit_mach_bc_segments() {
3824        let segmented_inputs = BallisticInputs {
3825            bc_value: 0.8,
3826            use_bc_segments: false,
3827            bc_segments: Some(vec![(1.0, 0.2), (2.0, 0.4)]),
3828            bc_segments_data: None,
3829            ..BallisticInputs::default()
3830        };
3831
3832        let mut expected_inputs = segmented_inputs.clone();
3833        expected_inputs.bc_value = 0.3;
3834        expected_inputs.bc_segments = None;
3835
3836        let atmosphere = AtmosphericConditions::default();
3837        let segmented_solver = TrajectorySolver::new(
3838            segmented_inputs,
3839            WindConditions::default(),
3840            atmosphere.clone(),
3841        );
3842        let expected_solver = TrajectorySolver::new(
3843            expected_inputs,
3844            WindConditions::default(),
3845            atmosphere,
3846        );
3847        let position = Vector3::zeros();
3848        let (density, _, temp_c, pressure_hpa) = segmented_solver.resolved_atmosphere();
3849        let (_, local_speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
3850            segmented_solver.atmosphere.altitude,
3851            segmented_solver.atmosphere.altitude,
3852            temp_c,
3853            pressure_hpa,
3854            density / 1.225,
3855            segmented_solver.atmosphere.humidity,
3856        );
3857        let velocity = Vector3::new(1.5 * local_speed_of_sound, 0.0, 0.0);
3858        let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
3859
3860        let segmented_acceleration = segmented_solver.calculate_acceleration(
3861            &position,
3862            &velocity,
3863            &Vector3::zeros(),
3864            resolved_atmo,
3865        );
3866        let expected_acceleration = expected_solver.calculate_acceleration(
3867            &position,
3868            &velocity,
3869            &Vector3::zeros(),
3870            resolved_atmo,
3871        );
3872
3873        assert!(
3874            (segmented_acceleration.x - expected_acceleration.x).abs() < 1e-12,
3875            "Mach 1.5 must interpolate BC 0.3: segmented ax={} expected ax={}",
3876            segmented_acceleration.x,
3877            expected_acceleration.x
3878        );
3879    }
3880}
3881
3882#[cfg(test)]
3883mod custom_drag_table_validation_tests {
3884    use super::*;
3885
3886    #[test]
3887    fn solve_accepts_zero_bc_when_custom_table_present() {
3888        let mut inputs = BallisticInputs::default();
3889        inputs.bc_value = 0.0; // ignored when a table is set
3890        inputs.bullet_mass = 0.0106;
3891        inputs.bullet_diameter = 0.00782;
3892        inputs.muzzle_velocity = 850.0;
3893        inputs.custom_drag_table = Some(crate::drag::DragTable::new(
3894            vec![0.5, 1.0, 2.0, 3.0],
3895            vec![0.23, 0.40, 0.30, 0.26],
3896        ));
3897        let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
3898        // Must not error on the bc_value gate.
3899        assert!(solver.solve().is_ok());
3900    }
3901
3902    #[test]
3903    fn solve_still_requires_bc_without_table() {
3904        let mut inputs = BallisticInputs::default();
3905        inputs.bc_value = 0.0;
3906        inputs.bullet_mass = 0.0106;
3907        inputs.bullet_diameter = 0.00782;
3908        inputs.muzzle_velocity = 850.0;
3909        let solver = TrajectorySolver::new(inputs, WindConditions::default(), AtmosphericConditions::default());
3910        assert!(solver.solve().is_err());
3911    }
3912}
3913
3914#[cfg(test)]
3915mod humid_local_mach_tests {
3916    use super::*;
3917
3918    fn solver_with_station_humidity(humidity_percent: f64) -> TrajectorySolver {
3919        let inputs = BallisticInputs {
3920            custom_drag_table: Some(crate::drag::DragTable::new(vec![0.5, 1.5], vec![0.1, 1.1])),
3921            ..BallisticInputs::default()
3922        };
3923        TrajectorySolver::new(
3924            inputs,
3925            WindConditions::default(),
3926            AtmosphericConditions {
3927                temperature: 30.0,
3928                pressure: 1013.25,
3929                humidity: humidity_percent,
3930                altitude: 0.0,
3931            },
3932        )
3933    }
3934
3935    fn acceleration(solver: &TrajectorySolver, base_ratio: f64) -> Vector3<f64> {
3936        solver.calculate_acceleration(
3937            &Vector3::zeros(),
3938            &Vector3::new(350.0, 0.0, 0.0),
3939            &Vector3::zeros(),
3940            (30.0, 1013.25, base_ratio),
3941        )
3942    }
3943
3944    #[test]
3945    fn local_mach_uses_station_humidity_when_density_is_held_constant() {
3946        let dry = acceleration(&solver_with_station_humidity(0.0), 1.0);
3947        let humid = acceleration(&solver_with_station_humidity(100.0), 1.0);
3948
3949        assert!(
3950            humid.x > dry.x,
3951            "humid sound speed should lower Mach and drag on the rising test curve: dry ax={} humid ax={}",
3952            dry.x,
3953            humid.x
3954        );
3955    }
3956
3957    #[test]
3958    fn active_atmosphere_zone_uses_zone_humidity_instead_of_station_humidity() {
3959        let zone_humidity = 80.0;
3960        let zone_ratio =
3961            crate::atmosphere::calculate_air_density_cimp(30.0, 1013.25, zone_humidity) / 1.225;
3962        let station_solver = solver_with_station_humidity(zone_humidity);
3963        let mut zoned_solver = solver_with_station_humidity(0.0);
3964        zoned_solver.set_atmo_segments(vec![(30.0, 1013.25, zone_humidity, 1_000.0)]);
3965
3966        let station = acceleration(&station_solver, zone_ratio);
3967        let zoned = acceleration(&zoned_solver, zone_ratio);
3968
3969        assert!(
3970            (zoned - station).norm() < 1e-12,
3971            "active zone T/P/RH should override the station atmosphere: station={station:?} zoned={zoned:?}"
3972        );
3973    }
3974}
3975
3976#[cfg(test)]
3977mod inclined_atmosphere_frame_tests {
3978    use super::*;
3979
3980    fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
3981        let (sin_angle, cos_angle) = angle.sin_cos();
3982        Vector3::new(
3983            level.x * cos_angle + level.y * sin_angle,
3984            -level.x * sin_angle + level.y * cos_angle,
3985            level.z,
3986        )
3987    }
3988
3989    #[test]
3990    fn inclined_positions_at_same_world_altitude_have_same_solver_acceleration() {
3991        let angle = std::f64::consts::FRAC_PI_6;
3992        let inputs = BallisticInputs {
3993            shooting_angle: angle,
3994            ..BallisticInputs::default()
3995        };
3996        let atmosphere = AtmosphericConditions {
3997            altitude: 100.0,
3998            ..AtmosphericConditions::default()
3999        };
4000        let solver = TrajectorySolver::new(inputs, WindConditions::default(), atmosphere);
4001        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4002        let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
4003        let velocity = Vector3::new(600.0, 0.0, 0.0);
4004        let along_slant = Vector3::new(1_000.0, 0.0, 0.0);
4005        let across_slant = Vector3::new(0.0, 500.0 / angle.cos(), 0.0);
4006
4007        let a = solver.calculate_acceleration(
4008            &along_slant,
4009            &velocity,
4010            &Vector3::zeros(),
4011            resolved_atmo,
4012        );
4013        let b = solver.calculate_acceleration(
4014            &across_slant,
4015            &velocity,
4016            &Vector3::zeros(),
4017            resolved_atmo,
4018        );
4019
4020        assert!(
4021            (a - b).norm() < 1e-10,
4022            "solver acceleration differs at equal world altitude: {a:?} vs {b:?}"
4023        );
4024    }
4025
4026    #[test]
4027    fn inclined_headwind_is_rotated_into_solver_frame() {
4028        let angle = std::f64::consts::FRAC_PI_6;
4029        let inputs = BallisticInputs {
4030            shooting_angle: angle,
4031            ..BallisticInputs::default()
4032        };
4033        let solver = TrajectorySolver::new(
4034            inputs,
4035            WindConditions::default(),
4036            AtmosphericConditions::default(),
4037        );
4038        let level_headwind = Vector3::new(-100.0, 0.0, 0.0);
4039        let velocity = expected_shot_frame_vector(level_headwind, angle);
4040        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4041        let actual = solver.calculate_acceleration(
4042            &Vector3::zeros(),
4043            &velocity,
4044            &level_headwind,
4045            (temp_c, pressure_hpa, density / 1.225),
4046        );
4047
4048        assert!(
4049            (actual - solver.gravity_acceleration()).norm() < 1e-12,
4050            "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
4051        );
4052    }
4053
4054    #[test]
4055    fn inclined_coriolis_is_rotated_into_solver_frame() {
4056        let angle = std::f64::consts::FRAC_PI_6;
4057        let latitude_deg = 45.0_f64;
4058        let shot_azimuth = 0.4_f64;
4059        let velocity = Vector3::new(600.0, 20.0, 5.0);
4060        let base_inputs = BallisticInputs {
4061            shooting_angle: angle,
4062            latitude: Some(latitude_deg),
4063            shot_azimuth,
4064            ..BallisticInputs::default()
4065        };
4066        let acceleration = |enable_coriolis| {
4067            let mut inputs = base_inputs.clone();
4068            inputs.enable_coriolis = enable_coriolis;
4069            let solver = TrajectorySolver::new(
4070                inputs,
4071                WindConditions::default(),
4072                AtmosphericConditions::default(),
4073            );
4074            let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4075            solver.calculate_acceleration(
4076                &Vector3::zeros(),
4077                &velocity,
4078                &Vector3::zeros(),
4079                (temp_c, pressure_hpa, density / 1.225),
4080            )
4081        };
4082
4083        let omega_earth = 7.2921159e-5_f64;
4084        let latitude = latitude_deg.to_radians();
4085        let level_omega = Vector3::new(
4086            omega_earth * latitude.cos() * shot_azimuth.cos(),
4087            omega_earth * latitude.sin(),
4088            -omega_earth * latitude.cos() * shot_azimuth.sin(),
4089        );
4090        let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
4091        let actual = acceleration(true) - acceleration(false);
4092
4093        assert!(
4094            (actual - expected).norm() < 1e-12,
4095            "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
4096        );
4097    }
4098}
4099
4100#[cfg(test)]
4101mod terminal_range_interpolation_tests {
4102    use super::*;
4103
4104    #[test]
4105    fn every_solver_appends_an_exact_max_range_endpoint() {
4106        let target_range = 0.1;
4107        let modes = [
4108            ("Euler", false, false),
4109            ("RK4", true, false),
4110            ("RK45", true, true),
4111        ];
4112
4113        for (name, use_rk4, use_adaptive_rk45) in modes {
4114            let inputs = BallisticInputs {
4115                use_rk4,
4116                use_adaptive_rk45,
4117                ground_threshold: f64::NEG_INFINITY,
4118                enable_trajectory_sampling: true,
4119                sample_interval: target_range,
4120                ..BallisticInputs::default()
4121            };
4122            let mut solver = TrajectorySolver::new(
4123                inputs,
4124                WindConditions::default(),
4125                AtmosphericConditions::default(),
4126            );
4127            solver.set_max_range(target_range);
4128
4129            let result = solver.solve().expect("short-range solve should succeed");
4130            let terminal = result.points.last().expect("terminal point is missing");
4131            let muzzle = result.points.first().expect("muzzle point is missing");
4132
4133            assert_eq!(
4134                terminal.position.x.to_bits(),
4135                target_range.to_bits(),
4136                "{name} did not terminate exactly at max_range"
4137            );
4138            assert_eq!(result.max_range.to_bits(), target_range.to_bits());
4139            assert!(
4140                result.time_of_flight > 0.0 && result.time_of_flight < solver.time_step,
4141                "{name} terminal time was not interpolated within the crossing step: {}",
4142                result.time_of_flight
4143            );
4144            assert_eq!(result.time_of_flight.to_bits(), terminal.time.to_bits());
4145            assert_eq!(
4146                result.impact_velocity.to_bits(),
4147                terminal.velocity_magnitude.to_bits()
4148            );
4149            assert_eq!(
4150                result.impact_energy.to_bits(),
4151                terminal.kinetic_energy.to_bits()
4152            );
4153            let expected_energy = 0.5 * solver.inputs.bullet_mass * result.impact_velocity.powi(2);
4154            assert!((result.impact_energy - expected_energy).abs() < 1e-12);
4155            assert!(terminal.velocity_magnitude < muzzle.velocity_magnitude);
4156            assert!(terminal.kinetic_energy < muzzle.kinetic_energy);
4157
4158            let terminal_sample = result
4159                .sampled_points
4160                .as_ref()
4161                .and_then(|samples| samples.last())
4162                .expect("terminal trajectory sample is missing");
4163            assert_eq!(
4164                terminal_sample.distance_m.to_bits(),
4165                target_range.to_bits(),
4166                "{name} sampling did not include max_range"
4167            );
4168            assert_eq!(
4169                terminal_sample.time_s.to_bits(),
4170                result.time_of_flight.to_bits()
4171            );
4172            assert_eq!(
4173                terminal_sample.velocity_mps.to_bits(),
4174                result.impact_velocity.to_bits()
4175            );
4176            assert!((terminal_sample.energy_j - result.impact_energy).abs() < 1e-12);
4177        }
4178    }
4179}
4180
4181#[cfg(test)]
4182mod precession_inertia_wiring_tests {
4183    use super::*;
4184
4185    #[test]
4186    fn solver_uses_projectile_specific_moments_of_inertia() {
4187        let mass_kg = 55.0 * 0.00006479891;
4188        let caliber_m = 0.224 * 0.0254;
4189        let length_m = 0.75 * 0.0254;
4190        let inputs = BallisticInputs {
4191            bullet_mass: mass_kg,
4192            bullet_diameter: caliber_m,
4193            bullet_length: length_m,
4194            muzzle_velocity: 800.0,
4195            twist_rate: 7.0,
4196            enable_precession_nutation: true,
4197            use_rk4: false,
4198            use_adaptive_rk45: false,
4199            ..BallisticInputs::default()
4200        };
4201        let mut solver = TrajectorySolver::new(
4202            inputs,
4203            WindConditions::default(),
4204            AtmosphericConditions::default(),
4205        );
4206        solver.set_max_range(0.1);
4207
4208        let (air_density, speed_of_sound, _, _) = solver.resolved_atmosphere();
4209        let velocity_mps = solver.inputs.muzzle_velocity;
4210        let velocity_fps = velocity_mps * 3.28084;
4211        let twist_rate_ft = solver.inputs.twist_rate / 12.0;
4212        let spin_rate_rad_s = (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI;
4213        let initial_state = AngularState {
4214            pitch_angle: 0.001,
4215            yaw_angle: 0.001,
4216            pitch_rate: 0.0,
4217            yaw_rate: 0.0,
4218            precession_angle: 0.0,
4219            nutation_phase: 0.0,
4220        };
4221        let params = PrecessionNutationParams {
4222            mass_kg,
4223            caliber_m,
4224            length_m,
4225            spin_rate_rad_s,
4226            spin_inertia: crate::spin_decay::calculate_moment_of_inertia(
4227                mass_kg, caliber_m, length_m, "ogive",
4228            ),
4229            transverse_inertia: crate::pitch_damping::calculate_transverse_moment_of_inertia(
4230                mass_kg, caliber_m, length_m, "ogive",
4231            ),
4232            velocity_mps,
4233            air_density_kg_m3: air_density,
4234            mach: velocity_mps / speed_of_sound,
4235            pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
4236            nutation_damping_factor: 0.05,
4237        };
4238        let expected = calculate_combined_angular_motion(
4239            &params,
4240            &initial_state,
4241            0.0,
4242            solver.time_step,
4243            0.001,
4244        );
4245        let actual = solver
4246            .solve()
4247            .expect("one-step solve should succeed")
4248            .angular_state
4249            .expect("precession/nutation was enabled");
4250
4251        assert!(
4252            (actual.precession_angle - expected.precession_angle).abs() < 1e-15,
4253            "precession phase used the wrong inertia: actual={}, expected={}",
4254            actual.precession_angle,
4255            expected.precession_angle
4256        );
4257        assert!(
4258            (actual.nutation_phase - expected.nutation_phase).abs() < 1e-15,
4259            "nutation phase used the wrong inertia: actual={}, expected={}",
4260            actual.nutation_phase,
4261            expected.nutation_phase
4262        );
4263    }
4264}
4265
4266#[cfg(test)]
4267mod form_factor_drag_tests {
4268    use super::*;
4269
4270    fn acceleration_with_form_factor_flag(enabled: bool) -> Vector3<f64> {
4271        let inputs = BallisticInputs {
4272            bc_value: 0.462,
4273            bc_type: DragModel::G1,
4274            bullet_model: Some("168gr SMK Match".to_string()),
4275            use_form_factor: enabled,
4276            ..BallisticInputs::default()
4277        };
4278        let solver = TrajectorySolver::new(
4279            inputs,
4280            WindConditions::default(),
4281            AtmosphericConditions::default(),
4282        );
4283        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4284        solver.calculate_acceleration(
4285            &Vector3::zeros(),
4286            &Vector3::new(600.0, 0.0, 0.0),
4287            &Vector3::zeros(),
4288            (temp_c, pressure_hpa, density / 1.225),
4289        )
4290    }
4291
4292    #[test]
4293    fn measured_bc_drag_does_not_apply_name_based_form_factor_again() {
4294        let baseline = acceleration_with_form_factor_flag(false);
4295        let flagged = acceleration_with_form_factor_flag(true);
4296
4297        assert!(
4298            (flagged - baseline).norm() < 1e-12,
4299            "published BC already encodes form factor: baseline={baseline:?} flagged={flagged:?}"
4300        );
4301    }
4302}
4303
4304#[cfg(test)]
4305mod rk45_adaptivity_tests {
4306    use super::*;
4307
4308    #[test]
4309    fn cli_rk45_error_norm_scales_components_independently() {
4310        let position = Vector3::new(1.0e9, 0.0, 0.0);
4311        let velocity = Vector3::new(800.0, 0.0, 0.0);
4312        let fifth_position = position;
4313        let fifth_velocity = velocity;
4314        let fourth_position = position;
4315        let fourth_velocity = Vector3::new(800.0, 1.0e-3, 0.0);
4316
4317        let error = cli_rk45_error_norm(
4318            &position,
4319            &velocity,
4320            &fifth_position,
4321            &fifth_velocity,
4322            &fourth_position,
4323            &fourth_velocity,
4324        );
4325        let expected = 1.0e-3 / 6.0_f64.sqrt();
4326
4327        assert!(
4328            (error - expected).abs() <= 1e-15,
4329            "large downrange position masked a velocity-component error: {error}"
4330        );
4331    }
4332
4333    fn discontinuous_wind_solver() -> TrajectorySolver {
4334        let inputs = BallisticInputs::default();
4335        let mut solver = TrajectorySolver::new(
4336            inputs,
4337            WindConditions::default(),
4338            AtmosphericConditions::default(),
4339        );
4340        solver.set_wind_segments(vec![
4341            crate::wind::WindSegment::new(0.0, 90.0, 4.0),
4342            crate::wind::WindSegment::new(1_000.0, 90.0, 10_000.0),
4343        ]);
4344        solver
4345    }
4346
4347    #[test]
4348    fn rk45_retries_discontinuous_trial_before_advancing() {
4349        let solver = discontinuous_wind_solver();
4350        let position = Vector3::new(0.0, solver.inputs.muzzle_height, 0.0);
4351        let velocity = Vector3::new(solver.inputs.muzzle_velocity, 0.0, 0.0);
4352        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4353        let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
4354        let dt = 0.01;
4355
4356        let rejected_trial = solver.rk45_step(
4357            &position,
4358            &velocity,
4359            dt,
4360            &Vector3::zeros(),
4361            RK45_TOLERANCE,
4362            resolved_atmo,
4363        );
4364        assert!(
4365            rejected_trial.error > RK45_TOLERANCE,
4366            "discontinuous full step must exceed tolerance, got {}",
4367            rejected_trial.error
4368        );
4369
4370        let accepted = solver.adaptive_rk45_step(
4371            &position,
4372            &velocity,
4373            dt,
4374            &Vector3::zeros(),
4375            resolved_atmo,
4376        );
4377        assert!(accepted.used_dt < dt, "oversized trial was not retried");
4378        assert!(
4379            accepted.error <= RK45_TOLERANCE || accepted.used_dt <= RK45_MIN_DT,
4380            "accepted error {} exceeds tolerance at dt {}",
4381            accepted.error,
4382            accepted.used_dt
4383        );
4384
4385        let accepted_trial = solver.rk45_step(
4386            &position,
4387            &velocity,
4388            accepted.used_dt,
4389            &Vector3::zeros(),
4390            RK45_TOLERANCE,
4391            resolved_atmo,
4392        );
4393        assert_eq!(accepted.position, accepted_trial.position);
4394        assert_eq!(accepted.velocity, accepted_trial.velocity);
4395        assert!((RK45_MIN_DT..=RK45_MAX_DT).contains(&accepted.next_dt));
4396    }
4397}
4398
4399#[cfg(test)]
4400mod ground_termination_tests {
4401    use super::*;
4402
4403    // Regression lock for the unified ground termination: solve_euler/solve_rk4/solve_rk45 all
4404    // loop while `position.y > ground_threshold` (default -100.0), so they agree with RK45. A
4405    // lofted shot that returns to launch level before reaching max_range must keep descending to
4406    // the -100 m floor instead of stopping at y = 0 — and RK4-fixed and RK45 must behave the same.
4407    #[test]
4408    fn rk4_and_rk45_descend_to_ground_threshold() {
4409        for adaptive in [false, true] {
4410            let mut inputs = BallisticInputs::default();
4411            inputs.muzzle_angle = 0.1; // ~5.7 deg: arcs up, then descends past launch level
4412            inputs.use_rk4 = true;
4413            inputs.use_adaptive_rk45 = adaptive;
4414            assert_eq!(
4415                inputs.ground_threshold, -100.0,
4416                "default ground_threshold is -100 m"
4417            );
4418
4419            let mut solver = TrajectorySolver::new(
4420                inputs,
4421                WindConditions::default(),
4422                AtmosphericConditions::default(),
4423            );
4424            // Huge max range: termination must be driven by ground_threshold, not the range cap.
4425            solver.set_max_range(1.0e7);
4426
4427            let result = solver.solve().expect("solve should succeed");
4428            let final_y = result
4429                .points
4430                .last()
4431                .expect("trajectory has points")
4432                .position
4433                .y;
4434            assert!(
4435                final_y < -1.0,
4436                "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
4437                 past launch level toward the ground_threshold floor, not stop at y = 0"
4438            );
4439        }
4440    }
4441}
4442
4443#[cfg(test)]
4444mod magnus_stability_tests {
4445    use super::*;
4446
4447    #[test]
4448    fn yaw_of_repose_magnus_force_is_vertical_and_twist_signed() {
4449        let acceleration = |enable_magnus, is_twist_right| {
4450            let inputs = BallisticInputs {
4451                muzzle_velocity: 822.96,
4452                bullet_mass: 168.0 * 0.00006479891,
4453                bullet_diameter: 0.308 * 0.0254,
4454                bullet_length: 1.215 * 0.0254,
4455                twist_rate: 10.0,
4456                is_twist_right,
4457                enable_magnus,
4458                ..BallisticInputs::default()
4459            };
4460            let solver = TrajectorySolver::new(
4461                inputs,
4462                WindConditions::default(),
4463                AtmosphericConditions::default(),
4464            );
4465            let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4466            solver.calculate_acceleration(
4467                &Vector3::zeros(),
4468                &Vector3::new(822.96, 0.0, 0.0),
4469                &Vector3::zeros(),
4470                (temp_c, pressure_hpa, density / 1.225),
4471            )
4472        };
4473
4474        let baseline = acceleration(false, true);
4475        let right_twist = acceleration(true, true) - baseline;
4476        let left_twist = acceleration(true, false) - baseline;
4477
4478        assert!(
4479            right_twist.y < 0.0,
4480            "right-hand Magnus must point down, got {right_twist:?}"
4481        );
4482        assert!(
4483            left_twist.y > 0.0,
4484            "left-hand Magnus must point up, got {left_twist:?}"
4485        );
4486        assert!((right_twist.y + left_twist.y).abs() < 1e-12);
4487        assert!(right_twist.x.abs() < 1e-12 && right_twist.z.abs() < 1e-12);
4488        assert!(left_twist.x.abs() < 1e-12 && left_twist.z.abs() < 1e-12);
4489    }
4490
4491    #[test]
4492    fn magnus_uses_velocity_corrected_muzzle_stability_gate() {
4493        let muzzle_velocity = 1_400.0 / 3.28084;
4494        let inputs = BallisticInputs {
4495            muzzle_velocity,
4496            bullet_mass: 168.0 * 0.00006479891,
4497            bullet_diameter: 0.308 * 0.0254,
4498            bullet_length: 1.215 * 0.0254,
4499            twist_rate: 15.0,
4500            enable_magnus: true,
4501            ..BallisticInputs::default()
4502        };
4503        let solver = TrajectorySolver::new(
4504            inputs.clone(),
4505            WindConditions::default(),
4506            AtmosphericConditions::default(),
4507        );
4508
4509        let bare_sg = crate::spin_drift::miller_stability(0.308, 168.0, 15.0, 1.215);
4510        let canonical_sg = solver.effective_spin_drift_sg();
4511        assert!(bare_sg > 1.0, "test requires bare Sg above the Magnus gate");
4512        assert!(
4513            canonical_sg < 1.0,
4514            "velocity-corrected Sg must be below the gate, got {canonical_sg}"
4515        );
4516
4517        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4518        let acceleration = solver.calculate_acceleration(
4519            &Vector3::zeros(),
4520            &Vector3::new(muzzle_velocity, 0.0, 0.0),
4521            &Vector3::zeros(),
4522            (temp_c, pressure_hpa, density / 1.225),
4523        );
4524        let mut baseline_inputs = inputs;
4525        baseline_inputs.enable_magnus = false;
4526        let baseline_solver = TrajectorySolver::new(
4527            baseline_inputs,
4528            WindConditions::default(),
4529            AtmosphericConditions::default(),
4530        );
4531        let baseline = baseline_solver.calculate_acceleration(
4532            &Vector3::zeros(),
4533            &Vector3::new(muzzle_velocity, 0.0, 0.0),
4534            &Vector3::zeros(),
4535            (temp_c, pressure_hpa, density / 1.225),
4536        );
4537
4538        assert_eq!(
4539            acceleration, baseline,
4540            "canonical Sg below 1 must suppress every Magnus acceleration component"
4541        );
4542    }
4543
4544    #[test]
4545    fn magnus_force_grows_as_fixed_spin_projectile_slows() {
4546        let inputs = BallisticInputs {
4547            muzzle_velocity: 800.0,
4548            bullet_mass: 168.0 * 0.00006479891,
4549            bullet_diameter: 0.308 * 0.0254,
4550            bullet_length: 1.215 * 0.0254,
4551            twist_rate: 12.0,
4552            enable_magnus: true,
4553            ..BallisticInputs::default()
4554        };
4555
4556        let magnus_acceleration = |speed_mps| {
4557            let evaluate = |enable_magnus| {
4558                let mut run_inputs = inputs.clone();
4559                run_inputs.enable_magnus = enable_magnus;
4560                let solver = TrajectorySolver::new(
4561                    run_inputs,
4562                    WindConditions::default(),
4563                    AtmosphericConditions::default(),
4564                );
4565                let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
4566                solver
4567                    .calculate_acceleration(
4568                        &Vector3::zeros(),
4569                        &Vector3::new(speed_mps, 0.0, 0.0),
4570                        &Vector3::zeros(),
4571                        (temp_c, pressure_hpa, density / 1.225),
4572                    )
4573                    .y
4574            };
4575            (evaluate(true) - evaluate(false)).abs()
4576        };
4577
4578        let fast = magnus_acceleration(200.0);
4579        let slow = magnus_acceleration(100.0);
4580        let ratio = slow / fast;
4581        let expected_ratio = 2.0_f64.powf(5.0 / 3.0);
4582
4583        assert!(fast > 0.0 && slow > 0.0, "fast={fast}, slow={slow}");
4584        assert!(
4585            (ratio - expected_ratio).abs() < 1e-3,
4586            "fixed-spin Magnus acceleration must grow downrange; slow/fast={ratio}, \
4587             expected={expected_ratio}"
4588        );
4589    }
4590}
4591
4592#[cfg(test)]
4593mod coriolis_direction_tests {
4594    use super::*;
4595    use std::f64::consts::FRAC_PI_2;
4596
4597    #[test]
4598    fn supersonic_crossing_flags_a_positive_range_sample() {
4599        // A supersonic shot that slows past Mach 1 must flag a sampled point as a Mach
4600        // transition. The underlying transonic_distances were a Vec::new() TODO, so this
4601        // flag was NEVER set regardless of trajectory — this is the regression guard.
4602        use crate::trajectory_sampling::TrajectoryFlag;
4603
4604        for (solver_name, use_rk4, use_adaptive_rk45) in [
4605            ("Euler", false, false),
4606            ("RK4", true, false),
4607            ("RK45", true, true),
4608        ] {
4609            let inputs = BallisticInputs {
4610                muzzle_velocity: 850.0,
4611                bc_value: 0.2,
4612                bc_type: DragModel::G7,
4613                muzzle_angle: 0.03,
4614                enable_trajectory_sampling: true,
4615                sample_interval: 50.0,
4616                use_rk4,
4617                use_adaptive_rk45,
4618                ..BallisticInputs::default()
4619            };
4620            let mut solver = TrajectorySolver::new(
4621                inputs,
4622                WindConditions::default(),
4623                AtmosphericConditions::default(),
4624            );
4625            solver.set_max_range(2000.0);
4626            let samples = solver
4627                .solve()
4628                .expect("supersonic solve should succeed")
4629                .sampled_points
4630                .expect("sampling was enabled");
4631            let flagged_distances: Vec<_> = samples
4632                .iter()
4633                .filter(|sample| sample.flags.contains(&TrajectoryFlag::MachTransition))
4634                .map(|sample| sample.distance_m)
4635                .collect();
4636
4637            assert!(
4638                !flagged_distances.is_empty()
4639                    && flagged_distances.iter().all(|distance| *distance > 0.0),
4640                "{solver_name} must flag genuine crossings only at positive range: {flagged_distances:?}"
4641            );
4642        }
4643    }
4644
4645    #[test]
4646    fn subsonic_launch_does_not_flag_a_muzzle_transition() {
4647        use crate::trajectory_sampling::TrajectoryFlag;
4648
4649        for (solver_name, use_rk4, use_adaptive_rk45) in [
4650            ("Euler", false, false),
4651            ("RK4", true, false),
4652            ("RK45", true, true),
4653        ] {
4654            let inputs = BallisticInputs {
4655                muzzle_velocity: 250.0,
4656                muzzle_angle: 0.02,
4657                enable_trajectory_sampling: true,
4658                sample_interval: 25.0,
4659                use_rk4,
4660                use_adaptive_rk45,
4661                ..BallisticInputs::default()
4662            };
4663            let mut solver = TrajectorySolver::new(
4664                inputs,
4665                WindConditions::default(),
4666                AtmosphericConditions::default(),
4667            );
4668            solver.set_max_range(300.0);
4669            let samples = solver
4670                .solve()
4671                .expect("subsonic solve should succeed")
4672                .sampled_points
4673                .expect("sampling was enabled");
4674
4675            assert!(
4676                samples
4677                    .iter()
4678                    .all(|sample| !sample.flags.contains(&TrajectoryFlag::MachTransition)),
4679                "{solver_name} marked a Mach transition for a launch already below Mach 1"
4680            );
4681        }
4682    }
4683
4684    #[test]
4685    fn mach_transition_tracker_requires_a_downward_crossing() {
4686        fn record(mach_values: &[f64]) -> Vec<f64> {
4687            let mut tracker = MachTransitionTracker::default();
4688            let mut distances = Vec::new();
4689            for (index, mach) in mach_values.iter().copied().enumerate() {
4690                tracker.record_downward_crossings(mach, index as f64 * 10.0, &mut distances);
4691            }
4692            distances
4693        }
4694
4695        assert!(record(&[0.9, 0.8, 0.7]).is_empty());
4696        assert_eq!(record(&[1.1, 1.05, 0.99]), vec![20.0]);
4697        assert_eq!(record(&[1.2, 1.19, 1.0, 0.99]), vec![10.0, 30.0]);
4698        assert_eq!(record(&[0.9, 1.3, 1.1, 0.9, 1.3, 0.8]), vec![20.0, 30.0]);
4699        assert!(record(&[1.3, f64::NAN, 1.1]).is_empty());
4700    }
4701
4702    #[test]
4703    fn humidity_percent_converts_and_clamps() {
4704        // MBA-722: BallisticInputs.humidity is a 0-1 fraction; the helper yields 0-100 percent.
4705        let mut i = BallisticInputs::default();
4706        i.humidity = 0.5;
4707        assert!((i.humidity_percent() - 50.0).abs() < 1e-9, "0.5 -> 50%");
4708        i.humidity = 0.0;
4709        assert_eq!(i.humidity_percent(), 0.0);
4710        i.humidity = 1.0;
4711        assert_eq!(i.humidity_percent(), 100.0);
4712        i.humidity = 1.5; // out of range -> clamped, never > 100
4713        assert_eq!(i.humidity_percent(), 100.0);
4714    }
4715
4716    /// Vertical position (m) at a given downrange `range_m`, for a shot fired along
4717    /// compass bearing `shot_azimuth` (radians, 0=N) with Coriolis enabled.
4718    fn vertical_at(shot_azimuth: f64, range_m: f64) -> f64 {
4719        let mut inputs = BallisticInputs::default();
4720        inputs.muzzle_velocity = 800.0;
4721        inputs.bc_value = 0.5;
4722        inputs.bc_type = DragModel::G7;
4723        inputs.muzzle_angle = 0.02; // ~20 mrad so it carries well past range_m
4724        inputs.enable_coriolis = true;
4725        inputs.latitude = Some(45.0);
4726        inputs.shot_azimuth = shot_azimuth;
4727        inputs.ground_threshold = f64::NEG_INFINITY; // never terminate early
4728        let mut solver = TrajectorySolver::new(
4729            inputs,
4730            WindConditions::default(),
4731            AtmosphericConditions::default(),
4732        );
4733        solver.set_max_range(range_m + 50.0);
4734        let r = solver.solve().expect("solve");
4735        let pts = &r.points;
4736        for i in 1..pts.len() {
4737            if pts[i].position.x >= range_m {
4738                let p1 = &pts[i - 1];
4739                let p2 = &pts[i];
4740                let t = (range_m - p1.position.x) / (p2.position.x - p1.position.x);
4741                return p1.position.y + t * (p2.position.y - p1.position.y);
4742            }
4743        }
4744        panic!("range {range_m} not reached");
4745    }
4746
4747    /// Regression for the shot-direction Coriolis bug: the Eötvös vertical term
4748    /// `a_up = +2Ω cosφ v_east` lifts an EAST shot and depresses a WEST shot, so at a
4749    /// common range east must sit HIGHER than west, with north in between. Before the
4750    /// fix, `--shot-direction` never reached the solver and E/W/N were identical.
4751    #[test]
4752    fn eotvos_east_higher_than_west() {
4753        let range = 600.0;
4754        let east = vertical_at(FRAC_PI_2, range); // 90° E
4755        let west = vertical_at(3.0 * FRAC_PI_2, range); // 270° W
4756        let north = vertical_at(0.0, range); // 0° N
4757        assert!(
4758            east > west,
4759            "east ({east:.5}) must be higher than west ({west:.5}) at {range} m (Eötvös)"
4760        );
4761        assert!(
4762            east > north && north > west,
4763            "north ({north:.5}) must lie between east ({east:.5}) and west ({west:.5})"
4764        );
4765        assert!(
4766            (east - west) > 1e-3,
4767            "E-W vertical separation ({:.6} m) should be physically meaningful, not FP noise",
4768            east - west
4769        );
4770    }
4771}
4772
4773#[cfg(test)]
4774mod cant_tests {
4775    use super::*;
4776
4777    fn base_inputs() -> BallisticInputs {
4778        let mut i = BallisticInputs::default();
4779        i.muzzle_velocity = 800.0;
4780        i.bc_value = 0.5;
4781        i.bc_type = DragModel::G7;
4782        i.bullet_mass = 0.0109;
4783        i.bullet_diameter = 0.00782;
4784        i.bullet_length = 0.0309;
4785        i.sight_height = 0.05;
4786        i.twist_rate = 10.0;
4787        i.use_rk4 = true;
4788        i
4789    }
4790
4791    fn solve_with(inputs: BallisticInputs, max_range: f64) -> TrajectoryResult {
4792        let mut s = TrajectorySolver::new(
4793            inputs,
4794            WindConditions::default(),
4795            AtmosphericConditions::default(),
4796        );
4797        s.set_max_range(max_range);
4798        s.solve().expect("solve")
4799    }
4800
4801    /// Interpolate (y, z) at downrange x.
4802    fn yz_at(result: &TrajectoryResult, x: f64) -> (f64, f64) {
4803        let pts = &result.points;
4804        for i in 1..pts.len() {
4805            if pts[i].position.x >= x {
4806                let (p1, p2) = (&pts[i - 1], &pts[i]);
4807                let dx = p2.position.x - p1.position.x;
4808                let t = if dx.abs() < 1e-12 { 0.0 } else { (x - p1.position.x) / dx };
4809                return (
4810                    p1.position.y + t * (p2.position.y - p1.position.y),
4811                    p1.position.z + t * (p2.position.z - p1.position.z),
4812                );
4813            }
4814        }
4815        panic!("trajectory never reached {x} m");
4816    }
4817
4818    #[test]
4819    fn cant_sign_clockwise_up_offset_goes_right_and_low() {
4820        // Upward zero offset + clockwise cant => POI right (+z) and low vs un-canted.
4821        let mut level = base_inputs();
4822        level.muzzle_angle = 0.003; // ~10 MOA up
4823        let mut canted = level.clone();
4824        canted.cant_angle = 10f64.to_radians();
4825
4826        let (y0, z0) = yz_at(&solve_with(level, 400.0), 300.0);
4827        let (y1, z1) = yz_at(&solve_with(canted, 400.0), 300.0);
4828        assert!(z1 > z0 + 0.01, "clockwise cant must move POI right: z0={z0} z1={z1}");
4829        assert!(y1 < y0 - 0.001, "clockwise cant must move POI low: y0={y0} y1={y1}");
4830    }
4831
4832    #[test]
4833    fn pure_cant_shows_bore_offset_near_range() {
4834        // No aim offset: the only lateral source near the muzzle is the swung bore,
4835        // z0 = -sight_height*sin(cant) (left of the aim plane for clockwise cant).
4836        let mut i = base_inputs();
4837        i.muzzle_angle = 0.0;
4838        i.cant_angle = 10f64.to_radians();
4839        let sh = i.sight_height;
4840        let r = solve_with(i, 60.0);
4841        let first = &r.points[1]; // just past the muzzle
4842        let expected = -sh * 10f64.to_radians().sin();
4843        assert!(
4844            (first.position.z - expected).abs() < 0.005,
4845            "near-muzzle lateral {} should be ~bore offset {expected}",
4846            first.position.z
4847        );
4848    }
4849
4850    #[test]
4851    fn zero_angle_is_independent_of_cant() {
4852        let a = base_inputs();
4853        let mut b = base_inputs();
4854        b.cant_angle = 15f64.to_radians();
4855        let za = calculate_zero_angle(a.clone(), 100.0, 0.0).expect("zero a");
4856        let zb = calculate_zero_angle(b.clone(), 100.0, 0.0).expect("zero b");
4857        assert_eq!(za.to_bits(), zb.to_bits(), "zeroing must ignore cant: {za} vs {zb}");
4858        // silence unused warnings
4859        let _ = (a.cant_angle, b.cant_angle);
4860    }
4861
4862    #[test]
4863    fn nonfinite_cant_is_rejected() {
4864        let mut i = base_inputs();
4865        i.cant_angle = f64::NAN;
4866        let s = TrajectorySolver::new(i, WindConditions::default(), AtmosphericConditions::default());
4867        assert!(s.solve().is_err());
4868    }
4869
4870    #[test]
4871    fn incline_and_cant_compose_without_breaking() {
4872        // 15-degree incline + 10-degree cant: finite result, cant still pushes right.
4873        let mut flat = base_inputs();
4874        flat.muzzle_angle = 0.003;
4875        flat.shooting_angle = 15f64.to_radians();
4876        let mut canted = flat.clone();
4877        canted.cant_angle = 10f64.to_radians();
4878        let (_, z_flat) = yz_at(&solve_with(flat, 400.0), 300.0);
4879        let (_, z_cant) = yz_at(&solve_with(canted, 400.0), 300.0);
4880        assert!(z_cant > z_flat, "cant must still deflect right on an incline");
4881    }
4882}
4883
4884#[cfg(test)]
4885mod vertical_wind_tests {
4886    use super::*;
4887
4888    fn base_inputs() -> BallisticInputs {
4889        let mut i = BallisticInputs::default();
4890        i.muzzle_velocity = 800.0;
4891        i.bc_value = 0.5;
4892        i.bc_type = DragModel::G7;
4893        i.bullet_mass = 0.0109;
4894        i.bullet_diameter = 0.00782;
4895        i.bullet_length = 0.0309;
4896        i.sight_height = 0.05;
4897        i.twist_rate = 10.0;
4898        i.use_rk4 = true;
4899        i
4900    }
4901
4902    /// Interpolate trajectory height (McCoy Y) at downrange distance `x`.
4903    fn y_at(result: &TrajectoryResult, x: f64) -> f64 {
4904        let pts = &result.points;
4905        for i in 1..pts.len() {
4906            if pts[i].position.x >= x {
4907                let (p1, p2) = (&pts[i - 1], &pts[i]);
4908                let dx = p2.position.x - p1.position.x;
4909                let t = if dx.abs() < 1e-12 { 0.0 } else { (x - p1.position.x) / dx };
4910                return p1.position.y + t * (p2.position.y - p1.position.y);
4911            }
4912        }
4913        panic!("trajectory never reached {x} m");
4914    }
4915
4916    fn solve_with(inputs: BallisticInputs, wind: WindConditions, max_range: f64) -> TrajectoryResult {
4917        let mut s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
4918        s.set_max_range(max_range);
4919        s.solve().expect("solve")
4920    }
4921
4922    #[test]
4923    fn updraft_raises_poi_downrange() {
4924        // No shear, no segments: this exercises the constant-wind sites in
4925        // solve_euler/solve_rk4/solve_rk45 directly (MBA-728).
4926        let calm_inputs = base_inputs();
4927        let calm_wind = WindConditions::default();
4928        let updraft = WindConditions {
4929            vertical_speed: 5.0,
4930            ..Default::default()
4931        };
4932
4933        let calm = solve_with(calm_inputs.clone(), calm_wind, 500.0);
4934        let updraft_result = solve_with(calm_inputs, updraft, 500.0);
4935
4936        let y_calm = y_at(&calm, 400.0);
4937        let y_updraft = y_at(&updraft_result, 400.0);
4938        assert!(
4939            y_updraft > y_calm,
4940            "5 m/s updraft must raise POI at 400m: calm={y_calm}, updraft={y_updraft}"
4941        );
4942    }
4943
4944    #[test]
4945    fn zero_vertical_is_default_and_finite_required() {
4946        assert_eq!(WindConditions::default().vertical_speed, 0.0);
4947
4948        let inputs = base_inputs();
4949        let wind = WindConditions {
4950            vertical_speed: f64::NAN,
4951            ..Default::default()
4952        };
4953        let s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
4954        assert!(
4955            s.solve().is_err(),
4956            "NaN wind.vertical_speed must be rejected by validate_for_solve"
4957        );
4958    }
4959}