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