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