Skip to main content

ballistics_engine/
cli_api.rs

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