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