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