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