Skip to main content

ballistics_engine/
cli_api.rs

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