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