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