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::mc_stats::{wilson_interval, BernoulliConfidenceSequence, ConfidenceLevel, Welford};
4use crate::pitch_damping::{calculate_pitch_damping_coefficient, PitchDampingCoefficients};
5use crate::precession_nutation::{
6    calculate_combined_angular_motion, projectile_moments_of_inertia, AngularState,
7    PrecessionNutationParams,
8};
9use crate::trajectory_observation::{bracket_param, Bracket, TrajectoryTermination};
10use crate::trajectory_sampling::{
11    projected_sample_count, sample_trajectory, TrajectoryData, TrajectoryOutputs, TrajectorySample,
12};
13use crate::wind_shear::WindShearModel;
14use crate::DragModel;
15use nalgebra::{Vector3, Vector6};
16use std::error::Error;
17use std::fmt;
18
19/// Unit system for CLI-style inputs and outputs.
20///
21/// The single crate-wide unit-system selector, shared by the CLI binary
22/// (`--units`), the truing core ([`crate::truing`]) and the WEZ sweep core
23/// ([`crate::wez`]). It only selects how user-facing quantities are
24/// interpreted and displayed; the solver itself always works in SI.
25///
26/// Variant order (Metric first) is load-bearing for the CLI: clap lists
27/// `--units` possible values in declaration order.
28#[derive(Debug, Clone, Copy, PartialEq)]
29#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
30pub enum UnitSystem {
31    /// Metric units (velocity in m/s, mass in grams, distance in meters, diameter in mm, Celsius)
32    Metric,
33    /// Imperial units (velocity in fps, mass in grains, distance in yards, diameter in inches, Fahrenheit)
34    Imperial,
35}
36
37// Output format for results
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub enum OutputFormat {
40    Table,
41    Json,
42    Csv,
43}
44
45/// Which standard atmosphere a ballistic coefficient's numeric value is referenced to
46/// (MBA-1365).
47///
48/// A BC is not a pure property of the bullet — it also encodes an assumed reference air
49/// density. This engine's retardation constant ([`crate::constants::CD_TO_RETARD`]) is
50/// calibrated against the ICAO Standard Atmosphere's sea-level density
51/// ([`crate::constants::ICAO_DENSITY_LB_FT3`]), which most modern published BCs already
52/// assume. Some vendors (notably Sierra, Hornady, and Barnes for many bullets) instead
53/// publish BCs referenced to the older, denser Army Standard Metro atmosphere
54/// ([`crate::constants::ASM_DENSITY_LB_FT3`]). Feeding an ASM-referenced BC into this
55/// engine as if it were ICAO-referenced under-predicts drag by about 1.8% — declaring
56/// `ArmyStandardMetro` here corrects for that exactly once, at input normalization (see
57/// [`crate::constants::ASM_TO_ICAO_BC`] and `TrajectorySolver::new`).
58///
59/// Does not apply when a custom drag table (`BallisticInputs::custom_drag_table`) is flown as
60/// the projectile's own curve: that path divides by sectional density, not a BC value, so no
61/// BC reference conversion is physically meaningful there (see
62/// `BallisticInputs::bc_reference_standard_inert_warning`). A table flown as a reference curve
63/// (`BallisticInputs::custom_drag_reference`) divides by the BC exactly as a built-in model
64/// does, so the conversion applies to it as it does to a G-model.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
67pub enum BcReferenceStandard {
68    /// The ICAO Standard Atmosphere reference density — matches this engine's
69    /// retardation constant. The default; byte-identical to pre-MBA-1365 behavior.
70    #[default]
71    Icao,
72    /// The (older) Army Standard Metro reference density used by some vendor-published
73    /// BCs (e.g. many Sierra/Hornady/Barnes bullets).
74    ArmyStandardMetro,
75}
76
77/// Shared text for the MBA-1365 "`--bc-reference army-standard-metro` is inert" warning —
78/// single source of truth for every surface that can trigger it (native CLI, WASM,
79/// [`BallisticInputs::bc_reference_standard_inert_warning`]) so the wording can't drift.
80pub const BC_REFERENCE_STANDARD_INERT_WARNING: &str =
81    "warning: --bc-reference army-standard-metro has no effect together with a custom drag \
82     table (--drag-table): the deck's Cd is divided by sectional density, not a BC value, so \
83     no BC-reference conversion applies";
84
85// Error type for CLI operations
86#[derive(Debug)]
87pub struct BallisticsError {
88    message: String,
89}
90
91impl fmt::Display for BallisticsError {
92    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
93        write!(f, "{}", self.message)
94    }
95}
96
97impl Error for BallisticsError {}
98
99impl From<String> for BallisticsError {
100    fn from(msg: String) -> Self {
101        BallisticsError { message: msg }
102    }
103}
104
105impl From<&str> for BallisticsError {
106    fn from(msg: &str) -> Self {
107        BallisticsError {
108            message: msg.to_string(),
109        }
110    }
111}
112
113/// Which plane sampled drop values are referenced to (MBA-1403).
114///
115/// See [`BallisticInputs::drops_reference`]. This is an output-mode toggle for the
116/// trajectory sampler, not new plane machinery: the solver implements incline as gravity
117/// rotation into the shot frame, so sampled drop is already perpendicular to the LOS and
118/// the JBM-equivalent "target" mode is a `1 / cos(shooting_angle)` reference transform
119/// plus relabeling.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
121pub enum DropsReference {
122    /// Drop measured perpendicular to the line of sight (the historical default).
123    #[default]
124    Los,
125    /// Drop measured vertically in the target plane (JBM's "target plane" reference):
126    /// the LOS-perpendicular drop scaled by `1 / cos(shooting_angle)`.
127    Target,
128}
129
130// Ballistic input parameters - MBA-151 Reconciled Structure
131// Unified structure used by both ballistics-engine and ballistics_rust
132// Duplicates removed, all necessary fields included
133#[derive(Debug, Clone)]
134pub struct BallisticInputs {
135    // Core ballistics parameters (using intuitive names)
136    pub bc_value: f64,      // Ballistic coefficient (G1, G7, etc.)
137    pub bc_type: DragModel, // Drag model (G1, G7, G8, etc.)
138    /// Which standard atmosphere `bc_value`/`bc_segments`/`bc_segments_data` are
139    /// referenced to (MBA-1365). `Icao` (the default) is a no-op; `ArmyStandardMetro`
140    /// is converted to the ICAO reference exactly once, in `TrajectorySolver::new`,
141    /// before any retardation math runs. See [`BcReferenceStandard`].
142    pub bc_reference_standard: BcReferenceStandard,
143    pub bullet_mass: f64,     // kg
144    pub muzzle_velocity: f64, // m/s
145    pub bullet_diameter: f64, // meters
146    pub bullet_length: f64,   // meters
147
148    // Targeting and positioning
149    pub muzzle_angle: f64,    // radians (launch angle)
150    pub target_distance: f64, // meters
151    pub azimuth_angle: f64, // horizontal aiming angle in radians (small aim offset within the shot frame)
152    /// Compass bearing the shot is fired ALONG, radians, 0 = North, π/2 = East.
153    /// Used only by the Coriolis model (Earth-rotation depends on which way downrange
154    /// points relative to true North). Distinct from `azimuth_angle`, which is the
155    /// small horizontal *aiming* offset and rotates the launch velocity.
156    pub shot_azimuth: f64,
157    pub shooting_angle: f64, // uphill/downhill angle in radians
158    /// Rifle cant angle in radians about the line of sight — positive = clockwise from the
159    /// shooter's view (top of the scope tips right). Rotates the sight-frame aim offsets
160    /// (`muzzle_angle`, `azimuth_angle`) about the LOS and swings the bore's sight-height
161    /// offset laterally, producing the classic canted-rifle POI error (right-and-low for
162    /// clockwise cant with an upward zero). Zeroing always solves un-canted ("zero level,
163    /// fire canted"). NOTE: treats `muzzle_angle` as a sight-frame offset — the standard
164    /// zero-then-fire usage; a raw gravity-frame launch angle would not rotate physically.
165    /// 0.0 = level rifle (bit-identical to pre-cant behavior). (MBA-1286)
166    pub cant_angle: f64,
167    pub sight_height: f64, // meters above bore
168    /// Lateral offset between the sight axis and the bore axis, meters (MBA-1396;
169    /// offset-mounted optics): positive = the sight sits RIGHT of the bore, so the bore
170    /// starts LEFT of the line of sight (initial lateral position `z -= offset`). When a
171    /// zero is solved, a windage-zero convergence term of `offset / zero_distance` is
172    /// added to `azimuth_angle` (see [`BallisticInputs::windage_zero_bias_rad`]) so the
173    /// trajectory crosses the LOS laterally at the zero range — matching AB/JBM/Shooter,
174    /// not a constant parallel offset. Without a zero solve (an explicit muzzle angle),
175    /// only the physical displacement applies. Distinct from `zero_poi_horizontal_m`,
176    /// which is an angular ZERO-STATE bias, not mount geometry. 0.0 (the default) is
177    /// byte-identical to pre-MBA-1396 behavior.
178    pub sight_offset_lateral_m: f64,
179    pub muzzle_height: f64, // meters above ground
180    pub target_height: f64, // meters above ground for zeroing
181    /// Deliberate vertical point-of-impact offset AT THE ZERO RANGE, meters (MBA-1359;
182    /// Kestrel "zero height" semantics): positive = the rifle is deliberately zeroed to
183    /// impact HIGH by this much at the zero distance. Applied POST-solve by
184    /// `calculate_and_set_zero_angle` as a constant angular bias of
185    /// `zero_poi_vertical_m / zero_distance` on the solved elevation — the zero trials
186    /// themselves always solve perfect convergence, and the small-angle addition is
187    /// identical in both zero target frames. 0.0 (the default) is byte-identical to
188    /// pre-MBA-1359 behavior.
189    pub zero_poi_vertical_m: f64,
190    /// Deliberate horizontal point-of-impact offset AT THE ZERO RANGE, meters (MBA-1359;
191    /// Kestrel "zero offset" semantics): positive = impacts RIGHT by this much at the
192    /// zero distance. Applied POST-solve as an azimuth bias of
193    /// `zero_poi_horizontal_m / zero_distance` when a zero is solved — see
194    /// [`BallisticInputs::windage_zero_bias_rad`]. 0.0 (the default) is neutral.
195    pub zero_poi_horizontal_m: f64,
196    pub ground_threshold: f64, // meters below which to stop
197
198    // Environmental conditions
199    pub altitude: f64,    // meters
200    pub temperature: f64, // Celsius
201    pub pressure: f64,    // millibars/hPa
202    /// Relative humidity as a FRACTION in `[0, 1]` (e.g. 0.5 = 50%). NOTE the scale
203    /// differs from [`AtmosphericConditions::humidity`], which is a PERCENT in `[0, 100]`.
204    /// The atmosphere helpers (`calculate_air_density_*`) expect percent, so convert via
205    /// [`BallisticInputs::humidity_percent`] before passing this value to them (MBA-722).
206    pub humidity: f64,
207    pub latitude: Option<f64>, // degrees
208
209    // Wind conditions
210    pub wind_speed: f64, // m/s
211    pub wind_angle: f64, // radians (0=headwind, PI/2=from right)
212
213    // Bullet characteristics
214    pub twist_rate: f64,               // inches per turn
215    pub is_twist_right: bool,          // right-hand twist
216    pub caliber_inches: f64,           // diameter in inches
217    pub weight_grains: f64,            // mass in grains
218    pub manufacturer: Option<String>,  // Bullet manufacturer
219    pub bullet_model: Option<String>,  // Bullet model name
220    pub bullet_id: Option<String>,     // Unique bullet identifier
221    pub bullet_cluster: Option<usize>, // BC cluster ID for cluster_bc module
222
223    // Integration method selection
224    pub use_rk4: bool,           // Use RK4 integration instead of Euler
225    pub use_adaptive_rk45: bool, // Use RK45 adaptive step size integration
226
227    // Advanced effects flags
228    pub enable_advanced_effects: bool,
229    pub enable_magnus: bool,   // Magnus force (independent of Coriolis)
230    pub enable_coriolis: bool, // Coriolis deflection (requires latitude)
231    pub use_powder_sensitivity: bool,
232    pub powder_temp_sensitivity: f64, // m/s per degree Celsius
233    pub powder_temp: f64,             // Celsius
234    /// Optional measured powder-temperature -> muzzle-velocity curve, as
235    /// (temperature_celsius, muzzle_velocity_m_s) points sorted ascending by
236    /// temperature. When present it supersedes the linear `powder_temp_sensitivity`
237    /// model: the muzzle velocity is interpolated from this table at the ambient
238    /// `temperature` (clamped to the endpoints — no extrapolation beyond measured
239    /// data). This is the data-driven, non-linear alternative to the constant slope.
240    pub powder_temp_curve: Option<Vec<(f64, f64)>>,
241    /// Temperature (Celsius) at which to interpolate `powder_temp_curve` — the POWDER
242    /// temperature, which may differ from the ambient `temperature` (air). `None` uses
243    /// `temperature`. Decouples the velocity lookup from the air-density temperature.
244    pub powder_curve_temp_c: Option<f64>,
245    pub tipoff_yaw: f64,            // radians
246    pub tipoff_decay_distance: f64, // meters
247    /// Quadratic yaw-drag coefficient CD_delta2 (per rad^2, actual-Cd space) applied
248    /// additively to the tip-off yaw: CD = CD0 + CD_delta2 * delta^2 (McCoy, Modern
249    /// Exterior Ballistics). Literature values for spitzer rifle bullets run ~4-20
250    /// per rad^2 (7.62 M80 ~= 9.6); the default 7.5 is a documented mid-range
251    /// constant. Only active when `tipoff_yaw` is nonzero (MBA-1227).
252    pub cd_delta2: f64,
253    /// Enables velocity-keyed `bc_segments_data`. Explicit Mach-keyed `bc_segments` retain their
254    /// legacy behavior and remain active when this flag is false.
255    ///
256    /// An empty `bc_segments_data` is the same as `None`. With the flag on and no velocity
257    /// ladder, every solver flies a non-empty Mach `bc_segments` table. Without one,
258    /// `TrajectorySolver` and `fast_trajectory::fast_integrate` fly the scalar BC, while the RK45
259    /// kernel behind `fast_integrate_with_segments` /
260    /// `trajectory_integration::integrate_trajectory` / `derivatives::compute_derivatives`
261    /// auto-estimates a ladder when `bc_segments` is `None` (MBA-955, legacy opt-in).
262    pub use_bc_segments: bool,
263    pub bc_segments: Option<Vec<(f64, f64)>>, // Mach-BC pairs
264    pub bc_segments_data: Option<Vec<crate::BCSegmentData>>, // Velocity-BC segments
265    pub use_enhanced_spin_drift: bool,
266    /// Legacy compatibility flag. Name-derived "form factors" are intentionally not multiplied
267    /// into reference Cd when `bc_value` is already the retardation denominator (MBA-1184).
268    pub use_form_factor: bool,
269    pub enable_wind_shear: bool,
270    pub wind_shear_model: String,
271    pub enable_trajectory_sampling: bool,
272    pub sample_interval: f64, // meters
273    /// Which plane sampled drop values are referenced to (MBA-1403). The default
274    /// [`DropsReference::Los`] keeps the historical behavior (drop perpendicular to the
275    /// line of sight) byte-identical; [`DropsReference::Target`] reports drop as vertical
276    /// in the target plane — the LOS-perpendicular drop scaled by
277    /// `1 / cos(shooting_angle)` (JBM's "target plane" checkbox) — and, when a non-zero
278    /// `target_height` is supplied, slopes the sampler's LOS datum toward it. This is an
279    /// OUTPUT-mode toggle: the solved trajectory itself is unchanged, and zero-solve
280    /// `target_height` semantics are untouched.
281    pub drops_reference: DropsReference,
282    pub enable_pitch_damping: bool,
283    pub enable_precession_nutation: bool,
284    // MBA-959: apply aerodynamic jump as a muzzle launch-angle perturbation.
285    // EXPERIMENTAL — the underlying model is heuristic and not yet validated; default OFF.
286    pub enable_aerodynamic_jump: bool,
287    pub use_cluster_bc: bool, // Use cluster-based BC degradation
288
289    // Custom drag model support
290    /// A caller-supplied Cd-vs-Mach curve that replaces the `bc_type` G-model curve at every
291    /// drag site (MBA-940). It is used as-is: no transonic correction and no form factor is
292    /// applied to it, and outside its Mach range the nearest end value is held. What the curve
293    /// describes, and so what it is divided by, is decided by [`Self::custom_drag_reference`]:
294    ///
295    /// * `false` (the default): the projectile's OWN drag coefficient, divided by its
296    ///   sectional density. `bc_value` and any BC segments are unused for drag. This is the
297    ///   only reading the CLI's `--drag-table`, the WASM terminal, the array FFI and saved
298    ///   profiles have, and solve-json v1's `kind: "projectile"`.
299    /// * `true`: a REFERENCE curve, such as an airgun pellet law, that the BCs were measured
300    ///   against, divided by the resolved BC exactly as a built-in model's curve is.
301    ///
302    /// See [`Self::custom_drag_denominator`].
303    pub custom_drag_table: Option<crate::drag::DragTable>,
304    /// Whole-curve multiplier applied to the custom deck's interpolated Cd (MBA-1356):
305    /// `Cd_used = table.interpolate(mach) * cd_scale`. Meaningful only alongside
306    /// `custom_drag_table` — it is read at the three custom-deck interpolation sites in
307    /// cli_api.rs/derivatives.rs/fast_trajectory.rs and left untouched on the standard
308    /// G-model/BC path (those users true their drag via `--bc-adjustment` instead; scaling
309    /// a G-model's reference table too would double-count). It multiplies the curve before
310    /// the division, under either reading of [`Self::custom_drag_reference`]. solve-json v1's
311    /// `kind: "reference"` table without a BC ladder is installed as the own-curve path with
312    /// `cd_scale = SD / BC`, which flies `Cd_ref / BC`; with a ladder it is installed with
313    /// `custom_drag_reference` set and `cd_scale = 1.0` instead, since one whole-curve scale
314    /// cannot carry a BC that changes from band to band. Default `1.0` is neutral
315    /// (byte-identical to the pre-MBA-1356 behavior). Validated finite and > 0 by
316    /// `validate_for_solve`.
317    pub cd_scale: f64,
318    /// Whether `custom_drag_table` is a REFERENCE curve that the BCs were measured against
319    /// (`true`), rather than the projectile's own Cd (`false`, the default). Inert without a
320    /// table.
321    ///
322    /// When set, the drag sites in cli_api.rs, derivatives.rs and fast_trajectory.rs divide the
323    /// table's Cd (times `cd_scale`) by the BC each of them has already resolved for the current
324    /// speed, exactly as they divide a built-in model's Cd: a velocity ladder's band BC
325    /// (`bc_segments_data` under `use_bc_segments`, selected and smoothstep-blended by
326    /// `velocity_segment_bc`), a Mach-keyed `bc_segments` BC, or the scalar `bc_value` in an
327    /// interior gap or without a ladder. The BC matters again on this path, so
328    /// `validate_for_solve` requires a finite positive `bc_value`, and an `ArmyStandardMetro`
329    /// `bc_reference_standard` is converted exactly as it is for a built-in model.
330    ///
331    /// solve-json v1 sets it for a `kind: "reference"` `projectile.drag_table` sent with a
332    /// `projectile.bc_segments` ladder; no other front end sets it. Every path that copies a
333    /// table between input structs must carry it alongside `cd_scale`
334    /// (`TrajectoryParams::custom_drag_reference`): dropped beside `cd_scale = 1.0`, it would fly
335    /// the reference curve as the projectile's own Cd over sectional density, wrong by the
336    /// projectile's whole form factor (the MBA-1356 dropped-field class).
337    pub custom_drag_reference: bool,
338
339    // Legacy field for compatibility
340    pub bc_type_str: Option<String>,
341}
342
343impl BallisticInputs {
344    /// `humidity` as a PERCENT in `[0, 100]`, clamped — the scale the atmosphere
345    /// density helpers expect. Centralizes the 0–1 → 0–100 conversion so callers don't
346    /// re-derive it (and can't accidentally feed the raw 0–1 fraction as a percentage).
347    /// See the field doc on [`BallisticInputs::humidity`] (MBA-722).
348    pub fn humidity_percent(&self) -> f64 {
349        (self.humidity * 100.0).clamp(0.0, 100.0)
350    }
351
352    /// Azimuth bias (radians) a zero solved at `zero_distance_m` applies to the launch
353    /// direction. Two independent terms share this one convergence point:
354    /// * MBA-1359 — the deliberate horizontal POI offset at the zero range
355    ///   (`zero_poi_horizontal_m`), an angular zero-state bias;
356    /// * MBA-1396 — the lateral sight-mount offset (`sight_offset_lateral_m`): the bore
357    ///   starts `offset` left of the LOS (see `initial_position`), so the windage zero
358    ///   steers `offset / zero_distance` right to cross the LOS at the zero range.
359    ///
360    /// `calculate_and_set_zero_angle` applies this to its OWN solver's `azimuth_angle`;
361    /// callers that copy a solved zero angle onto separate flight inputs (the CLI/WASM
362    /// auto-zero paths) must add this to their flight `azimuth_angle` themselves — the
363    /// returned elevation angle cannot carry it. Returns exactly `0.0` when both offsets
364    /// are `0.0` or `zero_distance_m` is not positive, so default inputs stay
365    /// byte-identical.
366    pub fn windage_zero_bias_rad(&self, zero_distance_m: f64) -> f64 {
367        if zero_distance_m > 0.0 {
368            (self.zero_poi_horizontal_m + self.sight_offset_lateral_m) / zero_distance_m
369        } else {
370            0.0
371        }
372    }
373
374    /// Sectional density in lb/in²: `weight_grains / 7000 / diameter_in²`.
375    ///
376    /// Derived from the imperial mirror fields (`weight_grains` / `caliber_inches`), falling
377    /// back to the SI `bullet_mass` (kg) / `bullet_diameter` (meters) for SI-only callers
378    /// (mirrors the fallbacks in derivatives.rs). `None` when neither source is usable.
379    pub fn sectional_density_lb_in2(&self) -> Option<f64> {
380        let weight_gr = if self.weight_grains > 0.0 {
381            self.weight_grains
382        } else {
383            self.bullet_mass / crate::constants::GRAINS_TO_KG // kg -> grains
384        };
385        let diameter_in = if self.caliber_inches > 0.0 {
386            self.caliber_inches
387        } else {
388            self.bullet_diameter / 0.0254 // meters -> inches
389        };
390        if weight_gr > 0.0 && diameter_in > 0.0 {
391            Some(weight_gr / 7000.0 / (diameter_in * diameter_in))
392        } else {
393            None
394        }
395    }
396
397    /// Retardation denominator to use when `custom_drag_table` is active.
398    ///
399    /// `resolved_bc` is the BC the calling drag site has already resolved for the current
400    /// speed, by the same precedence it applies to a built-in model: a velocity ladder's band
401    /// BC, else a Mach-segment BC, else the scalar `bc_value`.
402    ///
403    /// * **Reference curve** ([`Self::custom_drag_reference`]): returns `resolved_bc`, the very
404    ///   denominator a built-in model divides by, so the table flies `Cd_ref / BC` and a BC
405    ///   ladder's band BCs reach it.
406    /// * **The projectile's own curve** (the default): the table supplies the projectile's
407    ///   ACTUAL drag coefficient, so the point-mass retardation formula must divide it by the
408    ///   projectile's SECTIONAL DENSITY (lb/in²), not by a ballistic coefficient: BC = SD / i
409    ///   (form factor i vs the reference projectile), and with the projectile's own curve
410    ///   i == 1, so Cd_own / SD == Cd_ref / BC. Dividing the curve's Cd by `bc_value` made
411    ///   custom-table trajectories wrongly scale with whatever BC happened to be set. Falls back
412    ///   to `resolved_bc` (with a one-time stderr warning) when mass/diameter are unavailable,
413    ///   so degenerate inputs degrade to the old behavior instead of panicking.
414    pub fn custom_drag_denominator(&self, resolved_bc: f64) -> f64 {
415        if self.custom_drag_reference {
416            return resolved_bc;
417        }
418        match self.sectional_density_lb_in2() {
419            Some(sd) => sd,
420            None => {
421                static WARN_ONCE: std::sync::Once = std::sync::Once::new();
422                WARN_ONCE.call_once(|| {
423                    eprintln!(
424                        "Warning: custom drag table active but bullet mass/diameter are \
425                         unavailable; falling back to bc_value for the retardation denominator"
426                    );
427                });
428                resolved_bc
429            }
430        }
431    }
432
433    /// Whether a declared `bc_reference_standard` is inert on this configuration
434    /// (MBA-1365), and if so, the message explaining why.
435    ///
436    /// A custom drag table (`custom_drag_table`) flown as the projectile's own curve supplies
437    /// its actual Cd and divides by sectional density, not by a BC value (see
438    /// `custom_drag_denominator`), so an `ArmyStandardMetro` reference has no physical effect
439    /// once such a table is active. A table flown as a reference curve
440    /// (`custom_drag_reference`) divides by the BC like a built-in model, so the conversion
441    /// is meaningful there and this returns `None`. Pure and side-effect-free — same shape as
442    /// `main.rs`'s `adjustment_unit_noop_warning` (MBA-1414) — so each surface decides how to
443    /// surface it: the native CLI prints it to stderr once per run, while the WASM browser
444    /// terminal (no visible stderr) splices it into its table-output text instead.
445    pub fn bc_reference_standard_inert_warning(&self) -> Option<&'static str> {
446        if self.custom_drag_table.is_some()
447            && !self.custom_drag_reference
448            && matches!(
449                self.bc_reference_standard,
450                BcReferenceStandard::ArmyStandardMetro
451            )
452        {
453            Some(BC_REFERENCE_STANDARD_INERT_WARNING)
454        } else {
455            None
456        }
457    }
458
459    /// Apply every input-conditioning step integration requires, exactly once (MBA-1415).
460    ///
461    /// **Every public entry point into integration must call this**, not just
462    /// [`TrajectorySolver::new`]. `fast_trajectory::fast_integrate` and
463    /// `fast_integrate_with_segments` are `pub` and are called directly by the Python
464    /// binding, so conditioning that lives only in the solver constructor is invisible to
465    /// those consumers: the caller sets a field, gets no error, and the value is silently
466    /// ignored. That exact shape has already cost this project twice — MBA-1296 (a dropped
467    /// field that zeroed Coriolis in production) and the MBA-1356 review catch
468    /// (`cd_scale` dropped by the segmented fast path). Adding a step here rather than at a
469    /// call site is what keeps the next one from repeating it.
470    ///
471    /// **Idempotent**, and it must stay that way: Monte Carlo builds a solver per sample, and
472    /// the fast entry points may receive inputs that already passed through a solver. Each
473    /// step below is either an absolute override (derived fields, powder-resolved velocity) or
474    /// self-disarming (the BC conversion rewrites `bc_reference_standard` to the reference it
475    /// just converted into, so a second call finds nothing to do). A step that scales a value
476    /// in place without disarming itself would double-apply silently — do not add one.
477    pub fn normalize_for_solve(&mut self) {
478        // MBA-1365: normalize a declared Army-Standard-Metro BC reference to the ICAO
479        // reference this engine's retardation constant (CD_TO_RETARD) is calibrated against,
480        // before any retardation math runs. Every BC representation on these inputs (the
481        // scalar, explicit Mach segments, and velocity segments) is a real BC and gets the
482        // same treatment. `Icao` (the default) takes this branch never, so callers that never
483        // set the field are byte-identical to pre-MBA-1365 behavior. A custom drag table flown
484        // as the projectile's own curve divides by sectional density rather than a BC, so the
485        // conversion is physically inert there — see `bc_reference_standard_inert_warning`,
486        // which callers surface at their own display sites (native stderr / WASM table text)
487        // from their OWN copy of the inputs, before this runs. A table flown as a reference
488        // curve (`custom_drag_reference`) divides by these same BCs, so it needs the conversion
489        // exactly as a built-in model does.
490        if matches!(
491            self.bc_reference_standard,
492            BcReferenceStandard::ArmyStandardMetro
493        ) {
494            self.bc_value *= crate::constants::ASM_TO_ICAO_BC;
495            if let Some(segments) = self.bc_segments.as_mut() {
496                for (_mach, bc) in segments.iter_mut() {
497                    *bc *= crate::constants::ASM_TO_ICAO_BC;
498                }
499            }
500            if let Some(segments) = self.bc_segments_data.as_mut() {
501                for segment in segments.iter_mut() {
502                    segment.bc_value *= crate::constants::ASM_TO_ICAO_BC;
503                }
504            }
505            // Disarm: these BC values are now ICAO-referenced, which is simply true, and it
506            // makes a second call a no-op instead of a silent second scaling.
507            self.bc_reference_standard = BcReferenceStandard::Icao;
508        }
509
510        // Derived imperial fields, recomputed from the canonical SI values. These are an
511        // absolute override, NOT a fallback: a caller that sets only `caliber_inches` /
512        // `weight_grains` and leaves the SI fields at their defaults has those imperial values
513        // overwritten here. SI is authoritative on these inputs.
514        self.caliber_inches = self.bullet_diameter / 0.0254;
515        self.weight_grains = self.bullet_mass / crate::constants::GRAINS_TO_KG;
516
517        // Resolve the muzzle velocity for the ambient temperature before integration. A
518        // measured powder-temperature -> velocity curve (data-driven, non-linear) takes
519        // precedence when supplied; otherwise fall back to the linear powder-temperature-
520        // sensitivity model (MBA-963). Both operate in canonical SI (Celsius, m/s), so every
521        // solver built from these inputs — the main trajectory AND the zero-angle search —
522        // sees the same temperature-resolved velocity. In particular, when a zero solve passes
523        // the zero-day temperature, the curve automatically yields the zero-day velocity.
524        // (The curve interpolates at the POWDER temperature — powder_curve_temp_c, falling
525        // back to ambient. Air temperature still drives density separately; this only sets the
526        // velocity. Absolute override, so applying it twice is a no-op.)
527        self.muzzle_velocity = resolve_powder_adjusted_velocity(
528            self.muzzle_velocity,
529            self.temperature,
530            self.use_powder_sensitivity,
531            self.powder_temp_sensitivity,
532            self.powder_temp,
533            self.powder_temp_curve.as_deref(),
534            self.powder_curve_temp_c,
535        );
536    }
537}
538
539impl Default for BallisticInputs {
540    fn default() -> Self {
541        let mass_kg = 0.01;
542        let diameter_m = 0.00762;
543        let bc = 0.5;
544        let muzzle_angle_rad = 0.0;
545        let bc_type = DragModel::G1;
546
547        Self {
548            // Core ballistics parameters
549            bc_value: bc,
550            bc_type,
551            bc_reference_standard: BcReferenceStandard::Icao,
552            bullet_mass: mass_kg,
553            muzzle_velocity: 800.0,
554            bullet_diameter: diameter_m,
555            // MBA-1135: mass-based length estimate so the default is self-consistent with the
556            // default mass/diameter (was a mass-blind 4.5-caliber literal). The twist default below
557            // stays a fixed 1:12" per the ticket (a constant is a sensible velocity-agnostic default).
558            bullet_length: crate::stability::estimate_bullet_length_m(diameter_m, mass_kg),
559
560            // Targeting and positioning
561            muzzle_angle: muzzle_angle_rad,
562            target_distance: 100.0,
563            azimuth_angle: 0.0,
564            shot_azimuth: 0.0,
565            shooting_angle: 0.0,
566            cant_angle: 0.0,
567            sight_height: 0.05,
568            sight_offset_lateral_m: 0.0, // Sight directly above the bore (MBA-1396)
569            muzzle_height: 0.0,          // Default 0 - height is in sight_height
570            target_height: 0.0,          // Target at ground level by default
571            zero_poi_vertical_m: 0.0,    // No deliberate POI offset at the zero range (MBA-1359)
572            zero_poi_horizontal_m: 0.0,
573            ground_threshold: -100.0, // Effectively disable ground detection (allow bullet to drop 100m below start)
574
575            // Environmental conditions
576            altitude: 0.0,
577            temperature: 15.0,
578            pressure: 1013.25, // Standard sea level pressure (millibars)
579            humidity: 0.5,     // 50% relative humidity
580            latitude: None,
581
582            // Wind conditions
583            wind_speed: 0.0,
584            wind_angle: 0.0,
585
586            // Bullet characteristics
587            twist_rate: 12.0, // 1:12" typical
588            is_twist_right: true,
589            caliber_inches: diameter_m / 0.0254, // Convert to inches
590            weight_grains: mass_kg / crate::constants::GRAINS_TO_KG, // Convert to grains
591            manufacturer: None,
592            bullet_model: None,
593            bullet_id: None,
594            bullet_cluster: None,
595
596            // Integration method selection
597            use_rk4: true,           // Use Runge-Kutta methods by default
598            use_adaptive_rk45: true, // Default to RK45 adaptive for best accuracy
599
600            // Advanced effects (disabled by default)
601            enable_advanced_effects: false,
602            enable_magnus: false,
603            enable_coriolis: false,
604            use_powder_sensitivity: false,
605            powder_temp_sensitivity: 0.0,
606            powder_temp: 15.0,
607            powder_temp_curve: None,
608            powder_curve_temp_c: None,
609            tipoff_yaw: 0.0,
610            tipoff_decay_distance: 50.0,
611            cd_delta2: 7.5,
612            use_bc_segments: false,
613            bc_segments: None,
614            bc_segments_data: None,
615            use_enhanced_spin_drift: false,
616            use_form_factor: false,
617            enable_wind_shear: false,
618            wind_shear_model: "none".to_string(),
619            enable_trajectory_sampling: false,
620            sample_interval: 10.0,                // Default 10 meter intervals
621            drops_reference: DropsReference::Los, // historical LOS-perpendicular drops
622            enable_pitch_damping: false,
623            enable_precession_nutation: false,
624            enable_aerodynamic_jump: false,
625            use_cluster_bc: false, // Disabled by default for backward compatibility
626
627            // Custom drag model support
628            custom_drag_table: None,
629            cd_scale: 1.0,
630            custom_drag_reference: false,
631
632            // Legacy field for compatibility
633            bc_type_str: None,
634        }
635    }
636}
637
638/// Interpolate a muzzle velocity (m/s) from a measured powder-temperature curve at
639/// `temp_c` (Celsius). `curve` is `(temperature_celsius, velocity_m_s)` points; it is
640/// sorted ascending by temperature before use. Values below the first point or above
641/// the last are CLAMPED to the endpoint velocity (no extrapolation beyond measured
642/// data), and segments are linearly interpolated. A single point yields a constant.
643pub fn interpolate_powder_temp_curve(curve: &[(f64, f64)], temp_c: f64) -> f64 {
644    debug_assert!(!curve.is_empty());
645    if curve.is_empty() {
646        return 0.0;
647    }
648    // Defensive: accept unsorted input by sorting a local copy only when needed.
649    // Callers (CLI/WASM parsers) already sort, so the common path is a no-op scan.
650    let mut sorted;
651    let pts: &[(f64, f64)] = if curve.windows(2).all(|w| w[0].0 <= w[1].0) {
652        curve
653    } else {
654        sorted = curve.to_vec();
655        sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
656        &sorted
657    };
658    let n = pts.len();
659    if temp_c <= pts[0].0 {
660        return pts[0].1; // clamp below the coldest measured point
661    }
662    if temp_c >= pts[n - 1].0 {
663        return pts[n - 1].1; // clamp above the hottest measured point
664    }
665    for i in 1..n {
666        let (t0, v0) = pts[i - 1];
667        let (t1, v1) = pts[i];
668        if temp_c <= t1 {
669            let span = t1 - t0;
670            if span.abs() < f64::EPSILON {
671                return v1; // coincident temps: avoid divide-by-zero, take the upper
672            }
673            let f = (temp_c - t0) / span;
674            return v0 + f * (v1 - v0);
675        }
676    }
677    pts[n - 1].1
678}
679
680/// Parse a `--sweep START:END:STEP` temperature range (display units) for the
681/// `powder` command — ONE parser shared by the native CLI and WASM so their
682/// validation cannot drift. Returns the
683/// inclusive row temperatures. Guarded: STEP must be positive, END >= START, and the
684/// row count is capped so a typo can't emit an unbounded table.
685pub fn parse_powder_sweep(s: &str) -> Result<Vec<f64>, String> {
686    const MAX_SWEEP_ROWS: usize = 500;
687    let parts: Vec<&str> = s.split(':').collect();
688    if parts.len() != 3 {
689        return Err(format!(
690            "Invalid --sweep '{}': expected START:END:STEP (e.g. \"20:110:10\")",
691            s
692        ));
693    }
694    let parse = |p: &str, name: &str| -> Result<f64, String> {
695        p.trim()
696            .parse::<f64>()
697            .map_err(|_| format!("Invalid --sweep {}: '{}' is not a number", name, p.trim()))
698    };
699    let start = parse(parts[0], "START")?;
700    let end = parse(parts[1], "END")?;
701    let step = parse(parts[2], "STEP")?;
702    if !step.is_finite() || step <= 0.0 {
703        return Err(format!("Invalid --sweep STEP {}: must be positive", step));
704    }
705    if !start.is_finite() || !end.is_finite() || end < start {
706        return Err(format!(
707            "Invalid --sweep range {}:{}: END must be >= START",
708            start, end
709        ));
710    }
711    // Row count computed and bounds-checked in f64 BEFORE the usize cast: a huge
712    // range would saturate the cast and overflow the `+ 1` (panic under
713    // overflow-checks, wrap-to-zero in release — silently bypassing this cap).
714    // The epsilon keeps a fractional STEP whose quotient lands a hair below an
715    // integer (e.g. 0:0.3:0.1 -> 2.9999999999999996) from dropping the END row.
716    let n_f = ((end - start) / step + 1e-9).floor();
717    if !n_f.is_finite() || n_f + 1.0 > MAX_SWEEP_ROWS as f64 {
718        return Err(format!(
719            "--sweep would produce more than {} rows; use a larger STEP",
720            MAX_SWEEP_ROWS
721        ));
722    }
723    let n = n_f as usize + 1;
724    // Index-multiplied (not accumulated) so float drift can't drop the END row.
725    Ok((0..n).map(|i| start + step * i as f64).collect())
726}
727
728/// Resolve the powder-temperature-adjusted muzzle velocity (m/s) — the velocity the
729/// solver actually flies. A non-empty measured `powder_temp_curve` takes precedence
730/// (interpolated at `powder_curve_temp_c`, falling back to the ambient
731/// `ambient_temperature_c`; clamped at the endpoints) and REPLACES the nominal
732/// velocity; otherwise, when `use_powder_sensitivity` is set, the linear model adds
733/// `sensitivity x (ambient - reference)` to it. All inputs canonical SI (Celsius,
734/// m/s). This is the single shared implementation behind the trajectory solve, the
735/// `powder` CLI/WASM command (MBA-737), and anything else that must agree with them.
736pub fn resolve_powder_adjusted_velocity(
737    nominal_velocity_mps: f64,
738    ambient_temperature_c: f64,
739    use_powder_sensitivity: bool,
740    powder_temp_sensitivity_mps_per_c: f64,
741    powder_reference_temp_c: f64,
742    powder_temp_curve: Option<&[(f64, f64)]>,
743    powder_curve_temp_c: Option<f64>,
744) -> f64 {
745    if let Some(curve) = powder_temp_curve {
746        if !curve.is_empty() {
747            let lookup_c = powder_curve_temp_c.unwrap_or(ambient_temperature_c);
748            return interpolate_powder_temp_curve(curve, lookup_c);
749        }
750        // A supplied-but-empty curve has always suppressed the linear fallback
751        // (the historical `else if`); preserve that exactly.
752        return nominal_velocity_mps;
753    }
754    if use_powder_sensitivity {
755        let temp_delta_c = ambient_temperature_c - powder_reference_temp_c;
756        return nominal_velocity_mps + powder_temp_sensitivity_mps_per_c * temp_delta_c;
757    }
758    nominal_velocity_mps
759}
760
761// Wind conditions
762#[derive(Debug, Clone)]
763pub struct WindConditions {
764    pub speed: f64, // m/s
765    // radians, wind-FROM convention: 0 = headwind, PI/2 = from the right,
766    // PI = tailwind, 3*PI/2 = from the left (matches WindSock / the bindings).
767    pub direction: f64,
768    /// Vertical wind component, m/s. POSITIVE = UPDRAFT (raises POI downrange); negative =
769    /// downdraft. Default 0.0. Enters the wind vector via [`crate::wind::wind_vector`]'s third
770    /// argument (MBA-728). Boundary-layer shear scales horizontal wind only — vertical passes
771    /// through unscaled wherever shear is applied on top of this. This scalar field (like
772    /// [`WindConditions::speed`]/[`WindConditions::direction`]) is ignored once downrange wind
773    /// segments are set on the solver — each [`crate::wind::WindSegment`] carries its own
774    /// `vertical_mps` instead.
775    pub vertical_speed: f64,
776}
777
778impl Default for WindConditions {
779    fn default() -> Self {
780        Self {
781            speed: 0.0,
782            direction: 0.0,
783            vertical_speed: 0.0,
784        }
785    }
786}
787
788// Atmospheric conditions
789#[derive(Debug, Clone)]
790pub struct AtmosphericConditions {
791    pub temperature: f64, // Celsius
792    pub pressure: f64,    // hPa
793    /// Relative humidity as a PERCENT in `[0, 100]`. NOTE: [`BallisticInputs::humidity`]
794    /// uses a 0–1 FRACTION instead — convert with `BallisticInputs::humidity_percent` when
795    /// crossing between them (MBA-722).
796    pub humidity: f64,
797    pub altitude: f64, // meters
798}
799
800impl Default for AtmosphericConditions {
801    fn default() -> Self {
802        Self {
803            temperature: 15.0,
804            pressure: 1013.25,
805            humidity: 50.0,
806            altitude: 0.0,
807        }
808    }
809}
810
811// Trajectory point data
812#[derive(Debug, Clone)]
813pub struct TrajectoryPoint {
814    pub time: f64,
815    pub position: Vector3<f64>,
816    pub velocity_magnitude: f64,
817    pub kinetic_energy: f64,
818    /// The projectile's own drag coefficient at this point (MBA-1423).
819    ///
820    /// Filled in one pass after integration finishes, not at the sites that build this struct,
821    /// so a solver family added later cannot silently omit it. `None` when sectional density is
822    /// unavailable — see [`TrajectorySolver::effective_drag_coefficient`] for the definition and
823    /// why this is the projectile's Cd rather than the reference table's.
824    pub drag_coefficient: Option<f64>,
825}
826
827impl TrajectoryPoint {
828    /// The value `--with-drag-coefficient` emits for this point, or `None` when the JSON key
829    /// must be ABSENT — flag off, or sectional density unknown (MBA-1427).
830    ///
831    /// Host-compilable on purpose. The browser terminal's emit site lives in `src/wasm.rs`,
832    /// which is `cfg(target_arch = "wasm32")`-gated out of every native build — CI never
833    /// executes a line of it. Keeping the gating rule here means a native test can pin the
834    /// absent-not-null contract even though the surface that applies it only compiles for wasm.
835    pub fn drag_coefficient_json_value(&self, with_drag_coefficient: bool) -> Option<f64> {
836        if with_drag_coefficient {
837            self.drag_coefficient
838        } else {
839            None
840        }
841    }
842}
843
844// Trajectory result
845#[derive(Debug, Clone)]
846pub struct TrajectoryResult {
847    pub max_range: f64,
848    pub max_height: f64,
849    pub time_of_flight: f64,
850    pub impact_velocity: f64,
851    pub impact_energy: f64,
852    /// Projectile mass used to derive full-state observation energy.
853    pub projectile_mass_kg: f64,
854    /// Height of the horizontal line of sight in the solver's ground-referenced frame.
855    pub line_of_sight_height_m: f64,
856    /// Station speed of sound used for Mach observations and transition flags.
857    pub station_speed_of_sound_mps: f64,
858    /// Explicit reason the integration stopped; consumers must not infer this from the endpoint.
859    pub termination: TrajectoryTermination,
860    pub points: Vec<TrajectoryPoint>,
861    pub sampled_points: Option<Vec<TrajectorySample>>, // Trajectory samples at regular intervals
862    pub min_pitch_damping: Option<f64>, // Minimum pitch damping coefficient (for stability warning)
863    pub transonic_mach: Option<f64>,    // Mach number when entering transonic regime
864    pub angular_state: Option<AngularState>, // Final angular state if precession/nutation enabled
865    pub max_yaw_angle: Option<f64>,     // Maximum yaw angle during flight (radians)
866    pub max_precession_angle: Option<f64>, // Maximum precession angle (radians)
867    // MBA-959: aerodynamic-jump components applied at the muzzle (None unless
868    // enable_aerodynamic_jump). EXPERIMENTAL.
869    pub aerodynamic_jump: Option<crate::aerodynamic_jump::AerodynamicJumpComponents>,
870    /// Downrange distance (m) of the downward Mach 1.2 crossing (station speed of sound),
871    /// populated identically by all three solver paths (Euler/RK4/RK45). `None` if the
872    /// trajectory never crosses 1.2 while descending (e.g. launched already below 1.2, or the
873    /// solve terminates while still above it). MBA-1405: feeds `mv_calibration_window`.
874    pub mach_1_2_distance_m: Option<f64>,
875    /// Downrange distance (m) of the downward Mach 1.0 crossing. `None` if the trajectory
876    /// never goes subsonic within the solve. MBA-1405: the far edge of the MV calibration
877    /// window (`mv_calibration_window`) is this distance.
878    pub mach_1_0_distance_m: Option<f64>,
879    /// Downrange distance (m) of the downward Mach 0.9 crossing. `None` if the trajectory
880    /// never crosses 0.9 while descending. MBA-1405: feeds `dsf_window_start`. NOTE: unlike
881    /// the 1.2/1.0 crossings, this one is intentionally NOT reflected in the historical flat
882    /// `transonic_distances` Vec threaded through
883    /// [`crate::trajectory_sampling::TrajectoryData`] — existing consumers of that Vec
884    /// interpret it strictly as `{1.2, 1.0}`, so this field is the only place the 0.9
885    /// crossing is recorded.
886    pub mach_0_9_distance_m: Option<f64>,
887}
888
889const RK45_TOLERANCE: f64 = 1e-6;
890const RK45_SAFETY_FACTOR: f64 = 0.9;
891const RK45_MAX_DT: f64 = 0.01;
892const RK45_MIN_DT: f64 = 1e-6;
893const TRAJECTORY_TIME_LIMIT_S: f64 = 100.0;
894
895/// Hard ceiling for points retained by a single [`TrajectorySolver`] result.
896///
897/// The cap applies across Euler, fixed RK4, and adaptive RK45, including the exact terminal
898/// endpoint. Solves that would exceed it return [`BallisticsError`] instead of
899/// truncating or growing their point buffer without bound.
900pub const MAX_TRAJECTORY_POINTS: usize = 250_000;
901
902/// Pack the CLI solver's split position/velocity vectors into the shared six-component RK45 norm.
903fn cli_rk45_error_norm(
904    position: &Vector3<f64>,
905    velocity: &Vector3<f64>,
906    fifth_position: &Vector3<f64>,
907    fifth_velocity: &Vector3<f64>,
908    fourth_position: &Vector3<f64>,
909    fourth_velocity: &Vector3<f64>,
910) -> f64 {
911    let pack_state = |position: &Vector3<f64>, velocity: &Vector3<f64>| {
912        Vector6::new(
913            position.x, position.y, position.z, velocity.x, velocity.y, velocity.z,
914        )
915    };
916    let state = pack_state(position, velocity);
917    let fifth_order = pack_state(fifth_position, fifth_velocity);
918    let fourth_order = pack_state(fourth_position, fourth_velocity);
919
920    crate::trajectory_integration::rk45_error_norm(&state, &fifth_order, &fourth_order)
921}
922
923struct Rk45Trial {
924    position: Vector3<f64>,
925    velocity: Vector3<f64>,
926    suggested_dt: f64,
927    error: f64,
928}
929
930struct Rk45AcceptedStep {
931    position: Vector3<f64>,
932    velocity: Vector3<f64>,
933    used_dt: f64,
934    next_dt: f64,
935    error: f64,
936}
937
938/// Tracks the downward (decelerating) Mach crossings of a solved trajectory.
939///
940/// `record_downward_crossings` is called once per integration step from all three solver
941/// paths (Euler/RK4/RK45). It both (a) appends to the historical flat `distances` Vec — used
942/// unchanged by [`crate::trajectory_sampling`] to flag sampled points — and (b) records each
943/// threshold's exact crossing distance on `self`, for callers that want the labeled value
944/// directly (MBA-1405) rather than positionally decoding the flat Vec.
945///
946/// IMPORTANT: only the historical 1.2/1.0 thresholds are appended to `distances`. The 0.9
947/// threshold added for MBA-1405 is recorded ONLY on `self.mach_0_9_distance_m` — existing
948/// consumers of the flat Vec interpret it strictly as `{mach_1_2, mach_1_0}` (in that order,
949/// when present), so appending a third entry would silently corrupt their positional
950/// assumptions.
951#[derive(Default)]
952struct MachTransitionTracker {
953    previous_mach: Option<f64>,
954    crossed_transonic: bool,
955    crossed_subsonic: bool,
956    crossed_narrow: bool,
957    /// Downrange distance (m) of the downward Mach 1.2 crossing, once seen. Mirrors the first
958    /// flat-Vec entry.
959    mach_1_2_distance_m: Option<f64>,
960    /// Downrange distance (m) of the downward Mach 1.0 crossing, once seen. Mirrors the second
961    /// flat-Vec entry.
962    mach_1_0_distance_m: Option<f64>,
963    /// Downrange distance (m) of the downward Mach 0.9 crossing, once seen. NOT reflected in
964    /// the flat Vec (see struct docs).
965    mach_0_9_distance_m: Option<f64>,
966}
967
968impl MachTransitionTracker {
969    fn record_downward_crossings(&mut self, mach: f64, downrange_m: f64, distances: &mut Vec<f64>) {
970        if !mach.is_finite() {
971            self.previous_mach = None;
972            return;
973        }
974
975        if let Some(previous_mach) = self.previous_mach {
976            if !self.crossed_transonic && previous_mach >= 1.2 && mach < 1.2 {
977                self.crossed_transonic = true;
978                distances.push(downrange_m);
979                self.mach_1_2_distance_m = Some(downrange_m);
980            }
981            if !self.crossed_subsonic && previous_mach >= 1.0 && mach < 1.0 {
982                self.crossed_subsonic = true;
983                distances.push(downrange_m);
984                self.mach_1_0_distance_m = Some(downrange_m);
985            }
986            if !self.crossed_narrow && previous_mach >= 0.9 && mach < 0.9 {
987                self.crossed_narrow = true;
988                // Intentionally NOT pushed to `distances` — see struct docs.
989                self.mach_0_9_distance_m = Some(downrange_m);
990            }
991        }
992        self.previous_mach = Some(mach);
993    }
994}
995
996impl TrajectoryResult {
997    /// Interpolate position at a given downrange distance (X coordinate, McCoy).
998    /// Returns the interpolated (x, y, z) position at that range.
999    /// If the target range exceeds the trajectory, returns the last point.
1000    pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
1001        if self.points.is_empty() {
1002            return None;
1003        }
1004
1005        // Find the two points that bracket the target range
1006        for i in 0..self.points.len() - 1 {
1007            let p1 = &self.points[i];
1008            let p2 = &self.points[i + 1];
1009
1010            // Check if target range is between these two points (X is downrange)
1011            if p1.position.x <= target_range && p2.position.x >= target_range {
1012                // Linear interpolation factor
1013                let dx = p2.position.x - p1.position.x;
1014                if dx.abs() < 1e-10 {
1015                    return Some(p1.position);
1016                }
1017                let t = (target_range - p1.position.x) / dx;
1018
1019                // Interpolate Y and Z, use exact target_range for X
1020                return Some(Vector3::new(
1021                    target_range,
1022                    p1.position.y + t * (p2.position.y - p1.position.y),
1023                    p1.position.z + t * (p2.position.z - p1.position.z),
1024                ));
1025            }
1026        }
1027
1028        // Target range is beyond trajectory - return last point
1029        self.points.last().map(|p| p.position)
1030    }
1031}
1032
1033// Trajectory solver
1034#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1035enum StationAtmosphereResolution {
1036    /// Preserve the historical CLI/FFI convention: sea-level standard values at a nonzero
1037    /// altitude are treated as omitted and resolved from the ICAO atmosphere.
1038    LegacyDefaultSentinels,
1039    /// Temperature and pressure have already been resolved by a presence-aware caller and must
1040    /// remain authoritative even when they equal the historical sentinel values.
1041    Authoritative,
1042}
1043
1044#[derive(Clone)]
1045pub struct TrajectorySolver {
1046    inputs: BallisticInputs,
1047    wind: WindConditions,
1048    atmosphere: AtmosphericConditions,
1049    station_atmosphere_resolution: StationAtmosphereResolution,
1050    max_range: f64,
1051    time_step: f64,
1052    max_trajectory_points: usize,
1053    cluster_bc: Option<ClusterBCDegradation>,
1054    /// Geometry-derived `(longitudinal, transverse)` moments used by angular diagnostics.
1055    precession_nutation_inertias: (f64, f64),
1056    /// Optional downrange-segmented wind. When `Some`, the per-step wind vector is
1057    /// looked up by downrange distance from this `WindSock` and the scalar `wind`
1058    /// field is ignored. When `None`, the constant `wind` vector is used (default),
1059    /// so a non-segmented solve is numerically identical to pre-feature behavior.
1060    wind_sock: Option<crate::wind::WindSock>,
1061    /// Optional downrange-segmented atmosphere (MBA-1137). When `Some`, the per-substep local
1062    /// atmosphere recompute samples the base (station-referenced) temperature/pressure/humidity by
1063    /// downrange distance from this `AtmoSock`, then feeds them through the SAME altitude-lapse
1064    /// pipeline as a single-station solve — so the downrange zone and the vertical altitude lapse
1065    /// compose without double-counting. When `None` (default), the resolved single-station
1066    /// conditions are used.
1067    atmo_sock: Option<crate::atmosphere::AtmoSock>,
1068}
1069
1070/// Which frame a zero solve's `target_height_m` lives in (MBA-1412).
1071///
1072/// `SightLine`: the classic zero contract — the height is sight geometry (typically the sight
1073/// height) and the rifle zeroes LEVEL, per the MBA-1286 doctrine that a zero is a property of a
1074/// level rifle; `shooting_angle` applies to the subsequent shot only. Used by
1075/// `calculate_zero_angle_with_conditions` and every legacy surface behind it (bindings, WASM,
1076/// FFI, CLI).
1077///
1078/// `WorldVertical`: solve-json v1's documented contract (docs/SOLVE_JSON_V1.md) — the height is
1079/// an absolute world-vertical height above the ground datum; inclined zeroing projects the
1080/// shot-frame trajectory into the world frame (MBA-1302 behavior, unchanged).
1081#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1082pub(crate) enum ZeroTargetFrame {
1083    SightLine,
1084    WorldVertical,
1085}
1086
1087/// Both line-of-sight crossings a single fixed bore angle can produce (Tier 2 whole-branch
1088/// review, C2): a rifle whose sight sits above its bore generally crosses a level line of
1089/// sight TWICE on a rising shot — an ascending "near zero" close to the muzzle, and a
1090/// descending "far zero" past the apex. The classic 25/300-yard battle-zero pairing is one
1091/// bore angle producing both. `TrajectorySolver`'s internal `find_zero_range` and the public
1092/// `calculate_zero_range_from_angle_*` functions return both rather than silently choosing
1093/// one, because `find_zero_angle`'s own choice of root depends on the target distance it was
1094/// asked to solve for — the forward and inverse solvers are NOT a single-valued pair.
1095///
1096/// Either field may be `None` when the solved envelope doesn't reach that crossing (e.g. a
1097/// short `--max-range` that stops before the far crossing, or a target height high enough
1098/// that the trajectory never rises to it at all). At least one is always `Some` on `Ok`.
1099#[derive(Debug, Clone, Copy, PartialEq)]
1100pub struct ZeroCrossings {
1101    /// Downrange distance (meters) of the near, ascending crossing, if the solved envelope
1102    /// reaches it.
1103    pub near_m: Option<f64>,
1104    /// Downrange distance (meters) of the far, descending crossing -- what "sighted in at D
1105    /// yards" conventionally means -- if the solved envelope reaches it.
1106    pub far_m: Option<f64>,
1107}
1108
1109impl TrajectorySolver {
1110    pub fn new(
1111        inputs: BallisticInputs,
1112        wind: WindConditions,
1113        atmosphere: AtmosphericConditions,
1114    ) -> Self {
1115        Self::new_with_station_atmosphere_resolution(
1116            inputs,
1117            wind,
1118            atmosphere,
1119            StationAtmosphereResolution::LegacyDefaultSentinels,
1120        )
1121    }
1122
1123    /// Construct a solver from station temperature and pressure that a presence-aware service
1124    /// has already resolved. Unlike [`Self::new`], exact sea-level standard values remain
1125    /// authoritative at nonzero altitude rather than acting as legacy omission sentinels.
1126    ///
1127    /// `pub` (MBA-1397; was `pub(crate)`): a QNH-aware caller (native CLI, WASM) that has
1128    /// already reduced a declared altimeter setting to station pressure via
1129    /// [`crate::atmosphere::resolve_station_conditions_with_pressure_mode`] needs this
1130    /// constructor too, and those callers live in a different crate (`src/main.rs` is a
1131    /// separate binary crate over the library). Widening visibility only, no signature
1132    /// change: every existing `pub(crate)` caller is unaffected.
1133    pub fn new_with_resolved_station_atmosphere(
1134        inputs: BallisticInputs,
1135        wind: WindConditions,
1136        atmosphere: AtmosphericConditions,
1137    ) -> Self {
1138        Self::new_with_station_atmosphere_resolution(
1139            inputs,
1140            wind,
1141            atmosphere,
1142            StationAtmosphereResolution::Authoritative,
1143        )
1144    }
1145
1146    fn new_with_station_atmosphere_resolution(
1147        mut inputs: BallisticInputs,
1148        wind: WindConditions,
1149        atmosphere: AtmosphericConditions,
1150        station_atmosphere_resolution: StationAtmosphereResolution,
1151    ) -> Self {
1152        // MBA-1415: every input-conditioning step lives in one shared, idempotent helper that
1153        // the fast_integrate* entry points call too. Keeping it out of this constructor is the
1154        // point: conditioning that lives only here is invisible to the external bindings that
1155        // call the fast path directly.
1156        inputs.normalize_for_solve();
1157
1158        // Initialize cluster BC if enabled
1159        let cluster_bc = if inputs.use_cluster_bc {
1160            Some(ClusterBCDegradation::new())
1161        } else {
1162            None
1163        };
1164        let precession_nutation_inertias = projectile_moments_of_inertia(
1165            inputs.bullet_mass,
1166            inputs.bullet_diameter,
1167            inputs.bullet_length,
1168        );
1169
1170        Self {
1171            inputs,
1172            wind,
1173            atmosphere,
1174            station_atmosphere_resolution,
1175            max_range: 1000.0,
1176            time_step: 0.001,
1177            max_trajectory_points: MAX_TRAJECTORY_POINTS,
1178            cluster_bc,
1179            precession_nutation_inertias,
1180            wind_sock: None,
1181            atmo_sock: None,
1182        }
1183    }
1184
1185    pub fn set_max_range(&mut self, range: f64) {
1186        self.max_range = range;
1187    }
1188
1189    pub fn set_time_step(&mut self, step: f64) {
1190        self.time_step = step;
1191    }
1192
1193    /// Calculate a level-rifle zero with this solver's configured atmosphere, wind (including
1194    /// downrange segments), effects, integration method, and time step, then install the resulting
1195    /// muzzle angle on this solver. The solver is mutated only after a zero has converged.
1196    pub(crate) fn calculate_and_set_zero_angle(
1197        &mut self,
1198        target_distance_m: f64,
1199        target_height_m: f64,
1200        frame: ZeroTargetFrame,
1201    ) -> Result<f64, BallisticsError> {
1202        let angle = self.find_zero_angle(target_distance_m, target_height_m, frame)?;
1203        // MBA-1359: a deliberate POI offset at the zero range (Kestrel ZH/ZO) is an angular
1204        // bias ON the solved angle, applied post-solve so the zero trials above still solve
1205        // perfect convergence. The small-angle addition is frame-independent (identical in
1206        // SightLine and WorldVertical), and with both offsets at their 0.0 defaults the
1207        // additions below are exact no-ops (byte-identical). The returned angle carries the
1208        // vertical bias so auto-zero callers that copy it onto flight inputs inherit it; the
1209        // horizontal bias lands on THIS solver's azimuth_angle — separate flight inputs must
1210        // add `windage_zero_bias_rad` themselves.
1211        let angle = if target_distance_m > 0.0 {
1212            angle + self.inputs.zero_poi_vertical_m / target_distance_m
1213        } else {
1214            angle
1215        };
1216        self.inputs.muzzle_angle = angle;
1217        self.apply_windage_zero_bias(target_distance_m);
1218        Ok(angle)
1219    }
1220
1221    /// Apply just the windage-zero convergence bias (see
1222    /// [`BallisticInputs::windage_zero_bias_rad`]) to this solver's azimuth, for a zero at
1223    /// `target_distance_m`, without searching for or installing an elevation.
1224    ///
1225    /// Used by [`Self::calculate_and_set_zero_angle`] above as part of a full zero search, and
1226    /// by solve_v1's round-trip re-solve path (0.33.0 decision-support Task 2) on its own: when
1227    /// an explicit `muzzle_angle_rad` already carries the correct elevation (round-tripped from
1228    /// a previous `resolved_request`, which bakes any `zero_poi_vertical_m` bias into that
1229    /// angle already), the elevation search does not run, but the windage term is a SEPARATE
1230    /// quantity that depends only on `zero_distance_m` -- not on whether the elevation was
1231    /// searched for or supplied directly -- so it must still be applied here or an
1232    /// offset-mounted sight / deliberate horizontal zero bias would silently stop converging
1233    /// once a request round-trips.
1234    pub(crate) fn apply_windage_zero_bias(&mut self, target_distance_m: f64) {
1235        self.inputs.azimuth_angle += self.inputs.windage_zero_bias_rad(target_distance_m);
1236    }
1237
1238    fn find_zero_angle(
1239        &self,
1240        target_distance_m: f64,
1241        target_height_m: f64,
1242        frame: ZeroTargetFrame,
1243    ) -> Result<f64, BallisticsError> {
1244        // Binary search for the angle that hits the target. The bracket starts at [0, 0.2] rad
1245        // and widens downward for a target below where a level bore lands, or upward for one
1246        // beyond the initial top angle.
1247        let mut low_angle = 0.0;
1248        let mut high_angle = 0.2; // about 11 degrees
1249        let tolerance = 1e-7;
1250        let max_iterations = 60;
1251
1252        // MBA-194: validate the initial bracket before starting the binary search.
1253        let low_height = self.zero_trial_height_at(low_angle, target_distance_m, frame)?;
1254        let high_height = self.zero_trial_height_at(high_angle, target_distance_m, frame)?;
1255
1256        match (low_height, high_height) {
1257            (Some(low_height), Some(high_height)) => {
1258                let low_error = low_height - target_height_m;
1259                let high_error = high_height - target_height_m;
1260
1261                if low_error > 0.0 && high_error > 0.0 {
1262                    // Both angles overshoot: the target sits below where a level bore lands
1263                    // (a shooter above the target), so the bore must point below horizontal.
1264                    // The search used to stop at 0 rad and fail. Lower the bottom of the
1265                    // bracket in the same steps the undershoot branch raises the top, down to
1266                    // 45 degrees. A trial that ends short of the target distance (the ground
1267                    // stop) is below the target too, exactly as the bisection below treats it.
1268                    let mut expanded = false;
1269                    for multiplier in [1.0, 2.0, 3.0, 4.0] {
1270                        let new_low = (-0.2_f64 * multiplier).max(-0.785);
1271                        match self.zero_trial_height_at(new_low, target_distance_m, frame) {
1272                            Ok(Some(height)) if height - target_height_m > 0.0 => {}
1273                            Ok(_) => {
1274                                high_angle = low_angle;
1275                                low_angle = new_low;
1276                                expanded = true;
1277                                break;
1278                            }
1279                            Err(_) => {}
1280                        }
1281                    }
1282                    if !expanded {
1283                        return Err("Cannot find zero angle: target is below the trajectory even at the steepest downward angle".into());
1284                    }
1285                } else if low_error < 0.0 && high_error < 0.0 {
1286                    // Both angles undershoot. Preserve the historical expansion up to 45 degrees.
1287                    let mut expanded = false;
1288                    for multiplier in [2.0, 3.0, 4.0] {
1289                        let new_high = (high_angle * multiplier).min(0.785);
1290                        if let Ok(Some(height)) =
1291                            self.zero_trial_height_at(new_high, target_distance_m, frame)
1292                        {
1293                            if height - target_height_m > 0.0 {
1294                                high_angle = new_high;
1295                                expanded = true;
1296                                break;
1297                            }
1298                        }
1299                        if new_high >= 0.785 {
1300                            break;
1301                        }
1302                    }
1303                    if !expanded {
1304                        return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
1305                    }
1306                }
1307            }
1308            (None, Some(_)) => {
1309                // The low angle does not reach the target while the high angle does; the search
1310                // will raise the low end until it reaches a valid trajectory.
1311            }
1312            (Some(_), None) => {
1313                return Err(
1314                    "Cannot find zero angle: high angle trajectory doesn't reach target distance"
1315                        .into(),
1316                );
1317            }
1318            (None, None) => {
1319                return Err(
1320                    "Cannot find zero angle: trajectory cannot reach target distance at any angle"
1321                        .into(),
1322                );
1323            }
1324        }
1325
1326        for _ in 0..max_iterations {
1327            let mid_angle = (low_angle + high_angle) / 2.0;
1328            match self.zero_trial_height_at(mid_angle, target_distance_m, frame)? {
1329                Some(height) => {
1330                    let error = height - target_height_m;
1331                    // MBA-193: height accuracy is the primary convergence criterion. At 0.1 mm,
1332                    // short-range zero-day atmosphere differences remain observable.
1333                    if error.abs() < 0.0001 {
1334                        return Ok(mid_angle);
1335                    }
1336
1337                    // Only use angle tolerance after precision is exhausted and the remaining
1338                    // height error is still practically acceptable.
1339                    if (high_angle - low_angle).abs() < tolerance {
1340                        if error.abs() < 0.01 {
1341                            return Ok(mid_angle);
1342                        }
1343                        return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
1344                    }
1345
1346                    if error > 0.0 {
1347                        high_angle = mid_angle;
1348                    } else {
1349                        low_angle = mid_angle;
1350                    }
1351                }
1352                None => {
1353                    low_angle = mid_angle;
1354                    if (high_angle - low_angle).abs() < tolerance {
1355                        return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
1356                    }
1357                }
1358            }
1359        }
1360
1361        Err("Failed to find zero angle".into())
1362    }
1363
1364    /// Solve one zero-angle trial without losing any solver configuration. Only the trial clone's
1365    /// launch angle, level-rifle convention, and integration range differ from the final solve.
1366    fn zero_trial_height_at(
1367        &self,
1368        angle_rad: f64,
1369        target_distance_m: f64,
1370        frame: ZeroTargetFrame,
1371    ) -> Result<Option<f64>, BallisticsError> {
1372        let mut trial = self.clone();
1373        trial.inputs.muzzle_angle = angle_rad;
1374        // MBA-959: zero on the bare bore so aerodynamic jump remains an additive fire-time POI
1375        // shift rather than being silently absorbed by the zero search.
1376        trial.inputs.enable_aerodynamic_jump = false;
1377        // MBA-1286: a zero is a property of a level rifle's sight geometry. Cant is applied only
1378        // to the subsequent shot.
1379        trial.inputs.cant_angle = 0.0;
1380        // MBA-1412: in the SightLine contract the incline is likewise a shot-time condition,
1381        // not sight geometry. MBA-1302 left it in the trial while shot_frame_altitude lifted
1382        // heights into the gravity frame, adding d*sin(shooting_angle) (~9 m at 5.71 deg /
1383        // 100 yd) against a sight-frame target height, so no inclined legacy caller could
1384        // bracket. Zero level; shoot inclined. solve-json v1's WorldVertical contract keeps
1385        // the MBA-1302 projection (its callers supply world-frame heights).
1386        if frame == ZeroTargetFrame::SightLine {
1387            trial.inputs.shooting_angle = 0.0;
1388        }
1389        trial.set_max_range(target_distance_m * 2.0);
1390        let result = trial.solve()?;
1391
1392        for (index, point) in result.points.iter().enumerate() {
1393            if point.position.x >= target_distance_m {
1394                let shot_y_m = if index == 0 {
1395                    point.position.y
1396                } else {
1397                    let previous = &result.points[index - 1];
1398                    let span = point.position.x - previous.position.x;
1399                    let fraction = (target_distance_m - previous.position.x) / span;
1400                    previous.position.y + fraction * (point.position.y - previous.position.y)
1401                };
1402                return Ok(Some(crate::atmosphere::shot_frame_altitude(
1403                    0.0,
1404                    target_distance_m,
1405                    shot_y_m,
1406                    trial.inputs.shooting_angle,
1407                )));
1408            }
1409        }
1410        Ok(None)
1411    }
1412
1413    /// Inverse of [`Self::find_zero_angle`] (MBA-1402), corrected by the Tier 2 whole-branch
1414    /// review (C2): given a FIXED bore angle rather than searching for one, run that
1415    /// trajectory and locate the downrange distance(s) where it crosses `target_height_m` —
1416    /// the zero RANGE(S) a stored/portable bore angle implies under THESE conditions. Hornady
1417    /// and Kestrel 4DOF treat the zero angle as the portable quantity (captured once, then
1418    /// reusable independent of the day it was solved); this is what makes that value usable
1419    /// again.
1420    ///
1421    /// A rifle whose sight sits above the bore generally crosses a level line of sight TWICE
1422    /// on a rising shot: once ascending, close to the muzzle (the "near zero"), and once
1423    /// descending past the apex (the "far zero"). Both are equally real zero ranges for the
1424    /// SAME bore angle — this is the classic 25/300-yard battle-zero relationship: a rifle
1425    /// zeroed at 25 yd (the near crossing of its bore angle) is, for the same angle, ALSO
1426    /// zeroed again around 300 yd (the far crossing). [`Self::find_zero_angle`]'s own choice
1427    /// of root depends on which target distance it was asked to solve for, so this is not a
1428    /// single-valued inverse of that function — it is this function's job to report both
1429    /// roots it can find rather than silently pick one and call it "the" zero range.
1430    ///
1431    /// Returns [`ZeroCrossings`] with the near (first ascending, error negative → positive)
1432    /// and far (descending, error positive → negative) crossings independently, either of
1433    /// which may be absent if the solved envelope doesn't reach it (e.g. `--max-range` too
1434    /// short to reach the far crossing, or an angle so shallow / a target height so high that
1435    /// the near crossing never happens). Errors only when NEITHER crossing is found. Each
1436    /// crossing distance is linearly interpolated between its two bracketing sample points,
1437    /// mirroring `zero_trial_height_at`'s own height-at-a-known-distance interpolation in the
1438    /// other direction.
1439    fn find_zero_range(
1440        &self,
1441        angle_rad: f64,
1442        target_height_m: f64,
1443        frame: ZeroTargetFrame,
1444    ) -> Result<ZeroCrossings, BallisticsError> {
1445        let mut trial = self.clone();
1446        trial.inputs.muzzle_angle = angle_rad;
1447        // Mirrors zero_trial_height_at's trial setup exactly (MBA-959 / MBA-1286 / MBA-1412),
1448        // so both directions apply the same conventions.
1449        trial.inputs.enable_aerodynamic_jump = false;
1450        trial.inputs.cant_angle = 0.0;
1451        if frame == ZeroTargetFrame::SightLine {
1452            trial.inputs.shooting_angle = 0.0;
1453        }
1454        let result = trial.solve()?;
1455
1456        // The height-above-target-height error is, for a normal flat-fire trajectory,
1457        // unimodal: it starts below the line of sight (bore below sight), rises through it
1458        // ONCE (ascending -- the near zero), continues to the apex, then falls back through
1459        // it ONCE more (descending -- the far zero) before diverging away for good. So at
1460        // most one ascending and one descending crossing exist; classify each sign change by
1461        // direction rather than by position (first/last) so a near-only trajectory (solved
1462        // range too short to reach the far crossing) is never mislabeled as "far".
1463        let mut near_crossing: Option<f64> = None;
1464        let mut far_crossing: Option<f64> = None;
1465        let mut previous: Option<(f64, f64)> = None; // (downrange_m, height_error_m)
1466        for point in &result.points {
1467            let height = crate::atmosphere::shot_frame_altitude(
1468                0.0,
1469                point.position.x,
1470                point.position.y,
1471                trial.inputs.shooting_angle,
1472            );
1473            let error = height - target_height_m;
1474            if let Some((prev_x, prev_error)) = previous {
1475                if prev_error == 0.0 {
1476                    // An exact touch is itself a crossing; assign it to whichever slot is
1477                    // still open (near first, since it can only occur before far).
1478                    if near_crossing.is_none() {
1479                        near_crossing = Some(prev_x);
1480                    } else {
1481                        far_crossing = Some(prev_x);
1482                    }
1483                }
1484                if prev_error * error < 0.0 {
1485                    let fraction = prev_error / (prev_error - error);
1486                    let crossing = prev_x + fraction * (point.position.x - prev_x);
1487                    if prev_error < 0.0 && error > 0.0 {
1488                        // Ascending: the near zero. Keep only the first one found.
1489                        if near_crossing.is_none() {
1490                            near_crossing = Some(crossing);
1491                        }
1492                    } else {
1493                        // Descending: the far zero.
1494                        far_crossing = Some(crossing);
1495                    }
1496                }
1497            }
1498            previous = Some((point.position.x, error));
1499        }
1500        // The loop above only resolves a point once a later point makes it `previous`; catch
1501        // the trajectory's very last sampled point landing exactly on the line too.
1502        if let Some((last_x, last_error)) = previous {
1503            if last_error == 0.0 {
1504                if near_crossing.is_none() {
1505                    near_crossing = Some(last_x);
1506                } else {
1507                    far_crossing = Some(last_x);
1508                }
1509            }
1510        }
1511
1512        if near_crossing.is_none() && far_crossing.is_none() {
1513            return Err(BallisticsError::from(
1514                "Cannot find zero range: this angle never crosses the target height within the \
1515                 solved range (angle too shallow to reach it, or both crossings lie beyond \
1516                 the solver's max range)."
1517                    .to_string(),
1518            ));
1519        }
1520
1521        Ok(ZeroCrossings {
1522            near_m: near_crossing,
1523            far_m: far_crossing,
1524        })
1525    }
1526
1527    /// Equivalent horizontal range for an inclined shot (MBA-1395): the flat-fire range
1528    /// whose ANGULAR elevation correction — relative to the SAME dialed zero — equals the
1529    /// inclined solution's angular correction at `target_range_m`. This is the
1530    /// "shoot-to" range SIG BDX (AMR), Leica (EHR), and Gunwerks BR2 report so users of
1531    /// fixed BDC turrets/reticles can dial as if the shot were flat, computed by an
1532    /// inverse lookup over one flat re-solve (McDonald's Sierra inclined-fire treatment /
1533    /// Litz) rather than the rifleman's-rule cosine approximation, which matches LINEAR
1534    /// drop and accumulates error at long range.
1535    ///
1536    /// The solver's current state IS the inclined solution: `muzzle_angle` carries the
1537    /// solved zero and `shooting_angle` the look angle. The flat reference clones the
1538    /// solver and zeroes only `shooting_angle` (the WASM auto-zero flat-clone precedent),
1539    /// keeping the zero, wind, atmosphere, and integration configuration identical, so
1540    /// both angular corrections are measured against the same zero.
1541    ///
1542    /// Returns `None` where the inverse is ill-defined instead of guessing:
1543    /// - `target_range_m <= zero_distance_m` (inside the zero, no BDC correction exists);
1544    /// - the inclined correction at the target is not positive (bullet at/above the LOS);
1545    /// - either solve fails, or the flat solve cannot bracket the correction.
1546    ///
1547    /// Past the zero range the flat correction is monotone in range, so a plain bisection
1548    /// over the flat solve's interpolated corrections converges; the answer is refined to
1549    /// centimeter level, far below any display precision.
1550    pub fn equivalent_horizontal_range(
1551        &self,
1552        target_range_m: f64,
1553        zero_distance_m: f64,
1554    ) -> Option<f64> {
1555        if !target_range_m.is_finite() || !zero_distance_m.is_finite() {
1556            return None;
1557        }
1558        if target_range_m <= zero_distance_m || target_range_m <= 0.0 {
1559            return None;
1560        }
1561
1562        // Height of the bullet path at a downrange distance, linearly interpolated
1563        // between integration points (mirrors zero_trial_height_at's interpolation).
1564        // In the shot frame the LOS is the x-axis, so drop below the LOS is
1565        // `los_height - y` for the inclined and flat solves alike.
1566        fn path_y_at(points: &[TrajectoryPoint], distance_m: f64) -> Option<f64> {
1567            match bracket_param(points.len(), |i| points[i].position.x, distance_m) {
1568                // Below the first point: clamp to it (mirrors zero_trial_height_at's
1569                // interpolation, which the doc comment above promises). Fewer than two points
1570                // is not a case a real solved trajectory reaches; treat it like Above (no
1571                // extrapolation) rather than guessing.
1572                Bracket::Below => Some(points[0].position.y),
1573                Bracket::Above | Bracket::Degenerate => None,
1574                Bracket::Inside { lo, t } => {
1575                    let hi = lo + 1;
1576                    Some(
1577                        points[lo].position.y + t * (points[hi].position.y - points[lo].position.y),
1578                    )
1579                }
1580            }
1581        }
1582
1583        // Inclined solution's angular correction at the target (radians, small-angle:
1584        // drop below the LOS divided by range). Sampling is display-side only — turn it
1585        // off in both internal solves.
1586        let mut inclined = self.clone();
1587        inclined.inputs.enable_trajectory_sampling = false;
1588        let inclined_result = inclined.solve().ok()?;
1589        let los_height = inclined_result.line_of_sight_height_m;
1590        let inclined_drop = los_height - path_y_at(&inclined_result.points, target_range_m)?;
1591        let correction = inclined_drop / target_range_m;
1592        if correction <= 0.0 {
1593            return None;
1594        }
1595
1596        // One flat re-solve against the same zero.
1597        let mut flat = self.clone();
1598        flat.inputs.enable_trajectory_sampling = false;
1599        flat.inputs.shooting_angle = 0.0;
1600        let flat_result = flat.solve().ok()?;
1601        let flat_correction_at = |range_m: f64| -> Option<f64> {
1602            Some((los_height - path_y_at(&flat_result.points, range_m)?) / range_m)
1603        };
1604
1605        // Bracket, then bisect. Gravity's along-LOS component only shrinks under a look
1606        // angle, so the flat correction at the true range bounds the inclined one from
1607        // above and the equivalent range lies in (zero_distance, target_range]. The flat
1608        // clone can strike the ground SHORT of the inclined terminal (an uphill shot
1609        // stays airborne longer), so the upper bracket is additionally capped at the
1610        // flat solve's own terminal distance — the shoot-to range is shorter than the
1611        // true range, so the cap does not exclude the root.
1612        let flat_terminal_m = flat_result.points.last().map(|p| p.position.x)?;
1613        let mut low = zero_distance_m.max(1.0);
1614        let mut high = target_range_m.min(flat_terminal_m);
1615        if high <= low {
1616            return None;
1617        }
1618        if flat_correction_at(low)? - correction > 0.0 {
1619            return None; // no bracket below
1620        }
1621        if flat_correction_at(high)? - correction < 0.0 {
1622            return None; // no bracket above (correction not reachable flat)
1623        }
1624        for _ in 0..60 {
1625            let mid = 0.5 * (low + high);
1626            let error = flat_correction_at(mid)? - correction;
1627            if error.abs() == 0.0 {
1628                return Some(mid);
1629            }
1630            if error < 0.0 {
1631                low = mid;
1632            } else {
1633                high = mid;
1634            }
1635            if high - low < 0.01 {
1636                break;
1637            }
1638        }
1639        Some(0.5 * (low + high))
1640    }
1641
1642    /// Reject malformed state before it reaches an integration loop.
1643    ///
1644    /// `new` resolves powder-temperature velocity overrides and refreshes the imperial mirror
1645    /// fields, so validation belongs here: it sees the effective muzzle velocity, covers values
1646    /// changed through solver setters, and applies uniformly to Euler, RK4, and RK45.
1647    fn validate_for_solve(&self) -> Result<(), BallisticsError> {
1648        let require_finite = |name: &str, value: f64| {
1649            if value.is_finite() {
1650                Ok(())
1651            } else {
1652                Err(BallisticsError::from(format!("{name} must be finite")))
1653            }
1654        };
1655        let require_positive = |name: &str, value: f64| {
1656            if value.is_finite() && value > 0.0 {
1657                Ok(())
1658            } else {
1659                Err(BallisticsError::from(format!(
1660                    "{name} must be finite and greater than zero"
1661                )))
1662            }
1663        };
1664
1665        // These four quantities are required by every point-mass solve. In particular, validate
1666        // muzzle_velocity after `new` has applied a measured curve or linear powder correction.
1667        // A custom drag table flown as the projectile's own curve supplies the actual Cd and
1668        // divides by sectional density, so bc_value is physically ignored there (see
1669        // custom_drag_denominator). A table flown as a reference curve (custom_drag_reference)
1670        // divides by the BC exactly as a G-model does, so bc_value is required in every case but
1671        // the own-curve one; mass + diameter are always required (they drive the SD denominator
1672        // when an own-curve table is set).
1673        if self.inputs.custom_drag_table.is_none() || self.inputs.custom_drag_reference {
1674            require_positive("bc_value", self.inputs.bc_value)?;
1675        }
1676        require_positive("bullet_mass", self.inputs.bullet_mass)?;
1677        require_positive("bullet_diameter", self.inputs.bullet_diameter)?;
1678        require_positive("muzzle_velocity", self.inputs.muzzle_velocity)?;
1679        // MBA-1356: cd_scale multiplies the custom-deck Cd; a non-finite or non-positive value
1680        // would zero/invert drag or poison integration with NaN. Required unconditionally (not
1681        // gated on custom_drag_table) since the default (1.0) is always valid and a caller could
1682        // set an invalid scale without ever setting a table. The [0.5, 2.0] "unusual" range is a
1683        // CLI-level warning (Task 2), not a hard engine rule.
1684        require_positive("cd_scale", self.inputs.cd_scale)?;
1685
1686        require_finite("muzzle_angle", self.inputs.muzzle_angle)?;
1687        require_finite("azimuth_angle", self.inputs.azimuth_angle)?;
1688        require_finite("shooting_angle", self.inputs.shooting_angle)?;
1689        require_finite("cant_angle", self.inputs.cant_angle)?;
1690        require_finite("muzzle_height", self.inputs.muzzle_height)?;
1691
1692        // MBA-1359: a deliberate zero POI offset is a small linear offset at the zero range
1693        // (fractions of an inch to a few inches). |1 m| is far beyond any plausible
1694        // deliberate offset and almost certainly a unit error (inches/cm passed as meters).
1695        for (name, value) in [
1696            ("zero_poi_vertical_m", self.inputs.zero_poi_vertical_m),
1697            ("zero_poi_horizontal_m", self.inputs.zero_poi_horizontal_m),
1698        ] {
1699            require_finite(name, value)?;
1700            if value.abs() >= 1.0 {
1701                return Err(BallisticsError::from(format!(
1702                    "{name} must be smaller than 1.0 m in magnitude (it is a linear POI \
1703                     offset at the zero range, in meters)"
1704                )));
1705            }
1706        }
1707
1708        // MBA-1396: a lateral sight-mount offset is physically bounded by rail/mount
1709        // geometry (an inch or two). |0.5 m| is almost certainly a unit error
1710        // (inches/mm passed as meters).
1711        require_finite("sight_offset_lateral_m", self.inputs.sight_offset_lateral_m)?;
1712        if self.inputs.sight_offset_lateral_m.abs() >= 0.5 {
1713            return Err(BallisticsError::from(
1714                "sight_offset_lateral_m must be smaller than 0.5 m in magnitude (it is \
1715                 the lateral sight-to-bore mount offset, in meters)",
1716            ));
1717        }
1718
1719        // Negative infinity is the documented ignore-ground sentinel. NaN and positive infinity
1720        // make the loop condition meaningless and are rejected.
1721        if !(self.inputs.ground_threshold.is_finite()
1722            || self.inputs.ground_threshold == f64::NEG_INFINITY)
1723        {
1724            return Err(BallisticsError::from(
1725                "ground_threshold must be finite or negative infinity",
1726            ));
1727        }
1728
1729        match &self.wind_sock {
1730            Some(wind_sock) => wind_sock
1731                .validate_segments()
1732                .map_err(BallisticsError::from)?,
1733            None => {
1734                require_finite("wind.speed", self.wind.speed)?;
1735                require_finite("wind.direction", self.wind.direction)?;
1736                require_finite("wind.vertical_speed", self.wind.vertical_speed)?;
1737            }
1738        }
1739
1740        require_finite("atmosphere.temperature", self.atmosphere.temperature)?;
1741        require_finite("atmosphere.pressure", self.atmosphere.pressure)?;
1742        require_finite("atmosphere.humidity", self.atmosphere.humidity)?;
1743        require_finite("atmosphere.altitude", self.atmosphere.altitude)?;
1744
1745        require_positive("max_range", self.max_range)?;
1746        // Adaptive RK45 owns its step size; the caller-provided fixed step is used only by Euler
1747        // and fixed RK4.
1748        if !self.inputs.use_rk4 || !self.inputs.use_adaptive_rk45 {
1749            require_positive("time_step", self.time_step)?;
1750        }
1751
1752        if self.inputs.enable_trajectory_sampling {
1753            require_finite("sight_height", self.inputs.sight_height)?;
1754            require_positive("sample_interval", self.inputs.sample_interval)?;
1755            projected_sample_count(self.max_range, self.inputs.sample_interval)?;
1756        }
1757
1758        // MBA-1403: the target-plane drops reference divides sampled drop by
1759        // cos(shooting_angle); at or beyond 90 degrees the transform is undefined.
1760        // Gated on the non-default mode only, so LOS-mode validation is unchanged.
1761        if self.inputs.drops_reference == DropsReference::Target {
1762            require_finite("target_height", self.inputs.target_height)?;
1763            if self.inputs.shooting_angle.cos() <= 1e-9 {
1764                return Err(BallisticsError::from(
1765                    "drops reference 'target' is undefined for shooting angles at or beyond 90 degrees",
1766                ));
1767            }
1768        }
1769
1770        if self.inputs.enable_coriolis {
1771            require_finite("shot_azimuth", self.inputs.shot_azimuth)?;
1772            if let Some(latitude) = self.inputs.latitude {
1773                require_finite("latitude", latitude)?;
1774            }
1775        }
1776
1777        Ok(())
1778    }
1779
1780    /// Public solve results must never report success with NaN or infinity, nor with values a
1781    /// physical trajectory cannot produce: a negative terminal downrange distance, time of
1782    /// flight, speed, or energy (MBA-1293 — a stiff-input integration explosion reported
1783    /// `Ok(max_range: -50.59)`). The input gate catches malformed scalar state; this
1784    /// postcondition also covers overflow and malformed optional tables/segments without
1785    /// imposing arbitrary upper bounds on otherwise finite inputs.
1786    fn validate_result_sanity(&self, result: &TrajectoryResult) -> Result<(), BallisticsError> {
1787        let require_finite = |name: &str, value: f64| {
1788            if value.is_finite() {
1789                Ok(())
1790            } else {
1791                Err(BallisticsError::from(format!(
1792                    "trajectory result contains non-finite {name}"
1793                )))
1794            }
1795        };
1796        let require_non_negative = |name: &str, value: f64| {
1797            if value >= 0.0 {
1798                Ok(())
1799            } else {
1800                Err(BallisticsError::from(format!(
1801                    "trajectory result contains non-physical negative {name} ({value})"
1802                )))
1803            }
1804        };
1805        let require_indexed_finite = |collection: &str, index: usize, field: &str, value: f64| {
1806            if value.is_finite() {
1807                Ok(())
1808            } else {
1809                Err(BallisticsError::from(format!(
1810                    "trajectory result contains non-finite {collection}[{index}].{field}"
1811                )))
1812            }
1813        };
1814        let require_indexed_non_negative = |collection: &str,
1815                                            index: usize,
1816                                            field: &str,
1817                                            value: f64| {
1818            if value >= 0.0 {
1819                Ok(())
1820            } else {
1821                Err(BallisticsError::from(format!(
1822                        "trajectory result contains non-physical negative {collection}[{index}].{field} ({value})"
1823                    )))
1824            }
1825        };
1826
1827        require_finite("max_range", result.max_range)?;
1828        require_finite("max_height", result.max_height)?;
1829        require_finite("time_of_flight", result.time_of_flight)?;
1830        require_finite("impact_velocity", result.impact_velocity)?;
1831        require_finite("impact_energy", result.impact_energy)?;
1832        require_finite("projectile_mass_kg", result.projectile_mass_kg)?;
1833        require_finite("line_of_sight_height_m", result.line_of_sight_height_m)?;
1834        require_finite(
1835            "station_speed_of_sound_mps",
1836            result.station_speed_of_sound_mps,
1837        )?;
1838
1839        // The solve starts at x = 0 and only ever fires downrange, so these scalars are
1840        // non-negative for every physically meaningful trajectory. (max_height is exempt:
1841        // points can legitimately sit below y = 0 with an elevated muzzle.)
1842        require_non_negative("max_range", result.max_range)?;
1843        require_non_negative("time_of_flight", result.time_of_flight)?;
1844        require_non_negative("impact_velocity", result.impact_velocity)?;
1845        require_non_negative("impact_energy", result.impact_energy)?;
1846        require_non_negative("projectile_mass_kg", result.projectile_mass_kg)?;
1847        require_non_negative(
1848            "station_speed_of_sound_mps",
1849            result.station_speed_of_sound_mps,
1850        )?;
1851
1852        for (index, point) in result.points.iter().enumerate() {
1853            require_indexed_finite("points", index, "time", point.time)?;
1854            require_indexed_finite("points", index, "position.x", point.position.x)?;
1855            require_indexed_finite("points", index, "position.y", point.position.y)?;
1856            require_indexed_finite("points", index, "position.z", point.position.z)?;
1857            require_indexed_finite(
1858                "points",
1859                index,
1860                "velocity_magnitude",
1861                point.velocity_magnitude,
1862            )?;
1863            require_indexed_finite("points", index, "kinetic_energy", point.kinetic_energy)?;
1864            require_indexed_non_negative("points", index, "time", point.time)?;
1865            require_indexed_non_negative(
1866                "points",
1867                index,
1868                "velocity_magnitude",
1869                point.velocity_magnitude,
1870            )?;
1871            require_indexed_non_negative("points", index, "kinetic_energy", point.kinetic_energy)?;
1872        }
1873
1874        if let Some(samples) = &result.sampled_points {
1875            for (index, sample) in samples.iter().enumerate() {
1876                require_indexed_finite("sampled_points", index, "distance_m", sample.distance_m)?;
1877                require_indexed_finite("sampled_points", index, "drop_m", sample.drop_m)?;
1878                require_indexed_finite(
1879                    "sampled_points",
1880                    index,
1881                    "wind_drift_m",
1882                    sample.wind_drift_m,
1883                )?;
1884                require_indexed_finite(
1885                    "sampled_points",
1886                    index,
1887                    "velocity_mps",
1888                    sample.velocity_mps,
1889                )?;
1890                require_indexed_finite("sampled_points", index, "energy_j", sample.energy_j)?;
1891                require_indexed_finite("sampled_points", index, "time_s", sample.time_s)?;
1892            }
1893        }
1894
1895        for (name, value) in [
1896            ("min_pitch_damping", result.min_pitch_damping),
1897            ("transonic_mach", result.transonic_mach),
1898            ("max_yaw_angle", result.max_yaw_angle),
1899            ("max_precession_angle", result.max_precession_angle),
1900        ] {
1901            if let Some(value) = value {
1902                require_finite(name, value)?;
1903            }
1904        }
1905
1906        if let Some(state) = result.angular_state {
1907            for (name, value) in [
1908                ("angular_state.pitch_angle", state.pitch_angle),
1909                ("angular_state.yaw_angle", state.yaw_angle),
1910                ("angular_state.pitch_rate", state.pitch_rate),
1911                ("angular_state.yaw_rate", state.yaw_rate),
1912                ("angular_state.precession_angle", state.precession_angle),
1913                ("angular_state.nutation_phase", state.nutation_phase),
1914            ] {
1915                require_finite(name, value)?;
1916            }
1917        }
1918
1919        if let Some(jump) = result.aerodynamic_jump {
1920            for (name, value) in [
1921                ("aerodynamic_jump.vertical_jump_moa", jump.vertical_jump_moa),
1922                (
1923                    "aerodynamic_jump.horizontal_jump_moa",
1924                    jump.horizontal_jump_moa,
1925                ),
1926                ("aerodynamic_jump.jump_angle_rad", jump.jump_angle_rad),
1927                (
1928                    "aerodynamic_jump.magnus_component_moa",
1929                    jump.magnus_component_moa,
1930                ),
1931                ("aerodynamic_jump.yaw_component_moa", jump.yaw_component_moa),
1932                (
1933                    "aerodynamic_jump.stabilization_factor",
1934                    jump.stabilization_factor,
1935                ),
1936            ] {
1937                require_finite(name, value)?;
1938            }
1939        }
1940
1941        Ok(())
1942    }
1943
1944    /// Integration methods store the pre-step state in `points`. Validate each newly accepted
1945    /// state as well, otherwise a poisoned final step could terminate the loop and leave only the
1946    /// previous finite point in an apparently successful result.
1947    ///
1948    /// Beyond finiteness, an accepted state must respect the physical speed budget: drag is
1949    /// dissipative (it drives the projectile toward the wind frame, never past it), Magnus and
1950    /// Coriolis act perpendicular to the velocity and do no work, and gravity adds at most g*t.
1951    /// Ground-frame speed therefore cannot legitimately exceed muzzle speed + strongest wind +
1952    /// g*t. Exceeding that budget means the integrator itself diverged — for stiff inputs the
1953    /// minimum-step RK45 acceptance can multiply speed by orders of magnitude in one step
1954    /// (MBA-1293: 13x and a sign reversal in a single 1 microsecond step) — so the solve must
1955    /// fail rather than report the garbage as `Ok`.
1956    fn validate_integration_state(
1957        &self,
1958        position: &Vector3<f64>,
1959        velocity: &Vector3<f64>,
1960        time: f64,
1961    ) -> Result<(), BallisticsError> {
1962        if !(position.iter().all(|value| value.is_finite())
1963            && velocity.iter().all(|value| value.is_finite())
1964            && time.is_finite())
1965        {
1966            return Err(BallisticsError::from(
1967                "trajectory integration produced a non-finite state (often from physically \
1968                 extreme inputs — e.g. an absurd bore/muzzle height placing the launch far \
1969                 from sea level, or a degenerate atmosphere; check those inputs, or set \
1970                 --altitude explicitly)",
1971            ));
1972        }
1973
1974        let speed = velocity.magnitude();
1975        let budget = self.speed_budget(time);
1976        if speed > budget {
1977            return Err(BallisticsError::from(format!(
1978                "trajectory integration diverged: speed {speed:.3e} m/s at t={time:.6}s exceeds \
1979                 the physical budget of {budget:.3e} m/s"
1980            )));
1981        }
1982        Ok(())
1983    }
1984
1985    /// Ceiling on ground-frame speed a physical trajectory can reach by time `t` (see
1986    /// [`Self::validate_integration_state`]). The factor-2 slack absorbs boundary-layer
1987    /// wind-shear amplification and integrator transients; genuine divergence clears the
1988    /// budget by orders of magnitude.
1989    fn speed_budget(&self, time: f64) -> f64 {
1990        let scalar_wind = self.wind.speed.abs() + self.wind.vertical_speed.abs();
1991        let wind_bound = match &self.wind_sock {
1992            Some(sock) => scalar_wind.max(sock.max_speed_mps()),
1993            None => scalar_wind,
1994        };
1995        2.0 * (self.inputs.muzzle_velocity + wind_bound + 10.0)
1996            + crate::constants::G_ACCEL_MPS2 * time
1997    }
1998
1999    /// Store one public trajectory point, collapsing a downrange stall, without exceeding the
2000    /// per-solve resource budget. `collapsed_steps` is the solve's own count of earlier calls that
2001    /// replaced the previous point instead of storing a new one.
2002    fn push_trajectory_point(
2003        &self,
2004        points: &mut Vec<TrajectoryPoint>,
2005        point: TrajectoryPoint,
2006        collapsed_steps: &mut usize,
2007    ) -> Result<(), BallisticsError> {
2008        // The budget counts calls, not stored points. Every earlier call either stored a point or
2009        // collapsed one, so points.len() + collapsed_steps is the number of earlier calls, which
2010        // is what points.len() was when every call stored a point. The limit therefore fires at
2011        // the same step, with the same message, whether or not the flight stalled (MBA-1599).
2012        if points.len() + *collapsed_steps >= self.max_trajectory_points {
2013            return Err(BallisticsError::from(format!(
2014                "trajectory point limit of {} exceeded",
2015                self.max_trajectory_points
2016            )));
2017        }
2018        // MBA-1599: the public trajectory is indexed by downrange distance, so stored x must
2019        // strictly increase. A drag-dominated flight (the bottom of the BC range, a slow
2020        // bc_segments band, or a long fall to a deep ground_threshold) can shed so much downrange
2021        // speed that a whole step moves x by less than half a ULP: x + vx*dt == x while y keeps
2022        // falling. Two states cannot both be kept at one representable range, so the newer state
2023        // wins, the same rule append_terminal_endpoint applies to the terminal point. The
2024        // collapsed step is still counted against max_trajectory_points above, so a stall that
2025        // outlasts the budget fails at the step where it always did; only the stored points
2026        // shrink. A strict DECREASE is deliberately still pushed: that is a reversal, which the
2027        // checked observation API reports.
2028        if let Some(last) = points.last_mut() {
2029            if point.position.x == last.position.x {
2030                *last = point;
2031                *collapsed_steps += 1;
2032                return Ok(());
2033            }
2034        }
2035        points.push(point);
2036        Ok(())
2037    }
2038
2039    /// Supply downrange-segmented wind. Each segment is `(speed_kmh, angle_deg,
2040    /// until_distance_m)`; the wind for a given downrange distance is the first
2041    /// segment whose `until_distance_m` exceeds it (a step function), and wind is
2042    /// zero beyond the last segment. An empty list clears segmented wind (reverts
2043    /// to the scalar `wind`). The angle convention matches `WindConditions`
2044    /// (0 = headwind, 90 = from the right).
2045    pub fn set_wind_segments(&mut self, segments: Vec<crate::wind::WindSegment>) {
2046        self.wind_sock = if segments.is_empty() {
2047            None
2048        } else {
2049            Some(crate::wind::WindSock::new(segments))
2050        };
2051    }
2052
2053    /// Supply downrange-segmented atmosphere (MBA-1137). Each segment is
2054    /// `(temp_c, pressure_hpa, humidity_percent, until_distance_m)`, defined at the shooter base
2055    /// altitude; the per-substep local-atmosphere recompute selects the active zone by downrange
2056    /// distance (first zone whose `until_distance_m` exceeds it; the last zone is held beyond the
2057    /// final threshold). The zone's base conditions are composed with the vertical altitude lapse
2058    /// via `get_local_atmosphere_humid`, so a steeply-arcing shot still sees the y-lapse on top of
2059    /// the zone base. An empty list clears segmented atmosphere (reverts to the resolved
2060    /// single-station conditions).
2061    pub fn set_atmo_segments(&mut self, segments: Vec<crate::atmosphere::AtmoSegment>) {
2062        self.atmo_sock = if segments.is_empty() {
2063            None
2064        } else {
2065            Some(crate::atmosphere::AtmoSock::new(segments))
2066        };
2067    }
2068
2069    /// Effective initial launch direction `(elevation, azimuth)` in radians, including
2070    /// the aerodynamic-jump muzzle perturbation when `enable_aerodynamic_jump` is set.
2071    ///
2072    /// Aerodynamic jump is the fixed angular departure imparted as the projectile
2073    /// transitions from the constrained bore to free flight; applying it as an initial
2074    /// launch-angle offset is the physically correct integration point. Returns the bare
2075    /// `(muzzle_angle, azimuth_angle)` when the flag is off, so a default solve is
2076    /// numerically identical to pre-feature behavior. (MBA-959)
2077    fn launch_angles_from(
2078        &self,
2079        aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
2080    ) -> (f64, f64) {
2081        let (mut elev, mut azim) = (self.inputs.muzzle_angle, self.inputs.azimuth_angle);
2082        // MBA-1286: cant rotates the sight-frame aim offsets about the line of sight.
2083        // Positive = clockwise from the shooter: the upward zero correction leaks right
2084        // (+z) and shrinks by cos(cant) -> POI right and low. Exactly-0.0 skips all float
2085        // ops so un-canted solves stay bit-identical. Aerodynamic jump is added AFTER the
2086        // rotation: it arises at bore exit from crosswind/spin in the ground frame, not
2087        // from the rifle's sight geometry.
2088        if self.inputs.cant_angle != 0.0 {
2089            let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
2090            let (e0, a0) = (elev, azim);
2091            elev = e0 * cos_c - a0 * sin_c;
2092            azim = a0 * cos_c + e0 * sin_c;
2093        }
2094        match aj {
2095            Some(c) => {
2096                // vertical_/horizontal_jump_moa ARE the jump angles expressed in MOA.
2097                const MOA_PER_RAD: f64 = 3437.7467707849;
2098                (
2099                    elev + c.vertical_jump_moa / MOA_PER_RAD,
2100                    azim + c.horizontal_jump_moa / MOA_PER_RAD,
2101                )
2102            }
2103            None => (elev, azim),
2104        }
2105    }
2106
2107    /// Compute the aerodynamic-jump components for the current inputs, or `None` when the
2108    /// feature is disabled / inputs are degenerate.
2109    ///
2110    /// Uses Bryan Litz's crosswind aerodynamic-jump estimator
2111    /// (`Y = 0.01*Sg - 0.0024*L + 0.032` MOA/mph) fed by the engine's own Miller Sg.
2112    /// Aerodynamic jump is a vertical effect, so only the elevation is perturbed.
2113    /// The estimator is a regression best near Sg ~ 1.75 — see MBA-959.
2114    fn aerodynamic_jump_components(
2115        &self,
2116    ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
2117        if !self.inputs.enable_aerodynamic_jump {
2118            return None;
2119        }
2120        // Reject degenerate/non-finite inputs before they can reach the launch angle.
2121        // A bare `<= 0.0` test lets NaN through (NaN comparisons are always false), and a
2122        // NaN/Inf here would poison the muzzle angle and collapse the whole trajectory.
2123        let diameter_m = self.inputs.bullet_diameter;
2124        if !(self.inputs.twist_rate.is_finite()
2125            && self.inputs.twist_rate != 0.0
2126            && diameter_m.is_finite()
2127            && diameter_m > 0.0
2128            && self.inputs.bullet_length.is_finite()
2129            && self.inputs.bullet_length > 0.0
2130            && self.inputs.muzzle_velocity.is_finite())
2131        {
2132            return None;
2133        }
2134
2135        // Engine's own gyroscopic (Miller) stability factor — same Sg shown elsewhere.
2136        let (_, _, temp_c, pressure_hpa) = self.resolved_atmosphere();
2137        let sg = crate::stability::compute_stability_coefficient(
2138            &self.inputs,
2139            (self.atmosphere.altitude, temp_c, pressure_hpa, 0.0),
2140        );
2141        if !(sg.is_finite() && sg > 0.0) {
2142            return None;
2143        }
2144        let length_calibers = self.inputs.bullet_length / diameter_m;
2145
2146        // Crosswind-from-the-right (mph) for Litz's estimator. Wind direction uses the
2147        // wind-FROM convention (0 = headwind, +90deg = from the right), matching the
2148        // fast-integrate path (fast_trajectory::aerodynamic_jump_launch_offset_rad) and
2149        // the lateral windage sign, so a from-the-right wind on a right-twist barrel
2150        // jumps the impact UP and drifts it left.
2151        const MS_TO_MPH: f64 = 2.236_936_292_054_4;
2152        let crosswind_from_right_mps = if let Some(sock) = &self.wind_sock {
2153            -sock.vector_for_range_stateless(0.0)[2]
2154        } else {
2155            self.wind.speed * self.wind.direction.sin()
2156        };
2157        let crosswind_from_right_mph = crosswind_from_right_mps * MS_TO_MPH;
2158
2159        let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
2160            sg,
2161            length_calibers,
2162            crosswind_from_right_mph,
2163            self.inputs.is_twist_right,
2164        );
2165        if !vertical_jump_moa.is_finite() {
2166            return None;
2167        }
2168
2169        const MOA_PER_RAD: f64 = 3437.7467707849;
2170        Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
2171            vertical_jump_moa,
2172            // Aerodynamic jump is a vertical effect; the Litz estimator has no horizontal term.
2173            horizontal_jump_moa: 0.0,
2174            jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
2175            magnus_component_moa: 0.0,
2176            yaw_component_moa: 0.0,
2177            stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
2178        })
2179    }
2180
2181    fn resolved_atmosphere(&self) -> (f64, f64, f64, f64) {
2182        let (temp_c, pressure_hpa) = match self.station_atmosphere_resolution {
2183            StationAtmosphereResolution::LegacyDefaultSentinels => {
2184                crate::atmosphere::resolve_station_conditions(
2185                    self.atmosphere.temperature,
2186                    self.atmosphere.pressure,
2187                    self.atmosphere.altitude,
2188                )
2189            }
2190            StationAtmosphereResolution::Authoritative => {
2191                (self.atmosphere.temperature, self.atmosphere.pressure)
2192            }
2193        };
2194        let (density, speed_of_sound) = crate::atmosphere::calculate_atmosphere(
2195            self.atmosphere.altitude,
2196            Some(temp_c),
2197            Some(pressure_hpa),
2198            self.atmosphere.humidity,
2199        );
2200        (density, speed_of_sound, temp_c, pressure_hpa)
2201    }
2202
2203    fn precession_nutation_params(
2204        &self,
2205        velocity_mps: f64,
2206        air_density_kg_m3: f64,
2207        speed_of_sound_mps: f64,
2208    ) -> PrecessionNutationParams {
2209        let (spin_inertia, transverse_inertia) = self.precession_nutation_inertias;
2210        let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
2211            let velocity_fps = velocity_mps * 3.28084;
2212            let twist_rate_ft = self.inputs.twist_rate / 12.0;
2213            (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
2214        } else {
2215            0.0
2216        };
2217
2218        PrecessionNutationParams {
2219            mass_kg: self.inputs.bullet_mass,
2220            caliber_m: self.inputs.bullet_diameter,
2221            length_m: self.inputs.bullet_length,
2222            spin_rate_rad_s,
2223            spin_inertia,
2224            transverse_inertia,
2225            velocity_mps,
2226            air_density_kg_m3,
2227            mach: velocity_mps / speed_of_sound_mps,
2228            pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
2229            nutation_damping_factor: 0.05,
2230        }
2231    }
2232
2233    /// Append the exact state at the earliest boundary crossed by the final integration step.
2234    ///
2235    /// Each solver stores its pre-step state. Keeping only that point makes early ground and time
2236    /// exits indistinguishable from ordinary integration knots, and historically left the
2237    /// reported endpoint one step short. Interpolating all supported boundaries here gives every
2238    /// solver one explicit terminal point and one authoritative termination reason.
2239    /// `collapsed_steps` is the solve's running count from push_trajectory_point, so a stored
2240    /// terminal point is checked against the same budget as every integration step.
2241    fn append_terminal_endpoint(
2242        &self,
2243        points: &mut Vec<TrajectoryPoint>,
2244        post_position: Vector3<f64>,
2245        post_velocity: Vector3<f64>,
2246        post_time: f64,
2247        max_height: &mut f64,
2248        collapsed_steps: &mut usize,
2249    ) -> Result<TrajectoryTermination, BallisticsError> {
2250        let previous = points
2251            .last()
2252            .cloned()
2253            .ok_or_else(|| BallisticsError::from("No trajectory points generated"))?;
2254
2255        let mut crossings = Vec::with_capacity(3);
2256        if previous.position.x < self.max_range && post_position.x >= self.max_range {
2257            let span = post_position.x - previous.position.x;
2258            if span.is_finite() && span > 0.0 {
2259                crossings.push((
2260                    (self.max_range - previous.position.x) / span,
2261                    TrajectoryTermination::MaxRange,
2262                ));
2263            }
2264        }
2265        if self.inputs.ground_threshold.is_finite()
2266            && previous.position.y > self.inputs.ground_threshold
2267            && post_position.y <= self.inputs.ground_threshold
2268        {
2269            let span = post_position.y - previous.position.y;
2270            if span.is_finite() && span < 0.0 {
2271                crossings.push((
2272                    (self.inputs.ground_threshold - previous.position.y) / span,
2273                    TrajectoryTermination::GroundThreshold,
2274                ));
2275            }
2276        }
2277        if previous.time < TRAJECTORY_TIME_LIMIT_S && post_time >= TRAJECTORY_TIME_LIMIT_S {
2278            let span = post_time - previous.time;
2279            if span.is_finite() && span > 0.0 {
2280                crossings.push((
2281                    (TRAJECTORY_TIME_LIMIT_S - previous.time) / span,
2282                    TrajectoryTermination::TimeLimit,
2283                ));
2284            }
2285        }
2286
2287        let (fraction, termination) = crossings
2288            .into_iter()
2289            .filter(|(fraction, _)| fraction.is_finite() && (0.0..=1.0).contains(fraction))
2290            .min_by(|left, right| {
2291                let priority = |termination: TrajectoryTermination| match termination {
2292                    TrajectoryTermination::GroundThreshold => 0,
2293                    TrajectoryTermination::MaxRange => 1,
2294                    TrajectoryTermination::TimeLimit => 2,
2295                    TrajectoryTermination::VelocityFloor => 3,
2296                };
2297                left.0
2298                    .total_cmp(&right.0)
2299                    .then_with(|| priority(left.1).cmp(&priority(right.1)))
2300            })
2301            .ok_or_else(|| {
2302                BallisticsError::from(
2303                    "trajectory integration stopped without crossing a supported boundary",
2304                )
2305            })?;
2306
2307        let mut position = previous.position + (post_position - previous.position) * fraction;
2308        match termination {
2309            TrajectoryTermination::MaxRange => position.x = self.max_range,
2310            TrajectoryTermination::GroundThreshold => {
2311                position.y = self.inputs.ground_threshold;
2312            }
2313            TrajectoryTermination::TimeLimit | TrajectoryTermination::VelocityFloor => {}
2314        }
2315        let velocity_magnitude = previous.velocity_magnitude
2316            + (post_velocity.magnitude() - previous.velocity_magnitude) * fraction;
2317        let mut time = previous.time + (post_time - previous.time) * fraction;
2318        if termination == TrajectoryTermination::TimeLimit {
2319            time = TRAJECTORY_TIME_LIMIT_S;
2320        }
2321        let kinetic_energy =
2322            0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
2323
2324        if position.y > *max_height {
2325            *max_height = position.y;
2326        }
2327        let terminal_point = TrajectoryPoint {
2328            time,
2329            position,
2330            velocity_magnitude,
2331            kinetic_energy,
2332            drag_coefficient: None,
2333        };
2334        if terminal_point.position.x < previous.position.x {
2335            return Err(BallisticsError::from(
2336                "trajectory terminal state reversed downrange before the crossed boundary",
2337            ));
2338        }
2339        if terminal_point.position.x == previous.position.x {
2340            // A very early ground/time crossing can be distinct in time but less than one ULP
2341            // downrange. There is no representable range at which to retain both states, so make
2342            // the terminal state authoritative instead of creating a duplicate-X trajectory that
2343            // the checked observation API must reject (see push_trajectory_point).
2344            let last = points.last_mut().ok_or_else(|| {
2345                BallisticsError::from("trajectory points disappeared during terminal finalization")
2346            })?;
2347            *last = terminal_point;
2348        } else {
2349            self.push_trajectory_point(points, terminal_point, collapsed_steps)?;
2350        }
2351        Ok(termination)
2352    }
2353
2354    fn gravity_acceleration(&self) -> Vector3<f64> {
2355        let theta = self.inputs.shooting_angle;
2356        Vector3::new(
2357            -crate::constants::G_ACCEL_MPS2 * theta.sin(),
2358            -crate::constants::G_ACCEL_MPS2 * theta.cos(),
2359            0.0,
2360        )
2361    }
2362
2363    fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
2364        // Scale the operative surface wind by the boundary-layer multiplier. `altitude_m` is the
2365        // bullet's height relative to the muzzle (McCoy Y). The multiplier is floored at 1.0, so
2366        // flat-fire trajectories keep ~full wind and only high-arcing shots see increased wind.
2367        //
2368        // We build the vector with THIS solver's non-shear sign convention (X=-cos, Z=-sin; see
2369        // the `wind_vector` used in solve_rk4/solve_euler, matching WindSock) and scale it, so that
2370        // "shear on" equals "shear off" * ratio (ratio == 1.0 for flat fire). An earlier revision
2371        // attenuated the wind near the line of sight and flipped its sign relative to the non-shear
2372        // path; this keeps them sign-consistent.
2373        // Map the requested model name to the boundary-layer model (MBA-965).
2374        // Names match wind_shear::get_wind_at_position. Unknown strings should
2375        // never reach here (the CLI parses an enum), but default to PowerLaw to
2376        // preserve the historical "exponential" behaviour for any caller that
2377        // forwards an unexpected value.
2378        let model = match self.inputs.wind_shear_model.as_str() {
2379            "logarithmic" => WindShearModel::Logarithmic,
2380            "power_law" | "powerlaw" | "exponential" => WindShearModel::PowerLaw,
2381            "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
2382            "custom_layers" | "custom" => WindShearModel::CustomLayers,
2383            _ => WindShearModel::PowerLaw,
2384        };
2385        let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio(altitude_m, model);
2386
2387        // 0deg = headwind, 90deg = from the right (McCoy wind-FROM convention, matching
2388        // WindConditions / WindSock); wind enters drag via velocity - wind.
2389        //
2390        // MBA-728: the horizontal vector is built with vertical=0.0 and scaled by speed_ratio,
2391        // then wind.vertical_speed is added back UNSCALED — boundary-layer shear scales
2392        // horizontal wind only, vertical passes through as-is.
2393        crate::wind::wind_vector(self.wind.speed, self.wind.direction, 0.0) * speed_ratio
2394            + Vector3::new(0.0, self.wind.vertical_speed, 0.0)
2395    }
2396
2397    pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
2398        self.validate_for_solve()?;
2399        let mut result = if self.inputs.use_rk4 {
2400            if self.inputs.use_adaptive_rk45 {
2401                self.solve_rk45()?
2402            } else {
2403                self.solve_rk4()?
2404            }
2405        } else {
2406            self.solve_euler()?
2407        };
2408        self.apply_spin_drift(&mut result);
2409        self.validate_result_sanity(&result)?;
2410        Ok(result)
2411    }
2412
2413    /// Gyroscopic spin drift via the empirical Litz model, applied in the engine
2414    /// (not the WASM formatter) so it covers Euler/RK4/RK45 and all consumers.
2415    /// Uses the canonical SI fields and converts to grains/inches correctly,
2416    /// avoiding the kg/m-vs-grains/in unit bug in `calculate_enhanced_spin_drift`.
2417    /// Frame (McCoy): Z = lateral (windage), so drift adds to `position.z`.
2418    fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
2419        if !self.inputs.use_enhanced_spin_drift {
2420            return;
2421        }
2422        let d_in = self.inputs.bullet_diameter / 0.0254; // m -> in
2423        let m_gr = self.inputs.bullet_mass / crate::constants::GRAINS_TO_KG; // kg -> grains
2424        let twist_in = self.inputs.twist_rate; // inches/turn
2425        if d_in <= 0.0 || m_gr <= 0.0 || twist_in <= 0.0 {
2426            return;
2427        }
2428
2429        // MBA-1134 (rank 31): single source of truth for the muzzle Sg —
2430        // stability::compute_stability_coefficient via spin_drift::effective_sg_from_inputs. This
2431        // ADDS the (v/2800)^(1/3) muzzle-velocity term the bare miller_stability() lacked, so the
2432        // spin-drift Sg now matches the reported SG and the aerodynamic-jump Sg. The linear Miller
2433        // density correction ((T/T0)*(P0/P), a no-op at sea-level standard) and the 4.5-caliber
2434        // length fallback are handled inside effective_sg_from_inputs.
2435        let sg = self.effective_spin_drift_sg();
2436
2437        for p in result.points.iter_mut() {
2438            if p.time <= 0.0 {
2439                continue;
2440            }
2441            // Canonical Litz drift, shared with the fast / Monte-Carlo path (spin_drift::litz_*).
2442            p.position.z +=
2443                crate::spin_drift::litz_drift_meters(sg, p.time, self.inputs.is_twist_right);
2444        }
2445
2446        // sampled_points are snapshotted from the PRE-drift trajectory inside each solver, so the
2447        // sampled wind_drift_m column would omit the spin drift that result.points carry. Apply
2448        // the same canonical Litz drift to keep the two user-facing outputs consistent.
2449        if let Some(samples) = result.sampled_points.as_mut() {
2450            for s in samples.iter_mut() {
2451                if s.time_s <= 0.0 {
2452                    continue;
2453                }
2454                s.wind_drift_m +=
2455                    crate::spin_drift::litz_drift_meters(sg, s.time_s, self.inputs.is_twist_right);
2456            }
2457        }
2458    }
2459
2460    /// Muzzle gyroscopic stability Sg used by the empirical Litz spin-drift post-process
2461    /// (MBA-1134). Extracted so the exact value is unit-testable and provably identical to the Sg
2462    /// the fast / Monte-Carlo path uses — both go through
2463    /// [`crate::spin_drift::effective_sg_from_inputs`] with the resolved muzzle atmosphere.
2464    fn effective_spin_drift_sg(&self) -> f64 {
2465        let (_, _, temp_c, press_hpa) = self.resolved_atmosphere();
2466        crate::spin_drift::effective_sg_from_inputs(&self.inputs, temp_c, press_hpa)
2467    }
2468
2469    /// Bore muzzle position at t=0 (bore-origin frame, `muzzle_height` above ground).
2470    /// With cant the rifle rotates about the LINE OF SIGHT, so the bore — sight_height
2471    /// below the sight — swings laterally by `-sight_height*sin(cant)` (left of the aim
2472    /// plane for clockwise cant) and rises by `sight_height*(1-cos(cant))` toward the
2473    /// pivot. (MBA-1286)
2474    ///
2475    /// A lateral sight-mount offset (MBA-1396) additionally displaces the bore
2476    /// `sight_offset_lateral_m` from the LOS (a sight mounted right of the bore puts the
2477    /// bore left of the sight line). Cant rotates the WHOLE bore-to-sight displacement
2478    /// vector rigidly about the LOS, so the vertical drop `-sight_height` and the lateral
2479    /// offset `-sight_offset_lateral_m` couple: rotating `(u, w) = (-sh, -off)` by the
2480    /// cant angle gives `y += sh*(1-cos) + off*sin` and `z = -sh*sin - off*cos`. Both the
2481    /// cant-only case (off = 0) and the offset-only case (cos = 1, sin = 0) collapse to
2482    /// the historical terms, so only a simultaneously canted AND offset rifle sees the
2483    /// coupling. The windage-zero convergence that makes the trajectory cross the LOS at
2484    /// the zero range lives in `windage_zero_bias_rad`, not here. Exactly-0.0 cant AND
2485    /// offset return the historical position (bit-identical).
2486    fn initial_position(&self) -> Vector3<f64> {
2487        if self.inputs.cant_angle == 0.0 && self.inputs.sight_offset_lateral_m == 0.0 {
2488            return Vector3::new(0.0, self.inputs.muzzle_height, 0.0);
2489        }
2490        let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
2491        let sh = self.inputs.sight_height;
2492        let off = self.inputs.sight_offset_lateral_m;
2493        Vector3::new(
2494            0.0,
2495            self.inputs.muzzle_height + sh * (1.0 - cos_c) + off * sin_c,
2496            -sh * sin_c - off * cos_c,
2497        )
2498    }
2499
2500    /// Shared post-integration sampling used by all three integrators (MBA-1403).
2501    ///
2502    /// Builds the `TrajectoryData`/`TrajectoryOutputs` pair and runs
2503    /// [`sample_trajectory`] when `enable_trajectory_sampling` is set. This is the
2504    /// single place the sampler's LOS datum (`target_vertical_height_m`) is chosen,
2505    /// replacing three per-integrator copies that had to be edited in parallel.
2506    fn build_sampled_points(
2507        &self,
2508        points: &[TrajectoryPoint],
2509        max_height: f64,
2510        transonic_distances: Vec<f64>,
2511        mach_transitions: &MachTransitionTracker,
2512    ) -> Result<Option<Vec<TrajectorySample>>, BallisticsError> {
2513        if !self.inputs.enable_trajectory_sampling {
2514            return Ok(None);
2515        }
2516
2517        let last_point = points.last().ok_or("No trajectory points generated")?;
2518        let trajectory_data = TrajectoryData {
2519            times: points.iter().map(|p| p.time).collect(),
2520            positions: points.iter().map(|p| p.position).collect(),
2521            velocities: points
2522                .iter()
2523                .map(|p| {
2524                    // Reconstruct velocity vectors from magnitude (approximate)
2525                    Vector3::new(0.0, 0.0, p.velocity_magnitude)
2526                })
2527                .collect(),
2528            transonic_distances, // populated by the integrator at each Mach-threshold crossing
2529            mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2530            mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2531            mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2532        };
2533
2534        // For LOS calculation in ground-referenced coordinates:
2535        // sight_position_m is the sight's actual y-position above ground
2536        // (muzzle_height + sight_height, not just sight_height)
2537        // For flat shots, target is at same height as the sight (horizontal LOS)
2538        let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
2539        let target_reference = self.inputs.drops_reference == DropsReference::Target;
2540        // MBA-1403: only the (new, non-default) target mode reads `target_height` here,
2541        // and only when a real (non-zero) height was supplied — the sampler's LOS then
2542        // slopes toward the actual target instead of staying at the sight height. LOS
2543        // mode keeps the historical datum byte-identically, and zero-solve
2544        // `target_height` semantics (an explicit function argument, never this field)
2545        // are untouched.
2546        let target_vertical_height_m = if target_reference && self.inputs.target_height != 0.0 {
2547            self.inputs.target_height
2548        } else {
2549            sight_position_m
2550        };
2551        let outputs = TrajectoryOutputs {
2552            target_distance_horiz_m: last_point.position.x, // X is downrange
2553            target_vertical_height_m,
2554            time_of_flight_s: last_point.time,
2555            max_ord_dist_horiz_m: max_height,
2556            sight_height_m: sight_position_m,
2557        };
2558
2559        // Sample at specified intervals
2560        let mut samples = sample_trajectory(
2561            &trajectory_data,
2562            &outputs,
2563            self.inputs.sample_interval,
2564            self.inputs.bullet_mass,
2565        )?;
2566
2567        // MBA-1403 target-plane reference: scale the LOS-perpendicular drop to vertical
2568        // in the target plane. Scaling by the positive constant 1/cos preserves signs
2569        // and ordering, so the sampler's zero-crossing/apex flags are unaffected.
2570        // validate_for_solve rejects target mode at |shooting_angle| >= 90 degrees, so
2571        // cos_theta is strictly positive here. Downstream mil/moa conversions derive
2572        // from drop_m, so they inherit the transform without further edits.
2573        if target_reference {
2574            let cos_theta = self.inputs.shooting_angle.cos();
2575            for sample in &mut samples {
2576                sample.drop_m /= cos_theta;
2577            }
2578        }
2579        Ok(Some(samples))
2580    }
2581
2582    fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
2583        // Simple trajectory integration using Euler method
2584        let mut time = 0.0;
2585        // Bullet starts at the BORE position, which is muzzle_height above ground
2586        // The sight is sight_height ABOVE the bore, so we don't add sight_height here
2587        // cant-adjusted via initial_position (MBA-1286)
2588        let mut position = self.initial_position();
2589        // Calculate initial velocity components with both elevation and azimuth
2590        // McCoy coordinate system: X=downrange, Y=vertical, Z=lateral (right)
2591        // Launch direction includes the aerodynamic-jump muzzle perturbation when enabled
2592        // (a no-op returning the bare muzzle/azimuth angles otherwise). MBA-959. Computed
2593        // once here and reused for the result so it isn't evaluated twice per solve.
2594        let aj_components = self.aerodynamic_jump_components();
2595        let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
2596        let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
2597        let mut velocity = Vector3::new(
2598            horizontal_velocity * launch_azim.cos(), // X: downrange (forward)
2599            self.inputs.muzzle_velocity * launch_elev.sin(), // Y: vertical component
2600            horizontal_velocity * launch_azim.sin(), // Z: lateral (side deviation)
2601        );
2602
2603        let mut points = Vec::new();
2604        // Steps push_trajectory_point collapsed onto the previous point; they still count
2605        // against the point budget (MBA-1599).
2606        let mut collapsed_steps = 0;
2607        let mut max_height = position.y;
2608        let mut min_pitch_damping = f64::INFINITY; // Track minimum pitch damping coefficient
2609        let mut transonic_mach = None; // Track when we enter transonic
2610                                       // Downrange distances where the projectile crosses Mach 1.2 (transonic) then Mach 1.0
2611                                       // (subsonic), so the sampled trajectory output can flag those transitions
2612                                       // (trajectory_sampling::add_trajectory_flags consumes this).
2613        let mut transonic_distances: Vec<f64> = Vec::new();
2614        let mut mach_transitions = MachTransitionTracker::default();
2615
2616        // Initialize angular state for precession/nutation tracking
2617        let mut angular_state = if self.inputs.enable_precession_nutation {
2618            Some(AngularState {
2619                pitch_angle: 0.001, // Small initial disturbance
2620                yaw_angle: 0.001,
2621                pitch_rate: 0.0,
2622                yaw_rate: 0.0,
2623                precession_angle: 0.0,
2624                nutation_phase: 0.0,
2625            })
2626        } else {
2627            None
2628        };
2629        let mut max_yaw_angle = 0.0;
2630        let mut max_precession_angle = 0.0;
2631
2632        // Calculate air density
2633        let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2634            self.resolved_atmosphere();
2635        // MBA-1136 (rank 30): base density RATIO for the local-altitude atmosphere recompute done
2636        // per-substep inside calculate_acceleration. The `air_density` / `speed_of_sound` above
2637        // stay the frozen station values, still used for the Mach-transition, pitch-damping and
2638        // precession/nutation diagnostics (which are intentionally referenced to station Mach).
2639        let base_ratio = air_density / 1.225;
2640
2641        // Wind vector (McCoy): X=downrange (head/tail wind), Y=0, Z=lateral (crosswind)
2642        // 0deg = headwind, 90deg = from the right (McCoy wind-FROM convention, matching
2643        // WindSock); wind enters drag via velocity - wind. Used when no segmented wind.
2644        // MBA-728: no shear/no segments here, so vertical_speed passes straight through
2645        // (there is no horizontal-only scaling step on this path).
2646        let wind_vector = crate::wind::wind_vector(
2647            self.wind.speed,
2648            self.wind.direction,
2649            self.wind.vertical_speed,
2650        );
2651
2652        // Pitch-damping coefficients depend only on the (constant) bullet_model; compute once
2653        // instead of re-deriving them (with a to_lowercase alloc) every integration step.
2654        let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2655            self.inputs.bullet_model.as_deref().unwrap_or("default"),
2656        );
2657
2658        // Main integration loop (X is downrange)
2659        while position.x < self.max_range
2660            && position.y > self.inputs.ground_threshold
2661            && time < TRAJECTORY_TIME_LIMIT_S
2662        {
2663            // Store trajectory point
2664            let velocity_magnitude = velocity.magnitude();
2665            let kinetic_energy =
2666                0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
2667
2668            self.push_trajectory_point(
2669                &mut points,
2670                TrajectoryPoint {
2671                    time,
2672                    position,
2673                    velocity_magnitude,
2674                    kinetic_energy,
2675                    drag_coefficient: None,
2676                },
2677                &mut collapsed_steps,
2678            )?;
2679
2680            // Record Mach-transition distances (constant sea-level speed of sound, matching the
2681            // transonic_mach tracking). Each threshold is recorded once, in descending order.
2682            {
2683                let mach_here = if speed_of_sound > 0.0 {
2684                    velocity_magnitude / speed_of_sound
2685                } else {
2686                    0.0
2687                };
2688                mach_transitions.record_downward_crossings(
2689                    mach_here,
2690                    position.x,
2691                    &mut transonic_distances,
2692                );
2693            }
2694
2695            // Track max height
2696            if position.y > max_height {
2697                max_height = position.y;
2698            }
2699
2700            // Calculate pitch damping if enabled
2701            if self.inputs.enable_pitch_damping {
2702                let mach = velocity_magnitude / speed_of_sound;
2703
2704                // Track when we enter transonic
2705                if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2706                    transonic_mach = Some(mach);
2707                }
2708
2709                // Calculate pitch damping coefficient
2710                let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2711
2712                // Track minimum (most critical for stability)
2713                if pitch_damping < min_pitch_damping {
2714                    min_pitch_damping = pitch_damping;
2715                }
2716            }
2717
2718            // Calculate precession/nutation if enabled
2719            if self.inputs.enable_precession_nutation {
2720                if let Some(ref mut state) = angular_state {
2721                    let velocity_magnitude = velocity.magnitude();
2722                    let params = self.precession_nutation_params(
2723                        velocity_magnitude,
2724                        air_density,
2725                        speed_of_sound,
2726                    );
2727
2728                    // Update angular state
2729                    *state = calculate_combined_angular_motion(
2730                        &params,
2731                        state,
2732                        time,
2733                        self.time_step,
2734                        0.001, // Initial disturbance
2735                    );
2736
2737                    // Track maximums
2738                    if state.yaw_angle.abs() > max_yaw_angle {
2739                        max_yaw_angle = state.yaw_angle.abs();
2740                    }
2741                    if state.precession_angle.abs() > max_precession_angle {
2742                        max_precession_angle = state.precession_angle.abs();
2743                    }
2744                }
2745            }
2746
2747            // Use the same acceleration kernel as RK4/RK45 so all three solvers share ONE drag
2748            // model. solve_euler previously used a bespoke frontal-area drag (0.5*rho*Cd*A*v^2/m)
2749            // that IGNORED the ballistic coefficient entirely (diverging up to ~2.3x from the
2750            // BC-retardation RK4/RK45 path), and also omitted the Magnus/Coriolis terms.
2751            // calculate_acceleration applies BC-retardation drag, gravity, Coriolis, Magnus, wind
2752            // shear, and the zero-relative-velocity gravity-only guard.
2753            let acceleration = self.calculate_acceleration(
2754                &position,
2755                &velocity,
2756                &wind_vector,
2757                (resolved_temp_c, resolved_press_hpa, base_ratio),
2758            );
2759
2760            // Update state
2761            velocity += acceleration * self.time_step;
2762            position += velocity * self.time_step;
2763            time += self.time_step;
2764            self.validate_integration_state(&position, &velocity, time)?;
2765        }
2766
2767        let termination = self.append_terminal_endpoint(
2768            &mut points,
2769            position,
2770            velocity,
2771            time,
2772            &mut max_height,
2773            &mut collapsed_steps,
2774        )?;
2775
2776        // Get final values
2777        // MBA-1423: fill the per-point Cd here, once, rather than inside each integrator loop.
2778        // Every solver family reaches this line, so none can leave the field empty while the
2779        // others populate it. Must precede the `last_point` borrow below.
2780        self.annotate_drag_coefficients(&mut points, speed_of_sound);
2781
2782        let last_point = points.last().ok_or("No trajectory points generated")?;
2783
2784        // Create trajectory sampling data if enabled (shared helper, MBA-1403)
2785        let sampled_points =
2786            self.build_sampled_points(&points, max_height, transonic_distances, &mach_transitions)?;
2787
2788        Ok(TrajectoryResult {
2789            max_range: last_point.position.x, // X is downrange
2790            max_height,
2791            time_of_flight: last_point.time,
2792            impact_velocity: last_point.velocity_magnitude,
2793            impact_energy: last_point.kinetic_energy,
2794            projectile_mass_kg: self.inputs.bullet_mass,
2795            line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
2796            station_speed_of_sound_mps: speed_of_sound,
2797            termination,
2798            points,
2799            sampled_points,
2800            min_pitch_damping: if self.inputs.enable_pitch_damping {
2801                Some(min_pitch_damping)
2802            } else {
2803                None
2804            },
2805            transonic_mach,
2806            angular_state,
2807            max_yaw_angle: if self.inputs.enable_precession_nutation {
2808                Some(max_yaw_angle)
2809            } else {
2810                None
2811            },
2812            max_precession_angle: if self.inputs.enable_precession_nutation {
2813                Some(max_precession_angle)
2814            } else {
2815                None
2816            },
2817            aerodynamic_jump: aj_components,
2818            mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2819            mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2820            mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2821        })
2822    }
2823
2824    fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
2825        // RK4 trajectory integration for better accuracy
2826        let mut time = 0.0;
2827        // Bullet starts at the BORE position, which is muzzle_height above ground
2828        // The sight is sight_height ABOVE the bore, so we don't add sight_height here
2829        // The sight_height affects the LOS calculation and zero angle, not the starting position
2830        // cant-adjusted via initial_position (MBA-1286)
2831        let mut position = self.initial_position();
2832
2833        // Calculate initial velocity components with both elevation and azimuth
2834        // McCoy coordinate system: X=downrange, Y=vertical, Z=lateral (right)
2835        // Launch direction includes the aerodynamic-jump muzzle perturbation when enabled
2836        // (a no-op returning the bare muzzle/azimuth angles otherwise). MBA-959. Computed
2837        // once here and reused for the result so it isn't evaluated twice per solve.
2838        let aj_components = self.aerodynamic_jump_components();
2839        let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
2840        let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
2841        let mut velocity = Vector3::new(
2842            horizontal_velocity * launch_azim.cos(), // X: downrange (forward)
2843            self.inputs.muzzle_velocity * launch_elev.sin(), // Y: vertical component
2844            horizontal_velocity * launch_azim.sin(), // Z: lateral (side deviation)
2845        );
2846
2847        let mut points = Vec::new();
2848        // Steps push_trajectory_point collapsed onto the previous point; they still count
2849        // against the point budget (MBA-1599).
2850        let mut collapsed_steps = 0;
2851        let mut max_height = position.y;
2852        let mut min_pitch_damping = f64::INFINITY; // Track minimum pitch damping coefficient
2853        let mut transonic_mach = None; // Track when we enter transonic
2854                                       // Downrange distances where the projectile crosses Mach 1.2 (transonic) then Mach 1.0
2855                                       // (subsonic), so the sampled trajectory output can flag those transitions
2856                                       // (trajectory_sampling::add_trajectory_flags consumes this).
2857        let mut transonic_distances: Vec<f64> = Vec::new();
2858        let mut mach_transitions = MachTransitionTracker::default();
2859
2860        // Initialize angular state for precession/nutation tracking
2861        let mut angular_state = if self.inputs.enable_precession_nutation {
2862            Some(AngularState {
2863                pitch_angle: 0.001, // Small initial disturbance
2864                yaw_angle: 0.001,
2865                pitch_rate: 0.0,
2866                yaw_rate: 0.0,
2867                precession_angle: 0.0,
2868                nutation_phase: 0.0,
2869            })
2870        } else {
2871            None
2872        };
2873        let mut max_yaw_angle = 0.0;
2874        let mut max_precession_angle = 0.0;
2875
2876        // Calculate air density
2877        let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2878            self.resolved_atmosphere();
2879        // MBA-1136 (rank 30): base density RATIO for the local-altitude atmosphere recompute done
2880        // per-substep inside calculate_acceleration. The `air_density` / `speed_of_sound` above
2881        // stay the frozen station values, still used for the Mach-transition, pitch-damping and
2882        // precession/nutation diagnostics (which are intentionally referenced to station Mach).
2883        let base_ratio = air_density / 1.225;
2884
2885        // Wind vector (McCoy): X=downrange (head/tail wind), Y=0, Z=lateral (crosswind)
2886        // 0deg = headwind, 90deg = from the right (McCoy wind-FROM convention, matching
2887        // WindSock); wind enters drag via velocity - wind. Used when no segmented wind.
2888        // MBA-728: no shear/no segments here, so vertical_speed passes straight through
2889        // (there is no horizontal-only scaling step on this path).
2890        let wind_vector = crate::wind::wind_vector(
2891            self.wind.speed,
2892            self.wind.direction,
2893            self.wind.vertical_speed,
2894        );
2895
2896        // Pitch-damping coefficients depend only on the (constant) bullet_model; compute once
2897        // instead of re-deriving them (with a to_lowercase alloc) every integration step.
2898        let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2899            self.inputs.bullet_model.as_deref().unwrap_or("default"),
2900        );
2901
2902        // Main RK4 integration loop (X is downrange)
2903        while position.x < self.max_range
2904            && position.y > self.inputs.ground_threshold
2905            && time < TRAJECTORY_TIME_LIMIT_S
2906        {
2907            // Store trajectory point
2908            let velocity_magnitude = velocity.magnitude();
2909            let kinetic_energy =
2910                0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
2911
2912            self.push_trajectory_point(
2913                &mut points,
2914                TrajectoryPoint {
2915                    time,
2916                    position,
2917                    velocity_magnitude,
2918                    kinetic_energy,
2919                    drag_coefficient: None,
2920                },
2921                &mut collapsed_steps,
2922            )?;
2923
2924            // Record Mach-transition distances (constant sea-level speed of sound, matching the
2925            // transonic_mach tracking). Each threshold is recorded once, in descending order.
2926            {
2927                let mach_here = if speed_of_sound > 0.0 {
2928                    velocity_magnitude / speed_of_sound
2929                } else {
2930                    0.0
2931                };
2932                mach_transitions.record_downward_crossings(
2933                    mach_here,
2934                    position.x,
2935                    &mut transonic_distances,
2936                );
2937            }
2938
2939            if position.y > max_height {
2940                max_height = position.y;
2941            }
2942
2943            // Calculate pitch damping if enabled (RK4 solver)
2944            if self.inputs.enable_pitch_damping {
2945                let mach = velocity_magnitude / speed_of_sound;
2946
2947                // Track when we enter transonic
2948                if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2949                    transonic_mach = Some(mach);
2950                }
2951
2952                // Calculate pitch damping coefficient
2953                let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2954
2955                // Track minimum (most critical for stability)
2956                if pitch_damping < min_pitch_damping {
2957                    min_pitch_damping = pitch_damping;
2958                }
2959            }
2960
2961            // Calculate precession/nutation if enabled (RK4 solver)
2962            if self.inputs.enable_precession_nutation {
2963                if let Some(ref mut state) = angular_state {
2964                    let velocity_magnitude = velocity.magnitude();
2965                    let params = self.precession_nutation_params(
2966                        velocity_magnitude,
2967                        air_density,
2968                        speed_of_sound,
2969                    );
2970
2971                    // Update angular state
2972                    *state = calculate_combined_angular_motion(
2973                        &params,
2974                        state,
2975                        time,
2976                        self.time_step,
2977                        0.001, // Initial disturbance
2978                    );
2979
2980                    // Track maximums
2981                    if state.yaw_angle.abs() > max_yaw_angle {
2982                        max_yaw_angle = state.yaw_angle.abs();
2983                    }
2984                    if state.precession_angle.abs() > max_precession_angle {
2985                        max_precession_angle = state.precession_angle.abs();
2986                    }
2987                }
2988            }
2989
2990            // RK4 method
2991            let dt = self.time_step;
2992
2993            // k1
2994            let acc1 = self.calculate_acceleration(
2995                &position,
2996                &velocity,
2997                &wind_vector,
2998                (resolved_temp_c, resolved_press_hpa, base_ratio),
2999            );
3000
3001            // k2
3002            let pos2 = position + velocity * (dt * 0.5);
3003            let vel2 = velocity + acc1 * (dt * 0.5);
3004            let acc2 = self.calculate_acceleration(
3005                &pos2,
3006                &vel2,
3007                &wind_vector,
3008                (resolved_temp_c, resolved_press_hpa, base_ratio),
3009            );
3010
3011            // k3
3012            let pos3 = position + vel2 * (dt * 0.5);
3013            let vel3 = velocity + acc2 * (dt * 0.5);
3014            let acc3 = self.calculate_acceleration(
3015                &pos3,
3016                &vel3,
3017                &wind_vector,
3018                (resolved_temp_c, resolved_press_hpa, base_ratio),
3019            );
3020
3021            // k4
3022            let pos4 = position + vel3 * dt;
3023            let vel4 = velocity + acc3 * dt;
3024            let acc4 = self.calculate_acceleration(
3025                &pos4,
3026                &vel4,
3027                &wind_vector,
3028                (resolved_temp_c, resolved_press_hpa, base_ratio),
3029            );
3030
3031            // Update position and velocity
3032            position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
3033            velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
3034            time += dt;
3035            self.validate_integration_state(&position, &velocity, time)?;
3036        }
3037
3038        let termination = self.append_terminal_endpoint(
3039            &mut points,
3040            position,
3041            velocity,
3042            time,
3043            &mut max_height,
3044            &mut collapsed_steps,
3045        )?;
3046
3047        // Get final values
3048        // MBA-1423: fill the per-point Cd here, once, rather than inside each integrator loop.
3049        // Every solver family reaches this line, so none can leave the field empty while the
3050        // others populate it. Must precede the `last_point` borrow below.
3051        self.annotate_drag_coefficients(&mut points, speed_of_sound);
3052
3053        let last_point = points.last().ok_or("No trajectory points generated")?;
3054
3055        // Create trajectory sampling data if enabled (shared helper, MBA-1403)
3056        let sampled_points =
3057            self.build_sampled_points(&points, max_height, transonic_distances, &mach_transitions)?;
3058
3059        Ok(TrajectoryResult {
3060            max_range: last_point.position.x, // X is downrange
3061            max_height,
3062            time_of_flight: last_point.time,
3063            impact_velocity: last_point.velocity_magnitude,
3064            impact_energy: last_point.kinetic_energy,
3065            projectile_mass_kg: self.inputs.bullet_mass,
3066            line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
3067            station_speed_of_sound_mps: speed_of_sound,
3068            termination,
3069            points,
3070            sampled_points,
3071            min_pitch_damping: if self.inputs.enable_pitch_damping {
3072                Some(min_pitch_damping)
3073            } else {
3074                None
3075            },
3076            transonic_mach,
3077            angular_state,
3078            max_yaw_angle: if self.inputs.enable_precession_nutation {
3079                Some(max_yaw_angle)
3080            } else {
3081                None
3082            },
3083            max_precession_angle: if self.inputs.enable_precession_nutation {
3084                Some(max_precession_angle)
3085            } else {
3086                None
3087            },
3088            aerodynamic_jump: aj_components,
3089            mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
3090            mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
3091            mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
3092        })
3093    }
3094
3095    fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
3096        // RK45 adaptive step size integration (Dormand-Prince method)
3097        let mut time = 0.0;
3098        // Bullet starts at the BORE position, which is muzzle_height above ground
3099        // The sight is sight_height ABOVE the bore, so we don't add sight_height here
3100        // cant-adjusted via initial_position (MBA-1286)
3101        let mut position = self.initial_position();
3102
3103        // Calculate initial velocity components
3104        // McCoy coordinate system: X=downrange, Y=vertical, Z=lateral (right)
3105        // Launch direction includes the aerodynamic-jump muzzle perturbation when enabled
3106        // (a no-op returning the bare muzzle/azimuth angles otherwise). MBA-959. Computed
3107        // once here and reused for the result so it isn't evaluated twice per solve.
3108        let aj_components = self.aerodynamic_jump_components();
3109        let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
3110        let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
3111        let mut velocity = Vector3::new(
3112            horizontal_velocity * launch_azim.cos(), // X: downrange (forward)
3113            self.inputs.muzzle_velocity * launch_elev.sin(), // Y: vertical component
3114            horizontal_velocity * launch_azim.sin(), // Z: lateral (side deviation)
3115        );
3116
3117        let mut points = Vec::new();
3118        // Steps push_trajectory_point collapsed onto the previous point; they still count
3119        // against the point budget (MBA-1599).
3120        let mut collapsed_steps = 0;
3121        let mut max_height = position.y;
3122        let mut dt = 0.001; // Initial step size
3123
3124        // Air density and wind are constant for the whole solve (self.atmosphere / self.wind
3125        // are immutable); compute once instead of every iteration (mirrors solve_rk4).
3126        let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
3127            self.resolved_atmosphere();
3128        // MBA-1136 (rank 30): base density RATIO for the local-altitude atmosphere recompute done
3129        // per-substep inside calculate_acceleration. The `air_density` / `speed_of_sound` above
3130        // stay the frozen station values, still used for the Mach-transition, pitch-damping and
3131        // precession/nutation diagnostics (which are intentionally referenced to station Mach).
3132        let base_ratio = air_density / 1.225;
3133        // 0deg = headwind, 90deg = from the right (McCoy wind-FROM convention, matching
3134        // WindSock); wind enters drag via velocity - wind. Used when no segmented wind.
3135        // MBA-728: no shear/no segments here, so vertical_speed passes straight through
3136        // (there is no horizontal-only scaling step on this path).
3137        let wind_vector = crate::wind::wind_vector(
3138            self.wind.speed,
3139            self.wind.direction,
3140            self.wind.vertical_speed,
3141        );
3142
3143        // Mach-transition distances for the sampled-output flags (see solve_euler/solve_rk4).
3144        let mut transonic_distances: Vec<f64> = Vec::new();
3145        let mut mach_transitions = MachTransitionTracker::default();
3146
3147        // Pitch-damping / precession diagnostics (MBA-966). Previously only the
3148        // Euler and fixed-RK4 solvers tracked these, so the default adaptive
3149        // RK45 path always reported null even with --enable-pitch-damping /
3150        // --enable-precession set. Mirror the RK4 tracking here.
3151        let mut min_pitch_damping = f64::INFINITY;
3152        let mut transonic_mach: Option<f64> = None;
3153        let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
3154            self.inputs.bullet_model.as_deref().unwrap_or("default"),
3155        );
3156        let mut angular_state = if self.inputs.enable_precession_nutation {
3157            Some(AngularState {
3158                pitch_angle: 0.001,
3159                yaw_angle: 0.001,
3160                pitch_rate: 0.0,
3161                yaw_rate: 0.0,
3162                precession_angle: 0.0,
3163                nutation_phase: 0.0,
3164            })
3165        } else {
3166            None
3167        };
3168        let mut max_yaw_angle = 0.0;
3169        let mut max_precession_angle = 0.0;
3170
3171        while position.x < self.max_range
3172            && position.y > self.inputs.ground_threshold
3173            && time < TRAJECTORY_TIME_LIMIT_S
3174        {
3175            // Store current point
3176            let velocity_magnitude = velocity.magnitude();
3177            let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
3178
3179            self.push_trajectory_point(
3180                &mut points,
3181                TrajectoryPoint {
3182                    time,
3183                    position,
3184                    velocity_magnitude,
3185                    kinetic_energy,
3186                    drag_coefficient: None,
3187                },
3188                &mut collapsed_steps,
3189            )?;
3190
3191            // Record Mach-transition distances (constant sea-level speed of sound, matching the
3192            // transonic_mach tracking). Each threshold is recorded once, in descending order.
3193            {
3194                let mach_here = if speed_of_sound > 0.0 {
3195                    velocity_magnitude / speed_of_sound
3196                } else {
3197                    0.0
3198                };
3199                mach_transitions.record_downward_crossings(
3200                    mach_here,
3201                    position.x,
3202                    &mut transonic_distances,
3203                );
3204            }
3205
3206            if position.y > max_height {
3207                max_height = position.y;
3208            }
3209
3210            // Pitch damping (RK45 solver) — track the minimum coefficient and the
3211            // Mach at which the projectile enters the transonic band (MBA-966).
3212            if self.inputs.enable_pitch_damping {
3213                let mach = velocity_magnitude / speed_of_sound;
3214                if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
3215                    transonic_mach = Some(mach);
3216                }
3217                let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
3218                if pitch_damping < min_pitch_damping {
3219                    min_pitch_damping = pitch_damping;
3220                }
3221            }
3222
3223            // Retry the same state until the embedded error estimate accepts the
3224            // candidate. No trajectory or angular state advances on rejection.
3225            let accepted_step = self.adaptive_rk45_step(
3226                &position,
3227                &velocity,
3228                dt,
3229                &wind_vector,
3230                (resolved_temp_c, resolved_press_hpa, base_ratio),
3231            );
3232            debug_assert!(
3233                accepted_step.error <= RK45_TOLERANCE || accepted_step.used_dt <= RK45_MIN_DT
3234            );
3235
3236            // Precession / nutation advances only after the translational step
3237            // is accepted, using that accepted interval rather than a rejected
3238            // trial's dt.
3239            if self.inputs.enable_precession_nutation {
3240                if let Some(ref mut state) = angular_state {
3241                    let params = self.precession_nutation_params(
3242                        velocity_magnitude,
3243                        air_density,
3244                        speed_of_sound,
3245                    );
3246
3247                    *state = calculate_combined_angular_motion(
3248                        &params,
3249                        state,
3250                        time,
3251                        accepted_step.used_dt,
3252                        0.001,
3253                    );
3254
3255                    if state.yaw_angle.abs() > max_yaw_angle {
3256                        max_yaw_angle = state.yaw_angle.abs();
3257                    }
3258                    if state.precession_angle.abs() > max_precession_angle {
3259                        max_precession_angle = state.precession_angle.abs();
3260                    }
3261                }
3262            }
3263
3264            position = accepted_step.position;
3265            velocity = accepted_step.velocity;
3266            time += accepted_step.used_dt;
3267            self.validate_integration_state(&position, &velocity, time)?;
3268
3269            // Adapt the step size for the NEXT iteration.
3270            dt = accepted_step.next_dt;
3271        }
3272
3273        // Ensure we have at least one point
3274        if points.is_empty() {
3275            return Err(BallisticsError::from("No trajectory points calculated"));
3276        }
3277
3278        // Shared MBA-968/MBA-1218 range-crossing interpolation for all solver modes.
3279        let termination = self.append_terminal_endpoint(
3280            &mut points,
3281            position,
3282            velocity,
3283            time,
3284            &mut max_height,
3285            &mut collapsed_steps,
3286        )?;
3287
3288        // MBA-1423: see the sibling solvers — fill the per-point Cd before the borrow below.
3289        self.annotate_drag_coefficients(&mut points, speed_of_sound);
3290
3291        let last_point = points.last().unwrap();
3292
3293        // Generate sampled trajectory points if enabled (shared helper, MBA-1403)
3294        let sampled_points =
3295            self.build_sampled_points(&points, max_height, transonic_distances, &mach_transitions)?;
3296
3297        Ok(TrajectoryResult {
3298            max_range: last_point.position.x, // X is downrange
3299            max_height,
3300            time_of_flight: last_point.time,
3301            impact_velocity: last_point.velocity_magnitude,
3302            impact_energy: last_point.kinetic_energy,
3303            projectile_mass_kg: self.inputs.bullet_mass,
3304            line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
3305            station_speed_of_sound_mps: speed_of_sound,
3306            termination,
3307            points,
3308            sampled_points,
3309            min_pitch_damping: if self.inputs.enable_pitch_damping {
3310                Some(min_pitch_damping)
3311            } else {
3312                None
3313            },
3314            transonic_mach,
3315            angular_state,
3316            max_yaw_angle: if self.inputs.enable_precession_nutation {
3317                Some(max_yaw_angle)
3318            } else {
3319                None
3320            },
3321            max_precession_angle: if self.inputs.enable_precession_nutation {
3322                Some(max_precession_angle)
3323            } else {
3324                None
3325            },
3326            aerodynamic_jump: aj_components,
3327            mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
3328            mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
3329            mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
3330        })
3331    }
3332
3333    fn adaptive_rk45_step(
3334        &self,
3335        position: &Vector3<f64>,
3336        velocity: &Vector3<f64>,
3337        initial_dt: f64,
3338        wind_vector: &Vector3<f64>,
3339        resolved_atmo: (f64, f64, f64),
3340    ) -> Rk45AcceptedStep {
3341        let mut trial_dt = initial_dt;
3342
3343        loop {
3344            let trial = self.rk45_step(
3345                position,
3346                velocity,
3347                trial_dt,
3348                wind_vector,
3349                RK45_TOLERANCE,
3350                resolved_atmo,
3351            );
3352            // A finite-but-extreme input or malformed optional curve can overflow an embedded
3353            // trial. Do not let a NaN suggested step poison `trial_dt` and retry forever: shrink
3354            // to the minimum step, return the non-finite trial there, and let the immediate
3355            // integration-state check turn it into a clean Err.
3356            let next_dt = if trial.suggested_dt.is_finite() {
3357                (RK45_SAFETY_FACTOR * trial.suggested_dt).clamp(RK45_MIN_DT, RK45_MAX_DT)
3358            } else {
3359                RK45_MIN_DT
3360            };
3361
3362            if trial.error <= RK45_TOLERANCE || trial_dt <= RK45_MIN_DT {
3363                return Rk45AcceptedStep {
3364                    position: trial.position,
3365                    velocity: trial.velocity,
3366                    used_dt: trial_dt,
3367                    next_dt,
3368                    error: trial.error,
3369                };
3370            }
3371
3372            trial_dt = next_dt;
3373        }
3374    }
3375
3376    fn rk45_step(
3377        &self,
3378        position: &Vector3<f64>,
3379        velocity: &Vector3<f64>,
3380        dt: f64,
3381        wind_vector: &Vector3<f64>,
3382        tolerance: f64,
3383        resolved_atmo: (f64, f64, f64), // (base_temp_c, base_press_hpa, base_ratio)
3384    ) -> Rk45Trial {
3385        // Dormand-Prince coefficients
3386        const A21: f64 = 1.0 / 5.0;
3387        const A31: f64 = 3.0 / 40.0;
3388        const A32: f64 = 9.0 / 40.0;
3389        const A41: f64 = 44.0 / 45.0;
3390        const A42: f64 = -56.0 / 15.0;
3391        const A43: f64 = 32.0 / 9.0;
3392        const A51: f64 = 19372.0 / 6561.0;
3393        const A52: f64 = -25360.0 / 2187.0;
3394        const A53: f64 = 64448.0 / 6561.0;
3395        const A54: f64 = -212.0 / 729.0;
3396        const A61: f64 = 9017.0 / 3168.0;
3397        const A62: f64 = -355.0 / 33.0;
3398        const A63: f64 = 46732.0 / 5247.0;
3399        const A64: f64 = 49.0 / 176.0;
3400        const A65: f64 = -5103.0 / 18656.0;
3401        const A71: f64 = 35.0 / 384.0;
3402        const A73: f64 = 500.0 / 1113.0;
3403        const A74: f64 = 125.0 / 192.0;
3404        const A75: f64 = -2187.0 / 6784.0;
3405        const A76: f64 = 11.0 / 84.0;
3406
3407        // 5th order coefficients
3408        const B1: f64 = 35.0 / 384.0;
3409        const B3: f64 = 500.0 / 1113.0;
3410        const B4: f64 = 125.0 / 192.0;
3411        const B5: f64 = -2187.0 / 6784.0;
3412        const B6: f64 = 11.0 / 84.0;
3413
3414        // 4th order coefficients for error estimation
3415        const B1_ERR: f64 = 5179.0 / 57600.0;
3416        const B3_ERR: f64 = 7571.0 / 16695.0;
3417        const B4_ERR: f64 = 393.0 / 640.0;
3418        const B5_ERR: f64 = -92097.0 / 339200.0;
3419        const B6_ERR: f64 = 187.0 / 2100.0;
3420        const B7_ERR: f64 = 1.0 / 40.0;
3421
3422        // Compute RK45 stages
3423        let k1_v = self.calculate_acceleration(position, velocity, wind_vector, resolved_atmo);
3424        let k1_p = *velocity;
3425
3426        let p2 = position + dt * A21 * k1_p;
3427        let v2 = velocity + dt * A21 * k1_v;
3428        let k2_v = self.calculate_acceleration(&p2, &v2, wind_vector, resolved_atmo);
3429        let k2_p = v2;
3430
3431        let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
3432        let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
3433        let k3_v = self.calculate_acceleration(&p3, &v3, wind_vector, resolved_atmo);
3434        let k3_p = v3;
3435
3436        let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
3437        let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
3438        let k4_v = self.calculate_acceleration(&p4, &v4, wind_vector, resolved_atmo);
3439        let k4_p = v4;
3440
3441        let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
3442        let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
3443        let k5_v = self.calculate_acceleration(&p5, &v5, wind_vector, resolved_atmo);
3444        let k5_p = v5;
3445
3446        let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
3447        let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
3448        let k6_v = self.calculate_acceleration(&p6, &v6, wind_vector, resolved_atmo);
3449        let k6_p = v6;
3450
3451        let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
3452        let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
3453        let k7_v = self.calculate_acceleration(&p7, &v7, wind_vector, resolved_atmo);
3454        let k7_p = v7;
3455
3456        // 5th order solution
3457        let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
3458        let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
3459
3460        // 4th order solution for error estimate
3461        let pos_err = position
3462            + dt * (B1_ERR * k1_p
3463                + B3_ERR * k3_p
3464                + B4_ERR * k4_p
3465                + B5_ERR * k5_p
3466                + B6_ERR * k6_p
3467                + B7_ERR * k7_p);
3468        let vel_err = velocity
3469            + dt * (B1_ERR * k1_v
3470                + B3_ERR * k3_v
3471                + B4_ERR * k4_v
3472                + B5_ERR * k5_v
3473                + B6_ERR * k6_v
3474                + B7_ERR * k7_v);
3475
3476        // Estimate error
3477        let error = cli_rk45_error_norm(position, velocity, &new_pos, &new_vel, &pos_err, &vel_err);
3478
3479        // Calculate new step size
3480        let dt_new = if error < tolerance {
3481            dt * (tolerance / error).powf(0.2).min(2.0)
3482        } else {
3483            dt * (tolerance / error).powf(0.25).max(0.1)
3484        };
3485
3486        Rk45Trial {
3487            position: new_pos,
3488            velocity: new_vel,
3489            suggested_dt: dt_new,
3490            error,
3491        }
3492    }
3493
3494    fn apply_cluster_bc_correction(&self, base_bc: f64, velocity_fps: f64) -> f64 {
3495        if let Some(ref cluster_bc) = self.cluster_bc {
3496            cluster_bc.apply_correction_for_drag_model(
3497                base_bc,
3498                self.inputs.caliber_inches,
3499                self.inputs.weight_grains,
3500                velocity_fps,
3501                self.inputs.bc_type,
3502            )
3503        } else {
3504            base_bc
3505        }
3506    }
3507
3508    fn calculate_acceleration(
3509        &self,
3510        position: &Vector3<f64>,
3511        velocity: &Vector3<f64>,
3512        wind_vector: &Vector3<f64>,
3513        resolved_atmo: (f64, f64, f64), // (base_temp_c, base_press_hpa, base_ratio) hoisted per-solve
3514    ) -> Vector3<f64> {
3515        // Resolve the wind at this point. Downrange-segmented wind (when supplied)
3516        // takes precedence and is sampled by downrange distance (position.x) per
3517        // step; otherwise altitude-dependent shear (if enabled); otherwise the
3518        // constant `wind_vector`. Segmented wind is not combined with shear (the
3519        // CLI/WASM front-ends reject that combination), so the order is safe.
3520        let actual_wind = if let Some(ref sock) = self.wind_sock {
3521            sock.vector_for_range_stateless(position.x)
3522        } else if self.inputs.enable_wind_shear {
3523            self.get_wind_at_altitude(position.y)
3524        } else {
3525            *wind_vector
3526        };
3527        let actual_wind =
3528            crate::derivatives::level_vector_to_shot_frame(actual_wind, self.inputs.shooting_angle);
3529
3530        let relative_velocity = velocity - actual_wind;
3531        let velocity_magnitude = relative_velocity.magnitude();
3532
3533        if velocity_magnitude < 0.001 {
3534            return self.gravity_acceleration();
3535        }
3536
3537        // MBA-1136 (rank 30): recompute the atmosphere at the LOCAL substep altitude instead of
3538        // holding the frozen station scalars for the whole flight. This mirrors what
3539        // derivatives.rs / fast_trajectory.rs already do, so all three solver families vary air
3540        // density AND speed of sound with altitude (matters on elevated / long-range shots; a
3541        // no-op at the shooter altitude, where the ratio-based density recovers the station value
3542        // exactly). base_* were resolved once per solve via resolved_atmosphere().
3543        //
3544        // `base_temp_c` / `base_press_hpa` are the STATION conditions that seed the local
3545        // atmosphere calculation below. Magnus dynamic stability consumes the resulting local
3546        // density rather than freezing a launch-density correction.
3547        let (base_temp_c, base_press_hpa, station_ratio) = resolved_atmo;
3548
3549        // MBA-1137: downrange-segmented atmosphere. When an AtmoSock is present, swap the BASE
3550        // (station-referenced) T/P/H tuple for the active zone selected by downrange distance
3551        // (position.x), recomputing the per-zone base density ratio via CIPM. That swapped base
3552        // then flows through the SAME altitude-lapse pipeline, so downrange zone selection and the
3553        // world-vertical altitude lapse compose — the zone sets the base density/humidity, and the
3554        // lapse multiplies on top of it (no double-count). When None, this is the resolved
3555        // single-station base.
3556        let (drag_base_temp_c, drag_base_press_hpa, drag_base_ratio, drag_humidity_percent) =
3557            if let Some(ref sock) = self.atmo_sock {
3558                let (zone_temp_c, zone_press_hpa, zone_humidity) = sock.atmo_for_range(position.x);
3559                let zone_base_ratio = crate::atmosphere::calculate_air_density_cimp(
3560                    zone_temp_c,
3561                    zone_press_hpa,
3562                    zone_humidity,
3563                ) / 1.225;
3564                (zone_temp_c, zone_press_hpa, zone_base_ratio, zone_humidity)
3565            } else {
3566                (
3567                    base_temp_c,
3568                    base_press_hpa,
3569                    station_ratio,
3570                    self.atmosphere.humidity,
3571                )
3572            };
3573        let local_alt = crate::atmosphere::shot_frame_altitude(
3574            self.atmosphere.altitude,
3575            position.x,
3576            position.y,
3577            self.inputs.shooting_angle,
3578        );
3579        let (air_density, speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
3580            local_alt,
3581            self.atmosphere.altitude,
3582            drag_base_temp_c,
3583            drag_base_press_hpa,
3584            drag_base_ratio,
3585            drag_humidity_percent,
3586        );
3587
3588        // Resolve the Cd and the retardation denominator through the shared resolver. MBA-1423
3589        // reports a per-point Cd derived from these same two values, so the number a consumer
3590        // charts cannot drift from the number actually flown here.
3591        let (cd, retard_denom) = self.drag_terms(velocity_magnitude, speed_of_sound);
3592
3593        // Convert velocity to fps (still used below outside the BC lookup).
3594        let velocity_fps = velocity_magnitude * 3.28084;
3595
3596        // Use proper ballistics retardation formula
3597        // This matches the proven formula from fast_trajectory.rs
3598        // The standard retardation factor converts Cd to drag deceleration
3599        // Note: velocity_fps already calculated above for BC segment lookup
3600        let cd_to_retard = crate::constants::CD_TO_RETARD;
3601        let standard_factor = cd * cd_to_retard;
3602        let density_scale = air_density / 1.225; // Scale relative to standard air (1.225 kg/m³)
3603
3604        // Drag acceleration in ft/s² then convert to m/s²
3605        let a_drag_ft_s2 =
3606            (velocity_fps * velocity_fps) * standard_factor * density_scale / retard_denom;
3607        let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; // ft/s² to m/s²
3608
3609        // Apply drag opposite to velocity direction
3610        let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
3611
3612        // Total acceleration = drag + gravity. `shooting_angle` rotates gravity into the shot
3613        // frame for inclined fire; at 0 deg this is the normal vertical-only gravity vector.
3614        let mut accel = drag_acceleration + self.gravity_acceleration();
3615
3616        // Coriolis (Earth rotation). McCoy frame: X=downrange, Y=vertical, Z=lateral,
3617        // azimuth 0 = North. McCoy frame: X=downrange, Y=vertical, Z=lateral.
3618        if self.inputs.enable_coriolis {
3619            if let Some(lat_deg) = self.inputs.latitude {
3620                let omega_earth = 7.2921159e-5_f64; // rad/s
3621                let lat = lat_deg.to_radians();
3622                let az = self.inputs.shot_azimuth; // compass bearing (0=N), NOT the aiming offset
3623                                                   // Earth's angular velocity in level downrange/up/lateral axes.
3624                                                   // Projecting Omega=(0, Ω cosφ, Ω sinφ) [local E,N,U] by azimuth gives
3625                                                   // a NEGATIVE lateral component:
3626                                                   // lateral = downrange × up points East for a North shot, and
3627                                                   // Omega·East = -Ω cosφ sin(az). The previous code dropped that sign.
3628                let omega = Vector3::new(
3629                    omega_earth * lat.cos() * az.cos(),  // X: downrange
3630                    omega_earth * lat.sin(),             // Y: vertical
3631                    -omega_earth * lat.cos() * az.sin(), // Z: lateral (MBA-938: corrected sign)
3632                );
3633                let omega = crate::derivatives::level_vector_to_shot_frame(
3634                    omega,
3635                    self.inputs.shooting_angle,
3636                );
3637                // Coriolis acceleration is the physical -2 Ω×v (MBA-938). The old +2 with
3638                // an "output-preserving relabel" justification produced left-ward drift for
3639                // a North shot in the Northern hemisphere; first principles (and the +Eötvös
3640                // lift for East shots) require -2 with the corrected omega above.
3641                accel += -2.0 * omega.cross(velocity);
3642            }
3643        }
3644
3645        // Magnus force (spinning projectile). SI units in this solver.
3646        // MBA-1134 (rank 35): the canonical empirical Litz spin-drift post-process
3647        // (apply_spin_drift) already captures the gyroscopic yaw-of-repose lateral, so the
3648        // explicit Magnus side force must NOT be added on top of it — otherwise the two lateral
3649        // models stack and double-count the drift. Suppress Magnus whenever Litz spin drift is
3650        // active. (The inverse is intentionally NOT done: Litz is not suppressed when Magnus is on.)
3651        if self.inputs.enable_magnus
3652            && !self.inputs.use_enhanced_spin_drift
3653            && self.inputs.bullet_diameter > 0.0
3654            && self.inputs.twist_rate > 0.0
3655        {
3656            let diameter_m = self.inputs.bullet_diameter;
3657            let (spin_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
3658                self.inputs.muzzle_velocity,
3659                velocity_magnitude,
3660                self.inputs.twist_rate,
3661                diameter_m,
3662            );
3663            // Mach and dynamic stability both use the LOCAL atmosphere recomputed above.
3664            let mach = velocity_magnitude / speed_of_sound;
3665
3666            // Imperial conversions for the stability / yaw-of-repose helpers.
3667            let d_in = self.inputs.bullet_diameter / 0.0254;
3668            let m_gr = self.inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
3669            let l_in = if self.inputs.bullet_length > 0.0 {
3670                self.inputs.bullet_length / 0.0254
3671            } else {
3672                // MBA-1135: mass-based length estimate (was a mass-blind 4.5-caliber default).
3673                let est_m = crate::stability::estimate_bullet_length_m(
3674                    self.inputs.bullet_diameter,
3675                    self.inputs.bullet_mass,
3676                );
3677                if est_m > 0.0 {
3678                    est_m / 0.0254
3679                } else {
3680                    4.5 * d_in
3681                }
3682            };
3683            // Use current-flight Sg with the muzzle-set spin. The helper back-calculates the
3684            // effective twist from fixed spin and current airspeed, so Sg and yaw of repose grow
3685            // downrange instead of remaining tied to launch conditions.
3686            let sg = crate::spin_drift::calculate_dynamic_stability(
3687                m_gr,
3688                velocity_magnitude,
3689                spin_rad_s,
3690                d_in,
3691                l_in,
3692                air_density,
3693            );
3694
3695            // Yaw of repose (radians); zero for unstable bullets (Sg <= 1).
3696            let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
3697                sg,
3698                velocity_magnitude,
3699                spin_rad_s,
3700                0.0, // crosswind handled elsewhere
3701                0.0, // pitch rate not tracked
3702                air_density,
3703                d_in,
3704                l_in,
3705                m_gr,
3706                mach,
3707                "match",
3708                false,
3709            );
3710
3711            // Proper McCoy Magnus FORCE: F = q S C_Npa (pd/2V) sin(alpha_R).
3712            let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
3713            let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
3714            let magnus_force = 0.5
3715                * air_density
3716                * velocity_magnitude.powi(2)
3717                * area
3718                * c_np
3719                * spin_param
3720                * yaw_rad.sin();
3721
3722            // The yaw of repose is lateral, so its Magnus force follows gravity projected normal
3723            // to flight (down for right-hand twist). Lateral yaw lift belongs to the separate Litz
3724            // spin-drift model and must not be synthesized from this Magnus magnitude.
3725            if magnus_force.abs() > 1e-12 {
3726                if let Some(dir) = crate::derivatives::yaw_of_repose_magnus_direction(
3727                    relative_velocity,
3728                    self.gravity_acceleration(),
3729                    self.inputs.is_twist_right,
3730                ) {
3731                    accel += (magnus_force / self.inputs.bullet_mass) * dir;
3732                }
3733            }
3734        }
3735
3736        accel
3737    }
3738
3739    /// The reference drag coefficient and the retardation denominator this solver divides by,
3740    /// at one speed.
3741    ///
3742    /// This is the single place the two are resolved. `calculate_acceleration` divides one by
3743    /// the other to produce drag; [`Self::effective_drag_coefficient`] recombines them into the
3744    /// projectile's own Cd for reporting (MBA-1423). Keeping both callers on one resolver is
3745    /// deliberate: a second copy of the BC-precedence ladder below is exactly the kind of
3746    /// duplicate that drifts silently once someone edits one of them.
3747    ///
3748    /// The denominator is a BC (lb/in²) for a G-model and for a custom table flown as a
3749    /// reference curve (`custom_drag_reference`), and the sectional density for a custom table
3750    /// flown as the projectile's own curve, because that table already supplies the
3751    /// projectile's actual Cd (see [`BallisticInputs::custom_drag_denominator`]).
3752    fn drag_terms(&self, velocity_magnitude: f64, speed_of_sound: f64) -> (f64, f64) {
3753        let cd = self.calculate_drag_coefficient(velocity_magnitude, speed_of_sound);
3754
3755        let velocity_fps = velocity_magnitude * 3.28084;
3756
3757        // Match the other solver families' BC precedence: enabled velocity-keyed segments first,
3758        // then legacy Mach-keyed segments, then the scalar BC. `use_bc_segments` gates velocity
3759        // tables, while explicit Mach segments remain active when it is false; derivatives.rs and
3760        // the fast solver preserve that legacy contract for callers that provide a Mach table.
3761        let (base_bc, bc_from_segments) = if let Some(segments) = self
3762            .inputs
3763            .bc_segments_data
3764            .as_ref()
3765            .filter(|segments| self.inputs.use_bc_segments && !segments.is_empty())
3766        {
3767            // Find matching segment for current velocity.
3768            (
3769                crate::bc_estimation::velocity_segment_bc(
3770                    velocity_fps,
3771                    segments,
3772                    self.inputs.bc_value,
3773                ),
3774                true,
3775            )
3776        } else if let Some(segments) = self
3777            .inputs
3778            .bc_segments
3779            .as_ref()
3780            .filter(|segments| !segments.is_empty())
3781        {
3782            (
3783                crate::derivatives::interpolated_bc(
3784                    velocity_magnitude / speed_of_sound,
3785                    segments,
3786                    Some(&self.inputs),
3787                ),
3788                true,
3789            )
3790        } else {
3791            (self.inputs.bc_value, false)
3792        };
3793
3794        // Segment tables already own the velocity-dependent BC shape. Stacking the empirical
3795        // cluster ladder on top would apply that shape twice (MBA-1175). Cluster correction is
3796        // therefore only a fallback for a scalar BC, regardless of which explicit segment
3797        // representation supplied the active value.
3798        let effective_bc = if bc_from_segments {
3799            base_bc
3800        } else {
3801            self.apply_cluster_bc_correction(base_bc, velocity_fps)
3802        };
3803        // The scalar BC is validated at the solve boundary. Retain a small denominator floor for
3804        // explicit segment tables, whose interpolated values are independent caller data.
3805        let effective_bc = effective_bc.max(1e-6);
3806
3807        // When a custom drag table is active, calculate_drag_coefficient returned the table's
3808        // Cd. For the projectile's own curve the retardation denominator must be the sectional
3809        // density (lb/in²), not a BC: Cd_own / SD == Cd_ref / BC. A reference curve divides by
3810        // the BC resolved above instead, band and all, exactly as a G-model does
3811        // (see BallisticInputs::custom_drag_denominator).
3812        let retard_denom = if self.inputs.custom_drag_table.is_some() {
3813            self.inputs.custom_drag_denominator(effective_bc)
3814        } else {
3815            effective_bc
3816        };
3817
3818        (cd, retard_denom)
3819    }
3820
3821    /// The projectile's own drag coefficient at one speed — the curve a shooter would compare
3822    /// against a measured CDM trace, not the reference-table Cd (MBA-1423).
3823    ///
3824    /// A G-model Cd describes the *standard* projectile, so reporting it directly would hand a
3825    /// consumer the same G7 curve no matter which bullet was flown. Scaling by the form factor
3826    /// `SD / BC` converts it to this projectile's actual Cd, from the identity that both
3827    /// descriptions must produce the same retardation:
3828    ///
3829    /// ```text
3830    /// Cd_own / SD == Cd_ref / BC   =>   Cd_own == Cd_ref * SD / BC
3831    /// ```
3832    ///
3833    /// Because the denominator comes from the same internal resolver the integrator divides by,
3834    /// a velocity- or Mach-segmented BC
3835    /// carries into the result and its band steps appear in the reported curve. The same
3836    /// expression is correct for both readings of a custom drag table: for the projectile's own
3837    /// curve the denominator *is* the sectional density and the scale factor collapses to 1,
3838    /// and for a reference curve (`custom_drag_reference`) the denominator is the resolved BC,
3839    /// exactly as for a G-model.
3840    ///
3841    /// Returns `None` when the projectile's mass or diameter is unavailable, since sectional
3842    /// density — and therefore the projectile's own Cd — is undefined without both.
3843    pub fn effective_drag_coefficient(
3844        &self,
3845        velocity_magnitude: f64,
3846        speed_of_sound: f64,
3847    ) -> Option<f64> {
3848        if !velocity_magnitude.is_finite() || speed_of_sound <= 1e-9 {
3849            return None;
3850        }
3851        let sectional_density = self.inputs.sectional_density_lb_in2()?;
3852        let (cd, retard_denom) = self.drag_terms(velocity_magnitude, speed_of_sound);
3853        if retard_denom <= 0.0 {
3854            return None;
3855        }
3856        let effective = cd * sectional_density / retard_denom;
3857        effective.is_finite().then_some(effective)
3858    }
3859
3860    /// Fill every point's [`TrajectoryPoint::drag_coefficient`] in one pass (MBA-1423).
3861    ///
3862    /// Deliberately a pass over the finished vector rather than a value set where points are
3863    /// built: this solver constructs them in several places — terminal interpolation plus one
3864    /// loop per integrator family — and a pass covers a site added later for free.
3865    ///
3866    /// `speed_of_sound` must be the station value the result reports Mach against, so that a
3867    /// consumer plotting Cd against Mach gets a self-consistent pair. Feeding each step's local
3868    /// speed of sound instead would attribute a Cd to a Mach the document never shows.
3869    fn annotate_drag_coefficients(&self, points: &mut [TrajectoryPoint], speed_of_sound: f64) {
3870        for point in points.iter_mut() {
3871            point.drag_coefficient =
3872                self.effective_drag_coefficient(point.velocity_magnitude, speed_of_sound);
3873        }
3874    }
3875
3876    fn calculate_drag_coefficient(&self, velocity: f64, speed_of_sound: f64) -> f64 {
3877        let mach = velocity / speed_of_sound;
3878
3879        // MBA-940: a user-supplied custom drag table is the final Cd, used as-is — no G-model
3880        // lookup, no transonic shape correction, no form factor. The supplied curve already
3881        // encodes the projectile's true drag, so applying those would distort/double-count it.
3882        if let Some(ref table) = self.inputs.custom_drag_table {
3883            // MBA-1357: cd_scale is a single whole-curve drag multiplier applied here, at the
3884            // Cd lookup site. The Mach-keyed DSF table (truing_dsf.rs) is a SEPARATE, drop-only
3885            // post-processing correction applied to a solved TrajectoryResult's points after
3886            // integration finishes — it never touches this Cd/drag-force computation.
3887            return table.interpolate(mach) * self.inputs.cd_scale;
3888        }
3889
3890        // A published/measured BC already contains the projectile form factor (BC = SD / i).
3891        // Multiplying reference Cd by a second name-derived factor double-counts shape.
3892        crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type)
3893    }
3894}
3895
3896// Monte Carlo parameters
3897#[derive(Debug, Clone)]
3898pub struct MonteCarloParams {
3899    pub num_simulations: usize,
3900    pub velocity_std_dev: f64,
3901    pub angle_std_dev: f64,
3902    pub bc_std_dev: f64,
3903    pub wind_speed_std_dev: f64,
3904    pub target_distance: Option<f64>,
3905    pub base_wind_speed: f64,
3906    pub base_wind_direction: f64,
3907    pub azimuth_std_dev: f64, // Horizontal aiming variation in radians
3908}
3909
3910impl Default for MonteCarloParams {
3911    fn default() -> Self {
3912        Self {
3913            num_simulations: 1000,
3914            velocity_std_dev: 1.0,
3915            angle_std_dev: 0.001,
3916            bc_std_dev: 0.01,
3917            wind_speed_std_dev: 1.0,
3918            target_distance: None,
3919            base_wind_speed: 0.0,
3920            base_wind_direction: 0.0,
3921            azimuth_std_dev: 0.001, // Default horizontal spread ~0.057 degrees
3922        }
3923    }
3924}
3925
3926// Monte Carlo results
3927#[derive(Debug, Clone)]
3928pub struct MonteCarloResults {
3929    pub ranges: Vec<f64>,
3930    pub impact_velocities: Vec<f64>,
3931    /// Deviations from the baseline point of aim at the target plane.
3932    ///
3933    /// A sample that falls short of the plane is encoded as
3934    /// `(0, TARGET_NOT_REACHED_SENTINEL_M, 0)` so it remains aligned with
3935    /// `ranges` and `impact_velocities` and still counts as a miss.
3936    pub impact_positions: Vec<Vector3<f64>>,
3937}
3938
3939/// Default hit-zone radius (meters) around the point of aim at the target plane — a 30 cm
3940/// circle. Shared by the CLI, FFI, and WASM so "hit probability" means the same thing everywhere.
3941pub const DEFAULT_HIT_RADIUS_M: f64 = 0.3;
3942
3943/// Vertical-position marker for a Monte Carlo sample that never reached the target plane.
3944///
3945/// The marker preserves the equal-length result-vector and C-ABI contract. Exclude marked
3946/// positions from target-plane dispersion statistics, but keep them in the denominator for hit
3947/// probability because they are definite misses.
3948pub const TARGET_NOT_REACHED_SENTINEL_M: f64 = -1.0e9;
3949
3950impl MonteCarloResults {
3951    /// Whether an encoded impact position represents a finite arrival at the target plane.
3952    pub fn position_reached_target(position: &Vector3<f64>) -> bool {
3953        position.iter().all(|component| component.is_finite())
3954            && position.y != TARGET_NOT_REACHED_SENTINEL_M
3955    }
3956
3957    /// Number of recorded simulations that reached the target plane.
3958    pub fn target_arrival_count(&self) -> usize {
3959        self.impact_positions
3960            .iter()
3961            .filter(|position| Self::position_reached_target(position))
3962            .count()
3963    }
3964
3965    /// Fraction of recorded simulations that fell short of (or otherwise failed to produce a
3966    /// finite position at) the target plane.
3967    pub fn target_shortfall_fraction(&self) -> f64 {
3968        if self.impact_positions.is_empty() {
3969            return 0.0;
3970        }
3971        (self.impact_positions.len() - self.target_arrival_count()) as f64
3972            / self.impact_positions.len() as f64
3973    }
3974
3975    /// Upper-median radial miss among samples that reached the target plane.
3976    ///
3977    /// This preserves the CLI's historical radial-to-baseline "CEP (approx)" convention while
3978    /// preventing the finite target-shortfall marker from becoming the median (MBA-1159).
3979    /// Returns `None` when no recorded simulation reached the target plane.
3980    pub fn target_plane_cep(&self) -> Option<f64> {
3981        let mut radial_misses: Vec<f64> = self
3982            .impact_positions
3983            .iter()
3984            .filter(|position| Self::position_reached_target(position))
3985            .map(Vector3::norm)
3986            .filter(|miss| miss.is_finite())
3987            .collect();
3988        radial_misses.sort_by(f64::total_cmp);
3989        if radial_misses.is_empty() {
3990            None
3991        } else {
3992            Some(radial_misses[radial_misses.len() / 2])
3993        }
3994    }
3995
3996    /// Fraction of simulations whose impact at the target plane lands within `hit_radius_m`
3997    /// of the point of aim. `impact_positions` are deviations from the baseline at the target
3998    /// plane (the downrange component is 0), so the vector norm is the radial miss distance.
3999    /// Samples that fall short of the target remain in the denominator and count as misses.
4000    /// Returns 0.0 when there are no samples.
4001    ///
4002    /// Single source of truth for hit probability — previously the CLI used a range-precision
4003    /// notion and the FFI a position notion with a redundant clause, so they disagreed.
4004    pub fn hit_probability(&self, hit_radius_m: f64) -> f64 {
4005        if self.impact_positions.is_empty() {
4006            return 0.0;
4007        }
4008        let hits = self
4009            .impact_positions
4010            .iter()
4011            .filter(|position| Self::position_is_hit(position, hit_radius_m))
4012            .count();
4013        hits as f64 / self.impact_positions.len() as f64
4014    }
4015
4016    /// Whether one encoded impact position is a hit inside `hit_radius_m` of the point of aim.
4017    ///
4018    /// The single source of truth for the hit predicate itself, as
4019    /// [`hit_probability`](Self::hit_probability) is for the ratio. Both that method and the
4020    /// adaptive driver ([`run_monte_carlo_adaptive_seeded`]) call this, so "hit" means the
4021    /// same thing whether it was counted over a retained result vector or streamed one trial
4022    /// at a time and then discarded.
4023    ///
4024    /// A sample that never reached the target plane is a definite miss, never a hit: the
4025    /// shortfall marker's norm is enormous, but the marker is excluded explicitly rather than
4026    /// left to depend on that.
4027    pub fn position_is_hit(position: &Vector3<f64>, hit_radius_m: f64) -> bool {
4028        Self::position_reached_target(position) && position.norm() < hit_radius_m
4029    }
4030
4031    /// Fixed-`n` Wilson companion to [`hit_probability`](Self::hit_probability): the same point
4032    /// estimate, plus a confidence interval for it and the `n` behind it.
4033    ///
4034    /// Returns `(p_hat, (ci_low, ci_high), n)`. This is a *composition* of two existing
4035    /// functions, not a second derivation of either: `p_hat` is
4036    /// [`hit_probability`](Self::hit_probability) called directly, and the bounds are
4037    /// [`wilson_interval`] over the hit count and `impact_positions.len()`. The denominator is
4038    /// therefore identical -- samples that fell short of the target plane are counted as
4039    /// misses in `n`, exactly as `hit_probability` counts them -- and the two can never drift
4040    /// apart under maintenance.
4041    ///
4042    /// # When this is the wrong interval
4043    ///
4044    /// A fixed-`n` interval is only valid if `n` was fixed *before* the data was seen. Use
4045    /// this for a run of a pre-declared `num_simulations`. If the sample count was instead
4046    /// chosen by watching the results -- stopping once the interval looked tight enough --
4047    /// this interval has no coverage guarantee at all; use [`run_monte_carlo_adaptive_seeded`],
4048    /// whose interval is anytime-valid, for that.
4049    ///
4050    /// With no samples the result is `(0.0, (0.0, 1.0), 0)`: the `0.0` that `hit_probability`
4051    /// reports, alongside the total-ignorance interval `wilson_interval` reports at `n == 0`.
4052    pub fn hit_probability_wilson(
4053        &self,
4054        hit_radius_m: f64,
4055        level: ConfidenceLevel,
4056    ) -> (f64, (f64, f64), u64) {
4057        let trials = self.impact_positions.len() as u64;
4058        let hits = self
4059            .impact_positions
4060            .iter()
4061            .filter(|position| Self::position_is_hit(position, hit_radius_m))
4062            .count() as u64;
4063        (
4064            self.hit_probability(hit_radius_m),
4065            wilson_interval(hits, trials, level),
4066            trials,
4067        )
4068    }
4069
4070    /// Fraction of simulations whose impact at the target plane lands within the axis-aligned
4071    /// rectangle `width_m` (lateral, Z) x `height_m` (vertical, Y) centered on the point of aim
4072    /// — i.e. within `width_m / 2` of center laterally AND `height_m / 2` of center vertically.
4073    ///
4074    /// This is the WEZ (Weapon Employment Zone, MBA-1317) counterpart of [`hit_probability`]'s
4075    /// circular radius for rectangular target sizes (e.g. an 18"x30" steel plate). Samples that
4076    /// fall short of the target plane remain in the denominator and count as misses, matching
4077    /// `hit_probability`'s convention. Returns 0.0 when there are no samples or when either
4078    /// dimension is non-positive.
4079    ///
4080    /// [`hit_probability`]: Self::hit_probability
4081    pub fn rect_hit_probability(&self, width_m: f64, height_m: f64) -> f64 {
4082        let dimensions_invalid =
4083            width_m.is_nan() || width_m <= 0.0 || height_m.is_nan() || height_m <= 0.0;
4084        if self.impact_positions.is_empty() || dimensions_invalid {
4085            return 0.0;
4086        }
4087        let half_width = width_m / 2.0;
4088        let half_height = height_m / 2.0;
4089        let hits = self
4090            .impact_positions
4091            .iter()
4092            .filter(|position| {
4093                Self::position_reached_target(position)
4094                    && position.z.abs() <= half_width
4095                    && position.y.abs() <= half_height
4096            })
4097            .count();
4098        hits as f64 / self.impact_positions.len() as f64
4099    }
4100}
4101
4102fn wind_from_signed_speed_sample(
4103    signed_speed: f64,
4104    sampled_direction: f64,
4105    vertical_speed: f64,
4106) -> WindConditions {
4107    // The base wind's vertical component rides along un-dispersed: vertical wind is a
4108    // systematic input (MBA-728), not a sampled dispersion source. Dropping it here
4109    // would make every per-sample solve disagree with the baseline solve by the whole
4110    // vertical deflection — a phantom bias in the MC statistics.
4111    if signed_speed < 0.0 {
4112        WindConditions {
4113            speed: -signed_speed,
4114            direction: sampled_direction + std::f64::consts::PI,
4115            vertical_speed,
4116        }
4117    } else {
4118        WindConditions {
4119            speed: signed_speed,
4120            direction: sampled_direction,
4121            vertical_speed,
4122        }
4123    }
4124}
4125
4126struct MonteCarloWindSampler {
4127    speed: rand_distr::Normal<f64>,
4128    direction: rand_distr::Normal<f64>,
4129    /// Base wind's vertical component, carried into every sample un-dispersed.
4130    vertical_speed: f64,
4131}
4132
4133impl MonteCarloWindSampler {
4134    fn new(
4135        base_wind: &WindConditions,
4136        wind_speed_std_dev: f64,
4137        wind_direction_std_dev: f64,
4138    ) -> Result<Self, BallisticsError> {
4139        use rand_distr::Normal;
4140
4141        if !wind_direction_std_dev.is_finite() || wind_direction_std_dev < 0.0 {
4142            return Err("Wind direction standard deviation must be finite and non-negative".into());
4143        }
4144
4145        let speed = Normal::new(base_wind.speed, wind_speed_std_dev)
4146            .map_err(|e| format!("Invalid wind speed distribution: {e}"))?;
4147        let direction = Normal::new(base_wind.direction, wind_direction_std_dev)
4148            .map_err(|e| format!("Invalid wind direction distribution: {e}"))?;
4149        Ok(Self {
4150            speed,
4151            direction,
4152            vertical_speed: base_wind.vertical_speed,
4153        })
4154    }
4155
4156    fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> WindConditions {
4157        use rand_distr::Distribution;
4158
4159        wind_from_signed_speed_sample(
4160            self.speed.sample(rng),
4161            self.direction.sample(rng),
4162            self.vertical_speed,
4163        )
4164    }
4165}
4166
4167/// One Monte Carlo trial's recorded outcome: the three quantities the legacy result vectors
4168/// hold, for one sample.
4169#[derive(Debug, Clone, Copy)]
4170struct TrialOutcome {
4171    /// Ground-impact range (m), recorded for every sample regardless of the target plane
4172    /// (MBA-967).
4173    range: f64,
4174    /// Ground-impact velocity (m/s), likewise recorded for every sample.
4175    impact_velocity: f64,
4176    /// Deviation from the baseline point of aim at the target plane, or
4177    /// `(0, TARGET_NOT_REACHED_SENTINEL_M, 0)` when this sample fell short of it.
4178    impact_position: Vector3<f64>,
4179}
4180
4181/// The per-trial context of a Monte Carlo run: the baseline solve, the resolved target plane
4182/// and the five input distributions, all built once and reused by every trial.
4183///
4184/// Extracted verbatim from the body of
4185/// [`run_monte_carlo_with_wind_and_direction_std_dev_using_rng`] so the adaptive driver
4186/// ([`run_monte_carlo_adaptive_seeded`]) runs the *identical* trial rather than a second copy
4187/// of it that could drift out of agreement.
4188///
4189/// # The draw order is a compatibility contract
4190///
4191/// [`Self::sample_one_trial`] consumes exactly six draws from the caller's RNG, in this order:
4192/// muzzle-velocity delta, muzzle angle, ballistic coefficient, azimuth, wind speed, wind
4193/// direction. Changing the count or the order shifts every subsequent sample of an
4194/// already-seeded run, so `monte_carlo_seeded_tests::legacy_seeded_estimates_are_pinned_bit_for_bit`
4195/// pins a committed seed's output bit-for-bit as the check on it.
4196///
4197/// The distributions are also *constructed* in the order the original inline code constructed
4198/// them. That is not cosmetic: construction is where an invalid standard deviation is
4199/// rejected, so the order decides which malformed parameter a caller is told about first.
4200struct MonteCarloTrialSampler {
4201    base_inputs: BallisticInputs,
4202    atmosphere: AtmosphericConditions,
4203    solver_max_range: f64,
4204    /// Resolved target plane: `params.target_distance` when the caller gave one, else the
4205    /// baseline solve's own max range.
4206    target_distance: f64,
4207    /// Baseline (undispersed) position at `target_distance`. Every trial's impact position is
4208    /// a deviation from this point.
4209    baseline_at_target: Vector3<f64>,
4210    velocity_delta_dist: rand_distr::Normal<f64>,
4211    angle_dist: rand_distr::Normal<f64>,
4212    bc_dist: rand_distr::Normal<f64>,
4213    wind_sampler: MonteCarloWindSampler,
4214    azimuth_dist: rand_distr::Normal<f64>,
4215}
4216
4217impl MonteCarloTrialSampler {
4218    /// Solves the baseline trajectory and builds the input distributions.
4219    ///
4220    /// Consumes no randomness -- every RNG draw happens in [`Self::sample_one_trial`].
4221    fn new(
4222        base_inputs: BallisticInputs,
4223        base_wind: &WindConditions,
4224        params: &MonteCarloParams,
4225        wind_direction_std_dev: f64,
4226    ) -> Result<Self, BallisticsError> {
4227        use rand_distr::Normal;
4228
4229        let atmosphere = AtmosphericConditions {
4230            temperature: base_inputs.temperature,
4231            pressure: base_inputs.pressure,
4232            humidity: base_inputs.humidity_percent(),
4233            altitude: base_inputs.altitude,
4234        };
4235        let target_hint = params
4236            .target_distance
4237            .unwrap_or(base_inputs.target_distance);
4238        let solver_max_range = target_hint.max(1000.0) * 2.0;
4239
4240        // First, calculate baseline trajectory with no variations
4241        let mut baseline_solver =
4242            TrajectorySolver::new(base_inputs.clone(), base_wind.clone(), atmosphere.clone());
4243        baseline_solver.set_max_range(solver_max_range);
4244        let baseline_result = baseline_solver.solve()?;
4245
4246        // Determine target distance: use explicit target or baseline max range
4247        let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
4248
4249        // Get baseline position at target distance (interpolated)
4250        let baseline_at_target = baseline_result
4251            .position_at_range(target_distance)
4252            .ok_or("Could not interpolate baseline at target distance")?;
4253
4254        // Create normal distributions for variations
4255        // Sample muzzle velocity as a DELTA and apply it after TrajectorySolver::new resolves the
4256        // powder-temperature model. Sampling an absolute value here let a powder curve overwrite
4257        // every draw in the constructor, collapsing the requested dispersion to zero (MBA-1176).
4258        let velocity_delta_dist = Normal::new(0.0, params.velocity_std_dev)
4259            .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
4260        let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
4261            .map_err(|e| format!("Invalid angle distribution: {}", e))?;
4262        let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
4263            .map_err(|e| format!("Invalid BC distribution: {}", e))?;
4264        // Direction uncertainty is an independent angular quantity in radians. Do not derive it from
4265        // wind-speed uncertainty: meters/second cannot supply an angular standard deviation.
4266        let wind_sampler = MonteCarloWindSampler::new(
4267            base_wind,
4268            params.wind_speed_std_dev,
4269            wind_direction_std_dev,
4270        )?;
4271        let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
4272            .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
4273
4274        Ok(Self {
4275            base_inputs,
4276            atmosphere,
4277            solver_max_range,
4278            target_distance,
4279            baseline_at_target,
4280            velocity_delta_dist,
4281            angle_dist,
4282            bc_dist,
4283            wind_sampler,
4284            azimuth_dist,
4285        })
4286    }
4287
4288    /// Draws one trial: six RNG draws, one trajectory solve, one recorded outcome.
4289    ///
4290    /// `None` means the trial produced nothing to record -- either the solve failed, or it
4291    /// succeeded but the target plane could not be interpolated despite being within range (a
4292    /// defensive branch). Both were a bare `continue` in the original loop, i.e. the trial was
4293    /// dropped rather than counted as a miss, and callers must preserve that.
4294    ///
4295    /// All six draws happen *before* the solve, so a dropped trial still consumes exactly six
4296    /// draws and the RNG stream stays aligned with a run in which it succeeded.
4297    fn sample_one_trial<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> Option<TrialOutcome> {
4298        use rand_distr::Distribution;
4299
4300        // Create varied inputs
4301        let mut inputs = self.base_inputs.clone();
4302        let muzzle_velocity_delta = self.velocity_delta_dist.sample(&mut *rng);
4303        inputs.muzzle_angle = self.angle_dist.sample(&mut *rng);
4304        inputs.bc_value = self.bc_dist.sample(&mut *rng).max(0.01);
4305        inputs.azimuth_angle = self.azimuth_dist.sample(&mut *rng); // Add horizontal variation
4306
4307        // Create varied wind (now based on base wind conditions)
4308        let wind = self.wind_sampler.sample(&mut *rng);
4309
4310        // Run trajectory. The sampled velocity delta is applied to the SOLVER's inputs, after
4311        // TrajectorySolver::new has resolved any powder-temperature model -- see the MBA-1176
4312        // note on `velocity_delta_dist` above.
4313        let mut solver = TrajectorySolver::new(inputs, wind, self.atmosphere.clone());
4314        solver.inputs.muzzle_velocity =
4315            (solver.inputs.muzzle_velocity + muzzle_velocity_delta).max(0.0);
4316        solver.set_max_range(self.solver_max_range);
4317        // Skip failed simulations
4318        let result = solver.solve().ok()?;
4319
4320        // MBA-967: do NOT skip samples that fall short of the target. range/velocity are
4321        // recorded at GROUND IMPACT for EVERY sample, so "Mean Range" is the ground-impact
4322        // distribution — independent of target_distance and consistent with `trajectory`.
4323        // All three result vectors still grow together per sample, so the equal-length FFI
4324        // ABI (exposed under one count) is preserved.
4325        let impact_position = if result.max_range < self.target_distance {
4326            // This sample never reached the target plane -> definite miss. Keep the
4327            // encoded miss finite but far outside any practical target radius.
4328            Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)
4329        } else {
4330            // defensive: drop the whole sample (keeps the result vectors aligned)
4331            let pos_at_target = result.position_at_range(self.target_distance)?;
4332            // Deviation from baseline at the SAME target distance (McCoy): X = downrange
4333            // (0 here), Y = vertical (elevation), Z = lateral (windage). Muzzle-angle
4334            // sampling already models vertical pointing dispersion, so do not add a
4335            // second independent vertical pointing draw here.
4336            Vector3::new(
4337                0.0,
4338                pos_at_target.y - self.baseline_at_target.y,
4339                pos_at_target.z - self.baseline_at_target.z,
4340            )
4341        };
4342
4343        Some(TrialOutcome {
4344            range: result.max_range,
4345            impact_velocity: result.impact_velocity,
4346            impact_position,
4347        })
4348    }
4349}
4350
4351// Run Monte Carlo simulation (backwards compatibility)
4352pub fn run_monte_carlo(
4353    base_inputs: BallisticInputs,
4354    params: MonteCarloParams,
4355) -> Result<MonteCarloResults, BallisticsError> {
4356    run_monte_carlo_with_direction_std_dev(base_inputs, params, 0.0)
4357}
4358
4359/// Run Monte Carlo with an independent wind-direction standard deviation in radians.
4360///
4361/// The older [`run_monte_carlo`] entry point remains source compatible and delegates here with
4362/// zero direction uncertainty.
4363pub fn run_monte_carlo_with_direction_std_dev(
4364    base_inputs: BallisticInputs,
4365    params: MonteCarloParams,
4366    wind_direction_std_dev: f64,
4367) -> Result<MonteCarloResults, BallisticsError> {
4368    let base_wind = WindConditions {
4369        speed: params.base_wind_speed,
4370        direction: params.base_wind_direction,
4371        vertical_speed: 0.0,
4372    };
4373    run_monte_carlo_with_wind_and_direction_std_dev(
4374        base_inputs,
4375        base_wind,
4376        params,
4377        wind_direction_std_dev,
4378    )
4379}
4380
4381// Run Monte Carlo simulation with wind
4382pub fn run_monte_carlo_with_wind(
4383    base_inputs: BallisticInputs,
4384    base_wind: WindConditions,
4385    params: MonteCarloParams,
4386) -> Result<MonteCarloResults, BallisticsError> {
4387    run_monte_carlo_with_wind_and_direction_std_dev(base_inputs, base_wind, params, 0.0)
4388}
4389
4390/// Run Monte Carlo with explicit base wind and independent direction uncertainty in radians.
4391///
4392/// The older [`run_monte_carlo_with_wind`] entry point delegates here with zero direction
4393/// uncertainty, preserving its API while removing the former speed-to-angle unit conflation.
4394pub fn run_monte_carlo_with_wind_and_direction_std_dev(
4395    base_inputs: BallisticInputs,
4396    base_wind: WindConditions,
4397    params: MonteCarloParams,
4398    wind_direction_std_dev: f64,
4399) -> Result<MonteCarloResults, BallisticsError> {
4400    let mut rng = rand::rng();
4401    run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
4402        base_inputs,
4403        base_wind,
4404        params,
4405        wind_direction_std_dev,
4406        &mut rng,
4407    )
4408}
4409
4410/// Run Monte Carlo with an explicit PRNG seed, for deterministic/reproducible output.
4411///
4412/// Otherwise identical to [`run_monte_carlo_with_wind_and_direction_std_dev`], which draws
4413/// from the process-global RNG instead. Intended for callers that need repeatable results
4414/// across runs — e.g. tests, or a WEZ (Weapon Employment Zone, MBA-1317) sweep that a caller
4415/// wants to reproduce exactly while tuning target size or wind-call error.
4416pub fn run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4417    base_inputs: BallisticInputs,
4418    base_wind: WindConditions,
4419    params: MonteCarloParams,
4420    wind_direction_std_dev: f64,
4421    seed: u64,
4422) -> Result<MonteCarloResults, BallisticsError> {
4423    use rand::{rngs::StdRng, SeedableRng};
4424    let mut rng = StdRng::seed_from_u64(seed);
4425    run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
4426        base_inputs,
4427        base_wind,
4428        params,
4429        wind_direction_std_dev,
4430        &mut rng,
4431    )
4432}
4433
4434fn run_monte_carlo_with_wind_and_direction_std_dev_using_rng<R: rand::Rng + ?Sized>(
4435    base_inputs: BallisticInputs,
4436    base_wind: WindConditions,
4437    params: MonteCarloParams,
4438    wind_direction_std_dev: f64,
4439    rng: &mut R,
4440) -> Result<MonteCarloResults, BallisticsError> {
4441    let mut ranges = Vec::new();
4442    let mut impact_velocities = Vec::new();
4443    let mut impact_positions = Vec::new();
4444
4445    let sampler =
4446        MonteCarloTrialSampler::new(base_inputs, &base_wind, &params, wind_direction_std_dev)?;
4447
4448    for _ in 0..params.num_simulations {
4449        // A dropped trial pushes nothing at all, exactly as the original `continue` arms did,
4450        // so all three vectors stay equal-length (the FFI exposes them under one count) --
4451        // they are simply shorter than `num_simulations`.
4452        if let Some(outcome) = sampler.sample_one_trial(rng) {
4453            ranges.push(outcome.range);
4454            impact_velocities.push(outcome.impact_velocity);
4455            impact_positions.push(outcome.impact_position);
4456        }
4457    }
4458
4459    if ranges.is_empty() {
4460        return Err("No successful simulations".into());
4461    }
4462
4463    Ok(MonteCarloResults {
4464        ranges,
4465        impact_velocities,
4466        impact_positions,
4467    })
4468}
4469
4470/// Schema version of [`AdaptiveMcReportV1`]'s JSON wire form.
4471pub const MC_ADAPTIVE_SCHEMA_VERSION_V1: u32 = 1;
4472
4473/// The value [`AdaptiveMcReportV1::method`] always carries: Robbins' beta-binomial mixture
4474/// confidence sequence, revision 1.
4475///
4476/// Named so a consumer can branch on the estimator rather than on the schema version -- the
4477/// schema can gain fields without the statistics changing, and the statistics could be
4478/// replaced without the field set changing.
4479pub const MC_ADAPTIVE_METHOD_V1: &str = "anytime_beta_binomial_mixture_cs_v1";
4480
4481/// The four disclosures every [`AdaptiveMcReportV1`] carries, in order.
4482///
4483/// These are stated in the payload itself rather than only in this crate's documentation,
4484/// because the payload is what travels: a consumer that renders a hit probability and an
4485/// interval has everything it needs to over-claim, and these four sentences are what stop it.
4486/// In order they disclose (0) that the interval covers sampling error only, not model error;
4487/// (1) that stopping early is legitimate here specifically because the sequence is
4488/// anytime-valid; (2) that the input dispersions are independent normals with no modeled
4489/// correlation; and (3) how the continuous statistics were accumulated and which denominator
4490/// the hit probability uses.
4491///
4492/// Pinned per-index, full-string, by
4493/// `monte_carlo_seeded_tests::adaptive_report_carries_schema_method_and_all_four_assumptions`.
4494pub const MC_ADAPTIVE_ASSUMPTIONS_V1: [&str; 4] = [
4495    "Sampling uncertainty only: intervals cover Monte Carlo sampling error, not model error in the trajectory solver or its inputs.",
4496    "Anytime-valid stopping: the beta-binomial mixture confidence sequence keeps its coverage guarantee despite stopping the moment the target half-width is met.",
4497    "Input dispersions are the independent normal distributions declared in MonteCarloParams; correlations between inputs are not modeled.",
4498    "Continuous statistics are streaming Welford moments over trials that reached the target plane, reported with sample (n-1) standard deviations; hit probability's denominator includes all trials.",
4499];
4500
4501/// How precise the hit probability has to get, and how much sampling may be spent getting
4502/// there.
4503///
4504/// [`Default`] reproduces the legacy fixed-count run's sample size as the *floor*
4505/// (`min_samples: 1_000`, which is [`MonteCarloParams::default`]'s `num_simulations`) and then
4506/// keeps going, in batches, until the interval is tight enough or the ceiling is hit.
4507#[derive(Debug, Clone)]
4508pub struct McConvergence {
4509    /// Confidence level of the reported interval.
4510    pub level: ConfidenceLevel,
4511    /// Stop once the confidence sequence's half-width is at or below this, in probability
4512    /// units. `0.02` means "the hit probability is known to about +-2 percentage points".
4513    pub target_half_width: f64,
4514    /// Never stop before this many trials, even if the interval is already tight. Guards
4515    /// against an early run of all-hits or all-misses producing a narrow interval off a
4516    /// handful of trials.
4517    pub min_samples: u64,
4518    /// Never run more than this many trials, even if the interval is still wide. This is what
4519    /// bounds the wall time of an unreachable `target_half_width`.
4520    pub max_samples: u64,
4521    /// How many trials to run between convergence checks. Larger batches check less often (a
4522    /// `bounds()` call is two 200-step bisections); smaller batches can stop sooner.
4523    pub batch_size: u64,
4524}
4525
4526impl Default for McConvergence {
4527    fn default() -> Self {
4528        Self {
4529            level: ConfidenceLevel::P95,
4530            target_half_width: 0.02,
4531            min_samples: 1_000,
4532            max_samples: 100_000,
4533            batch_size: 500,
4534        }
4535    }
4536}
4537
4538impl McConvergence {
4539    /// Rejects a configuration that cannot produce a meaningful run, naming the offending
4540    /// field.
4541    ///
4542    /// Checked once up front rather than defended against inside the loop: a zero
4543    /// `batch_size` would spin forever making no progress, and a non-positive or `NaN`
4544    /// `target_half_width` can never be met, so both would surface as a hang or a silent
4545    /// max-samples cap instead of an error the caller can read. `NaN` is caught by the
4546    /// `is_finite` test -- `NaN <= 0.0` is `false`, so a bare comparison would let it through.
4547    pub fn validate(&self) -> Result<(), String> {
4548        if !self.target_half_width.is_finite() || self.target_half_width <= 0.0 {
4549            return Err(format!(
4550                "McConvergence.target_half_width must be a finite value greater than zero (got {})",
4551                self.target_half_width
4552            ));
4553        }
4554        if self.batch_size == 0 {
4555            return Err("McConvergence.batch_size must be greater than zero".to_string());
4556        }
4557        if self.max_samples == 0 {
4558            return Err("McConvergence.max_samples must be greater than zero".to_string());
4559        }
4560        if self.max_samples < self.min_samples {
4561            return Err(format!(
4562                "McConvergence.max_samples ({}) must be at least McConvergence.min_samples ({})",
4563                self.max_samples, self.min_samples
4564            ));
4565        }
4566        Ok(())
4567    }
4568}
4569
4570/// Why an adaptive run stopped.
4571#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
4572#[serde(rename_all = "snake_case")]
4573pub enum McStopReason {
4574    /// The confidence sequence reached [`McConvergence::target_half_width`]. The reported
4575    /// precision is the requested precision.
4576    TargetHalfWidthMet,
4577    /// [`McConvergence::max_samples`] was exhausted first. The interval is whatever that many
4578    /// trials bought -- read `ci_low`/`ci_high`, not just `hit_probability`.
4579    MaxSamplesReached,
4580}
4581
4582/// The result of a confidence-controlled Monte Carlo run.
4583///
4584/// Produced by [`run_monte_carlo_adaptive_seeded`]. Unlike [`MonteCarloResults`] this retains
4585/// no per-trial data at all: the continuous quantities arrive as streaming [`Welford`] moments
4586/// and the hit/miss counts as a running confidence sequence, so a 100,000-trial run costs the
4587/// same memory as a 1,000-trial one.
4588#[derive(Debug, Clone, serde::Serialize)]
4589pub struct AdaptiveMcReportV1 {
4590    /// Always [`MC_ADAPTIVE_SCHEMA_VERSION_V1`].
4591    pub schema_version: u32,
4592    /// Always [`MC_ADAPTIVE_METHOD_V1`].
4593    pub method: String,
4594    /// Always the four sentences of [`MC_ADAPTIVE_ASSUMPTIONS_V1`], in order.
4595    pub assumptions: Vec<String>,
4596    /// Confidence level of `ci_low`/`ci_high`, as a whole-number percentage.
4597    pub confidence_percent: u32,
4598    /// Point estimate `S / n`: hits over all trials, including trials that never reached the
4599    /// target plane.
4600    pub hit_probability: f64,
4601    /// Lower bound of the anytime-valid interval.
4602    pub ci_low: f64,
4603    /// Upper bound of the anytime-valid interval.
4604    pub ci_high: f64,
4605    /// Trials actually folded into the statistics (`n`).
4606    pub samples: u64,
4607    /// Trials *drawn*, including any the solver could not complete.
4608    ///
4609    /// `attempts - samples` is the drop count. It is normally zero, and equals `samples`
4610    /// whenever it is -- but without this field a run with a 40% solver-failure rate is
4611    /// indistinguishable in the payload from a clean one at the same `samples`, which is the
4612    /// one thing `samples` alone cannot tell you. `attempts` is also what
4613    /// [`McConvergence::max_samples`] caps, so [`McStopReason::MaxSamplesReached`] always has
4614    /// `attempts == max_samples` -- but the reverse does not hold: a
4615    /// [`McStopReason::TargetHalfWidthMet`] stop on the very last batch can reach the same
4616    /// equality, so `attempts == max_samples` alone does not identify the stop reason.
4617    pub attempts: u64,
4618    /// Trials that reached the target plane -- the population behind the three at-target
4619    /// statistics below, and the `n` in their `n-1` standard deviations.
4620    ///
4621    /// This is the magnitude behind the asymmetry `assumptions[3]` discloses qualitatively:
4622    /// `mean_drop_at_target_m`, `mean_wind_drift_at_target_m` and `mean_impact_velocity_mps`
4623    /// describe these `arrivals` trials, while `hit_probability` is over all `samples` (a
4624    /// trial that fell short is a definite miss, so it is in that denominator but has no
4625    /// measured position to contribute). With `arrivals` well below `samples`, the at-target
4626    /// statistics describe only part of the run and this field is what says so.
4627    ///
4628    /// The fixed-count path exposes the same information as
4629    /// [`MonteCarloResults::target_arrival_count`] and
4630    /// [`MonteCarloResults::target_shortfall_fraction`]; `samples - arrivals` is the shortfall
4631    /// count and `1 - arrivals / samples` the shortfall fraction.
4632    pub arrivals: u64,
4633    /// Why the run stopped. Serializes as `"target_half_width_met"` / `"max_samples_reached"`.
4634    pub stop_reason: McStopReason,
4635    /// Hit-zone radius the probability was computed against.
4636    pub hit_radius_m: f64,
4637    /// Resolved target plane: `params.target_distance` if given, else the baseline's max range.
4638    pub target_distance_m: f64,
4639    /// Mean ground-impact velocity over trials that reached the target plane.
4640    ///
4641    /// The velocity is the one the legacy path records: speed at *ground impact*, not at the
4642    /// target plane (MBA-967). Only the conditioning is on reaching the plane.
4643    pub mean_impact_velocity_mps: f64,
4644    /// Sample (`n-1`) standard deviation of the above. `0.0` for fewer than two such trials.
4645    pub std_impact_velocity_mps: f64,
4646    /// Mean *vertical deviation from the baseline point of aim* at the target plane, in
4647    /// meters, positive high.
4648    ///
4649    /// Named "drop" for continuity with the rest of the API, but it is a deviation from the
4650    /// undispersed baseline, not a drop from the bore line: with symmetric input dispersions
4651    /// its expectation is near zero and the informative number is
4652    /// [`std_drop_at_target_m`](Self::std_drop_at_target_m).
4653    pub mean_drop_at_target_m: f64,
4654    /// Sample (`n-1`) standard deviation of the vertical deviation -- the run's vertical
4655    /// dispersion at the target.
4656    pub std_drop_at_target_m: f64,
4657    /// Mean lateral deviation from the baseline point of aim at the target plane, in meters.
4658    /// The same "deviation, not absolute" caveat as `mean_drop_at_target_m` applies.
4659    pub mean_wind_drift_at_target_m: f64,
4660    /// Sample (`n-1`) standard deviation of the lateral deviation -- the run's horizontal
4661    /// dispersion at the target.
4662    pub std_wind_drift_at_target_m: f64,
4663}
4664
4665/// Runs a Monte Carlo hit-probability estimate that decides its own sample size, from an
4666/// explicit seed.
4667///
4668/// # What this buys over the fixed-count path
4669///
4670/// [`run_monte_carlo_with_wind_and_direction_std_dev_seeded`] runs exactly
4671/// `params.num_simulations` trials and reports a point estimate; whether that count was enough
4672/// is left to the caller to guess. This instead runs until the answer is as precise as the
4673/// caller asked for, and reports the achieved precision either way. **`params.num_simulations`
4674/// is ignored here** -- the sample count comes from `convergence`, and
4675/// [`McConvergence::min_samples`] defaults to the same `1_000` so the floor matches the legacy
4676/// default.
4677///
4678/// # Why the interval is anytime-valid
4679///
4680/// Stopping when the interval looks tight enough is optional stopping, and a fixed-`n`
4681/// interval (Wilson, Wald, anything) checked repeatedly that way has no coverage guarantee:
4682/// the error rate grows with the number of peeks. [`BernoulliConfidenceSequence`] is instead
4683/// valid at every `n` simultaneously, so a data-dependent stopping rule is legitimate. The
4684/// price is a strictly wider interval at any given `n` than the fixed-`n`
4685/// [`MonteCarloResults::hit_probability_wilson`] would report on the same counts. Paying it is
4686/// the point.
4687///
4688/// # The trial is the same trial
4689///
4690/// Each trial is `MonteCarloTrialSampler::sample_one_trial` (crate-internal), the same body
4691/// and the same six-draw sequence the legacy loop runs, and the hit test is
4692/// [`MonteCarloResults::position_is_hit`], the same predicate
4693/// [`MonteCarloResults::hit_probability`] counts with. A trial that never reached the target
4694/// plane is a definite miss and stays in the denominator, exactly as it does there. Wind
4695/// direction is not dispersed: [`MonteCarloParams`] has no direction-sigma field, so this
4696/// passes `0.0`, matching [`run_monte_carlo_with_wind`].
4697///
4698/// # Sample accounting
4699///
4700/// Trials are run in batches of [`McConvergence::batch_size`] (the last batch truncated so the
4701/// ceiling is never overshot), and the stopping rule is evaluated after each batch: stop with
4702/// [`McStopReason::TargetHalfWidthMet`] once at least `min_samples` trials are in *and* the
4703/// half-width is at or below `target_half_width`; stop with
4704/// [`McStopReason::MaxSamplesReached`] once `max_samples` trials have been attempted.
4705///
4706/// The report carries three cardinalities, and they are three different numbers:
4707///
4708/// * `attempts` -- trials drawn. This is what `max_samples` caps.
4709/// * `samples` -- trials that produced an outcome, i.e. the `n` behind `hit_probability` and
4710///   the confidence interval.
4711/// * `arrivals` -- trials that reached the target plane, i.e. the `n` behind the three
4712///   at-target statistics.
4713///
4714/// `attempts >= samples >= arrivals` always. A trial whose solve fails is dropped rather than
4715/// counted as a miss -- the legacy loop's behaviour, preserved so the two paths cannot
4716/// disagree about what a solver failure means -- but it still consumes an attempt, so it
4717/// shows up as `attempts > samples`. With no dropped trials the two are equal, which is the
4718/// normal case; when they differ, `samples` can finish below `min_samples`, and the honest
4719/// report of that is `MaxSamplesReached` with the smaller `n` and the correspondingly wider
4720/// interval. A trial that solved but fell short of the target plane is a definite miss: it is
4721/// a sample (in `hit_probability`'s denominator) but not an arrival, so it shows up as
4722/// `samples > arrivals`. A run in which *every* trial was dropped is an error, not a report.
4723///
4724/// # `hit_radius_m` is not validated
4725///
4726/// Every [`McConvergence`] field is `Err`-checked up front, but `hit_radius_m` itself is not:
4727/// a `NaN` or negative radius makes [`MonteCarloResults::position_is_hit`] false for every
4728/// trial, yielding `p = 0.0` with a tight interval and (usually) a
4729/// [`McStopReason::TargetHalfWidthMet`] stop rather than an error. This is deliberate, not an
4730/// oversight -- it matches [`MonteCarloResults::hit_probability`]'s equally lenient legacy
4731/// posture on the same input, so this path cannot diverge from the fixed-count one over how a
4732/// bad radius is treated.
4733///
4734/// # Errors
4735///
4736/// Returns `Err` if `convergence` is not usable ([`McConvergence::validate`], which names the
4737/// offending field), if the baseline solve or an input distribution is invalid, or if no trial
4738/// at all produced an outcome.
4739pub fn run_monte_carlo_adaptive_seeded(
4740    base_inputs: &BallisticInputs,
4741    base_wind: &WindConditions,
4742    params: &MonteCarloParams,
4743    convergence: &McConvergence,
4744    hit_radius_m: f64,
4745    seed: u64,
4746) -> Result<AdaptiveMcReportV1, String> {
4747    use rand::{rngs::StdRng, SeedableRng};
4748
4749    convergence.validate()?;
4750
4751    // `wind_direction_std_dev = 0.0`: MonteCarloParams carries no direction sigma, so this
4752    // matches `run_monte_carlo_with_wind`'s treatment rather than inventing one.
4753    let sampler = MonteCarloTrialSampler::new(base_inputs.clone(), base_wind, params, 0.0)
4754        .map_err(|e| e.to_string())?;
4755
4756    let mut rng = StdRng::seed_from_u64(seed);
4757    let mut hits_cs = BernoulliConfidenceSequence::new(convergence.level);
4758    let mut impact_velocity = Welford::new();
4759    let mut drop_at_target = Welford::new();
4760    let mut drift_at_target = Welford::new();
4761
4762    let mut attempts: u64 = 0;
4763    let mut stop_reason = McStopReason::MaxSamplesReached;
4764
4765    while attempts < convergence.max_samples {
4766        // Truncate the final batch so `max_samples` is a ceiling on attempts, not a threshold
4767        // the last batch may overshoot.
4768        let batch = convergence
4769            .batch_size
4770            .min(convergence.max_samples - attempts);
4771        let mut batch_hits: u64 = 0;
4772        let mut batch_trials: u64 = 0;
4773
4774        for _ in 0..batch {
4775            attempts += 1;
4776            let Some(outcome) = sampler.sample_one_trial(&mut rng) else {
4777                continue; // dropped trial: not a miss, not a sample (see the legacy loop)
4778            };
4779            batch_trials += 1;
4780            if MonteCarloResults::position_is_hit(&outcome.impact_position, hit_radius_m) {
4781                batch_hits += 1;
4782            }
4783            // Continuous statistics are conditioned on arrival: a shortfall marker is a
4784            // placeholder, not a measured position, and folding it in would drag the moments
4785            // toward TARGET_NOT_REACHED_SENTINEL_M. It still counts as a miss above.
4786            if MonteCarloResults::position_reached_target(&outcome.impact_position) {
4787                impact_velocity.push(outcome.impact_velocity);
4788                drop_at_target.push(outcome.impact_position.y);
4789                drift_at_target.push(outcome.impact_position.z);
4790            }
4791        }
4792
4793        hits_cs.update_batch(batch_hits, batch_trials);
4794
4795        if hits_cs.trials() >= convergence.min_samples
4796            && hits_cs.half_width() <= convergence.target_half_width
4797        {
4798            stop_reason = McStopReason::TargetHalfWidthMet;
4799            break;
4800        }
4801    }
4802
4803    let samples = hits_cs.trials();
4804    if samples == 0 {
4805        return Err("No successful simulations".to_string());
4806    }
4807    let (ci_low, ci_high) = hits_cs.bounds();
4808
4809    Ok(AdaptiveMcReportV1 {
4810        schema_version: MC_ADAPTIVE_SCHEMA_VERSION_V1,
4811        method: MC_ADAPTIVE_METHOD_V1.to_string(),
4812        assumptions: MC_ADAPTIVE_ASSUMPTIONS_V1
4813            .iter()
4814            .map(|s| s.to_string())
4815            .collect(),
4816        confidence_percent: convergence.level.as_percent(),
4817        hit_probability: hits_cs.successes() as f64 / samples as f64,
4818        ci_low,
4819        ci_high,
4820        samples,
4821        attempts,
4822        // All three at-target accumulators are fed under one guard, in one block, so their
4823        // counts are equal by construction; reading the population off one of them binds this
4824        // field to the actual Welford `n` rather than to a parallel counter that could drift.
4825        arrivals: drop_at_target.count(),
4826        stop_reason,
4827        hit_radius_m,
4828        target_distance_m: sampler.target_distance,
4829        mean_impact_velocity_mps: impact_velocity.mean(),
4830        std_impact_velocity_mps: impact_velocity.sample_std(),
4831        mean_drop_at_target_m: drop_at_target.mean(),
4832        std_drop_at_target_m: drop_at_target.sample_std(),
4833        mean_wind_drift_at_target_m: drift_at_target.mean(),
4834        std_wind_drift_at_target_m: drift_at_target.sample_std(),
4835    })
4836}
4837
4838// Calculate zero angle for a target
4839pub fn calculate_zero_angle(
4840    inputs: BallisticInputs,
4841    target_distance: f64,
4842    target_height: f64,
4843) -> Result<f64, BallisticsError> {
4844    calculate_zero_angle_with_conditions(
4845        inputs,
4846        target_distance,
4847        target_height,
4848        WindConditions::default(),
4849        AtmosphericConditions::default(),
4850    )
4851}
4852
4853pub fn calculate_zero_angle_with_conditions(
4854    inputs: BallisticInputs,
4855    target_distance: f64,
4856    target_height: f64,
4857    wind: WindConditions,
4858    atmosphere: AtmosphericConditions,
4859) -> Result<f64, BallisticsError> {
4860    let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
4861    solver.calculate_and_set_zero_angle(target_distance, target_height, ZeroTargetFrame::SightLine)
4862}
4863
4864/// [`calculate_zero_angle_with_conditions`] for a presence-aware caller that has already
4865/// resolved station temperature/pressure (MBA-1397; e.g. reduced a declared QNH via
4866/// [`crate::atmosphere::resolve_station_conditions_with_pressure_mode`]). Unlike the base
4867/// function, `atmosphere`'s temperature/pressure are trusted as-is and never re-interpreted
4868/// through the legacy default-sentinel heuristic.
4869pub fn calculate_zero_angle_with_resolved_conditions(
4870    inputs: BallisticInputs,
4871    target_distance: f64,
4872    target_height: f64,
4873    wind: WindConditions,
4874    atmosphere: AtmosphericConditions,
4875) -> Result<f64, BallisticsError> {
4876    let mut solver =
4877        TrajectorySolver::new_with_resolved_station_atmosphere(inputs, wind, atmosphere);
4878    solver.calculate_and_set_zero_angle(target_distance, target_height, ZeroTargetFrame::SightLine)
4879}
4880
4881/// Generous solve envelope for [`calculate_zero_range_from_angle_with_conditions`] /
4882/// [`calculate_zero_range_from_angle_with_resolved_conditions`] (MBA-1402): a "zero" bore angle
4883/// is a near-horizontal shot, so its far line-of-sight crossing sits well inside this even at
4884/// extended small-arms ranges. Double the engine's own default 1000 m solve envelope
4885/// ([`TrajectorySolver::new`]) rather than something scaled to an (unknown, being solved for)
4886/// target distance; `solve()` still stops at ground impact well before this in the typical
4887/// case, so the larger envelope costs effectively nothing.
4888pub const ZERO_RANGE_FROM_ANGLE_MAX_RANGE_M: f64 = 2000.0;
4889
4890/// Solve the zero RANGE(S) that a fixed bore angle produces. Runs the trajectory at
4891/// `zero_angle_rad` and returns BOTH line-of-sight crossings it finds, as
4892/// [`ZeroCrossings`] — a rifle sighted above the bore generally crosses a level line of
4893/// sight twice on a rising shot (near, ascending, close to the muzzle; far, descending past
4894/// the apex).
4895///
4896/// **This is NOT the exact inverse of [`calculate_zero_angle_with_conditions`].** A single
4897/// bore angle generally implies two valid zero distances (the classic 25/300-yard
4898/// battle-zero relationship is exactly this: one angle, two zeros), and
4899/// [`calculate_zero_angle_with_conditions`]'s own choice of root depends on which target
4900/// distance it was asked to solve for. Round-tripping a solved angle back through this
4901/// function recovers the ORIGINAL distance as one of the two returned crossings, not
4902/// necessarily as `far_m` specifically — for short/flat zeros the forward solver's answer is
4903/// often the NEAR crossing.
4904pub fn calculate_zero_range_from_angle_with_conditions(
4905    inputs: BallisticInputs,
4906    zero_angle_rad: f64,
4907    target_height: f64,
4908    wind: WindConditions,
4909    atmosphere: AtmosphericConditions,
4910) -> Result<ZeroCrossings, BallisticsError> {
4911    let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
4912    solver.set_max_range(ZERO_RANGE_FROM_ANGLE_MAX_RANGE_M);
4913    solver.find_zero_range(zero_angle_rad, target_height, ZeroTargetFrame::SightLine)
4914}
4915
4916/// [`calculate_zero_range_from_angle_with_conditions`] for a presence-aware caller that has
4917/// already resolved station temperature/pressure — same relationship as
4918/// [`calculate_zero_angle_with_resolved_conditions`] has to
4919/// [`calculate_zero_angle_with_conditions`] (MBA-1397 pattern). See that function's doc
4920/// comment for why this returns both crossings rather than a single "the" zero range.
4921pub fn calculate_zero_range_from_angle_with_resolved_conditions(
4922    inputs: BallisticInputs,
4923    zero_angle_rad: f64,
4924    target_height: f64,
4925    wind: WindConditions,
4926    atmosphere: AtmosphericConditions,
4927) -> Result<ZeroCrossings, BallisticsError> {
4928    let mut solver =
4929        TrajectorySolver::new_with_resolved_station_atmosphere(inputs, wind, atmosphere);
4930    solver.set_max_range(ZERO_RANGE_FROM_ANGLE_MAX_RANGE_M);
4931    solver.find_zero_range(zero_angle_rad, target_height, ZeroTargetFrame::SightLine)
4932}
4933
4934/// What a BC estimate is fit against.
4935#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4936pub enum BcFitMode {
4937    /// Data points are `(distance_m, drop_m)` — the classic drop-curve fit.
4938    Drop,
4939    /// Data points are `(distance_m, velocity_mps)` — a velocity-retention fit,
4940    /// which is immune to zero / sight-height / launch-angle error.
4941    Velocity,
4942}
4943
4944/// The result of a single BC fit (one drag model, one fit basis).
4945#[derive(Debug, Clone, Copy)]
4946pub struct BcEstimate {
4947    /// The estimated ballistic coefficient.
4948    pub bc: f64,
4949    /// RMS residual across the data points, in fit units (meters of drop, or m/s of speed).
4950    pub rms_error: f64,
4951    /// Which standard drag model this BC is referenced to.
4952    pub drag_model: DragModel,
4953    /// Whether the fit was against drop or velocity data.
4954    pub mode: BcFitMode,
4955    /// True if the best fit landed at the edge of the physical BC search range — i.e. the
4956    /// data did not pin down an interior optimum (too sparse/short-range, or wrong units /
4957    /// atmosphere / zero). The reported `bc` is then a floor/ceiling, not a real estimate.
4958    pub at_bound: bool,
4959}
4960
4961/// Interpolate the fitted quantity (drop in meters, or speed in m/s) at a downrange
4962/// distance from a solved trajectory. `None` if the trajectory never reaches `target_dist`.
4963///
4964/// `drop_offset` is subtracted-from convention: for `Drop` the returned value is
4965/// `drop_offset - y`. With `drop_offset = 0` this is bore-referenced drop (flat fire);
4966/// with `drop_offset = sight_height` and a zeroed trajectory it is drop below the
4967/// (horizontal) line of sight — i.e. dope-card drop.
4968fn fit_value_at(
4969    points: &[TrajectoryPoint],
4970    target_dist: f64,
4971    mode: BcFitMode,
4972    drop_offset: f64,
4973) -> Option<f64> {
4974    let val = |p: &TrajectoryPoint| match mode {
4975        BcFitMode::Drop => drop_offset - p.position.y,
4976        BcFitMode::Velocity => p.velocity_magnitude,
4977    };
4978    for i in 0..points.len() {
4979        if points[i].position.x >= target_dist {
4980            if i == 0 {
4981                return Some(val(&points[0]));
4982            }
4983            let p1 = &points[i - 1];
4984            let p2 = &points[i];
4985            let dx = p2.position.x - p1.position.x;
4986            if dx.abs() < 1e-9 {
4987                return Some(val(p2));
4988            }
4989            let t = (target_dist - p1.position.x) / dx;
4990            return Some(val(p1) + t * (val(p2) - val(p1)));
4991        }
4992    }
4993    None
4994}
4995
4996fn fit_residual_sse(
4997    trajectory: &[TrajectoryPoint],
4998    observations: &[(f64, f64)],
4999    mode: BcFitMode,
5000    drop_offset: f64,
5001) -> Option<f64> {
5002    if observations.is_empty() {
5003        return None;
5004    }
5005    let mut total = 0.0;
5006    for (target_dist, target_val) in observations {
5007        // Scores are comparable only when every candidate contains every residual term.
5008        // Reject a trajectory that terminates before even one observation (MBA-1178).
5009        let value = fit_value_at(trajectory, *target_dist, mode, drop_offset)?;
5010        let error = value - target_val;
5011        total += error * error;
5012    }
5013    Some(total)
5014}
5015
5016/// Estimate a BC by fitting a simulated trajectory to measured data, for a chosen drag
5017/// model (G1, G7, …) and fit basis (drop or velocity). Uses a coarse 0.01 sweep over
5018/// plausible BCs followed by a 0.001 local refine around the coarse best.
5019///
5020/// `points` are `(distance_m, value_m_or_mps)` where the second element is drop in meters
5021/// (`BcFitMode::Drop`) or remaining speed in m/s (`BcFitMode::Velocity`).
5022///
5023/// The fit runs under `atmosphere` — BC is only meaningful relative to the air density the
5024/// data was measured at, so this must match the conditions the drop/velocity came from
5025/// (pass ICAO standard for a standard-atmosphere dope card).
5026///
5027/// `zero_range` selects the drop reference frame (ignored for velocity fits):
5028/// - `None` → **bore-referenced**: flat 0° fire, drop below the extended bore axis.
5029/// - `Some(range_m)` → **sight/dope-card-referenced**: the trajectory is zeroed at
5030///   `range_m` (using `sight_height`), and drop is measured below the horizontal line of
5031///   sight — i.e. exactly what a dope card zeroed at that range prints.
5032#[allow(clippy::too_many_arguments)] // Public compatibility API; grouping would be breaking.
5033pub fn estimate_bc_fit(
5034    velocity: f64,
5035    mass: f64,
5036    diameter: f64,
5037    points: &[(f64, f64)],
5038    drag_model: DragModel,
5039    mode: BcFitMode,
5040    atmosphere: AtmosphericConditions,
5041    zero_range: Option<f64>,
5042    sight_height: f64,
5043) -> Result<BcEstimate, BallisticsError> {
5044    if points.is_empty() {
5045        return Err(BallisticsError::from(
5046            "No data points provided for BC estimation.".to_string(),
5047        ));
5048    }
5049    let max_dist = points.iter().map(|(d, _)| *d).fold(0.0_f64, f64::max);
5050    // For a zeroed drop fit, drop is below the horizontal LOS which sits `sight_height`
5051    // above the bore at the muzzle: drop = sight_height - y. Bore-referenced fits use 0.
5052    let drop_offset = if zero_range.is_some() {
5053        sight_height
5054    } else {
5055        0.0
5056    };
5057
5058    // Sum of squared residuals for a trial BC; None unless the solve reaches ALL data points.
5059    let sse = |bc_value: f64| -> Option<f64> {
5060        let mut inputs = BallisticInputs {
5061            muzzle_velocity: velocity,
5062            bc_value,
5063            bc_type: drag_model,
5064            bullet_mass: mass,
5065            bullet_diameter: diameter,
5066            sight_height,
5067            ..Default::default()
5068        };
5069        // Zeroed fit: tilt the bore so the bullet crosses LOS at the zero range, so the
5070        // downrange drops match a dope card zeroed there. Bore fit leaves muzzle_angle = 0.
5071        if let Some(zr) = zero_range {
5072            // MBA-1130: zero to the LINE OF SIGHT (y = sight_height) at the zero range,
5073            // not the bore line (y = 0). Drop is measured as `drop_offset - y` with
5074            // drop_offset = sight_height, so a bore-referenced zero left drop != 0 at the
5075            // zero range and the drop-fit no longer round-tripped to the true BC. This
5076            // matches how range-table / come-up / dope-card generation zero.
5077            let za = calculate_zero_angle_with_conditions(
5078                inputs.clone(),
5079                zr,
5080                sight_height,
5081                WindConditions::default(),
5082                atmosphere.clone(),
5083            )
5084            .ok()?;
5085            inputs.muzzle_angle = za;
5086        }
5087        let mut solver =
5088            TrajectorySolver::new(inputs, WindConditions::default(), atmosphere.clone());
5089        solver.set_max_range(max_dist * 1.5);
5090        let result = solver.solve().ok()?;
5091        fit_residual_sse(&result.points, points, mode, drop_offset)
5092    };
5093
5094    // Physical BC search range, per drag model. Real G7 BCs top out well under 0.5 (0.7 is
5095    // a generous ceiling); G1 BCs run higher. Keeping G7 out of G1 territory means a fit
5096    // that runs to the ceiling reports a sane bound, not a nonsensical 1.2.
5097    let (bc_min, bc_max) = match drag_model {
5098        DragModel::G7 => (0.05, 0.70),
5099        _ => (0.10, 1.20),
5100    };
5101
5102    // Coarse sweep across the physical range.
5103    let mut best_bc = f64::NAN;
5104    let mut best_sse = f64::MAX;
5105    let mut bc = bc_min;
5106    while bc <= bc_max + 1e-9 {
5107        if let Some(s) = sse(bc) {
5108            if s < best_sse {
5109                best_sse = s;
5110                best_bc = bc;
5111            }
5112        }
5113        bc += 0.01;
5114    }
5115    if !best_bc.is_finite() {
5116        return Err(BallisticsError::from(
5117            "Unable to estimate BC from provided data. Check that the values and units are correct."
5118                .to_string(),
5119        ));
5120    }
5121
5122    // Local refine at 0.001 resolution around the coarse best (kept within the range).
5123    let lo = (best_bc - 0.01).max(bc_min);
5124    let hi = (best_bc + 0.01).min(bc_max);
5125    let mut bc = lo;
5126    while bc <= hi + 1e-9 {
5127        if let Some(s) = sse(bc) {
5128            if s < best_sse {
5129                best_sse = s;
5130                best_bc = bc;
5131            }
5132        }
5133        bc += 0.001;
5134    }
5135
5136    // A solution sitting on the search boundary means the data didn't determine an interior
5137    // optimum — the fit ran to the floor/ceiling. Flag it so callers don't trust the number.
5138    let at_bound = best_bc <= bc_min + 0.011 || best_bc >= bc_max - 0.011;
5139    // fit_residual_sse rejects partial trajectories, so best_sse contains exactly one residual
5140    // per input point and this denominator is also the honest matched-point count.
5141    let rms_error = (best_sse / points.len() as f64).sqrt();
5142    Ok(BcEstimate {
5143        bc: best_bc,
5144        rms_error,
5145        drag_model,
5146        mode,
5147        at_bound,
5148    })
5149}
5150
5151/// Estimate a G1 BC from a drop curve. Back-compatible wrapper over [`estimate_bc_fit`];
5152/// `points` are `(distance_m, drop_m)`.
5153pub fn estimate_bc_from_trajectory(
5154    velocity: f64,
5155    mass: f64,
5156    diameter: f64,
5157    points: &[(f64, f64)], // (distance, drop) pairs
5158) -> Result<f64, BallisticsError> {
5159    estimate_bc_fit(
5160        velocity,
5161        mass,
5162        diameter,
5163        points,
5164        DragModel::G1,
5165        BcFitMode::Drop,
5166        AtmosphericConditions::default(),
5167        None,
5168        0.05,
5169    )
5170    .map(|e| e.bc)
5171}
5172
5173// Add rand dependencies for Monte Carlo
5174use rand;
5175use rand_distr;
5176
5177#[cfg(test)]
5178mod mba737_powder_resolution_tests {
5179    use super::*;
5180
5181    #[test]
5182    fn linear_model_cold_powder_subtracts() {
5183        // 0.5486 m/s per degC (1 fps/degF), shot day 10 C below the 21.1 C reference.
5184        let v = resolve_powder_adjusted_velocity(823.0, 11.1, true, 0.5486, 21.1, None, None);
5185        assert!((v - (823.0 + 0.5486 * (11.1 - 21.1))).abs() < 1e-12);
5186        assert!(v < 823.0);
5187    }
5188
5189    #[test]
5190    fn linear_model_hot_powder_adds() {
5191        let v = resolve_powder_adjusted_velocity(823.0, 31.1, true, 0.5486, 21.1, None, None);
5192        assert!((v - (823.0 + 0.5486 * 10.0)).abs() < 1e-12);
5193    }
5194
5195    #[test]
5196    fn disabled_flag_is_passthrough() {
5197        let v = resolve_powder_adjusted_velocity(823.0, 40.0, false, 0.5486, 21.1, None, None);
5198        assert_eq!(v, 823.0);
5199    }
5200
5201    #[test]
5202    fn curve_overrides_linear_and_interpolates_at_powder_temp() {
5203        let curve = [(4.4, 798.6), (21.1, 823.0), (37.8, 841.2)];
5204        // Explicit powder temp decouples from ambient: interpolate at 4.4 C, not 30 C.
5205        let v = resolve_powder_adjusted_velocity(
5206            823.0,
5207            30.0,
5208            true,
5209            99.0,
5210            21.1,
5211            Some(&curve),
5212            Some(4.4),
5213        );
5214        assert!((v - 798.6).abs() < 1e-9);
5215    }
5216
5217    #[test]
5218    fn curve_falls_back_to_ambient_and_clamps() {
5219        let curve = [(4.4, 798.6), (37.8, 841.2)];
5220        // Ambient far below the coldest measured point: clamp, no extrapolation.
5221        let v = resolve_powder_adjusted_velocity(823.0, -40.0, true, 1.0, 21.1, Some(&curve), None);
5222        assert!((v - 798.6).abs() < 1e-9);
5223        let v_hot =
5224            resolve_powder_adjusted_velocity(823.0, 60.0, true, 1.0, 21.1, Some(&curve), None);
5225        assert!((v_hot - 841.2).abs() < 1e-9);
5226    }
5227
5228    #[test]
5229    fn empty_curve_suppresses_linear_fallback() {
5230        // Historical `else if` semantics: Some-but-empty curve means NO adjustment.
5231        let v = resolve_powder_adjusted_velocity(823.0, 40.0, true, 0.5486, 21.1, Some(&[]), None);
5232        assert_eq!(v, 823.0);
5233    }
5234
5235    #[test]
5236    fn sweep_huge_range_errors_instead_of_overflowing() {
5237        // Review finding: the old count math saturated the usize cast and the +1
5238        // panicked (debug) or wrapped to zero rows (release), bypassing the cap.
5239        assert!(parse_powder_sweep("0:1e20:1").is_err());
5240        assert!(parse_powder_sweep("0:1e308:1e-3").is_err());
5241    }
5242
5243    #[test]
5244    fn sweep_fractional_step_keeps_end_row() {
5245        // 0.3/0.1 floats to 2.9999999999999996; the END row must survive the floor.
5246        let rows = parse_powder_sweep("0:0.3:0.1").unwrap();
5247        assert_eq!(rows.len(), 4);
5248        assert!((rows[3] - 0.3).abs() < 1e-9);
5249    }
5250
5251    #[test]
5252    fn solver_and_helper_agree_on_linear_model() {
5253        // The solve() seam must fly exactly what the helper reports (MBA-737 contract).
5254        let inputs = BallisticInputs {
5255            use_powder_sensitivity: true,
5256            powder_temp_sensitivity: 0.5486,
5257            powder_temp: 21.1,
5258            temperature: 4.4,
5259            ..Default::default()
5260        };
5261        let expected = resolve_powder_adjusted_velocity(
5262            inputs.muzzle_velocity,
5263            inputs.temperature,
5264            true,
5265            0.5486,
5266            21.1,
5267            None,
5268            None,
5269        );
5270        let solver = TrajectorySolver::new(
5271            inputs,
5272            WindConditions::default(),
5273            AtmosphericConditions::default(),
5274        );
5275        assert!((solver.inputs.muzzle_velocity - expected).abs() < 1e-12);
5276    }
5277}
5278
5279#[cfg(test)]
5280mod mba1302_solver_seam_tests {
5281    use super::*;
5282    use crate::wind::WindSegment;
5283
5284    #[test]
5285    fn authoritative_station_atmosphere_preserves_explicit_standard_values_at_altitude() {
5286        let atmosphere = AtmosphericConditions {
5287            temperature: 15.0,
5288            pressure: 1013.25,
5289            humidity: 50.0,
5290            altitude: 2_000.0,
5291        };
5292        let legacy = TrajectorySolver::new(
5293            BallisticInputs::default(),
5294            WindConditions::default(),
5295            atmosphere.clone(),
5296        );
5297        let authoritative = TrajectorySolver::new_with_resolved_station_atmosphere(
5298            BallisticInputs::default(),
5299            WindConditions::default(),
5300            atmosphere,
5301        );
5302
5303        let (legacy_density, _, legacy_temp_c, legacy_pressure_hpa) = legacy.resolved_atmosphere();
5304        let (authoritative_density, _, authoritative_temp_c, authoritative_pressure_hpa) =
5305            authoritative.resolved_atmosphere();
5306        let (icao_temp_k, icao_pressure_pa) =
5307            crate::atmosphere::calculate_icao_standard_atmosphere(2_000.0);
5308        let (expected_authoritative_density, _) =
5309            crate::atmosphere::calculate_atmosphere(2_000.0, Some(15.0), Some(1013.25), 50.0);
5310
5311        assert!((legacy_temp_c - (icao_temp_k - 273.15)).abs() < 1e-12);
5312        assert!((legacy_pressure_hpa - icao_pressure_pa / 100.0).abs() < 1e-12);
5313        assert_eq!(authoritative_temp_c.to_bits(), 15.0_f64.to_bits());
5314        assert_eq!(authoritative_pressure_hpa.to_bits(), 1013.25_f64.to_bits());
5315        assert_eq!(
5316            authoritative_density.to_bits(),
5317            expected_authoritative_density.to_bits()
5318        );
5319        assert!(
5320            (authoritative_density - legacy_density).abs() > 0.1,
5321            "explicit standard values at altitude must differ from ICAO-at-altitude: explicit={authoritative_density}, ICAO={legacy_density}"
5322        );
5323    }
5324
5325    /// MBA-1397: the CLI/WASM `--pressure-type` mechanism precomputes station conditions via
5326    /// `atmosphere::resolve_station_conditions_with_pressure_mode` and constructs the solver
5327    /// with `new_with_resolved_station_atmosphere` UNCONDITIONALLY (not only for `Qnh`), to
5328    /// avoid re-deriving through the legacy sentinel a second time. This must be bit-for-bit
5329    /// equivalent to the historical `TrajectorySolver::new` (LegacyDefaultSentinels) path for
5330    /// every NON-ambiguous input -- i.e. for `PressureReferenceMode::Absolute`, precomputing
5331    /// and using the Authoritative constructor must reproduce `TrajectorySolver::new` exactly,
5332    /// which is the bit-level guarantee the CLI/WASM byte-identical-output tests rely on.
5333    #[test]
5334    fn precomputed_absolute_resolution_via_authoritative_matches_legacy_new() {
5335        for (temperature, pressure, altitude) in [
5336            (15.0, 1013.25, 0.0),    // sea-level default
5337            (15.0, 1013.25, 2000.0), // sentinel: omitted-pressure-at-altitude
5338            (-5.0, 850.0, 2000.0),   // explicit non-default station values
5339            (22.0, 950.0, 500.0),
5340        ] {
5341            let atmosphere = AtmosphericConditions {
5342                temperature,
5343                pressure,
5344                humidity: 50.0,
5345                altitude,
5346            };
5347            let legacy = TrajectorySolver::new(
5348                BallisticInputs::default(),
5349                WindConditions::default(),
5350                atmosphere.clone(),
5351            );
5352
5353            let (resolved_temp_c, resolved_pressure_hpa) =
5354                crate::atmosphere::resolve_station_conditions_with_pressure_mode(
5355                    temperature,
5356                    pressure,
5357                    altitude,
5358                    crate::atmosphere::PressureReferenceMode::Absolute,
5359                );
5360            let precomputed_atmosphere = AtmosphericConditions {
5361                temperature: resolved_temp_c,
5362                pressure: resolved_pressure_hpa,
5363                humidity: 50.0,
5364                altitude,
5365            };
5366            let precomputed = TrajectorySolver::new_with_resolved_station_atmosphere(
5367                BallisticInputs::default(),
5368                WindConditions::default(),
5369                precomputed_atmosphere,
5370            );
5371
5372            let (legacy_density, legacy_sos, legacy_temp_c, legacy_pressure_hpa) =
5373                legacy.resolved_atmosphere();
5374            let (pre_density, pre_sos, pre_temp_c, pre_pressure_hpa) =
5375                precomputed.resolved_atmosphere();
5376
5377            assert_eq!(
5378                legacy_temp_c.to_bits(),
5379                pre_temp_c.to_bits(),
5380                "temperature=({temperature}, {pressure}, {altitude})"
5381            );
5382            assert_eq!(
5383                legacy_pressure_hpa.to_bits(),
5384                pre_pressure_hpa.to_bits(),
5385                "pressure=({temperature}, {pressure}, {altitude})"
5386            );
5387            assert_eq!(legacy_density.to_bits(), pre_density.to_bits());
5388            assert_eq!(legacy_sos.to_bits(), pre_sos.to_bits());
5389        }
5390    }
5391
5392    fn configured_euler_zero(vertical_wind_mps: f64, time_step_s: f64) -> TrajectorySolver {
5393        let inputs = BallisticInputs {
5394            muzzle_velocity: 800.0,
5395            bc_value: 0.5,
5396            bc_type: DragModel::G7,
5397            bullet_mass: 0.0109,
5398            bullet_diameter: 0.00782,
5399            bullet_length: 0.0309,
5400            sight_height: 0.05,
5401            ground_threshold: -100.0,
5402            use_rk4: false,
5403            use_adaptive_rk45: false,
5404            ..BallisticInputs::default()
5405        };
5406        let mut solver = TrajectorySolver::new_with_resolved_station_atmosphere(
5407            inputs,
5408            WindConditions::default(),
5409            AtmosphericConditions::default(),
5410        );
5411        solver.set_max_range(300.0);
5412        solver.set_time_step(time_step_s);
5413        if vertical_wind_mps != 0.0 {
5414            solver.set_wind_segments(vec![WindSegment {
5415                speed_kmh: 0.0,
5416                angle_deg: 0.0,
5417                until_m: 400.0,
5418                vertical_mps: vertical_wind_mps,
5419            }]);
5420        }
5421        solver
5422    }
5423
5424    #[test]
5425    fn inclined_shot_zeroes_like_a_level_rifle() {
5426        // MBA-1412: since MBA-1302, zero_trial_height_at converted trial heights into the
5427        // gravity frame (shot_frame_altitude adds d*sin(shooting_angle) ~ 9 m at 5.71deg /
5428        // 100 yd) while find_zero_angle compared against a sight-frame target height, so ANY
5429        // inclined shot failed to bracket. A zero is a property of a level rifle's sight
5430        // geometry (same doctrine as MBA-1286's cant handling): the trial must solve level.
5431        const ZERO_DISTANCE_M: f64 = 91.44; // 100 yd
5432        const SIGHT_HEIGHT_M: f64 = 0.0381; // 1.5 in
5433
5434        let inputs = BallisticInputs {
5435            bc_value: 0.5,
5436            bullet_mass: 150.0 * 0.06479891 / 1000.0,
5437            muzzle_velocity: 2700.0 * 0.3048,
5438            sight_height: SIGHT_HEIGHT_M,
5439            ..Default::default()
5440        };
5441
5442        let mut level = inputs.clone();
5443        level.shooting_angle = 0.0;
5444        let level_angle = TrajectorySolver::new(level, Default::default(), Default::default())
5445            .find_zero_angle(ZERO_DISTANCE_M, SIGHT_HEIGHT_M, ZeroTargetFrame::SightLine)
5446            .expect("level zero must solve");
5447
5448        let mut inclined = inputs;
5449        inclined.shooting_angle = 5.71_f64.to_radians();
5450        let inclined_angle =
5451            TrajectorySolver::new(inclined, Default::default(), Default::default())
5452                .find_zero_angle(ZERO_DISTANCE_M, SIGHT_HEIGHT_M, ZeroTargetFrame::SightLine)
5453                .expect("MBA-1412: a 5.71 deg incline at a 100 yd zero must be solvable");
5454
5455        assert!(
5456            (inclined_angle - level_angle).abs() < 1e-9,
5457            "zeroing is level-rifle sight geometry; incline must not move the solved zero: \
5458             level={level_angle}, inclined={inclined_angle}"
5459        );
5460    }
5461
5462    #[test]
5463    fn target_below_the_level_bore_impact_zeroes_with_a_downward_bore() {
5464        // A target lower than where a level bore lands needs the bore pointed below
5465        // horizontal. The search used to start at 0 rad, so every such target collapsed onto
5466        // 0 rad and failed with "residual height error too large".
5467        const TARGET_DISTANCE_M: f64 = 457.2; // 500 yd
5468        let inputs = BallisticInputs {
5469            bc_value: 0.462,
5470            bullet_mass: 168.0 * 0.06479891 / 1000.0,
5471            bullet_diameter: 0.308 * 0.0254,
5472            muzzle_velocity: 2700.0 * 0.3048,
5473            sight_height: 0.05,
5474            ..Default::default()
5475        };
5476        let solver = TrajectorySolver::new(inputs, Default::default(), Default::default());
5477        let level_impact_m = solver
5478            .zero_trial_height_at(0.0, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
5479            .expect("level trial")
5480            .expect("level trial reaches the target distance");
5481
5482        // From just below the level impact down to a 30-degree depression (still above the
5483        // default -100 m ground threshold).
5484        for depth_m in [0.005, 1.0, 4.572, 91.44] {
5485            let target_m = level_impact_m - depth_m;
5486            let angle = solver
5487                .find_zero_angle(TARGET_DISTANCE_M, target_m, ZeroTargetFrame::SightLine)
5488                .unwrap_or_else(|e| panic!("target {depth_m} m below the level impact: {e}"));
5489            assert!(
5490                angle < 0.0,
5491                "a target below the level impact needs a downward bore: depth={depth_m} m angle={angle}"
5492            );
5493            let hit_m = solver
5494                .zero_trial_height_at(angle, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
5495                .expect("verify trial")
5496                .expect("zeroed trial reaches the target distance");
5497            assert!(
5498                (hit_m - target_m).abs() < 0.0001,
5499                "zero missed a target {depth_m} m below the level impact: hit={hit_m} target={target_m}"
5500            );
5501        }
5502    }
5503
5504    #[test]
5505    fn configured_zero_keeps_segments_method_and_time_step_then_sets_base_angle() {
5506        const TARGET_DISTANCE_M: f64 = 150.0;
5507        const TARGET_HEIGHT_M: f64 = 0.05;
5508
5509        // A deliberately coarse Euler step makes an accidental reset to the historical 1 ms
5510        // zeroing step observable, while remaining stable and physically meaningful.
5511        let mut segmented = configured_euler_zero(-10.0, 0.02);
5512        let coarse_height = segmented
5513            .zero_trial_height_at(0.0, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
5514            .expect("coarse configured trial")
5515            .expect("coarse trial reaches target");
5516        let mut fine = segmented.clone();
5517        fine.set_time_step(0.001);
5518        let fine_height = fine
5519            .zero_trial_height_at(0.0, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
5520            .expect("fine configured trial")
5521            .expect("fine trial reaches target");
5522        assert!(
5523            (coarse_height - fine_height).abs() > 1e-5,
5524            "configured Euler step must affect zero trials: coarse={coarse_height}, fine={fine_height}"
5525        );
5526
5527        let segmented_angle = segmented
5528            .calculate_and_set_zero_angle(
5529                TARGET_DISTANCE_M,
5530                TARGET_HEIGHT_M,
5531                ZeroTargetFrame::SightLine,
5532            )
5533            .expect("segmented zero");
5534        assert_eq!(
5535            segmented.inputs.muzzle_angle.to_bits(),
5536            segmented_angle.to_bits(),
5537            "successful zero must install its angle on the configured solver"
5538        );
5539        assert_eq!(segmented.time_step.to_bits(), 0.02_f64.to_bits());
5540        assert_eq!(segmented.max_range.to_bits(), 300.0_f64.to_bits());
5541        assert!(segmented.wind_sock.is_some());
5542        assert_eq!(
5543            segmented.station_atmosphere_resolution,
5544            StationAtmosphereResolution::Authoritative
5545        );
5546        let zero_height = segmented
5547            .zero_trial_height_at(
5548                segmented_angle,
5549                TARGET_DISTANCE_M,
5550                ZeroTargetFrame::SightLine,
5551            )
5552            .expect("verify segmented zero")
5553            .expect("zeroed trial reaches target");
5554        assert!(
5555            (zero_height - TARGET_HEIGHT_M).abs() < 0.0001,
5556            "configured zero missed target: height={zero_height}"
5557        );
5558
5559        let mut calm = configured_euler_zero(0.0, 0.02);
5560        let calm_angle = calm
5561            .calculate_and_set_zero_angle(
5562                TARGET_DISTANCE_M,
5563                TARGET_HEIGHT_M,
5564                ZeroTargetFrame::SightLine,
5565            )
5566            .expect("calm zero");
5567        assert!(
5568            (segmented_angle - calm_angle).abs() > 1e-5,
5569            "segmented vertical wind must participate in zero trials: segmented={segmented_angle}, calm={calm_angle}"
5570        );
5571    }
5572}
5573
5574#[cfg(test)]
5575mod result_sanity_tests {
5576    use super::*;
5577
5578    fn default_solver() -> TrajectorySolver {
5579        TrajectorySolver::new(
5580            BallisticInputs::default(),
5581            WindConditions::default(),
5582            AtmosphericConditions::default(),
5583        )
5584    }
5585
5586    fn minimal_result() -> TrajectoryResult {
5587        TrajectoryResult {
5588            max_range: 100.0,
5589            max_height: 1.0,
5590            time_of_flight: 0.5,
5591            impact_velocity: 700.0,
5592            impact_energy: 2450.0,
5593            projectile_mass_kg: 0.01,
5594            line_of_sight_height_m: 1.5,
5595            station_speed_of_sound_mps: 340.0,
5596            termination: TrajectoryTermination::MaxRange,
5597            points: vec![],
5598            sampled_points: None,
5599            min_pitch_damping: None,
5600            transonic_mach: None,
5601            angular_state: None,
5602            max_yaw_angle: None,
5603            max_precession_angle: None,
5604            aerodynamic_jump: None,
5605            mach_1_2_distance_m: None,
5606            mach_1_0_distance_m: None,
5607            mach_0_9_distance_m: None,
5608        }
5609    }
5610
5611    #[test]
5612    fn mba1293_negative_scalars_fail_the_result_postcondition() {
5613        let solver = default_solver();
5614        solver
5615            .validate_result_sanity(&minimal_result())
5616            .expect("a sane result must pass");
5617
5618        for (name, mutate) in [
5619            (
5620                "max_range",
5621                (|r| r.max_range = -50.588) as fn(&mut TrajectoryResult),
5622            ),
5623            ("time_of_flight", |r| r.time_of_flight = -1.0),
5624            ("impact_velocity", |r| r.impact_velocity = -700.0),
5625            ("impact_energy", |r| r.impact_energy = -1.0),
5626        ] {
5627            let mut result = minimal_result();
5628            mutate(&mut result);
5629            let error = solver
5630                .validate_result_sanity(&result)
5631                .expect_err("negative scalar must fail");
5632            assert!(
5633                error.to_string().contains(name),
5634                "error for {name} did not name the field: {error}"
5635            );
5636        }
5637    }
5638
5639    #[test]
5640    fn mba1293_speed_budget_bounds_legitimate_states_and_rejects_divergence() {
5641        let solver = default_solver();
5642        let mv = solver.inputs.muzzle_velocity;
5643
5644        // A state at muzzle speed is always inside the budget.
5645        let position = Vector3::new(10.0, 0.0, 0.0);
5646        solver
5647            .validate_integration_state(&position, &Vector3::new(mv, 0.0, 0.0), 0.01)
5648            .expect("muzzle-speed state must pass");
5649
5650        // The MBA-1293 explosion (13x the muzzle speed) must be rejected as divergence.
5651        let error = solver
5652            .validate_integration_state(&position, &Vector3::new(-13.0 * mv, 0.0, 0.0), 0.01)
5653            .expect_err("13x muzzle speed must fail the budget");
5654        assert!(error.to_string().contains("diverged"), "{error}");
5655
5656        // The budget grows with gravity's g*t so long lobbed arcs never trip it.
5657        let after_fall = mv + crate::constants::G_ACCEL_MPS2 * 60.0;
5658        solver
5659            .validate_integration_state(&position, &Vector3::new(0.0, -after_fall, 0.0), 60.0)
5660            .expect("gravity-accelerated speed within g*t must pass");
5661    }
5662}
5663
5664#[cfg(test)]
5665mod trajectory_point_budget_tests {
5666    use super::*;
5667    use crate::MAX_TRAJECTORY_SAMPLES;
5668
5669    fn solver_with_budget(
5670        use_rk4: bool,
5671        use_adaptive_rk45: bool,
5672        point_budget: usize,
5673        max_range: f64,
5674    ) -> TrajectorySolver {
5675        let inputs = BallisticInputs {
5676            use_rk4,
5677            use_adaptive_rk45,
5678            ground_threshold: f64::NEG_INFINITY,
5679            ..BallisticInputs::default()
5680        };
5681        let mut solver = TrajectorySolver::new(
5682            inputs,
5683            WindConditions::default(),
5684            AtmosphericConditions::default(),
5685        );
5686        solver.max_trajectory_points = point_budget;
5687        solver.set_max_range(max_range);
5688        solver.set_time_step(0.001);
5689        solver
5690    }
5691
5692    #[test]
5693    fn mba1283_every_solver_errors_instead_of_exceeding_point_budget() {
5694        for (mode, use_rk4, use_adaptive_rk45) in [
5695            ("Euler", false, false),
5696            ("RK4", true, false),
5697            ("RK45", true, true),
5698        ] {
5699            let error = solver_with_budget(use_rk4, use_adaptive_rk45, 3, 10.0)
5700                .solve()
5701                .expect_err("a solve requiring more than three points must fail");
5702            assert!(
5703                error.to_string().contains("point limit of 3"),
5704                "unexpected {mode} point-budget error: {error}"
5705            );
5706        }
5707    }
5708
5709    #[test]
5710    fn mba1283_interpolated_endpoint_counts_toward_point_budget() {
5711        for (mode, use_rk4, use_adaptive_rk45) in [
5712            ("Euler", false, false),
5713            ("RK4", true, false),
5714            ("RK45", true, true),
5715        ] {
5716            let result = solver_with_budget(use_rk4, use_adaptive_rk45, 2, 0.1)
5717                .solve()
5718                .expect("the initial point plus exact endpoint fit a two-point budget");
5719            assert_eq!(result.points.len(), 2, "unexpected {mode} point count");
5720
5721            let error = solver_with_budget(use_rk4, use_adaptive_rk45, 1, 0.1)
5722                .solve()
5723                .expect_err("the exact endpoint must not exceed a one-point budget");
5724            assert!(
5725                error.to_string().contains("point limit of 1"),
5726                "unexpected {mode} endpoint-budget error: {error}"
5727            );
5728        }
5729    }
5730
5731    /// MBA-1599: a stalled flight stores one point per downrange distance, but every step it
5732    /// collapses still counts against the point budget. The budget here is one more than the
5733    /// points the flight stores, so the stored points alone can never reach it and only the
5734    /// collapsed steps can. It is still far smaller than the steps the flight took, so the solve
5735    /// fails at the budget as it did when every step was stored, instead of running on to the
5736    /// ground threshold. (A budget of exactly the stored points would fail on the first collapse
5737    /// after the last stored point whether or not collapsed steps were counted.)
5738    #[test]
5739    fn mba1599_collapsed_stall_steps_count_toward_point_budget() {
5740        for (mode, use_rk4, use_adaptive_rk45, bc_value) in [
5741            ("Euler", false, false, 2.0e-4),
5742            ("RK4", true, false, 1.0e-4),
5743            ("RK45", true, true, 1.0e-4),
5744        ] {
5745            let stalled = |point_budget: usize| {
5746                let inputs = BallisticInputs {
5747                    bc_value,
5748                    bc_type: DragModel::G7,
5749                    bullet_mass: 0.01134,
5750                    bullet_diameter: 0.00782,
5751                    muzzle_velocity: 823.0,
5752                    use_rk4,
5753                    use_adaptive_rk45,
5754                    ground_threshold: -100.0,
5755                    ..BallisticInputs::default()
5756                };
5757                let mut solver = TrajectorySolver::new(
5758                    inputs,
5759                    WindConditions::default(),
5760                    AtmosphericConditions::default(),
5761                );
5762                solver.max_trajectory_points = point_budget;
5763                solver.set_max_range(300.0);
5764                solver.set_time_step(0.001);
5765                solver.solve()
5766            };
5767
5768            let flown = stalled(MAX_TRAJECTORY_POINTS)
5769                .unwrap_or_else(|error| panic!("{mode}: the stall ends inside the cap: {error}"));
5770            assert_eq!(
5771                flown.termination,
5772                TrajectoryTermination::GroundThreshold,
5773                "{mode}"
5774            );
5775            let stored = flown.points.len();
5776            let budget = stored + 1;
5777            let Err(error) = stalled(budget) else {
5778                panic!("{mode}: the flight fit a {budget}-point budget with {stored} stored points, so the collapsed steps were not counted");
5779            };
5780            assert!(
5781                error
5782                    .to_string()
5783                    .contains(&format!("trajectory point limit of {budget} exceeded")),
5784                "unexpected {mode} stall-budget error: {error}"
5785            );
5786        }
5787    }
5788
5789    /// MBA-1599: the budget counts calls, stored or collapsed, so the call that fails is call
5790    /// budget + 1 however many earlier calls collapsed. That is the call that failed when every
5791    /// call stored a point. The stored terminal point is held to the same count.
5792    #[test]
5793    fn mba1599_point_budget_fails_on_the_same_call_with_or_without_collapsing() {
5794        let solver = solver_with_budget(true, false, 4, 10.0);
5795        let point = |x: f64| TrajectoryPoint {
5796            time: x,
5797            position: Vector3::new(x, 0.0, 0.0),
5798            velocity_magnitude: 1.0,
5799            kinetic_energy: 0.0,
5800            drag_coefficient: None,
5801        };
5802        let assert_over_budget = |error: BallisticsError| {
5803            assert!(
5804                error
5805                    .to_string()
5806                    .contains("trajectory point limit of 4 exceeded"),
5807                "unexpected point-budget error: {error}"
5808            );
5809        };
5810        for distances in [
5811            [0.0, 1.0, 2.0, 3.0],
5812            [0.0, 1.0, 1.0, 2.0],
5813            [0.0, 0.0, 0.0, 0.0],
5814        ] {
5815            let mut points = Vec::new();
5816            let mut collapsed_steps = 0;
5817            for x in distances {
5818                solver
5819                    .push_trajectory_point(&mut points, point(x), &mut collapsed_steps)
5820                    .expect("four calls fit a four-point budget");
5821            }
5822            assert_eq!(points.len() + collapsed_steps, 4, "{distances:?}");
5823
5824            // A fifth call fails whether it would collapse or store a point.
5825            for x in [distances[3], 5.0] {
5826                let error = solver
5827                    .push_trajectory_point(
5828                        &mut points.clone(),
5829                        point(x),
5830                        &mut collapsed_steps.clone(),
5831                    )
5832                    .expect_err("a fifth call exceeds a four-point budget");
5833                assert_over_budget(error);
5834            }
5835            // So does a terminal point stored after four integration steps.
5836            let error = solver
5837                .append_terminal_endpoint(
5838                    &mut points.clone(),
5839                    Vector3::new(20.0, 0.0, 0.0),
5840                    Vector3::new(1.0, 0.0, 0.0),
5841                    10.0,
5842                    &mut 0.0,
5843                    &mut collapsed_steps.clone(),
5844                )
5845                .expect_err("the terminal point is a fifth call");
5846            assert_over_budget(error);
5847        }
5848    }
5849
5850    #[test]
5851    fn mba1299_every_solver_preflights_the_sample_budget() {
5852        for (mode, use_rk4, use_adaptive_rk45) in [
5853            ("Euler", false, false),
5854            ("RK4", true, false),
5855            ("RK45", true, true),
5856        ] {
5857            let inputs = BallisticInputs {
5858                use_rk4,
5859                use_adaptive_rk45,
5860                enable_trajectory_sampling: true,
5861                sample_interval: 1.0,
5862                ground_threshold: f64::NEG_INFINITY,
5863                ..BallisticInputs::default()
5864            };
5865            let mut solver = TrajectorySolver::new(
5866                inputs,
5867                WindConditions::default(),
5868                AtmosphericConditions::default(),
5869            );
5870            solver.set_max_range(MAX_TRAJECTORY_SAMPLES as f64);
5871            // If validation does not reject the sample grid before dispatch, the first attempted
5872            // integration point produces a distinct point-budget error.
5873            solver.max_trajectory_points = 0;
5874
5875            let error = solver
5876                .solve()
5877                .expect_err("an over-limit sample grid must fail before integration");
5878            assert!(
5879                error
5880                    .to_string()
5881                    .contains("trajectory sample limit of 250000 exceeded"),
5882                "unexpected {mode} sample-budget error: {error}"
5883            );
5884        }
5885    }
5886
5887    #[test]
5888    fn mba1299_normal_sampling_does_not_change_solver_results() {
5889        for (mode, use_rk4, use_adaptive_rk45) in [
5890            ("Euler", false, false),
5891            ("RK4", true, false),
5892            ("RK45", true, true),
5893        ] {
5894            let solve = |enable_trajectory_sampling| {
5895                let inputs = BallisticInputs {
5896                    use_rk4,
5897                    use_adaptive_rk45,
5898                    enable_trajectory_sampling,
5899                    sample_interval: 0.5,
5900                    ground_threshold: f64::NEG_INFINITY,
5901                    ..BallisticInputs::default()
5902                };
5903                let mut solver = TrajectorySolver::new(
5904                    inputs,
5905                    WindConditions::default(),
5906                    AtmosphericConditions::default(),
5907                );
5908                solver.set_max_range(2.0);
5909                solver.solve().expect("normal short-range solve")
5910            };
5911
5912            let baseline = solve(false);
5913            let sampled = solve(true);
5914            for (field, left, right) in [
5915                ("max_range", baseline.max_range, sampled.max_range),
5916                ("max_height", baseline.max_height, sampled.max_height),
5917                (
5918                    "time_of_flight",
5919                    baseline.time_of_flight,
5920                    sampled.time_of_flight,
5921                ),
5922                (
5923                    "impact_velocity",
5924                    baseline.impact_velocity,
5925                    sampled.impact_velocity,
5926                ),
5927                (
5928                    "impact_energy",
5929                    baseline.impact_energy,
5930                    sampled.impact_energy,
5931                ),
5932            ] {
5933                assert_eq!(
5934                    left.to_bits(),
5935                    right.to_bits(),
5936                    "{mode} sampling changed {field}"
5937                );
5938            }
5939            assert_eq!(baseline.points.len(), sampled.points.len());
5940            for (index, (left, right)) in baseline.points.iter().zip(&sampled.points).enumerate() {
5941                assert_eq!(
5942                    left.time.to_bits(),
5943                    right.time.to_bits(),
5944                    "{mode} point {index}"
5945                );
5946                assert_eq!(
5947                    left.position.map(f64::to_bits),
5948                    right.position.map(f64::to_bits),
5949                    "{mode} point {index} position"
5950                );
5951                assert_eq!(
5952                    left.velocity_magnitude.to_bits(),
5953                    right.velocity_magnitude.to_bits(),
5954                    "{mode} point {index} velocity"
5955                );
5956                assert_eq!(
5957                    left.kinetic_energy.to_bits(),
5958                    right.kinetic_energy.to_bits(),
5959                    "{mode} point {index} energy"
5960                );
5961            }
5962            assert!(baseline.sampled_points.is_none());
5963            let samples = sampled
5964                .sampled_points
5965                .expect("sampling-enabled solve should return observations");
5966            assert_eq!(
5967                samples
5968                    .iter()
5969                    .map(|sample| sample.distance_m)
5970                    .collect::<Vec<_>>(),
5971                vec![0.0, 0.5, 1.0, 1.5, 2.0],
5972                "{mode} normal sampling grid changed"
5973            );
5974        }
5975    }
5976}
5977
5978#[cfg(test)]
5979mod monte_carlo_result_tests {
5980    use super::*;
5981
5982    fn make_results(impact_positions: Vec<Vector3<f64>>) -> MonteCarloResults {
5983        let count = impact_positions.len();
5984        MonteCarloResults {
5985            ranges: vec![500.0; count],
5986            impact_velocities: vec![300.0; count],
5987            impact_positions,
5988        }
5989    }
5990
5991    #[test]
5992    fn target_plane_cep_excludes_shortfall_markers() {
5993        let mut positions: Vec<Vector3<f64>> = (1..=5)
5994            .map(|radius| Vector3::new(0.0, radius as f64, 0.0))
5995            .collect();
5996        positions.extend((0..5).map(|_| Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)));
5997        let results = make_results(positions);
5998
5999        assert_eq!(results.target_arrival_count(), 5);
6000        assert_eq!(results.target_shortfall_fraction(), 0.5);
6001        assert_eq!(results.target_plane_cep(), Some(3.0));
6002
6003        let one_shortfall = make_results(vec![
6004            Vector3::new(0.0, 1.0, 0.0),
6005            Vector3::new(0.0, 2.0, 0.0),
6006            Vector3::new(0.0, 3.0, 0.0),
6007            Vector3::new(0.0, 4.0, 0.0),
6008            Vector3::new(0.0, 5.0, 0.0),
6009            Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
6010        ]);
6011        assert_eq!(one_shortfall.target_plane_cep(), Some(3.0));
6012    }
6013
6014    #[test]
6015    fn all_shortfalls_have_no_cep_but_still_count_as_misses() {
6016        let all_shortfalls = make_results(vec![
6017            Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
6018            Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
6019        ]);
6020        assert_eq!(all_shortfalls.target_arrival_count(), 0);
6021        assert_eq!(all_shortfalls.target_shortfall_fraction(), 1.0);
6022        assert_eq!(all_shortfalls.target_plane_cep(), None);
6023        assert_eq!(all_shortfalls.hit_probability(0.3), 0.0);
6024
6025        let one_hit_one_shortfall = make_results(vec![
6026            Vector3::new(0.0, 0.1, 0.0),
6027            Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
6028        ]);
6029        assert_eq!(one_hit_one_shortfall.hit_probability(0.3), 0.5);
6030    }
6031
6032    // MBA-1317: WEZ rectangular hit probability.
6033    #[test]
6034    fn rect_hit_probability_checks_independent_axis_halves() {
6035        let results = make_results(vec![
6036            // Inside a 0.4 (lateral) x 0.6 (vertical) box: half-width 0.2, half-height 0.3.
6037            Vector3::new(0.0, 0.1, 0.1),
6038            // On the lateral edge (exactly half-width) -> counts as a hit ("<=").
6039            Vector3::new(0.0, 0.0, 0.2),
6040            // Outside laterally (just past half-width).
6041            Vector3::new(0.0, 0.0, 0.201),
6042            // Outside vertically (just past half-height).
6043            Vector3::new(0.0, 0.301, 0.0),
6044            // Shortfall marker: stays in the denominator, never a hit.
6045            Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
6046        ]);
6047        // 2 hits out of 5 samples.
6048        assert!((results.rect_hit_probability(0.4, 0.6) - 0.4).abs() < 1e-12);
6049    }
6050
6051    #[test]
6052    fn rect_hit_probability_matches_circular_hit_probability_for_a_centered_hit() {
6053        let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
6054        assert_eq!(results.rect_hit_probability(0.5, 0.5), 1.0);
6055        assert_eq!(results.hit_probability(0.3), 1.0);
6056    }
6057
6058    #[test]
6059    fn rect_hit_probability_is_zero_for_empty_or_nonpositive_dimensions() {
6060        let empty = make_results(vec![]);
6061        assert_eq!(empty.rect_hit_probability(1.0, 1.0), 0.0);
6062
6063        let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
6064        assert_eq!(results.rect_hit_probability(0.0, 1.0), 0.0);
6065        assert_eq!(results.rect_hit_probability(1.0, 0.0), 0.0);
6066        assert_eq!(results.rect_hit_probability(-1.0, 1.0), 0.0);
6067    }
6068}
6069
6070#[cfg(test)]
6071mod monte_carlo_seeded_tests {
6072    use super::*;
6073
6074    /// Shared fixture for the seeded Monte Carlo tests: the crate defaults at an explicit
6075    /// 800 m/s muzzle velocity in still air.
6076    ///
6077    /// The brief for this task assumed the module already had such a helper; it did not (every
6078    /// test above restated the same two literals inline), so it is introduced here and the
6079    /// adaptive-driver tests below share it. The existing tests keep their own inline fixtures
6080    /// deliberately: rewriting them to call this helper would change nothing about what they
6081    /// assert but would put unrelated churn in the same commit as a bit-for-bit pin.
6082    fn seeded_test_fixture() -> (BallisticInputs, WindConditions) {
6083        (
6084            BallisticInputs {
6085                muzzle_velocity: 800.0,
6086                ..BallisticInputs::default()
6087            },
6088            WindConditions::default(),
6089        )
6090    }
6091
6092    /// Dispersions for the adaptive-driver tests: a 300 m shot with small pointing and wind
6093    /// error, so almost every trial lands inside `DEFAULT_HIT_RADIUS_M` of the baseline.
6094    ///
6095    /// Tuned for *test wall time*, deliberately. A confidence sequence's half-width scales
6096    /// with `sqrt(p(1-p) * log(n) / n)`, so a hit probability near `1` reaches the default
6097    /// `0.02` target inside `min_samples` (1000 trials) while a `p` near `0.5` would need
6098    /// roughly 4-5x as many -- and every trial is a full trajectory solve, run unoptimised
6099    /// under `cargo test`. It stays a little short of a degenerate `p == 1`: a handful of
6100    /// misses in 1000 keeps the hit *counting* under test rather than trivially saturated.
6101    fn loose_params() -> MonteCarloParams {
6102        MonteCarloParams {
6103            num_simulations: 1, // deliberately absurd: the adaptive driver must ignore it
6104            velocity_std_dev: 3.0,
6105            angle_std_dev: 3.5e-4,
6106            bc_std_dev: 0.01,
6107            wind_speed_std_dev: 1.0,
6108            target_distance: Some(300.0),
6109            base_wind_speed: 0.0,
6110            base_wind_direction: 0.0,
6111            azimuth_std_dev: 3.5e-4,
6112        }
6113    }
6114
6115    /// Params whose target plane sits *inside* the ground-impact scatter, so some trials
6116    /// arrive at it and some fall short.
6117    ///
6118    /// The pin fixture records ground impact between 1907 m and 1936 m for this projectile, so
6119    /// a 1920 m plane splits the run. That split is the point: with `loose_params()` every
6120    /// trial arrives, which makes `arrivals`, `samples` and `attempts` numerically identical
6121    /// and therefore useless for catching a report that populates one of them from another.
6122    fn mixed_arrival_params() -> MonteCarloParams {
6123        MonteCarloParams {
6124            num_simulations: 1,
6125            target_distance: Some(1920.0),
6126            ..MonteCarloParams::default()
6127        }
6128    }
6129
6130    /// THE bit-for-bit compatibility contract for the legacy seeded Monte Carlo path
6131    /// (Plan C spec 9.5).
6132    ///
6133    /// Every literal below was captured by running this exact fixture against the tree at
6134    /// commit 5a421bf ("fix(decision-support): close the last three Plan B branch-review
6135    /// gaps"), i.e. the last commit before any Plan C change touched `src/cli_api.rs`, and
6136    /// printed with `{:?}` (shortest round-tripping form, so the pasted literal reproduces the
6137    /// captured `f64` to the bit).
6138    ///
6139    /// It exists to be run BEFORE and AFTER the per-trial body is extracted into
6140    /// [`MonteCarloTrialSampler::sample_one_trial`]. The extraction is only legitimate if the
6141    /// sequence and count of RNG draws per trial is unchanged, and that is not something the
6142    /// type system can check: a reordered or added draw shifts every subsequent sample and
6143    /// shows up here as a mismatch. If this test fails after a refactor of the trial loop, the
6144    /// refactor is wrong -- do not re-capture the literals.
6145    #[test]
6146    fn legacy_seeded_estimates_are_pinned_bit_for_bit() {
6147        let (inputs, wind) = seeded_test_fixture();
6148        let params = MonteCarloParams {
6149            num_simulations: 200,
6150            target_distance: Some(500.0),
6151            ..MonteCarloParams::default()
6152        };
6153
6154        let results = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
6155            inputs,
6156            wind,
6157            params,
6158            0.01,
6159            0x1352_5EED,
6160        )
6161        .expect("seeded legacy run");
6162
6163        // Every trial is recorded: no solve failed and no target-plane interpolation was
6164        // skipped, so the three vectors are all 200 long. This is asserted, not assumed --
6165        // an extraction that started dropping trials would otherwise shorten the vectors
6166        // while the first three entries below still matched.
6167        assert_eq!(results.ranges.len(), 200, "ranges length");
6168        assert_eq!(
6169            results.impact_velocities.len(),
6170            200,
6171            "impact_velocities length"
6172        );
6173        assert_eq!(
6174            results.impact_positions.len(),
6175            200,
6176            "impact_positions length"
6177        );
6178
6179        // 28 hits in 200 trials.
6180        assert_eq!(
6181            results.hit_probability(DEFAULT_HIT_RADIUS_M).to_bits(),
6182            0.14_f64.to_bits(),
6183            "hit_probability = {:?}",
6184            results.hit_probability(DEFAULT_HIT_RADIUS_M)
6185        );
6186
6187        let expected_ranges: [f64; 3] = [1907.972891143359, 1936.408435469319, 1912.8150447617645];
6188        let expected_velocities: [f64; 3] =
6189            [238.6187151542299, 239.91923651600106, 243.14112455427164];
6190        let expected_positions: [(f64, f64, f64); 3] = [
6191            (0.0, -0.0643556039548101, 0.7344970252014579),
6192            (0.0, 0.5769422971539162, 0.27227201756386726),
6193            (0.0, -0.7440425792472842, 0.1541804446282822),
6194        ];
6195
6196        for (i, expected) in expected_ranges.iter().enumerate() {
6197            assert_eq!(
6198                results.ranges[i].to_bits(),
6199                expected.to_bits(),
6200                "ranges[{i}] = {:?}, pinned {expected:?}",
6201                results.ranges[i]
6202            );
6203        }
6204        for (i, expected) in expected_velocities.iter().enumerate() {
6205            assert_eq!(
6206                results.impact_velocities[i].to_bits(),
6207                expected.to_bits(),
6208                "impact_velocities[{i}] = {:?}, pinned {expected:?}",
6209                results.impact_velocities[i]
6210            );
6211        }
6212        for (i, (x, y, z)) in expected_positions.iter().enumerate() {
6213            let actual = results.impact_positions[i];
6214            assert_eq!(
6215                actual.x.to_bits(),
6216                x.to_bits(),
6217                "impact_positions[{i}].x = {:?}",
6218                actual.x
6219            );
6220            assert_eq!(
6221                actual.y.to_bits(),
6222                y.to_bits(),
6223                "impact_positions[{i}].y = {:?}",
6224                actual.y
6225            );
6226            assert_eq!(
6227                actual.z.to_bits(),
6228                z.to_bits(),
6229                "impact_positions[{i}].z = {:?}",
6230                actual.z
6231            );
6232        }
6233    }
6234
6235    #[test]
6236    fn seeded_runs_are_deterministic_and_match_the_using_rng_path() {
6237        let inputs = BallisticInputs {
6238            muzzle_velocity: 800.0,
6239            ..BallisticInputs::default()
6240        };
6241        let params = MonteCarloParams {
6242            num_simulations: 64,
6243            target_distance: Some(200.0),
6244            ..MonteCarloParams::default()
6245        };
6246
6247        let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
6248            inputs.clone(),
6249            WindConditions::default(),
6250            params.clone(),
6251            0.01,
6252            42,
6253        )
6254        .expect("seeded run a");
6255        let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
6256            inputs,
6257            WindConditions::default(),
6258            params,
6259            0.01,
6260            42,
6261        )
6262        .expect("seeded run b");
6263
6264        assert_eq!(a.ranges.len(), b.ranges.len());
6265        for (ra, rb) in a.ranges.iter().zip(b.ranges.iter()) {
6266            assert_eq!(ra.to_bits(), rb.to_bits());
6267        }
6268        for (pa, pb) in a.impact_positions.iter().zip(b.impact_positions.iter()) {
6269            assert_eq!(pa.x.to_bits(), pb.x.to_bits());
6270            assert_eq!(pa.y.to_bits(), pb.y.to_bits());
6271            assert_eq!(pa.z.to_bits(), pb.z.to_bits());
6272        }
6273    }
6274
6275    #[test]
6276    fn different_seeds_generally_produce_different_draws() {
6277        let inputs = BallisticInputs {
6278            muzzle_velocity: 800.0,
6279            ..BallisticInputs::default()
6280        };
6281        let params = MonteCarloParams {
6282            num_simulations: 32,
6283            velocity_std_dev: 5.0,
6284            target_distance: Some(200.0),
6285            ..MonteCarloParams::default()
6286        };
6287
6288        let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
6289            inputs.clone(),
6290            WindConditions::default(),
6291            params.clone(),
6292            0.0,
6293            1,
6294        )
6295        .expect("seeded run a");
6296        let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
6297            inputs,
6298            WindConditions::default(),
6299            params,
6300            0.0,
6301            2,
6302        )
6303        .expect("seeded run b");
6304
6305        assert_ne!(a.impact_velocities, b.impact_velocities);
6306    }
6307
6308    #[test]
6309    fn adaptive_stops_at_target_half_width_on_an_easy_case() {
6310        let (inputs, wind) = seeded_test_fixture();
6311        let conv = McConvergence {
6312            target_half_width: 0.05,
6313            ..Default::default()
6314        };
6315        let r = run_monte_carlo_adaptive_seeded(
6316            &inputs,
6317            &wind,
6318            &loose_params(),
6319            &conv,
6320            DEFAULT_HIT_RADIUS_M,
6321            0x1352_ADA9,
6322        )
6323        .unwrap();
6324
6325        assert_eq!(r.stop_reason, McStopReason::TargetHalfWidthMet);
6326        assert!(
6327            (r.ci_high - r.ci_low) / 2.0 <= 0.05 + 1e-12,
6328            "half-width {} exceeds the requested 0.05",
6329            (r.ci_high - r.ci_low) / 2.0
6330        );
6331        assert!(r.samples >= conv.min_samples, "stopped below min_samples");
6332        assert!(r.samples < conv.max_samples, "did not actually stop early");
6333        assert!(r.samples.is_multiple_of(conv.batch_size) || r.samples == conv.min_samples);
6334        assert!(r.ci_low <= r.hit_probability && r.hit_probability <= r.ci_high);
6335
6336        // The report must describe the run it actually did, not the params it ignored.
6337        assert_eq!(r.hit_radius_m, DEFAULT_HIT_RADIUS_M);
6338        assert_eq!(r.target_distance_m, 300.0);
6339        assert_eq!(r.confidence_percent, 95);
6340        // `num_simulations` is 1 in `loose_params`; if it were honoured we would see 1 sample.
6341        assert!(
6342            r.samples > 1,
6343            "params.num_simulations must be ignored by the adaptive driver"
6344        );
6345        // The streaming moments ran over trials that reached the plane: a 300 m shot at
6346        // 800 m/s is still supersonic, and the dispersions are nonzero, so both must be too.
6347        assert!(
6348            r.mean_impact_velocity_mps > 0.0,
6349            "no impact velocity accumulated"
6350        );
6351        assert!(
6352            r.std_drop_at_target_m > 0.0 && r.std_wind_drift_at_target_m > 0.0,
6353            "dispersion collapsed: drop sd {} drift sd {}",
6354            r.std_drop_at_target_m,
6355            r.std_wind_drift_at_target_m
6356        );
6357    }
6358
6359    #[test]
6360    fn adaptive_caps_at_max_samples_on_an_impossible_target() {
6361        let (inputs, wind) = seeded_test_fixture();
6362        let conv = McConvergence {
6363            target_half_width: 1e-6,
6364            max_samples: 3_000,
6365            batch_size: 500,
6366            min_samples: 1_000,
6367            level: ConfidenceLevel::P95,
6368        };
6369        let r = run_monte_carlo_adaptive_seeded(
6370            &inputs,
6371            &wind,
6372            &loose_params(),
6373            &conv,
6374            DEFAULT_HIT_RADIUS_M,
6375            7,
6376        )
6377        .unwrap();
6378
6379        assert_eq!(r.stop_reason, McStopReason::MaxSamplesReached);
6380        assert_eq!(r.samples, 3_000);
6381    }
6382
6383    /// The adaptive middle: a stop that is decided by the DATA, not by either bound.
6384    ///
6385    /// Every other stopping test lands on a boundary -- the easy case and the determinism case
6386    /// both stop at exactly `min_samples`, and the impossible case runs to `max_samples`. None
6387    /// of them would notice if the half-width check were, say, only consulted on the first
6388    /// batch. Here `min_samples` is 0 and `max_samples` is far away, so the sample count is
6389    /// whatever the confidence sequence decides it is, and the assertions pin that it landed
6390    /// strictly inside both bounds after more than one batch.
6391    #[test]
6392    fn adaptive_stops_between_the_floor_and_the_ceiling() {
6393        let (inputs, wind) = seeded_test_fixture();
6394        let conv = McConvergence {
6395            level: ConfidenceLevel::P95,
6396            target_half_width: 0.03,
6397            min_samples: 0,
6398            max_samples: 10_000,
6399            batch_size: 100,
6400        };
6401        let r = run_monte_carlo_adaptive_seeded(
6402            &inputs,
6403            &wind,
6404            &loose_params(),
6405            &conv,
6406            DEFAULT_HIT_RADIUS_M,
6407            0x1352_5A1D,
6408        )
6409        .unwrap();
6410
6411        assert_eq!(r.stop_reason, McStopReason::TargetHalfWidthMet);
6412        assert!(r.samples > 0);
6413        assert!(
6414            r.samples.is_multiple_of(conv.batch_size),
6415            "samples {} is not a whole number of batches",
6416            r.samples
6417        );
6418        assert!(
6419            r.samples > conv.min_samples,
6420            "stopped on the floor, not on the data"
6421        );
6422        assert!(
6423            r.samples < conv.max_samples,
6424            "ran to the ceiling, so nothing adaptive was exercised"
6425        );
6426        assert!(
6427            r.samples > conv.batch_size,
6428            "stopped on the very first look ({} samples); the multi-batch path is untested",
6429            r.samples
6430        );
6431        assert!((r.ci_high - r.ci_low) / 2.0 <= 0.03 + 1e-12);
6432        assert!(r.ci_low <= r.hit_probability && r.hit_probability <= r.ci_high);
6433        assert_eq!(r.attempts, r.samples, "no trial should have been dropped");
6434    }
6435
6436    /// The final batch is truncated so `max_samples` is a hard ceiling, not a threshold the
6437    /// last batch overshoots.
6438    ///
6439    /// `750 = 500 + 250`, so the second batch must run short. With a `max_samples` that is a
6440    /// whole multiple of `batch_size` -- as in every other test here -- the
6441    /// `batch_size.min(max_samples - attempts)` clamp never actually clamps, and a driver that
6442    /// ignored it entirely would pass. This one would report 1000.
6443    #[test]
6444    fn adaptive_runs_a_truncated_final_batch_up_to_max_samples() {
6445        let (inputs, wind) = seeded_test_fixture();
6446        let conv = McConvergence {
6447            level: ConfidenceLevel::P95,
6448            target_half_width: 1e-6,
6449            min_samples: 0,
6450            max_samples: 750,
6451            batch_size: 500,
6452        };
6453        let r = run_monte_carlo_adaptive_seeded(
6454            &inputs,
6455            &wind,
6456            &loose_params(),
6457            &conv,
6458            DEFAULT_HIT_RADIUS_M,
6459            0x1352_7B10,
6460        )
6461        .unwrap();
6462
6463        assert_eq!(r.stop_reason, McStopReason::MaxSamplesReached);
6464        assert_eq!(
6465            r.samples, 750,
6466            "the 250-trial final batch did not run, or was not truncated"
6467        );
6468        assert_eq!(r.attempts, 750);
6469        assert!(!r.samples.is_multiple_of(conv.batch_size));
6470    }
6471
6472    #[test]
6473    fn adaptive_is_deterministic_for_a_seed() {
6474        let (inputs, wind) = seeded_test_fixture();
6475        let conv = McConvergence::default();
6476        let a = run_monte_carlo_adaptive_seeded(
6477            &inputs,
6478            &wind,
6479            &loose_params(),
6480            &conv,
6481            DEFAULT_HIT_RADIUS_M,
6482            99,
6483        )
6484        .unwrap();
6485        let b = run_monte_carlo_adaptive_seeded(
6486            &inputs,
6487            &wind,
6488            &loose_params(),
6489            &conv,
6490            DEFAULT_HIT_RADIUS_M,
6491            99,
6492        )
6493        .unwrap();
6494
6495        assert_eq!(a.hit_probability.to_bits(), b.hit_probability.to_bits());
6496        assert_eq!(a.samples, b.samples);
6497        assert_eq!(a.ci_low.to_bits(), b.ci_low.to_bits());
6498        assert_eq!(a.ci_high.to_bits(), b.ci_high.to_bits());
6499        // The streaming moments have to be reproducible too, not just the hit counts: they
6500        // are the part that would drift if the trial body consumed draws inconsistently.
6501        assert_eq!(
6502            a.mean_impact_velocity_mps.to_bits(),
6503            b.mean_impact_velocity_mps.to_bits()
6504        );
6505        assert_eq!(
6506            a.std_drop_at_target_m.to_bits(),
6507            b.std_drop_at_target_m.to_bits()
6508        );
6509    }
6510
6511    #[test]
6512    fn adaptive_report_carries_schema_method_and_all_four_assumptions() {
6513        let (inputs, wind) = seeded_test_fixture();
6514        // Nothing here depends on the statistics converging, so keep it to one batch. The
6515        // fixture is the mixed-arrival one so the three cardinalities come out DISTINCT --
6516        // see the assertions below.
6517        let conv = McConvergence {
6518            min_samples: 0,
6519            max_samples: 50,
6520            batch_size: 50,
6521            target_half_width: 1.0,
6522            level: ConfidenceLevel::P90,
6523        };
6524        let r = run_monte_carlo_adaptive_seeded(
6525            &inputs,
6526            &wind,
6527            &mixed_arrival_params(),
6528            &conv,
6529            DEFAULT_HIT_RADIUS_M,
6530            0x1352_D0C5,
6531        )
6532        .unwrap();
6533
6534        assert_eq!(r.schema_version, MC_ADAPTIVE_SCHEMA_VERSION_V1);
6535        assert_eq!(r.schema_version, 1);
6536        assert_eq!(r.method, "anytime_beta_binomial_mixture_cs_v1");
6537        assert_eq!(r.confidence_percent, 90);
6538
6539        // The three cardinalities, pinned against each other rather than each against 50.
6540        // `attempts` and `samples` are both 50 here (nothing was dropped), but `arrivals` is
6541        // strictly smaller because the 1920 m plane sits inside the ground-impact scatter --
6542        // so a report that populated `arrivals` from `samples` (or vice versa) fails here,
6543        // which it could not if all three were the same number.
6544        assert_eq!(r.attempts, 50, "one full batch was drawn");
6545        assert_eq!(r.samples, 50, "no trial was dropped by the solver");
6546        assert!(
6547            r.arrivals > 0 && r.arrivals < r.samples,
6548            "fixture must split the run: arrivals {} of samples {}",
6549            r.arrivals,
6550            r.samples
6551        );
6552        // ...and the at-target statistics really are the `arrivals` population: two or more
6553        // arrivals is what makes a Bessel-corrected standard deviation defined at all.
6554        assert!(
6555            r.arrivals >= 2,
6556            "arrivals {} too few for a sample sd",
6557            r.arrivals
6558        );
6559        assert!(r.std_drop_at_target_m > 0.0 && r.std_impact_velocity_mps > 0.0);
6560        // The ordering invariant the doc comments promise.
6561        assert!(r.attempts >= r.samples && r.samples >= r.arrivals);
6562
6563        // Length AND per-index full-string equality against literals restated here character
6564        // for character. Deliberately NOT compared against MC_ADAPTIVE_ASSUMPTIONS_V1: that
6565        // would be circular, passing for any edit to the constant. `contains()` is likewise
6566        // not a pin -- a substring probe survives a sentence being reworded, truncated, or
6567        // having its meaning inverted around the matched word.
6568        assert_eq!(r.assumptions.len(), 4, "exactly four assumptions expected");
6569        assert_eq!(
6570            r.assumptions[0],
6571            "Sampling uncertainty only: intervals cover Monte Carlo sampling error, not model error in the trajectory solver or its inputs."
6572        );
6573        assert_eq!(
6574            r.assumptions[1],
6575            "Anytime-valid stopping: the beta-binomial mixture confidence sequence keeps its coverage guarantee despite stopping the moment the target half-width is met."
6576        );
6577        assert_eq!(
6578            r.assumptions[2],
6579            "Input dispersions are the independent normal distributions declared in MonteCarloParams; correlations between inputs are not modeled."
6580        );
6581        assert_eq!(
6582            r.assumptions[3],
6583            "Continuous statistics are streaming Welford moments over trials that reached the target plane, reported with sample (n-1) standard deviations; hit probability's denominator includes all trials."
6584        );
6585
6586        // The stop reason is a snake_case string on the wire, not a variant name or an index.
6587        assert_eq!(
6588            serde_json::to_string(&McStopReason::TargetHalfWidthMet).unwrap(),
6589            "\"target_half_width_met\""
6590        );
6591        assert_eq!(
6592            serde_json::to_string(&McStopReason::MaxSamplesReached).unwrap(),
6593            "\"max_samples_reached\""
6594        );
6595    }
6596
6597    #[test]
6598    fn adaptive_rejects_nonsense_convergence() {
6599        let (inputs, wind) = seeded_test_fixture();
6600        let run = |conv: McConvergence| {
6601            run_monte_carlo_adaptive_seeded(
6602                &inputs,
6603                &wind,
6604                &loose_params(),
6605                &conv,
6606                DEFAULT_HIT_RADIUS_M,
6607                1,
6608            )
6609            .unwrap_err()
6610        };
6611
6612        for bad_width in [0.0, -0.01, f64::NAN] {
6613            let err = run(McConvergence {
6614                target_half_width: bad_width,
6615                ..Default::default()
6616            });
6617            assert!(
6618                err.contains("target_half_width"),
6619                "error must name the field, got: {err}"
6620            );
6621        }
6622
6623        let err = run(McConvergence {
6624            batch_size: 0,
6625            ..Default::default()
6626        });
6627        assert!(err.contains("batch_size"), "got: {err}");
6628
6629        let err = run(McConvergence {
6630            min_samples: 5_000,
6631            max_samples: 1_000,
6632            ..Default::default()
6633        });
6634        assert!(err.contains("max_samples"), "got: {err}");
6635        assert!(err.contains("min_samples"), "got: {err}");
6636
6637        let err = run(McConvergence {
6638            max_samples: 0,
6639            min_samples: 0,
6640            ..Default::default()
6641        });
6642        assert!(err.contains("max_samples"), "got: {err}");
6643
6644        // Validation happens before any trajectory work: a rejected config costs no solves.
6645        let err = McConvergence {
6646            batch_size: 0,
6647            ..Default::default()
6648        }
6649        .validate()
6650        .unwrap_err();
6651        assert!(err.contains("batch_size"));
6652    }
6653
6654    #[test]
6655    fn wilson_companion_matches_hit_probability_and_wilson_interval() {
6656        let (inputs, wind) = seeded_test_fixture();
6657        let params = MonteCarloParams {
6658            num_simulations: 128,
6659            target_distance: Some(500.0),
6660            ..MonteCarloParams::default()
6661        };
6662        let results = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
6663            inputs,
6664            wind,
6665            params,
6666            0.01,
6667            0x1352_C0DE,
6668        )
6669        .expect("seeded legacy run");
6670
6671        for level in [
6672            ConfidenceLevel::P90,
6673            ConfidenceLevel::P95,
6674            ConfidenceLevel::P99,
6675        ] {
6676            let (p_hat, (lo, hi), n) = results.hit_probability_wilson(DEFAULT_HIT_RADIUS_M, level);
6677
6678            // The companion is a composition, not a re-derivation: p_hat must be the very
6679            // number `hit_probability` returns, bit for bit.
6680            assert_eq!(
6681                p_hat.to_bits(),
6682                results.hit_probability(DEFAULT_HIT_RADIUS_M).to_bits()
6683            );
6684            assert_eq!(n, results.impact_positions.len() as u64);
6685
6686            // ...and the bounds must be `wilson_interval` over the same counts. The hit count
6687            // is recomputed here from the shared predicate rather than taken from the
6688            // companion, so a wrong denominator (e.g. excluding target shortfalls) fails.
6689            let hits = results
6690                .impact_positions
6691                .iter()
6692                .filter(|p| MonteCarloResults::position_is_hit(p, DEFAULT_HIT_RADIUS_M))
6693                .count() as u64;
6694            let (want_lo, want_hi) = wilson_interval(hits, n, level);
6695            assert_eq!(lo.to_bits(), want_lo.to_bits());
6696            assert_eq!(hi.to_bits(), want_hi.to_bits());
6697            assert!(lo <= p_hat && p_hat <= hi, "interval excludes p_hat");
6698        }
6699
6700        // Empty results: hit_probability's 0.0 alongside wilson_interval's n == 0 ignorance.
6701        let empty = MonteCarloResults {
6702            ranges: Vec::new(),
6703            impact_velocities: Vec::new(),
6704            impact_positions: Vec::new(),
6705        };
6706        assert_eq!(
6707            empty.hit_probability_wilson(DEFAULT_HIT_RADIUS_M, ConfidenceLevel::P95),
6708            (0.0, (0.0, 1.0), 0)
6709        );
6710    }
6711}
6712
6713#[cfg(test)]
6714mod monte_carlo_powder_curve_tests {
6715    use super::*;
6716    use rand::{rngs::StdRng, SeedableRng};
6717
6718    #[test]
6719    fn powder_curve_preserves_sampled_muzzle_velocity_dispersion() {
6720        let inputs = BallisticInputs {
6721            muzzle_velocity: 700.0,
6722            powder_temp_curve: Some(vec![(15.0, 800.0)]),
6723            powder_curve_temp_c: Some(15.0),
6724            ..BallisticInputs::default()
6725        };
6726        let params = MonteCarloParams {
6727            num_simulations: 16,
6728            velocity_std_dev: 20.0,
6729            angle_std_dev: 1e-12,
6730            bc_std_dev: 1e-12,
6731            wind_speed_std_dev: 1e-12,
6732            target_distance: Some(100.0),
6733            azimuth_std_dev: 1e-12,
6734            ..MonteCarloParams::default()
6735        };
6736
6737        let mut rng = StdRng::seed_from_u64(0x5EED_1176);
6738        let results = run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
6739            inputs,
6740            WindConditions::default(),
6741            params,
6742            0.0,
6743            &mut rng,
6744        )
6745        .expect("Monte Carlo solve");
6746        let min_velocity = results
6747            .impact_velocities
6748            .iter()
6749            .copied()
6750            .fold(f64::INFINITY, f64::min);
6751        let max_velocity = results
6752            .impact_velocities
6753            .iter()
6754            .copied()
6755            .fold(f64::NEG_INFINITY, f64::max);
6756
6757        assert!(
6758            max_velocity - min_velocity > 1.0,
6759            "20 m/s muzzle spread collapsed after curve resolution: impact-velocity span={} m/s",
6760            max_velocity - min_velocity
6761        );
6762    }
6763}
6764
6765#[cfg(test)]
6766mod monte_carlo_wind_sampling_tests {
6767    use super::*;
6768    use rand::{rngs::StdRng, SeedableRng};
6769
6770    #[test]
6771    fn wind_speed_sigma_does_not_change_seeded_direction_draws() {
6772        let base_wind = WindConditions {
6773            speed: 100.0,
6774            direction: 0.37,
6775            vertical_speed: 0.0,
6776        };
6777        let narrow_speed = MonteCarloWindSampler::new(&base_wind, 0.5, 0.2).unwrap();
6778        let wide_speed = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
6779        let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
6780        let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
6781        let mut speed_changed = false;
6782
6783        for _ in 0..32 {
6784            let narrow = narrow_speed.sample(&mut narrow_rng);
6785            let wide = wide_speed.sample(&mut wide_rng);
6786            assert!(narrow.speed > 0.0 && wide.speed > 0.0);
6787            assert_eq!(narrow.direction.to_bits(), wide.direction.to_bits());
6788            speed_changed |= narrow.speed.to_bits() != wide.speed.to_bits();
6789        }
6790        assert!(
6791            speed_changed,
6792            "different speed sigmas must still vary speed draws"
6793        );
6794    }
6795
6796    #[test]
6797    fn zero_direction_sigma_has_no_angular_jitter() {
6798        let base_wind = WindConditions {
6799            speed: 100.0,
6800            direction: 0.37,
6801            vertical_speed: 0.0,
6802        };
6803        let sampler = MonteCarloWindSampler::new(&base_wind, 4.0, 0.0).unwrap();
6804        let mut rng = StdRng::seed_from_u64(0x5EED_1223);
6805        let mut speed_changed = false;
6806
6807        for _ in 0..32 {
6808            let wind = sampler.sample(&mut rng);
6809            speed_changed |= wind.speed.to_bits() != base_wind.speed.to_bits();
6810            assert_eq!(wind.direction.to_bits(), base_wind.direction.to_bits());
6811        }
6812        assert!(speed_changed, "speed uncertainty should remain active");
6813    }
6814
6815    #[test]
6816    fn direction_sigma_controls_seeded_angular_spread_in_radians() {
6817        let base_wind = WindConditions {
6818            speed: 100.0,
6819            direction: 0.37,
6820            vertical_speed: 0.0,
6821        };
6822        let narrow = MonteCarloWindSampler::new(&base_wind, 4.0, 0.1).unwrap();
6823        let wide = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
6824        let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
6825        let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
6826        let mut nonzero_direction_draw = false;
6827
6828        for _ in 0..32 {
6829            let narrow_wind = narrow.sample(&mut narrow_rng);
6830            let wide_wind = wide.sample(&mut wide_rng);
6831            assert_eq!(narrow_wind.speed.to_bits(), wide_wind.speed.to_bits());
6832
6833            let narrow_delta = narrow_wind.direction - base_wind.direction;
6834            let wide_delta = wide_wind.direction - base_wind.direction;
6835            assert!((wide_delta - 2.0 * narrow_delta).abs() < 1e-12);
6836            nonzero_direction_draw |= narrow_delta.abs() > 1e-6;
6837        }
6838        assert!(
6839            nonzero_direction_draw,
6840            "positive radians sigma must vary direction"
6841        );
6842    }
6843
6844    #[test]
6845    fn direction_sigma_rejects_negative_or_nonfinite_values() {
6846        let base_wind = WindConditions::default();
6847        for sigma in [-0.1, f64::NAN, f64::INFINITY] {
6848            assert!(MonteCarloWindSampler::new(&base_wind, 1.0, sigma).is_err());
6849        }
6850    }
6851
6852    #[test]
6853    fn base_vertical_wind_rides_into_every_mc_sample() {
6854        // MBA-728: vertical wind is a systematic input, not a dispersion source —
6855        // every sampled wind must carry the base vertical un-dispersed. (Before
6856        // this fix, samples dropped it, biasing the whole MC cloud vs the baseline.)
6857        use rand::SeedableRng;
6858        let base_wind = WindConditions {
6859            vertical_speed: 4.2,
6860            ..Default::default()
6861        };
6862        let sampler = MonteCarloWindSampler::new(&base_wind, 1.0, 0.2).unwrap();
6863        let mut rng = rand::rngs::StdRng::seed_from_u64(7);
6864        for _ in 0..32 {
6865            let w = sampler.sample(&mut rng);
6866            assert_eq!(w.vertical_speed, 4.2);
6867        }
6868    }
6869
6870    #[test]
6871    fn negative_speed_sample_reverses_wind_direction() {
6872        let direction = 0.25;
6873        let signed_speed = -2.5;
6874        let wind = wind_from_signed_speed_sample(signed_speed, direction, 0.0);
6875        let positive_wind = wind_from_signed_speed_sample(2.5, direction, 0.0);
6876
6877        assert_eq!(wind.speed, 2.5);
6878        assert!(
6879            (wind.direction - (direction + std::f64::consts::PI)).abs() < f64::EPSILON,
6880            "negative speed must reverse direction by pi: got {}",
6881            wind.direction
6882        );
6883        assert_eq!(positive_wind.speed, 2.5);
6884        assert_eq!(positive_wind.direction, direction);
6885
6886        let normalized_x = -wind.speed * wind.direction.cos();
6887        let normalized_z = -wind.speed * wind.direction.sin();
6888        let signed_x = -signed_speed * direction.cos();
6889        let signed_z = -signed_speed * direction.sin();
6890        assert!((normalized_x - signed_x).abs() < 1e-12);
6891        assert!((normalized_z - signed_z).abs() < 1e-12);
6892    }
6893}
6894
6895#[cfg(test)]
6896mod bc_fit_objective_tests {
6897    use super::*;
6898
6899    fn velocity_point(range_m: f64, velocity_mps: f64) -> TrajectoryPoint {
6900        TrajectoryPoint {
6901            time: 0.0,
6902            position: Vector3::new(range_m, 0.0, 0.0),
6903            velocity_magnitude: velocity_mps,
6904            kinetic_energy: 0.0,
6905            drag_coefficient: None,
6906        }
6907    }
6908
6909    #[test]
6910    fn candidate_that_misses_an_observation_has_no_score() {
6911        let trajectory = vec![velocity_point(0.0, 800.0), velocity_point(100.0, 700.0)];
6912        let observations = vec![(50.0, 750.0), (150.0, 600.0)];
6913
6914        assert!(
6915            fit_residual_sse(&trajectory, &observations, BcFitMode::Velocity, 0.0).is_none(),
6916            "a candidate that reaches only one of two observations must not compete on partial SSE"
6917        );
6918
6919        let complete_observations = vec![(50.0, 740.0), (100.0, 680.0)];
6920        assert_eq!(
6921            fit_residual_sse(
6922                &trajectory,
6923                &complete_observations,
6924                BcFitMode::Velocity,
6925                0.0,
6926            ),
6927            Some(500.0)
6928        );
6929    }
6930}
6931
6932#[cfg(test)]
6933mod cluster_bc_reference_space_tests {
6934    use super::*;
6935
6936    fn acceleration_at_1100_fps(inputs: BallisticInputs) -> Vector3<f64> {
6937        let solver = TrajectorySolver::new(
6938            inputs,
6939            WindConditions::default(),
6940            AtmosphericConditions::default(),
6941        );
6942        let position = Vector3::zeros();
6943        let velocity = Vector3::new(1100.0 / 3.28084, 0.0, 0.0);
6944        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
6945        solver.calculate_acceleration(
6946            &position,
6947            &velocity,
6948            &Vector3::zeros(),
6949            (temp_c, pressure_hpa, density / 1.225),
6950        )
6951    }
6952
6953    #[test]
6954    fn solver_passes_g7_reference_model_to_cluster_classifier() {
6955        let inputs = BallisticInputs {
6956            bc_value: 0.190,
6957            bc_type: DragModel::G7,
6958            bullet_mass: 77.0 * crate::constants::GRAINS_TO_KG,
6959            bullet_diameter: 0.224 * 0.0254,
6960            use_cluster_bc: true,
6961            ..BallisticInputs::default()
6962        };
6963
6964        let solver = TrajectorySolver::new(
6965            inputs,
6966            WindConditions::default(),
6967            AtmosphericConditions::default(),
6968        );
6969        let corrected = solver.apply_cluster_bc_correction(0.190, 2800.0);
6970
6971        assert!(
6972            (corrected / 0.190 - 1.004).abs() < 1e-12,
6973            "solver selected the wrong G7 cluster multiplier: {}",
6974            corrected / 0.190
6975        );
6976    }
6977
6978    #[test]
6979    fn velocity_bc_segments_are_not_cluster_corrected_twice() {
6980        let segmented_clustered = BallisticInputs {
6981            bc_value: 0.5,
6982            bc_type: DragModel::G7,
6983            use_bc_segments: true,
6984            bc_segments_data: Some(vec![
6985                crate::BCSegmentData {
6986                    velocity_min: 0.0,
6987                    velocity_max: 1_600.0,
6988                    bc_value: 0.4,
6989                },
6990                crate::BCSegmentData {
6991                    velocity_min: 1_600.0,
6992                    velocity_max: 5_000.0,
6993                    bc_value: 0.45,
6994                },
6995            ]),
6996            use_cluster_bc: true,
6997            ..BallisticInputs::default()
6998        };
6999        let mut segmented_only = segmented_clustered.clone();
7000        segmented_only.use_cluster_bc = false;
7001        let mut constant_clustered = segmented_clustered.clone();
7002        constant_clustered.bc_value = 0.4;
7003        constant_clustered.bc_segments_data = None;
7004
7005        let stacked = acceleration_at_1100_fps(segmented_clustered);
7006        let segment_only = acceleration_at_1100_fps(segmented_only);
7007        let cluster_only = acceleration_at_1100_fps(constant_clustered);
7008
7009        assert!(
7010            (stacked.x - segment_only.x).abs() < 1e-12,
7011            "segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
7012            stacked.x,
7013            segment_only.x
7014        );
7015        assert!(
7016            (cluster_only.x - segment_only.x).abs() > 1e-6,
7017            "cluster correction must remain active for a constant BC"
7018        );
7019    }
7020
7021    #[test]
7022    fn mach_bc_segments_are_not_cluster_corrected_twice() {
7023        let mach_segmented_clustered = BallisticInputs {
7024            bc_value: 0.5,
7025            bc_type: DragModel::G7,
7026            use_bc_segments: false,
7027            bc_segments: Some(vec![(0.5, 0.3), (1.5, 0.5)]),
7028            use_cluster_bc: true,
7029            ..BallisticInputs::default()
7030        };
7031        let mut mach_segmented_only = mach_segmented_clustered.clone();
7032        mach_segmented_only.use_cluster_bc = false;
7033
7034        let stacked = acceleration_at_1100_fps(mach_segmented_clustered);
7035        let segment_only = acceleration_at_1100_fps(mach_segmented_only);
7036
7037        assert!(
7038            (stacked.x - segment_only.x).abs() < 1e-12,
7039            "Mach segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
7040            stacked.x,
7041            segment_only.x
7042        );
7043    }
7044}
7045
7046#[cfg(test)]
7047mod velocity_bc_flag_tests {
7048    use super::*;
7049
7050    fn acceleration_at_600_mps(inputs: BallisticInputs) -> Vector3<f64> {
7051        let solver = TrajectorySolver::new(
7052            inputs,
7053            WindConditions::default(),
7054            AtmosphericConditions::default(),
7055        );
7056        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
7057        solver.calculate_acceleration(
7058            &Vector3::zeros(),
7059            &Vector3::new(600.0, 0.0, 0.0),
7060            &Vector3::zeros(),
7061            (temp_c, pressure_hpa, density / 1.225),
7062        )
7063    }
7064
7065    #[test]
7066    fn velocity_bc_data_requires_opt_in_in_trajectory_solver() {
7067        let scalar_inputs = BallisticInputs {
7068            bc_value: 0.5,
7069            bc_type: DragModel::G7,
7070            ..BallisticInputs::default()
7071        };
7072        let mut disabled_inputs = scalar_inputs.clone();
7073        disabled_inputs.bc_segments_data = Some(vec![crate::BCSegmentData {
7074            velocity_min: 0.0,
7075            velocity_max: 4_000.0,
7076            bc_value: 0.46,
7077        }]);
7078        disabled_inputs.use_bc_segments = false;
7079        let mut enabled_inputs = disabled_inputs.clone();
7080        enabled_inputs.use_bc_segments = true;
7081        let mut mach_only_inputs = scalar_inputs.clone();
7082        mach_only_inputs.bc_segments = Some(vec![(0.0, 0.4), (3.0, 0.4)]);
7083        let mut disabled_with_both = mach_only_inputs.clone();
7084        disabled_with_both.bc_segments_data = disabled_inputs.bc_segments_data.clone();
7085
7086        let scalar = acceleration_at_600_mps(scalar_inputs);
7087        let disabled = acceleration_at_600_mps(disabled_inputs);
7088        let enabled = acceleration_at_600_mps(enabled_inputs);
7089        let mach_only = acceleration_at_600_mps(mach_only_inputs);
7090        let disabled_with_both = acceleration_at_600_mps(disabled_with_both);
7091
7092        assert_eq!(
7093            disabled.x.to_bits(),
7094            scalar.x.to_bits(),
7095            "a populated velocity table must not change drag while use_bc_segments is false"
7096        );
7097        assert!(
7098            enabled.x < disabled.x - 1.0,
7099            "enabling the lower BC table must increase drag: disabled ax={} enabled ax={}",
7100            disabled.x,
7101            enabled.x
7102        );
7103        assert_eq!(
7104            disabled_with_both.x.to_bits(),
7105            mach_only.x.to_bits(),
7106            "disabling velocity data must fall through to an explicit Mach table"
7107        );
7108    }
7109}
7110
7111#[cfg(test)]
7112mod mach_bc_segment_tests {
7113    use super::*;
7114
7115    #[test]
7116    fn trajectory_solver_interpolates_explicit_mach_bc_segments() {
7117        let segmented_inputs = BallisticInputs {
7118            bc_value: 0.8,
7119            use_bc_segments: false,
7120            bc_segments: Some(vec![(1.0, 0.2), (2.0, 0.4)]),
7121            bc_segments_data: None,
7122            ..BallisticInputs::default()
7123        };
7124
7125        let mut expected_inputs = segmented_inputs.clone();
7126        expected_inputs.bc_value = 0.3;
7127        expected_inputs.bc_segments = None;
7128
7129        let atmosphere = AtmosphericConditions::default();
7130        let segmented_solver = TrajectorySolver::new(
7131            segmented_inputs,
7132            WindConditions::default(),
7133            atmosphere.clone(),
7134        );
7135        let expected_solver =
7136            TrajectorySolver::new(expected_inputs, WindConditions::default(), atmosphere);
7137        let position = Vector3::zeros();
7138        let (density, _, temp_c, pressure_hpa) = segmented_solver.resolved_atmosphere();
7139        let (_, local_speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
7140            segmented_solver.atmosphere.altitude,
7141            segmented_solver.atmosphere.altitude,
7142            temp_c,
7143            pressure_hpa,
7144            density / 1.225,
7145            segmented_solver.atmosphere.humidity,
7146        );
7147        let velocity = Vector3::new(1.5 * local_speed_of_sound, 0.0, 0.0);
7148        let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
7149
7150        let segmented_acceleration = segmented_solver.calculate_acceleration(
7151            &position,
7152            &velocity,
7153            &Vector3::zeros(),
7154            resolved_atmo,
7155        );
7156        let expected_acceleration = expected_solver.calculate_acceleration(
7157            &position,
7158            &velocity,
7159            &Vector3::zeros(),
7160            resolved_atmo,
7161        );
7162
7163        assert!(
7164            (segmented_acceleration.x - expected_acceleration.x).abs() < 1e-12,
7165            "Mach 1.5 must interpolate BC 0.3: segmented ax={} expected ax={}",
7166            segmented_acceleration.x,
7167            expected_acceleration.x
7168        );
7169    }
7170}
7171
7172#[cfg(test)]
7173mod custom_drag_table_validation_tests {
7174    use super::*;
7175
7176    #[test]
7177    fn solve_accepts_zero_bc_when_custom_table_present() {
7178        let inputs = BallisticInputs {
7179            bc_value: 0.0, // ignored when a table is set
7180            bullet_mass: 0.0106,
7181            bullet_diameter: 0.00782,
7182            muzzle_velocity: 850.0,
7183            custom_drag_table: Some(crate::drag::DragTable::new(
7184                vec![0.5, 1.0, 2.0, 3.0],
7185                vec![0.23, 0.40, 0.30, 0.26],
7186            )),
7187            ..BallisticInputs::default()
7188        };
7189        let solver = TrajectorySolver::new(
7190            inputs,
7191            WindConditions::default(),
7192            AtmosphericConditions::default(),
7193        );
7194        // Must not error on the bc_value gate.
7195        assert!(solver.solve().is_ok());
7196    }
7197
7198    #[test]
7199    fn solve_still_requires_bc_without_table() {
7200        let inputs = BallisticInputs {
7201            bc_value: 0.0,
7202            bullet_mass: 0.0106,
7203            bullet_diameter: 0.00782,
7204            muzzle_velocity: 850.0,
7205            ..BallisticInputs::default()
7206        };
7207        let solver = TrajectorySolver::new(
7208            inputs,
7209            WindConditions::default(),
7210            AtmosphericConditions::default(),
7211        );
7212        assert!(solver.solve().is_err());
7213    }
7214}
7215
7216/// MBA-1356: cd_scale — a whole-curve multiplier on the custom-deck Cd.
7217#[cfg(test)]
7218mod cd_scale_tests {
7219    use super::*;
7220
7221    fn deck() -> crate::drag::DragTable {
7222        crate::drag::DragTable::new(vec![0.5, 1.0, 2.0, 3.0], vec![0.23, 0.40, 0.30, 0.26])
7223    }
7224
7225    fn deck_inputs(cd_scale: f64) -> BallisticInputs {
7226        BallisticInputs {
7227            bullet_mass: 0.0106,
7228            bullet_diameter: 0.00782,
7229            muzzle_velocity: 850.0,
7230            custom_drag_table: Some(deck()),
7231            cd_scale,
7232            ..BallisticInputs::default()
7233        }
7234    }
7235
7236    #[test]
7237    fn default_cd_scale_is_one() {
7238        assert_eq!(BallisticInputs::default().cd_scale, 1.0);
7239    }
7240
7241    /// (a) Default invariance: omitting `cd_scale` (picked up from `..Default::default()`)
7242    /// must be byte-identical to explicitly setting `1.0`, on the custom-deck Cd itself.
7243    #[test]
7244    fn cd_scale_absent_is_byte_identical_to_explicit_one() {
7245        let omitted = BallisticInputs {
7246            bullet_mass: 0.0106,
7247            bullet_diameter: 0.00782,
7248            muzzle_velocity: 850.0,
7249            custom_drag_table: Some(deck()),
7250            ..BallisticInputs::default()
7251        };
7252        let explicit = BallisticInputs {
7253            cd_scale: 1.0,
7254            ..omitted.clone()
7255        };
7256
7257        let solver_omitted = TrajectorySolver::new(
7258            omitted,
7259            WindConditions::default(),
7260            AtmosphericConditions::default(),
7261        );
7262        let solver_explicit = TrajectorySolver::new(
7263            explicit,
7264            WindConditions::default(),
7265            AtmosphericConditions::default(),
7266        );
7267
7268        let cd_omitted = solver_omitted.calculate_drag_coefficient(700.0, 340.0);
7269        let cd_explicit = solver_explicit.calculate_drag_coefficient(700.0, 340.0);
7270        assert_eq!(
7271            cd_omitted.to_bits(),
7272            cd_explicit.to_bits(),
7273            "default cd_scale must be bit-identical to an explicit 1.0"
7274        );
7275
7276        // And a full custom-deck solve (the existing pre-MBA-1356 test surface) must still
7277        // succeed unchanged with the field simply absent from the literal.
7278        let result = solver_omitted.solve();
7279        assert!(
7280            result.is_ok(),
7281            "existing custom-deck solves must pass unchanged"
7282        );
7283    }
7284
7285    /// The custom-deck interpolation site multiplies the deck's Cd by `cd_scale` exactly.
7286    #[test]
7287    fn cd_scale_multiplies_the_interpolated_cd_exactly() {
7288        let velocity = 700.0;
7289        let speed_of_sound = 340.0;
7290        let mach = velocity / speed_of_sound;
7291        let expected_unscaled = deck().interpolate(mach);
7292
7293        for &scale in &[0.90, 1.0, 1.10, 1.5] {
7294            let solver = TrajectorySolver::new(
7295                deck_inputs(scale),
7296                WindConditions::default(),
7297                AtmosphericConditions::default(),
7298            );
7299            let cd = solver.calculate_drag_coefficient(velocity, speed_of_sound);
7300            assert!(
7301                (cd - expected_unscaled * scale).abs() < 1e-12,
7302                "scale={scale}: cd={cd} expected={}",
7303                expected_unscaled * scale
7304            );
7305        }
7306    }
7307
7308    /// (b) Scale-direction test, cli_api (RK4/RK45) solver path: a higher cd_scale means more
7309    /// drag, so the solve loses more velocity (and correspondingly drops more) over the same
7310    /// downrange distance; a lower cd_scale loses less.
7311    #[test]
7312    fn cd_scale_direction_on_cli_api_solver() {
7313        let solve = |scale: f64| {
7314            TrajectorySolver::new(
7315                deck_inputs(scale),
7316                WindConditions::default(),
7317                AtmosphericConditions::default(),
7318            )
7319            .solve()
7320            .expect("custom-deck solve should succeed")
7321        };
7322
7323        let baseline = solve(1.0);
7324        let scaled_up = solve(1.10);
7325        let scaled_down = solve(0.90);
7326
7327        assert!(
7328            scaled_up.impact_velocity < baseline.impact_velocity,
7329            "cd_scale=1.10 must increase drag -> lower impact velocity: base={} up={}",
7330            baseline.impact_velocity,
7331            scaled_up.impact_velocity
7332        );
7333        assert!(
7334            scaled_down.impact_velocity > baseline.impact_velocity,
7335            "cd_scale=0.90 must decrease drag -> higher impact velocity: base={} down={}",
7336            baseline.impact_velocity,
7337            scaled_down.impact_velocity
7338        );
7339    }
7340
7341    /// (d) `validate_for_solve` rejects a non-finite or non-positive `cd_scale`.
7342    #[test]
7343    fn validate_for_solve_rejects_invalid_cd_scale() {
7344        for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
7345            let solver = TrajectorySolver::new(
7346                deck_inputs(bad),
7347                WindConditions::default(),
7348                AtmosphericConditions::default(),
7349            );
7350            assert!(
7351                solver.solve().is_err(),
7352                "cd_scale={bad} must be rejected by validate_for_solve"
7353            );
7354        }
7355    }
7356
7357    /// `require_positive("cd_scale", ...)` is unconditional in `validate_for_solve` (see the
7358    /// comment there) — it must reject an invalid scale even with NO custom drag table present,
7359    /// not just on the deck path the other tests in this module exercise. Otherwise a caller
7360    /// that sets an invalid `cd_scale` without ever touching `custom_drag_table` would slip
7361    /// through unvalidated (the field would simply go unread on this path, but the validation
7362    /// gate itself must not silently skip it).
7363    #[test]
7364    fn validate_for_solve_rejects_invalid_cd_scale_without_a_custom_drag_table() {
7365        for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
7366            let inputs = BallisticInputs {
7367                bc_value: 0.5,
7368                bc_type: crate::DragModel::G1,
7369                bullet_mass: 0.0106,
7370                bullet_diameter: 0.00782,
7371                muzzle_velocity: 850.0,
7372                cd_scale: bad,
7373                ..BallisticInputs::default()
7374            };
7375            assert!(
7376                inputs.custom_drag_table.is_none(),
7377                "precondition: no custom deck"
7378            );
7379            let solver = TrajectorySolver::new(
7380                inputs,
7381                WindConditions::default(),
7382                AtmosphericConditions::default(),
7383            );
7384            assert!(
7385                solver.solve().is_err(),
7386                "cd_scale={bad} must be rejected by validate_for_solve even without a custom \
7387                 drag table"
7388            );
7389        }
7390    }
7391
7392    /// cd_scale must be inert on the standard G-model/BC path (no custom_drag_table): the
7393    /// scale is read only inside the `custom_drag_table` branch, so a scale far from 1.0 must
7394    /// not perturb a plain G1/G7 solve at all.
7395    #[test]
7396    fn cd_scale_is_inert_without_a_custom_drag_table() {
7397        let make = |cd_scale: f64| BallisticInputs {
7398            bc_value: 0.5,
7399            bc_type: crate::DragModel::G1,
7400            bullet_mass: 0.0106,
7401            bullet_diameter: 0.00782,
7402            muzzle_velocity: 850.0,
7403            cd_scale,
7404            ..BallisticInputs::default()
7405        };
7406        let solver_neutral = TrajectorySolver::new(
7407            make(1.0),
7408            WindConditions::default(),
7409            AtmosphericConditions::default(),
7410        );
7411        let solver_far = TrajectorySolver::new(
7412            make(1.5),
7413            WindConditions::default(),
7414            AtmosphericConditions::default(),
7415        );
7416        let cd_neutral = solver_neutral.calculate_drag_coefficient(700.0, 340.0);
7417        let cd_far = solver_far.calculate_drag_coefficient(700.0, 340.0);
7418        assert_eq!(
7419            cd_neutral.to_bits(),
7420            cd_far.to_bits(),
7421            "cd_scale must not affect the G-model/BC drag path"
7422        );
7423    }
7424
7425    /// (c) Cross-solver consistency: cli_api (RK4/RK45), derivatives-driven, and fast_trajectory
7426    /// all shift together (same relative direction) under cd_scale != 1.0 on the same deck.
7427    #[test]
7428    fn cd_scale_shifts_all_three_solver_paths_in_the_same_direction() {
7429        // --- cli_api ---
7430        let cli_solve = |scale: f64| {
7431            TrajectorySolver::new(
7432                deck_inputs(scale),
7433                WindConditions::default(),
7434                AtmosphericConditions::default(),
7435            )
7436            .solve()
7437            .expect("cli_api custom-deck solve should succeed")
7438        };
7439        let cli_baseline = cli_solve(1.0);
7440        let cli_scaled = cli_solve(1.10);
7441        assert!(
7442            cli_scaled.impact_velocity < cli_baseline.impact_velocity,
7443            "cli_api: cd_scale=1.10 must lower impact velocity"
7444        );
7445
7446        // --- derivatives (the RK4/RK45 generic integrator's kernel) ---
7447        let derivatives_accel_x = |scale: f64| {
7448            let inputs = deck_inputs(scale);
7449            crate::derivatives::compute_derivatives(
7450                nalgebra::Vector3::zeros(),
7451                nalgebra::Vector3::new(700.0, 0.0, 0.0),
7452                &inputs,
7453                nalgebra::Vector3::zeros(),
7454                (1.225, 340.0, 0.0, 0.0),
7455                inputs.bc_value,
7456                None,
7457                0.0,
7458                None,
7459            )[3]
7460        };
7461        let deriv_baseline = derivatives_accel_x(1.0);
7462        let deriv_scaled = derivatives_accel_x(1.10);
7463        assert!(
7464            deriv_scaled < deriv_baseline,
7465            "derivatives: cd_scale=1.10 must make x-acceleration more negative (more drag): \
7466             base={deriv_baseline} scaled={deriv_scaled}"
7467        );
7468
7469        // --- fast_trajectory (fast_integrate) ---
7470        let fast_final_speed = |scale: f64| {
7471            let inputs = deck_inputs(scale);
7472            let wind_sock = crate::wind::WindSock::new(vec![]);
7473            let params = crate::fast_trajectory::FastIntegrationParams {
7474                horiz: 500.0,
7475                vert: 0.0,
7476                initial_state: [0.0, 0.0, 0.0, 850.0, 0.0, 0.0],
7477                t_span: (0.0, 5.0),
7478                atmo_params: (0.0, 15.0, 1013.25, 1.0),
7479                atmo_sock: None,
7480            };
7481            let solution = crate::fast_trajectory::fast_integrate(&inputs, &wind_sock, params);
7482            assert!(
7483                solution.success,
7484                "fast_integrate must succeed for scale={scale}"
7485            );
7486            let last = solution.t.len() - 1;
7487            let (vx, vy, vz) = (
7488                solution.y[3][last],
7489                solution.y[4][last],
7490                solution.y[5][last],
7491            );
7492            (vx * vx + vy * vy + vz * vz).sqrt()
7493        };
7494        let fast_baseline = fast_final_speed(1.0);
7495        let fast_scaled = fast_final_speed(1.10);
7496        assert!(
7497            fast_scaled < fast_baseline,
7498            "fast_trajectory: cd_scale=1.10 must lower final speed: base={fast_baseline} scaled={fast_scaled}"
7499        );
7500    }
7501}
7502
7503/// `custom_drag_reference`: a custom table flown as a reference curve, divided by the resolved
7504/// BC exactly as a built-in model's curve is, instead of the projectile's own Cd over sectional
7505/// density.
7506#[cfg(test)]
7507mod custom_drag_reference_tests {
7508    use super::*;
7509
7510    /// The engine's own G7 table, as a caller would send it.
7511    fn g7_table() -> crate::drag::DragTable {
7512        crate::drag::DragTable::from_csv_str(include_str!("../data/g7.csv"))
7513            .expect("bundled table parses")
7514    }
7515
7516    /// The engine's own G1 table, a curve far from `bc_type: G7`'s.
7517    fn g1_table() -> crate::drag::DragTable {
7518        crate::drag::DragTable::from_csv_str(include_str!("../data/g1.csv"))
7519            .expect("bundled table parses")
7520    }
7521
7522    /// A .308 175 gr G7 0.243 at 823 m/s with a two-band ladder (edge at 2400 fps) whose BCs both
7523    /// differ from the scalar. The imperial mirrors are set too, so the sectional density read
7524    /// off these inputs is the one `TrajectorySolver::new` re-derives from the SI fields.
7525    fn laddered_inputs() -> BallisticInputs {
7526        BallisticInputs {
7527            bc_value: 0.243,
7528            bc_type: DragModel::G7,
7529            bullet_mass: 175.0 * crate::constants::GRAINS_TO_KG,
7530            bullet_diameter: 0.308 * 0.0254,
7531            weight_grains: 175.0,
7532            caliber_inches: 0.308,
7533            muzzle_velocity: 823.0,
7534            use_bc_segments: true,
7535            bc_segments_data: Some(vec![
7536                crate::BCSegmentData {
7537                    velocity_min: 2400.0,
7538                    velocity_max: 4000.0,
7539                    bc_value: 0.260,
7540                },
7541                crate::BCSegmentData {
7542                    velocity_min: 0.0,
7543                    velocity_max: 2400.0,
7544                    bc_value: 0.220,
7545                },
7546            ]),
7547            ..BallisticInputs::default()
7548        }
7549    }
7550
7551    fn reference(inputs: BallisticInputs) -> BallisticInputs {
7552        BallisticInputs {
7553            custom_drag_table: Some(g7_table()),
7554            custom_drag_reference: true,
7555            ..inputs
7556        }
7557    }
7558
7559    fn solve(inputs: BallisticInputs) -> TrajectoryResult {
7560        let mut solver = TrajectorySolver::new(
7561            inputs,
7562            WindConditions::default(),
7563            AtmosphericConditions::default(),
7564        );
7565        solver.set_max_range(800.0);
7566        solver.solve().expect("solve")
7567    }
7568
7569    #[test]
7570    fn default_is_the_projectiles_own_curve() {
7571        assert!(!BallisticInputs::default().custom_drag_reference);
7572    }
7573
7574    #[test]
7575    fn the_denominator_is_the_resolved_bc_for_a_reference_curve() {
7576        let own = BallisticInputs {
7577            custom_drag_table: Some(g7_table()),
7578            ..laddered_inputs()
7579        };
7580        let sd = own.sectional_density_lb_in2().expect("SD");
7581        assert_eq!(own.custom_drag_denominator(0.220), sd);
7582        let reference = reference(laddered_inputs());
7583        assert_eq!(reference.custom_drag_denominator(0.220), 0.220);
7584        assert_eq!(reference.custom_drag_denominator(0.260), 0.260);
7585    }
7586
7587    /// The same ladder on the built-in G7 model and on the engine's G7 table sent as a reference
7588    /// curve is the same arithmetic, so the two fly bit-identically. Without the flag the table
7589    /// is read as the projectile's own Cd over sectional density, a different projectile.
7590    #[test]
7591    fn a_reference_curve_flies_a_ladder_exactly_as_the_builtin_model() {
7592        let builtin = solve(laddered_inputs());
7593        let table = solve(reference(laddered_inputs()));
7594        assert_eq!(table.points.len(), builtin.points.len());
7595        for (t, b) in table.points.iter().zip(&builtin.points) {
7596            assert_eq!(t.position, b.position);
7597            assert_eq!(
7598                t.velocity_magnitude.to_bits(),
7599                b.velocity_magnitude.to_bits()
7600            );
7601        }
7602
7603        let own_curve = solve(BallisticInputs {
7604            custom_drag_reference: false,
7605            ..reference(laddered_inputs())
7606        });
7607        assert!(
7608            (own_curve.impact_velocity - builtin.impact_velocity).abs() > 10.0,
7609            "the fixture must tell the readings apart: {} vs {}",
7610            own_curve.impact_velocity,
7611            builtin.impact_velocity
7612        );
7613    }
7614
7615    /// The table is what flies, not `bc_type`'s curve: the G7 table above is the same numbers as
7616    /// `bc_type: G7`, so it cannot tell them apart. The G1 table flown as a reference curve while
7617    /// `bc_type` stays G7 must equal the built-in G1 model with the same ladder, bit for bit; a
7618    /// drag site that read `bc_type` beside the flag would give the G7 answer instead.
7619    #[test]
7620    fn a_reference_curve_flies_the_table_not_bc_types_curve() {
7621        let table = solve(BallisticInputs {
7622            custom_drag_table: Some(g1_table()),
7623            custom_drag_reference: true,
7624            bc_type: DragModel::G7,
7625            ..laddered_inputs()
7626        });
7627        let builtin_g1 = solve(BallisticInputs {
7628            bc_type: DragModel::G1,
7629            ..laddered_inputs()
7630        });
7631        assert_eq!(table.points.len(), builtin_g1.points.len());
7632        for (t, b) in table.points.iter().zip(&builtin_g1.points) {
7633            assert_eq!(t.position, b.position);
7634            assert_eq!(
7635                t.velocity_magnitude.to_bits(),
7636                b.velocity_magnitude.to_bits()
7637            );
7638        }
7639
7640        let builtin_g7 = solve(laddered_inputs());
7641        assert!(
7642            (builtin_g1.impact_velocity - builtin_g7.impact_velocity).abs() > 50.0,
7643            "the fixture must tell the curves apart: G1 {} vs G7 {}",
7644            builtin_g1.impact_velocity,
7645            builtin_g7.impact_velocity
7646        );
7647    }
7648
7649    /// The BC divides the curve again, so it is held to the built-in model's rule, while the
7650    /// own-curve reading still ignores it.
7651    #[test]
7652    fn validate_for_solve_requires_a_bc_for_a_reference_curve() {
7653        for bad in [0.0, -0.1, f64::NAN, f64::INFINITY] {
7654            let inputs = BallisticInputs {
7655                bc_value: bad,
7656                ..reference(laddered_inputs())
7657            };
7658            let error = TrajectorySolver::new(
7659                inputs.clone(),
7660                WindConditions::default(),
7661                AtmosphericConditions::default(),
7662            )
7663            .solve()
7664            .expect_err("a reference curve needs a finite positive BC");
7665            assert!(error.to_string().contains("bc_value"), "{error}");
7666
7667            let own_curve = BallisticInputs {
7668                custom_drag_reference: false,
7669                use_bc_segments: false,
7670                bc_segments_data: None,
7671                ..inputs
7672            };
7673            assert!(
7674                TrajectorySolver::new(
7675                    own_curve,
7676                    WindConditions::default(),
7677                    AtmosphericConditions::default(),
7678                )
7679                .solve()
7680                .is_ok(),
7681                "bc_value={bad} is still ignored by the own-curve reading"
7682            );
7683        }
7684    }
7685
7686    /// An Army Standard Metro BC divides a reference curve, so its conversion is real: the
7687    /// scalar and every band convert exactly as they would on the built-in model, and no inert
7688    /// warning is raised.
7689    #[test]
7690    fn army_standard_metro_converts_a_reference_curves_bcs() {
7691        let asm = BallisticInputs {
7692            bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
7693            ..reference(laddered_inputs())
7694        };
7695        assert!(asm.bc_reference_standard_inert_warning().is_none());
7696        assert!(BallisticInputs {
7697            custom_drag_reference: false,
7698            ..asm.clone()
7699        }
7700        .bc_reference_standard_inert_warning()
7701        .is_some());
7702
7703        let converted = solve(asm);
7704        let builtin_asm = solve(BallisticInputs {
7705            bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
7706            ..laddered_inputs()
7707        });
7708        let icao = solve(reference(laddered_inputs()));
7709        assert_eq!(
7710            converted.impact_velocity.to_bits(),
7711            builtin_asm.impact_velocity.to_bits()
7712        );
7713        assert!(
7714            converted.impact_velocity < icao.impact_velocity,
7715            "the ASM BCs are smaller once converted, so the bullet must slow more: {} vs {}",
7716            converted.impact_velocity,
7717            icao.impact_velocity
7718        );
7719    }
7720
7721    /// The reported Cd is the projectile's own: the reference curve times SD over the band BC
7722    /// that flies, exactly as for a built-in model.
7723    #[test]
7724    fn the_reported_cd_is_the_projectiles_own() {
7725        let inputs = reference(laddered_inputs());
7726        let sd = inputs.sectional_density_lb_in2().expect("SD");
7727        let solver = TrajectorySolver::new(
7728            inputs,
7729            WindConditions::default(),
7730            AtmosphericConditions::default(),
7731        );
7732        let sos = 340.0;
7733        for (fps, band_bc) in [(2600.0, 0.260), (2200.0, 0.220)] {
7734            let velocity = fps / 3.28084;
7735            let expected = g7_table().interpolate(velocity / sos) * sd / band_bc;
7736            let reported = solver
7737                .effective_drag_coefficient(velocity, sos)
7738                .expect("mass and diameter are set");
7739            assert!(
7740                (reported - expected).abs() < 1e-12,
7741                "{fps} fps: reported {reported}, expected {expected}"
7742            );
7743        }
7744    }
7745}
7746
7747#[cfg(test)]
7748mod humid_local_mach_tests {
7749    use super::*;
7750
7751    fn solver_with_station_humidity(humidity_percent: f64) -> TrajectorySolver {
7752        let inputs = BallisticInputs {
7753            custom_drag_table: Some(crate::drag::DragTable::new(vec![0.5, 1.5], vec![0.1, 1.1])),
7754            ..BallisticInputs::default()
7755        };
7756        TrajectorySolver::new(
7757            inputs,
7758            WindConditions::default(),
7759            AtmosphericConditions {
7760                temperature: 30.0,
7761                pressure: 1013.25,
7762                humidity: humidity_percent,
7763                altitude: 0.0,
7764            },
7765        )
7766    }
7767
7768    fn acceleration(solver: &TrajectorySolver, base_ratio: f64) -> Vector3<f64> {
7769        solver.calculate_acceleration(
7770            &Vector3::zeros(),
7771            &Vector3::new(350.0, 0.0, 0.0),
7772            &Vector3::zeros(),
7773            (30.0, 1013.25, base_ratio),
7774        )
7775    }
7776
7777    #[test]
7778    fn local_mach_uses_station_humidity_when_density_is_held_constant() {
7779        let dry = acceleration(&solver_with_station_humidity(0.0), 1.0);
7780        let humid = acceleration(&solver_with_station_humidity(100.0), 1.0);
7781
7782        assert!(
7783            humid.x > dry.x,
7784            "humid sound speed should lower Mach and drag on the rising test curve: dry ax={} humid ax={}",
7785            dry.x,
7786            humid.x
7787        );
7788    }
7789
7790    #[test]
7791    fn active_atmosphere_zone_uses_zone_humidity_instead_of_station_humidity() {
7792        let zone_humidity = 80.0;
7793        let zone_ratio =
7794            crate::atmosphere::calculate_air_density_cimp(30.0, 1013.25, zone_humidity) / 1.225;
7795        let station_solver = solver_with_station_humidity(zone_humidity);
7796        let mut zoned_solver = solver_with_station_humidity(0.0);
7797        zoned_solver.set_atmo_segments(vec![(30.0, 1013.25, zone_humidity, 1_000.0)]);
7798
7799        let station = acceleration(&station_solver, zone_ratio);
7800        let zoned = acceleration(&zoned_solver, zone_ratio);
7801
7802        assert!(
7803            (zoned - station).norm() < 1e-12,
7804            "active zone T/P/RH should override the station atmosphere: station={station:?} zoned={zoned:?}"
7805        );
7806    }
7807}
7808
7809#[cfg(test)]
7810mod inclined_atmosphere_frame_tests {
7811    use super::*;
7812
7813    fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
7814        let (sin_angle, cos_angle) = angle.sin_cos();
7815        Vector3::new(
7816            level.x * cos_angle + level.y * sin_angle,
7817            -level.x * sin_angle + level.y * cos_angle,
7818            level.z,
7819        )
7820    }
7821
7822    #[test]
7823    fn inclined_positions_at_same_world_altitude_have_same_solver_acceleration() {
7824        let angle = std::f64::consts::FRAC_PI_6;
7825        let inputs = BallisticInputs {
7826            shooting_angle: angle,
7827            ..BallisticInputs::default()
7828        };
7829        let atmosphere = AtmosphericConditions {
7830            altitude: 100.0,
7831            ..AtmosphericConditions::default()
7832        };
7833        let solver = TrajectorySolver::new(inputs, WindConditions::default(), atmosphere);
7834        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
7835        let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
7836        let velocity = Vector3::new(600.0, 0.0, 0.0);
7837        let along_slant = Vector3::new(1_000.0, 0.0, 0.0);
7838        let across_slant = Vector3::new(0.0, 500.0 / angle.cos(), 0.0);
7839
7840        let a = solver.calculate_acceleration(
7841            &along_slant,
7842            &velocity,
7843            &Vector3::zeros(),
7844            resolved_atmo,
7845        );
7846        let b = solver.calculate_acceleration(
7847            &across_slant,
7848            &velocity,
7849            &Vector3::zeros(),
7850            resolved_atmo,
7851        );
7852
7853        assert!(
7854            (a - b).norm() < 1e-10,
7855            "solver acceleration differs at equal world altitude: {a:?} vs {b:?}"
7856        );
7857    }
7858
7859    #[test]
7860    fn inclined_headwind_is_rotated_into_solver_frame() {
7861        let angle = std::f64::consts::FRAC_PI_6;
7862        let inputs = BallisticInputs {
7863            shooting_angle: angle,
7864            ..BallisticInputs::default()
7865        };
7866        let solver = TrajectorySolver::new(
7867            inputs,
7868            WindConditions::default(),
7869            AtmosphericConditions::default(),
7870        );
7871        let level_headwind = Vector3::new(-100.0, 0.0, 0.0);
7872        let velocity = expected_shot_frame_vector(level_headwind, angle);
7873        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
7874        let actual = solver.calculate_acceleration(
7875            &Vector3::zeros(),
7876            &velocity,
7877            &level_headwind,
7878            (temp_c, pressure_hpa, density / 1.225),
7879        );
7880
7881        assert!(
7882            (actual - solver.gravity_acceleration()).norm() < 1e-12,
7883            "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
7884        );
7885    }
7886
7887    #[test]
7888    fn inclined_coriolis_is_rotated_into_solver_frame() {
7889        let angle = std::f64::consts::FRAC_PI_6;
7890        let latitude_deg = 45.0_f64;
7891        let shot_azimuth = 0.4_f64;
7892        let velocity = Vector3::new(600.0, 20.0, 5.0);
7893        let base_inputs = BallisticInputs {
7894            shooting_angle: angle,
7895            latitude: Some(latitude_deg),
7896            shot_azimuth,
7897            ..BallisticInputs::default()
7898        };
7899        let acceleration = |enable_coriolis| {
7900            let mut inputs = base_inputs.clone();
7901            inputs.enable_coriolis = enable_coriolis;
7902            let solver = TrajectorySolver::new(
7903                inputs,
7904                WindConditions::default(),
7905                AtmosphericConditions::default(),
7906            );
7907            let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
7908            solver.calculate_acceleration(
7909                &Vector3::zeros(),
7910                &velocity,
7911                &Vector3::zeros(),
7912                (temp_c, pressure_hpa, density / 1.225),
7913            )
7914        };
7915
7916        let omega_earth = 7.2921159e-5_f64;
7917        let latitude = latitude_deg.to_radians();
7918        let level_omega = Vector3::new(
7919            omega_earth * latitude.cos() * shot_azimuth.cos(),
7920            omega_earth * latitude.sin(),
7921            -omega_earth * latitude.cos() * shot_azimuth.sin(),
7922        );
7923        let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
7924        let actual = acceleration(true) - acceleration(false);
7925
7926        assert!(
7927            (actual - expected).norm() < 1e-12,
7928            "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
7929        );
7930    }
7931}
7932
7933#[cfg(test)]
7934mod terminal_range_interpolation_tests {
7935    use super::*;
7936
7937    #[test]
7938    fn terminal_finalizer_selects_the_earliest_crossed_boundary() {
7939        let inputs = BallisticInputs {
7940            ground_threshold: 0.0,
7941            ..BallisticInputs::default()
7942        };
7943        let mut solver = TrajectorySolver::new(
7944            inputs,
7945            WindConditions::default(),
7946            AtmosphericConditions::default(),
7947        );
7948        solver.set_max_range(120.0);
7949
7950        let previous_speed = 700.0;
7951        let mut points = vec![TrajectoryPoint {
7952            time: 99.0,
7953            position: Vector3::new(90.0, 1.0, -1.0),
7954            velocity_magnitude: previous_speed,
7955            kinetic_energy: 0.5 * solver.inputs.bullet_mass * previous_speed.powi(2),
7956            drag_coefficient: None,
7957        }];
7958        let mut max_height = 1.0;
7959        let termination = solver
7960            .append_terminal_endpoint(
7961                &mut points,
7962                Vector3::new(130.0, -3.0, 3.0),
7963                Vector3::new(600.0, 0.0, 0.0),
7964                101.0,
7965                &mut max_height,
7966                &mut 0,
7967            )
7968            .expect("the final step brackets supported boundaries");
7969
7970        assert_eq!(termination, TrajectoryTermination::GroundThreshold);
7971        assert_eq!(points.len(), 2);
7972        let terminal = points.last().expect("terminal point");
7973        assert_eq!(terminal.time, 99.5);
7974        assert_eq!(terminal.position, Vector3::new(100.0, 0.0, 0.0));
7975        assert_eq!(terminal.velocity_magnitude, 675.0);
7976        assert_eq!(
7977            terminal.kinetic_energy,
7978            0.5 * solver.inputs.bullet_mass * 675.0_f64.powi(2)
7979        );
7980
7981        // At x=100 the range and ground crossings tie; physical ground impact wins explicitly.
7982        solver.set_max_range(100.0);
7983        let mut tied_points = vec![points[0].clone()];
7984        assert_eq!(
7985            solver
7986                .append_terminal_endpoint(
7987                    &mut tied_points,
7988                    Vector3::new(130.0, -3.0, 3.0),
7989                    Vector3::new(600.0, 0.0, 0.0),
7990                    101.0,
7991                    &mut max_height,
7992                    &mut 0,
7993                )
7994                .expect("tied boundaries remain a valid terminal"),
7995            TrajectoryTermination::GroundThreshold
7996        );
7997    }
7998
7999    #[test]
8000    fn sub_ulp_terminal_crossing_replaces_instead_of_duplicating_range() {
8001        let ground_threshold = f64::from_bits(1.0_f64.to_bits() - 1);
8002        let inputs = BallisticInputs {
8003            ground_threshold,
8004            ..BallisticInputs::default()
8005        };
8006        let mut solver = TrajectorySolver::new(
8007            inputs,
8008            WindConditions::default(),
8009            AtmosphericConditions::default(),
8010        );
8011        solver.set_max_range(1_000.0);
8012
8013        let speed = 700.0;
8014        let mut points = vec![TrajectoryPoint {
8015            time: 0.0,
8016            position: Vector3::new(100.0, 1.0, 0.0),
8017            velocity_magnitude: speed,
8018            kinetic_energy: 0.5 * solver.inputs.bullet_mass * speed.powi(2),
8019            drag_coefficient: None,
8020        }];
8021        let mut max_height = 1.0;
8022        let termination = solver
8023            .append_terminal_endpoint(
8024                &mut points,
8025                Vector3::new(101.0, 0.0, 0.0),
8026                Vector3::new(699.0, 0.0, 0.0),
8027                1.0,
8028                &mut max_height,
8029                &mut 0,
8030            )
8031            .expect("sub-ULP ground crossing remains representable as one terminal state");
8032
8033        assert_eq!(termination, TrajectoryTermination::GroundThreshold);
8034        assert_eq!(points.len(), 1);
8035        assert_eq!(points[0].position.x, 100.0);
8036        assert_eq!(points[0].position.y.to_bits(), ground_threshold.to_bits());
8037        assert!(points[0].time > 0.0);
8038    }
8039
8040    /// MBA-1599: at the bottom of the BC range the projectile sheds its downrange speed within a
8041    /// few metres and then falls almost straight down. Once a whole step moves x by less than half
8042    /// a ULP, `x + vx*dt == x` while y keeps falling. Every solver must keep one stored point per
8043    /// downrange distance and still end at the real boundary, so the checked observation API
8044    /// accepts the flight. Euler flies 2e-4 because explicit Euler at 1 ms is unstable at 1e-4.
8045    #[test]
8046    fn downrange_stall_replaces_instead_of_duplicating_range() {
8047        for (name, use_rk4, use_adaptive_rk45, bc_value) in [
8048            ("Euler", false, false, 2.0e-4),
8049            ("RK4", true, false, 1.0e-4),
8050            ("RK45", true, true, 1.0e-4),
8051        ] {
8052            let inputs = BallisticInputs {
8053                bc_value,
8054                bc_type: DragModel::G7,
8055                bullet_mass: 0.01134,
8056                bullet_diameter: 0.00782,
8057                muzzle_velocity: 823.0,
8058                use_rk4,
8059                use_adaptive_rk45,
8060                ground_threshold: -100.0,
8061                ..BallisticInputs::default()
8062            };
8063            let mut solver = TrajectorySolver::new(
8064                inputs,
8065                WindConditions::default(),
8066                AtmosphericConditions::default(),
8067            );
8068            solver.set_max_range(300.0);
8069            solver.set_time_step(0.001);
8070
8071            let result = solver
8072                .solve()
8073                .unwrap_or_else(|error| panic!("{name}: a stalled flight still solves: {error}"));
8074            assert!(
8075                result
8076                    .points
8077                    .windows(2)
8078                    .all(|pair| pair[1].position.x > pair[0].position.x),
8079                "{name}: stored downrange distances must strictly increase"
8080            );
8081            assert_eq!(
8082                result.termination,
8083                TrajectoryTermination::GroundThreshold,
8084                "{name}"
8085            );
8086            assert!(
8087                result.max_range > 0.0 && result.max_range < 300.0,
8088                "{name}: the stall ends short of max_range, got {} m",
8089                result.max_range
8090            );
8091            let terminal = result.points.last().expect("terminal point");
8092            assert_eq!(terminal.position.y, -100.0, "{name}");
8093            result
8094                .sample_observations(50.0, 100)
8095                .unwrap_or_else(|error| panic!("{name}: checked sampling refused: {error}"));
8096        }
8097    }
8098
8099    #[test]
8100    fn every_solver_appends_an_exact_max_range_endpoint() {
8101        let target_range = 0.1;
8102        let modes = [
8103            ("Euler", false, false),
8104            ("RK4", true, false),
8105            ("RK45", true, true),
8106        ];
8107
8108        for (name, use_rk4, use_adaptive_rk45) in modes {
8109            let inputs = BallisticInputs {
8110                use_rk4,
8111                use_adaptive_rk45,
8112                ground_threshold: f64::NEG_INFINITY,
8113                enable_trajectory_sampling: true,
8114                sample_interval: target_range,
8115                ..BallisticInputs::default()
8116            };
8117            let mut solver = TrajectorySolver::new(
8118                inputs,
8119                WindConditions::default(),
8120                AtmosphericConditions::default(),
8121            );
8122            solver.set_max_range(target_range);
8123
8124            let result = solver.solve().expect("short-range solve should succeed");
8125            let terminal = result.points.last().expect("terminal point is missing");
8126            let muzzle = result.points.first().expect("muzzle point is missing");
8127
8128            assert_eq!(result.termination, TrajectoryTermination::MaxRange);
8129            assert_eq!(
8130                terminal.position.x.to_bits(),
8131                target_range.to_bits(),
8132                "{name} did not terminate exactly at max_range"
8133            );
8134            assert_eq!(result.max_range.to_bits(), target_range.to_bits());
8135            assert!(
8136                result.time_of_flight > 0.0 && result.time_of_flight < solver.time_step,
8137                "{name} terminal time was not interpolated within the crossing step: {}",
8138                result.time_of_flight
8139            );
8140            assert_eq!(result.time_of_flight.to_bits(), terminal.time.to_bits());
8141            assert_eq!(
8142                result.impact_velocity.to_bits(),
8143                terminal.velocity_magnitude.to_bits()
8144            );
8145            assert_eq!(
8146                result.impact_energy.to_bits(),
8147                terminal.kinetic_energy.to_bits()
8148            );
8149            let expected_energy = 0.5 * solver.inputs.bullet_mass * result.impact_velocity.powi(2);
8150            assert!((result.impact_energy - expected_energy).abs() < 1e-12);
8151            assert!(terminal.velocity_magnitude < muzzle.velocity_magnitude);
8152            assert!(terminal.kinetic_energy < muzzle.kinetic_energy);
8153
8154            let terminal_sample = result
8155                .sampled_points
8156                .as_ref()
8157                .and_then(|samples| samples.last())
8158                .expect("terminal trajectory sample is missing");
8159            assert_eq!(
8160                terminal_sample.distance_m.to_bits(),
8161                target_range.to_bits(),
8162                "{name} sampling did not include max_range"
8163            );
8164            assert_eq!(
8165                terminal_sample.time_s.to_bits(),
8166                result.time_of_flight.to_bits()
8167            );
8168            assert_eq!(
8169                terminal_sample.velocity_mps.to_bits(),
8170                result.impact_velocity.to_bits()
8171            );
8172            assert!((terminal_sample.energy_j - result.impact_energy).abs() < 1e-12);
8173        }
8174    }
8175}
8176
8177#[cfg(test)]
8178mod precession_inertia_wiring_tests {
8179    use super::*;
8180
8181    #[test]
8182    fn solver_uses_projectile_specific_moments_of_inertia() {
8183        let mass_kg = 55.0 * crate::constants::GRAINS_TO_KG;
8184        let caliber_m = 0.224 * 0.0254;
8185        let length_m = 0.75 * 0.0254;
8186        let inputs = BallisticInputs {
8187            bullet_mass: mass_kg,
8188            bullet_diameter: caliber_m,
8189            bullet_length: length_m,
8190            muzzle_velocity: 800.0,
8191            twist_rate: 7.0,
8192            enable_precession_nutation: true,
8193            use_rk4: false,
8194            use_adaptive_rk45: false,
8195            ..BallisticInputs::default()
8196        };
8197        let mut solver = TrajectorySolver::new(
8198            inputs,
8199            WindConditions::default(),
8200            AtmosphericConditions::default(),
8201        );
8202        solver.set_max_range(0.1);
8203
8204        let (air_density, speed_of_sound, _, _) = solver.resolved_atmosphere();
8205        let velocity_mps = solver.inputs.muzzle_velocity;
8206        let velocity_fps = velocity_mps * 3.28084;
8207        let twist_rate_ft = solver.inputs.twist_rate / 12.0;
8208        let spin_rate_rad_s = (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI;
8209        let initial_state = AngularState {
8210            pitch_angle: 0.001,
8211            yaw_angle: 0.001,
8212            pitch_rate: 0.0,
8213            yaw_rate: 0.0,
8214            precession_angle: 0.0,
8215            nutation_phase: 0.0,
8216        };
8217        let params = PrecessionNutationParams {
8218            mass_kg,
8219            caliber_m,
8220            length_m,
8221            spin_rate_rad_s,
8222            spin_inertia: crate::spin_decay::calculate_moment_of_inertia(
8223                mass_kg, caliber_m, length_m, "ogive",
8224            ),
8225            transverse_inertia: crate::pitch_damping::calculate_transverse_moment_of_inertia(
8226                mass_kg, caliber_m, length_m, "ogive",
8227            ),
8228            velocity_mps,
8229            air_density_kg_m3: air_density,
8230            mach: velocity_mps / speed_of_sound,
8231            pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
8232            nutation_damping_factor: 0.05,
8233        };
8234        let expected = calculate_combined_angular_motion(
8235            &params,
8236            &initial_state,
8237            0.0,
8238            solver.time_step,
8239            0.001,
8240        );
8241        let actual = solver
8242            .solve()
8243            .expect("one-step solve should succeed")
8244            .angular_state
8245            .expect("precession/nutation was enabled");
8246
8247        assert!(
8248            (actual.precession_angle - expected.precession_angle).abs() < 1e-15,
8249            "precession phase used the wrong inertia: actual={}, expected={}",
8250            actual.precession_angle,
8251            expected.precession_angle
8252        );
8253        assert!(
8254            (actual.nutation_phase - expected.nutation_phase).abs() < 1e-15,
8255            "nutation phase used the wrong inertia: actual={}, expected={}",
8256            actual.nutation_phase,
8257            expected.nutation_phase
8258        );
8259    }
8260}
8261
8262#[cfg(test)]
8263mod form_factor_drag_tests {
8264    use super::*;
8265
8266    fn acceleration_with_form_factor_flag(enabled: bool) -> Vector3<f64> {
8267        let inputs = BallisticInputs {
8268            bc_value: 0.462,
8269            bc_type: DragModel::G1,
8270            bullet_model: Some("168gr SMK Match".to_string()),
8271            use_form_factor: enabled,
8272            ..BallisticInputs::default()
8273        };
8274        let solver = TrajectorySolver::new(
8275            inputs,
8276            WindConditions::default(),
8277            AtmosphericConditions::default(),
8278        );
8279        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
8280        solver.calculate_acceleration(
8281            &Vector3::zeros(),
8282            &Vector3::new(600.0, 0.0, 0.0),
8283            &Vector3::zeros(),
8284            (temp_c, pressure_hpa, density / 1.225),
8285        )
8286    }
8287
8288    #[test]
8289    fn measured_bc_drag_does_not_apply_name_based_form_factor_again() {
8290        let baseline = acceleration_with_form_factor_flag(false);
8291        let flagged = acceleration_with_form_factor_flag(true);
8292
8293        assert!(
8294            (flagged - baseline).norm() < 1e-12,
8295            "published BC already encodes form factor: baseline={baseline:?} flagged={flagged:?}"
8296        );
8297    }
8298}
8299
8300#[cfg(test)]
8301mod rk45_adaptivity_tests {
8302    use super::*;
8303
8304    #[test]
8305    fn cli_rk45_error_norm_scales_components_independently() {
8306        let position = Vector3::new(1.0e9, 0.0, 0.0);
8307        let velocity = Vector3::new(800.0, 0.0, 0.0);
8308        let fifth_position = position;
8309        let fifth_velocity = velocity;
8310        let fourth_position = position;
8311        let fourth_velocity = Vector3::new(800.0, 1.0e-3, 0.0);
8312
8313        let error = cli_rk45_error_norm(
8314            &position,
8315            &velocity,
8316            &fifth_position,
8317            &fifth_velocity,
8318            &fourth_position,
8319            &fourth_velocity,
8320        );
8321        let expected = 1.0e-3 / 6.0_f64.sqrt();
8322
8323        assert!(
8324            (error - expected).abs() <= 1e-15,
8325            "large downrange position masked a velocity-component error: {error}"
8326        );
8327    }
8328
8329    fn discontinuous_wind_solver() -> TrajectorySolver {
8330        let inputs = BallisticInputs::default();
8331        let mut solver = TrajectorySolver::new(
8332            inputs,
8333            WindConditions::default(),
8334            AtmosphericConditions::default(),
8335        );
8336        solver.set_wind_segments(vec![
8337            crate::wind::WindSegment::new(0.0, 90.0, 4.0),
8338            crate::wind::WindSegment::new(1_000.0, 90.0, 10_000.0),
8339        ]);
8340        solver
8341    }
8342
8343    #[test]
8344    fn rk45_retries_discontinuous_trial_before_advancing() {
8345        let solver = discontinuous_wind_solver();
8346        let position = Vector3::new(0.0, solver.inputs.muzzle_height, 0.0);
8347        let velocity = Vector3::new(solver.inputs.muzzle_velocity, 0.0, 0.0);
8348        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
8349        let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
8350        let dt = 0.01;
8351
8352        let rejected_trial = solver.rk45_step(
8353            &position,
8354            &velocity,
8355            dt,
8356            &Vector3::zeros(),
8357            RK45_TOLERANCE,
8358            resolved_atmo,
8359        );
8360        assert!(
8361            rejected_trial.error > RK45_TOLERANCE,
8362            "discontinuous full step must exceed tolerance, got {}",
8363            rejected_trial.error
8364        );
8365
8366        let accepted =
8367            solver.adaptive_rk45_step(&position, &velocity, dt, &Vector3::zeros(), resolved_atmo);
8368        assert!(accepted.used_dt < dt, "oversized trial was not retried");
8369        assert!(
8370            accepted.error <= RK45_TOLERANCE || accepted.used_dt <= RK45_MIN_DT,
8371            "accepted error {} exceeds tolerance at dt {}",
8372            accepted.error,
8373            accepted.used_dt
8374        );
8375
8376        let accepted_trial = solver.rk45_step(
8377            &position,
8378            &velocity,
8379            accepted.used_dt,
8380            &Vector3::zeros(),
8381            RK45_TOLERANCE,
8382            resolved_atmo,
8383        );
8384        assert_eq!(accepted.position, accepted_trial.position);
8385        assert_eq!(accepted.velocity, accepted_trial.velocity);
8386        assert!((RK45_MIN_DT..=RK45_MAX_DT).contains(&accepted.next_dt));
8387    }
8388}
8389
8390#[cfg(test)]
8391mod ground_termination_tests {
8392    use super::*;
8393    use crate::trajectory_observation::TrajectoryObservationFlag;
8394
8395    #[test]
8396    fn every_solver_reports_one_exact_early_ground_endpoint() {
8397        for (name, use_rk4, use_adaptive_rk45) in [
8398            ("Euler", false, false),
8399            ("RK4", true, false),
8400            ("RK45", true, true),
8401        ] {
8402            let inputs = BallisticInputs {
8403                muzzle_height: 1.0,
8404                muzzle_angle: -0.2,
8405                ground_threshold: 0.0,
8406                use_rk4,
8407                use_adaptive_rk45,
8408                ..BallisticInputs::default()
8409            };
8410            let mut solver = TrajectorySolver::new(
8411                inputs,
8412                WindConditions::default(),
8413                AtmosphericConditions::default(),
8414            );
8415            solver.set_max_range(1_000.0);
8416
8417            let result = solver.solve().expect("early-ground solve should succeed");
8418            let terminal = result.points.last().expect("terminal point is missing");
8419
8420            assert_eq!(result.termination, TrajectoryTermination::GroundThreshold);
8421            assert_eq!(terminal.position.y.to_bits(), 0.0_f64.to_bits());
8422            assert!(
8423                terminal.position.x < 1_000.0,
8424                "{name} incorrectly reached max range"
8425            );
8426            assert_eq!(result.max_range.to_bits(), terminal.position.x.to_bits());
8427            assert_eq!(
8428                result
8429                    .points
8430                    .iter()
8431                    .filter(|point| point.position.y == 0.0)
8432                    .count(),
8433                1,
8434                "{name} did not retain exactly one ground endpoint"
8435            );
8436
8437            let observations = result
8438                .sample_observations(1.0, 100)
8439                .expect("checked early-ground sampling should succeed");
8440            assert!(observations[..observations.len() - 1]
8441                .iter()
8442                .all(|observation| observation.distance_m < terminal.position.x));
8443            let terminal_observation = observations.last().expect("terminal observation");
8444            assert_eq!(
8445                terminal_observation.distance_m.to_bits(),
8446                terminal.position.x.to_bits()
8447            );
8448            assert!(terminal_observation
8449                .flags
8450                .contains(&TrajectoryObservationFlag::Terminal));
8451            assert!(terminal_observation
8452                .flags
8453                .contains(&TrajectoryObservationFlag::GroundThreshold));
8454            assert_eq!(
8455                observations
8456                    .iter()
8457                    .filter(|observation| observation
8458                        .flags
8459                        .contains(&TrajectoryObservationFlag::Terminal))
8460                    .count(),
8461                1,
8462                "{name} repeated the terminal observation"
8463            );
8464        }
8465    }
8466
8467    // Regression lock for the unified ground termination: solve_euler/solve_rk4/solve_rk45 all
8468    // loop while `position.y > ground_threshold` (default -100.0), so they agree with RK45. A
8469    // lofted shot that returns to launch level before reaching max_range must keep descending to
8470    // the -100 m floor instead of stopping at y = 0 — and RK4-fixed and RK45 must behave the same.
8471    #[test]
8472    fn rk4_and_rk45_descend_to_ground_threshold() {
8473        for adaptive in [false, true] {
8474            let inputs = BallisticInputs {
8475                muzzle_angle: 0.1, // ~5.7 deg: arcs up, then descends past launch level
8476                use_rk4: true,
8477                use_adaptive_rk45: adaptive,
8478                ..BallisticInputs::default()
8479            };
8480            assert_eq!(
8481                inputs.ground_threshold, -100.0,
8482                "default ground_threshold is -100 m"
8483            );
8484
8485            let mut solver = TrajectorySolver::new(
8486                inputs,
8487                WindConditions::default(),
8488                AtmosphericConditions::default(),
8489            );
8490            // Huge max range: termination must be driven by ground_threshold, not the range cap.
8491            solver.set_max_range(1.0e7);
8492
8493            let result = solver.solve().expect("solve should succeed");
8494            let final_y = result
8495                .points
8496                .last()
8497                .expect("trajectory has points")
8498                .position
8499                .y;
8500            assert!(
8501                final_y < -1.0,
8502                "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
8503                 past launch level toward the ground_threshold floor, not stop at y = 0"
8504            );
8505        }
8506    }
8507}
8508
8509#[cfg(test)]
8510mod magnus_stability_tests {
8511    use super::*;
8512
8513    #[test]
8514    fn yaw_of_repose_magnus_force_is_vertical_and_twist_signed() {
8515        let acceleration = |enable_magnus, is_twist_right| {
8516            let inputs = BallisticInputs {
8517                muzzle_velocity: 822.96,
8518                bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
8519                bullet_diameter: 0.308 * 0.0254,
8520                bullet_length: 1.215 * 0.0254,
8521                twist_rate: 10.0,
8522                is_twist_right,
8523                enable_magnus,
8524                ..BallisticInputs::default()
8525            };
8526            let solver = TrajectorySolver::new(
8527                inputs,
8528                WindConditions::default(),
8529                AtmosphericConditions::default(),
8530            );
8531            let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
8532            solver.calculate_acceleration(
8533                &Vector3::zeros(),
8534                &Vector3::new(822.96, 0.0, 0.0),
8535                &Vector3::zeros(),
8536                (temp_c, pressure_hpa, density / 1.225),
8537            )
8538        };
8539
8540        let baseline = acceleration(false, true);
8541        let right_twist = acceleration(true, true) - baseline;
8542        let left_twist = acceleration(true, false) - baseline;
8543
8544        assert!(
8545            right_twist.y < 0.0,
8546            "right-hand Magnus must point down, got {right_twist:?}"
8547        );
8548        assert!(
8549            left_twist.y > 0.0,
8550            "left-hand Magnus must point up, got {left_twist:?}"
8551        );
8552        assert!((right_twist.y + left_twist.y).abs() < 1e-12);
8553        assert!(right_twist.x.abs() < 1e-12 && right_twist.z.abs() < 1e-12);
8554        assert!(left_twist.x.abs() < 1e-12 && left_twist.z.abs() < 1e-12);
8555    }
8556
8557    #[test]
8558    fn magnus_uses_velocity_corrected_muzzle_stability_gate() {
8559        let muzzle_velocity = 1_400.0 / 3.28084;
8560        let inputs = BallisticInputs {
8561            muzzle_velocity,
8562            bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
8563            bullet_diameter: 0.308 * 0.0254,
8564            bullet_length: 1.215 * 0.0254,
8565            twist_rate: 15.0,
8566            enable_magnus: true,
8567            ..BallisticInputs::default()
8568        };
8569        let solver = TrajectorySolver::new(
8570            inputs.clone(),
8571            WindConditions::default(),
8572            AtmosphericConditions::default(),
8573        );
8574
8575        let bare_sg = crate::spin_drift::miller_stability(0.308, 168.0, 15.0, 1.215);
8576        let canonical_sg = solver.effective_spin_drift_sg();
8577        assert!(bare_sg > 1.0, "test requires bare Sg above the Magnus gate");
8578        assert!(
8579            canonical_sg < 1.0,
8580            "velocity-corrected Sg must be below the gate, got {canonical_sg}"
8581        );
8582
8583        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
8584        let acceleration = solver.calculate_acceleration(
8585            &Vector3::zeros(),
8586            &Vector3::new(muzzle_velocity, 0.0, 0.0),
8587            &Vector3::zeros(),
8588            (temp_c, pressure_hpa, density / 1.225),
8589        );
8590        let mut baseline_inputs = inputs;
8591        baseline_inputs.enable_magnus = false;
8592        let baseline_solver = TrajectorySolver::new(
8593            baseline_inputs,
8594            WindConditions::default(),
8595            AtmosphericConditions::default(),
8596        );
8597        let baseline = baseline_solver.calculate_acceleration(
8598            &Vector3::zeros(),
8599            &Vector3::new(muzzle_velocity, 0.0, 0.0),
8600            &Vector3::zeros(),
8601            (temp_c, pressure_hpa, density / 1.225),
8602        );
8603
8604        assert_eq!(
8605            acceleration, baseline,
8606            "canonical Sg below 1 must suppress every Magnus acceleration component"
8607        );
8608    }
8609
8610    #[test]
8611    fn magnus_force_grows_as_fixed_spin_projectile_slows() {
8612        let inputs = BallisticInputs {
8613            muzzle_velocity: 800.0,
8614            bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
8615            bullet_diameter: 0.308 * 0.0254,
8616            bullet_length: 1.215 * 0.0254,
8617            twist_rate: 12.0,
8618            enable_magnus: true,
8619            ..BallisticInputs::default()
8620        };
8621
8622        let magnus_acceleration = |speed_mps| {
8623            let evaluate = |enable_magnus| {
8624                let mut run_inputs = inputs.clone();
8625                run_inputs.enable_magnus = enable_magnus;
8626                let solver = TrajectorySolver::new(
8627                    run_inputs,
8628                    WindConditions::default(),
8629                    AtmosphericConditions::default(),
8630                );
8631                let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
8632                solver
8633                    .calculate_acceleration(
8634                        &Vector3::zeros(),
8635                        &Vector3::new(speed_mps, 0.0, 0.0),
8636                        &Vector3::zeros(),
8637                        (temp_c, pressure_hpa, density / 1.225),
8638                    )
8639                    .y
8640            };
8641            (evaluate(true) - evaluate(false)).abs()
8642        };
8643
8644        let fast = magnus_acceleration(200.0);
8645        let slow = magnus_acceleration(100.0);
8646        let ratio = slow / fast;
8647        let expected_ratio = 2.0_f64.powf(5.0 / 3.0);
8648
8649        assert!(fast > 0.0 && slow > 0.0, "fast={fast}, slow={slow}");
8650        assert!(
8651            (ratio - expected_ratio).abs() < 1e-3,
8652            "fixed-spin Magnus acceleration must grow downrange; slow/fast={ratio}, \
8653             expected={expected_ratio}"
8654        );
8655    }
8656}
8657
8658#[cfg(test)]
8659mod coriolis_direction_tests {
8660    use super::*;
8661    use std::f64::consts::FRAC_PI_2;
8662
8663    #[test]
8664    fn supersonic_crossing_flags_a_positive_range_sample() {
8665        // A supersonic shot that slows past Mach 1 must flag a sampled point as a Mach
8666        // transition. The underlying transonic_distances were a Vec::new() TODO, so this
8667        // flag was NEVER set regardless of trajectory — this is the regression guard.
8668        use crate::trajectory_sampling::TrajectoryFlag;
8669
8670        for (solver_name, use_rk4, use_adaptive_rk45) in [
8671            ("Euler", false, false),
8672            ("RK4", true, false),
8673            ("RK45", true, true),
8674        ] {
8675            let inputs = BallisticInputs {
8676                muzzle_velocity: 850.0,
8677                bc_value: 0.2,
8678                bc_type: DragModel::G7,
8679                muzzle_angle: 0.03,
8680                enable_trajectory_sampling: true,
8681                sample_interval: 50.0,
8682                use_rk4,
8683                use_adaptive_rk45,
8684                ..BallisticInputs::default()
8685            };
8686            let mut solver = TrajectorySolver::new(
8687                inputs,
8688                WindConditions::default(),
8689                AtmosphericConditions::default(),
8690            );
8691            solver.set_max_range(2000.0);
8692            let samples = solver
8693                .solve()
8694                .expect("supersonic solve should succeed")
8695                .sampled_points
8696                .expect("sampling was enabled");
8697            let flagged_distances: Vec<_> = samples
8698                .iter()
8699                .filter(|sample| sample.flags.contains(&TrajectoryFlag::MachTransition))
8700                .map(|sample| sample.distance_m)
8701                .collect();
8702
8703            assert!(
8704                !flagged_distances.is_empty()
8705                    && flagged_distances.iter().all(|distance| *distance > 0.0),
8706                "{solver_name} must flag genuine crossings only at positive range: {flagged_distances:?}"
8707            );
8708        }
8709    }
8710
8711    #[test]
8712    fn subsonic_launch_does_not_flag_a_muzzle_transition() {
8713        use crate::trajectory_sampling::TrajectoryFlag;
8714
8715        for (solver_name, use_rk4, use_adaptive_rk45) in [
8716            ("Euler", false, false),
8717            ("RK4", true, false),
8718            ("RK45", true, true),
8719        ] {
8720            let inputs = BallisticInputs {
8721                muzzle_velocity: 250.0,
8722                muzzle_angle: 0.02,
8723                enable_trajectory_sampling: true,
8724                sample_interval: 25.0,
8725                use_rk4,
8726                use_adaptive_rk45,
8727                ..BallisticInputs::default()
8728            };
8729            let mut solver = TrajectorySolver::new(
8730                inputs,
8731                WindConditions::default(),
8732                AtmosphericConditions::default(),
8733            );
8734            solver.set_max_range(300.0);
8735            let samples = solver
8736                .solve()
8737                .expect("subsonic solve should succeed")
8738                .sampled_points
8739                .expect("sampling was enabled");
8740
8741            assert!(
8742                samples
8743                    .iter()
8744                    .all(|sample| !sample.flags.contains(&TrajectoryFlag::MachTransition)),
8745                "{solver_name} marked a Mach transition for a launch already below Mach 1"
8746            );
8747        }
8748    }
8749
8750    #[test]
8751    fn mach_transition_tracker_requires_a_downward_crossing() {
8752        fn record(mach_values: &[f64]) -> Vec<f64> {
8753            let mut tracker = MachTransitionTracker::default();
8754            let mut distances = Vec::new();
8755            for (index, mach) in mach_values.iter().copied().enumerate() {
8756                tracker.record_downward_crossings(mach, index as f64 * 10.0, &mut distances);
8757            }
8758            distances
8759        }
8760
8761        assert!(record(&[0.9, 0.8, 0.7]).is_empty());
8762        assert_eq!(record(&[1.1, 1.05, 0.99]), vec![20.0]);
8763        assert_eq!(record(&[1.2, 1.19, 1.0, 0.99]), vec![10.0, 30.0]);
8764        assert_eq!(record(&[0.9, 1.3, 1.1, 0.9, 1.3, 0.8]), vec![20.0, 30.0]);
8765        assert!(record(&[1.3, f64::NAN, 1.1]).is_empty());
8766    }
8767
8768    #[test]
8769    fn mach_transition_tracker_labels_0_9_without_touching_the_flat_vec() {
8770        // MBA-1405: the tracker gains a third (0.9) threshold, but it must NEVER be appended
8771        // to the flat `distances` Vec — only its own labeled field. Reuses the exact input
8772        // sequences pinned by `mach_transition_tracker_requires_a_downward_crossing` above (the
8773        // pre-change flat-Vec behavior for those sequences) and additionally asserts the
8774        // labeled fields.
8775        fn record(mach_values: &[f64]) -> (Vec<f64>, MachTransitionTracker) {
8776            let mut tracker = MachTransitionTracker::default();
8777            let mut distances = Vec::new();
8778            for (index, mach) in mach_values.iter().copied().enumerate() {
8779                tracker.record_downward_crossings(mach, index as f64 * 10.0, &mut distances);
8780            }
8781            (distances, tracker)
8782        }
8783
8784        // Already at/below 1.2 and 1.0 at the muzzle (never crosses those downward from
8785        // above), but DOES cross 0.9 downward at x=10 (previous_mach 0.9 >= 0.9, mach 0.8 < 0.9).
8786        let (distances, tracker) = record(&[0.9, 0.8, 0.7]);
8787        assert!(distances.is_empty()); // flat Vec unchanged (pinned pre-change value)
8788        assert_eq!(tracker.mach_1_2_distance_m, None);
8789        assert_eq!(tracker.mach_1_0_distance_m, None);
8790        assert_eq!(tracker.mach_0_9_distance_m, Some(10.0));
8791
8792        // Crosses 1.0 only (never reaches 1.2 from above, never reaches 0.9).
8793        let (distances, tracker) = record(&[1.1, 1.05, 0.99]);
8794        assert_eq!(distances, vec![20.0]);
8795        assert_eq!(tracker.mach_1_2_distance_m, None);
8796        assert_eq!(tracker.mach_1_0_distance_m, Some(20.0));
8797        assert_eq!(tracker.mach_0_9_distance_m, None);
8798
8799        // Crosses 1.2 then 1.0, never reaches 0.9 (lowest sample is 0.99).
8800        let (distances, tracker) = record(&[1.2, 1.19, 1.0, 0.99]);
8801        assert_eq!(distances, vec![10.0, 30.0]); // flat Vec unchanged
8802        assert_eq!(tracker.mach_1_2_distance_m, Some(10.0));
8803        assert_eq!(tracker.mach_1_0_distance_m, Some(30.0));
8804        assert_eq!(tracker.mach_0_9_distance_m, None);
8805
8806        // Crosses 1.2, then 1.0, then 0.9 — the flat Vec must be EXACTLY {20.0, 30.0} as
8807        // before (0.9 crossing at x=50.0 must NOT appear in it).
8808        let (distances, tracker) = record(&[0.9, 1.3, 1.1, 0.9, 1.3, 0.8]);
8809        assert_eq!(distances, vec![20.0, 30.0]); // flat Vec unchanged (pinned pre-change value)
8810        assert_eq!(tracker.mach_1_2_distance_m, Some(20.0));
8811        assert_eq!(tracker.mach_1_0_distance_m, Some(30.0));
8812        assert_eq!(tracker.mach_0_9_distance_m, Some(50.0));
8813        assert!(
8814            tracker.mach_1_2_distance_m < tracker.mach_1_0_distance_m
8815                && tracker.mach_1_0_distance_m < tracker.mach_0_9_distance_m,
8816            "labeled crossings must be strictly increasing downrange"
8817        );
8818
8819        // NAN resets tracking exactly as before; no labels set either.
8820        let (distances, tracker) = record(&[1.3, f64::NAN, 1.1]);
8821        assert!(distances.is_empty());
8822        assert_eq!(tracker.mach_1_2_distance_m, None);
8823        assert_eq!(tracker.mach_1_0_distance_m, None);
8824        assert_eq!(tracker.mach_0_9_distance_m, None);
8825    }
8826
8827    #[test]
8828    fn humidity_percent_converts_and_clamps() {
8829        // MBA-722: BallisticInputs.humidity is a 0-1 fraction; the helper yields 0-100 percent.
8830        let mut i = BallisticInputs {
8831            humidity: 0.5,
8832            ..BallisticInputs::default()
8833        };
8834        assert!((i.humidity_percent() - 50.0).abs() < 1e-9, "0.5 -> 50%");
8835        i.humidity = 0.0;
8836        assert_eq!(i.humidity_percent(), 0.0);
8837        i.humidity = 1.0;
8838        assert_eq!(i.humidity_percent(), 100.0);
8839        i.humidity = 1.5; // out of range -> clamped, never > 100
8840        assert_eq!(i.humidity_percent(), 100.0);
8841    }
8842
8843    /// Vertical position (m) at a given downrange `range_m`, for a shot fired along
8844    /// compass bearing `shot_azimuth` (radians, 0=N) with Coriolis enabled.
8845    fn vertical_at(shot_azimuth: f64, range_m: f64) -> f64 {
8846        let inputs = BallisticInputs {
8847            muzzle_velocity: 800.0,
8848            bc_value: 0.5,
8849            bc_type: DragModel::G7,
8850            muzzle_angle: 0.02, // ~20 mrad so it carries well past range_m
8851            enable_coriolis: true,
8852            latitude: Some(45.0),
8853            shot_azimuth,
8854            ground_threshold: f64::NEG_INFINITY, // never terminate early
8855            ..BallisticInputs::default()
8856        };
8857        let mut solver = TrajectorySolver::new(
8858            inputs,
8859            WindConditions::default(),
8860            AtmosphericConditions::default(),
8861        );
8862        solver.set_max_range(range_m + 50.0);
8863        let r = solver.solve().expect("solve");
8864        let pts = &r.points;
8865        for i in 1..pts.len() {
8866            if pts[i].position.x >= range_m {
8867                let p1 = &pts[i - 1];
8868                let p2 = &pts[i];
8869                let t = (range_m - p1.position.x) / (p2.position.x - p1.position.x);
8870                return p1.position.y + t * (p2.position.y - p1.position.y);
8871            }
8872        }
8873        panic!("range {range_m} not reached");
8874    }
8875
8876    /// Regression for the shot-direction Coriolis bug: the Eötvös vertical term
8877    /// `a_up = +2Ω cosφ v_east` lifts an EAST shot and depresses a WEST shot, so at a
8878    /// common range east must sit HIGHER than west, with north in between. Before the
8879    /// fix, `--shot-direction` never reached the solver and E/W/N were identical.
8880    #[test]
8881    fn eotvos_east_higher_than_west() {
8882        let range = 600.0;
8883        let east = vertical_at(FRAC_PI_2, range); // 90° E
8884        let west = vertical_at(3.0 * FRAC_PI_2, range); // 270° W
8885        let north = vertical_at(0.0, range); // 0° N
8886        assert!(
8887            east > west,
8888            "east ({east:.5}) must be higher than west ({west:.5}) at {range} m (Eötvös)"
8889        );
8890        assert!(
8891            east > north && north > west,
8892            "north ({north:.5}) must lie between east ({east:.5}) and west ({west:.5})"
8893        );
8894        assert!(
8895            (east - west) > 1e-3,
8896            "E-W vertical separation ({:.6} m) should be physically meaningful, not FP noise",
8897            east - west
8898        );
8899    }
8900
8901    /// MBA-1405: labeled Mach-crossing distances on `TrajectoryResult`, pinned exactly against
8902    /// a capture of the (pre-change) flat `transonic_distances` Vec taken from this same
8903    /// muzzle_velocity=850/bc=0.2 G7/angle=0.03/max_range=2000 scenario, for all three solver
8904    /// paths. Because `MachTransitionTracker::record_downward_crossings` writes the labeled
8905    /// field and pushes to the flat Vec from the SAME `downrange_m` value at the SAME crossing,
8906    /// these pinned labels ARE the flat-Vec content for the 1.2/1.0 thresholds — proving the
8907    /// flat Vec is untouched by the 0.9 addition.
8908    #[test]
8909    fn labeled_mach_crossings_match_pinned_pre_change_flat_vec_across_solvers() {
8910        // (solver_name, use_rk4, use_adaptive_rk45, expected mach_1_2, expected mach_1_0)
8911        let cases = [
8912            (
8913                "Euler",
8914                false,
8915                false,
8916                670.9878683238721_f64,
8917                805.5274119916264_f64,
8918            ),
8919            (
8920                "RK4",
8921                true,
8922                false,
8923                671.7257336844475_f64,
8924                805.933409072171_f64,
8925            ),
8926            (
8927                "RK45",
8928                true,
8929                true,
8930                672.4905711917901_f64,
8931                806.5709746782849_f64,
8932            ),
8933        ];
8934
8935        for (solver_name, use_rk4, use_adaptive_rk45, expected_1_2, expected_1_0) in cases {
8936            let inputs = BallisticInputs {
8937                muzzle_velocity: 850.0,
8938                bc_value: 0.2,
8939                bc_type: DragModel::G7,
8940                muzzle_angle: 0.03,
8941                use_rk4,
8942                use_adaptive_rk45,
8943                ..BallisticInputs::default()
8944            };
8945            let mut solver = TrajectorySolver::new(
8946                inputs,
8947                WindConditions::default(),
8948                AtmosphericConditions::default(),
8949            );
8950            solver.set_max_range(2000.0);
8951            let result = solver.solve().expect("solve should succeed");
8952
8953            assert_eq!(
8954                result.mach_1_2_distance_m,
8955                Some(expected_1_2),
8956                "{solver_name}: mach_1_2_distance_m must match the pinned pre-change flat-Vec value"
8957            );
8958            assert_eq!(
8959                result.mach_1_0_distance_m,
8960                Some(expected_1_0),
8961                "{solver_name}: mach_1_0_distance_m must match the pinned pre-change flat-Vec value"
8962            );
8963
8964            let mach_1_2 = result.mach_1_2_distance_m.expect("crosses 1.2");
8965            let mach_1_0 = result.mach_1_0_distance_m.expect("crosses 1.0");
8966            let mach_0_9 = result
8967                .mach_0_9_distance_m
8968                .expect("this trajectory also goes past 0.9 within 2000 m");
8969            assert!(
8970                mach_1_2 < mach_1_0 && mach_1_0 < mach_0_9,
8971                "{solver_name}: labeled crossings must be strictly increasing downrange \
8972                 (1.2={mach_1_2}, 1.0={mach_1_0}, 0.9={mach_0_9})"
8973            );
8974        }
8975    }
8976
8977    /// A trajectory that terminates while still well above Mach 1.2 must leave all three
8978    /// labeled crossings `None` (there is nothing to cross), across all three solver paths.
8979    #[test]
8980    fn labeled_mach_crossings_are_none_for_a_fully_supersonic_trajectory() {
8981        for (solver_name, use_rk4, use_adaptive_rk45) in [
8982            ("Euler", false, false),
8983            ("RK4", true, false),
8984            ("RK45", true, true),
8985        ] {
8986            let inputs = BallisticInputs {
8987                muzzle_velocity: 850.0,
8988                bc_value: 0.2,
8989                bc_type: DragModel::G7,
8990                muzzle_angle: 0.03,
8991                use_rk4,
8992                use_adaptive_rk45,
8993                ..BallisticInputs::default()
8994            };
8995            let mut solver = TrajectorySolver::new(
8996                inputs,
8997                WindConditions::default(),
8998                AtmosphericConditions::default(),
8999            );
9000            // Well short of the ~671 m downward 1.2 crossing measured for this load.
9001            solver.set_max_range(200.0);
9002            let result = solver.solve().expect("solve should succeed");
9003
9004            assert_eq!(
9005                result.mach_1_2_distance_m, None,
9006                "{solver_name}: must not report a 1.2 crossing that never happens"
9007            );
9008            assert_eq!(
9009                result.mach_1_0_distance_m, None,
9010                "{solver_name}: must not report a 1.0 crossing that never happens"
9011            );
9012            assert_eq!(
9013                result.mach_0_9_distance_m, None,
9014                "{solver_name}: must not report a 0.9 crossing that never happens"
9015            );
9016        }
9017    }
9018}
9019
9020#[cfg(test)]
9021mod cant_tests {
9022    use super::*;
9023
9024    fn base_inputs() -> BallisticInputs {
9025        BallisticInputs {
9026            muzzle_velocity: 800.0,
9027            bc_value: 0.5,
9028            bc_type: DragModel::G7,
9029            bullet_mass: 0.0109,
9030            bullet_diameter: 0.00782,
9031            bullet_length: 0.0309,
9032            sight_height: 0.05,
9033            twist_rate: 10.0,
9034            use_rk4: true,
9035            ..BallisticInputs::default()
9036        }
9037    }
9038
9039    fn solve_with(inputs: BallisticInputs, max_range: f64) -> TrajectoryResult {
9040        let mut s = TrajectorySolver::new(
9041            inputs,
9042            WindConditions::default(),
9043            AtmosphericConditions::default(),
9044        );
9045        s.set_max_range(max_range);
9046        s.solve().expect("solve")
9047    }
9048
9049    /// Interpolate (y, z) at downrange x.
9050    fn yz_at(result: &TrajectoryResult, x: f64) -> (f64, f64) {
9051        let pts = &result.points;
9052        for i in 1..pts.len() {
9053            if pts[i].position.x >= x {
9054                let (p1, p2) = (&pts[i - 1], &pts[i]);
9055                let dx = p2.position.x - p1.position.x;
9056                let t = if dx.abs() < 1e-12 {
9057                    0.0
9058                } else {
9059                    (x - p1.position.x) / dx
9060                };
9061                return (
9062                    p1.position.y + t * (p2.position.y - p1.position.y),
9063                    p1.position.z + t * (p2.position.z - p1.position.z),
9064                );
9065            }
9066        }
9067        panic!("trajectory never reached {x} m");
9068    }
9069
9070    #[test]
9071    fn cant_sign_clockwise_up_offset_goes_right_and_low() {
9072        // Upward zero offset + clockwise cant => POI right (+z) and low vs un-canted.
9073        let mut level = base_inputs();
9074        level.muzzle_angle = 0.003; // ~10 MOA up
9075        let mut canted = level.clone();
9076        canted.cant_angle = 10f64.to_radians();
9077
9078        let (y0, z0) = yz_at(&solve_with(level, 400.0), 300.0);
9079        let (y1, z1) = yz_at(&solve_with(canted, 400.0), 300.0);
9080        assert!(
9081            z1 > z0 + 0.01,
9082            "clockwise cant must move POI right: z0={z0} z1={z1}"
9083        );
9084        assert!(
9085            y1 < y0 - 0.001,
9086            "clockwise cant must move POI low: y0={y0} y1={y1}"
9087        );
9088    }
9089
9090    #[test]
9091    fn pure_cant_shows_bore_offset_near_range() {
9092        // No aim offset: the only lateral source near the muzzle is the swung bore,
9093        // z0 = -sight_height*sin(cant) (left of the aim plane for clockwise cant).
9094        let mut i = base_inputs();
9095        i.muzzle_angle = 0.0;
9096        i.cant_angle = 10f64.to_radians();
9097        let sh = i.sight_height;
9098        let r = solve_with(i, 60.0);
9099        let first = &r.points[1]; // just past the muzzle
9100        let expected = -sh * 10f64.to_radians().sin();
9101        assert!(
9102            (first.position.z - expected).abs() < 0.005,
9103            "near-muzzle lateral {} should be ~bore offset {expected}",
9104            first.position.z
9105        );
9106    }
9107
9108    #[test]
9109    fn zero_angle_is_independent_of_cant() {
9110        let a = base_inputs();
9111        let mut b = base_inputs();
9112        b.cant_angle = 15f64.to_radians();
9113        let za = calculate_zero_angle(a.clone(), 100.0, 0.0).expect("zero a");
9114        let zb = calculate_zero_angle(b.clone(), 100.0, 0.0).expect("zero b");
9115        assert_eq!(
9116            za.to_bits(),
9117            zb.to_bits(),
9118            "zeroing must ignore cant: {za} vs {zb}"
9119        );
9120        // silence unused warnings
9121        let _ = (a.cant_angle, b.cant_angle);
9122    }
9123
9124    #[test]
9125    fn nonfinite_cant_is_rejected() {
9126        let mut i = base_inputs();
9127        i.cant_angle = f64::NAN;
9128        let s = TrajectorySolver::new(
9129            i,
9130            WindConditions::default(),
9131            AtmosphericConditions::default(),
9132        );
9133        assert!(s.solve().is_err());
9134    }
9135
9136    #[test]
9137    fn incline_and_cant_compose_without_breaking() {
9138        // 15-degree incline + 10-degree cant: finite result, cant still pushes right.
9139        let mut flat = base_inputs();
9140        flat.muzzle_angle = 0.003;
9141        flat.shooting_angle = 15f64.to_radians();
9142        let mut canted = flat.clone();
9143        canted.cant_angle = 10f64.to_radians();
9144        let (_, z_flat) = yz_at(&solve_with(flat, 400.0), 300.0);
9145        let (_, z_cant) = yz_at(&solve_with(canted, 400.0), 300.0);
9146        assert!(
9147            z_cant > z_flat,
9148            "cant must still deflect right on an incline"
9149        );
9150    }
9151}
9152
9153#[cfg(test)]
9154mod vertical_wind_tests {
9155    use super::*;
9156
9157    fn base_inputs() -> BallisticInputs {
9158        BallisticInputs {
9159            muzzle_velocity: 800.0,
9160            bc_value: 0.5,
9161            bc_type: DragModel::G7,
9162            bullet_mass: 0.0109,
9163            bullet_diameter: 0.00782,
9164            bullet_length: 0.0309,
9165            sight_height: 0.05,
9166            twist_rate: 10.0,
9167            use_rk4: true,
9168            ..BallisticInputs::default()
9169        }
9170    }
9171
9172    /// Interpolate trajectory height (McCoy Y) at downrange distance `x`.
9173    fn y_at(result: &TrajectoryResult, x: f64) -> f64 {
9174        let pts = &result.points;
9175        for i in 1..pts.len() {
9176            if pts[i].position.x >= x {
9177                let (p1, p2) = (&pts[i - 1], &pts[i]);
9178                let dx = p2.position.x - p1.position.x;
9179                let t = if dx.abs() < 1e-12 {
9180                    0.0
9181                } else {
9182                    (x - p1.position.x) / dx
9183                };
9184                return p1.position.y + t * (p2.position.y - p1.position.y);
9185            }
9186        }
9187        panic!("trajectory never reached {x} m");
9188    }
9189
9190    fn solve_with(
9191        inputs: BallisticInputs,
9192        wind: WindConditions,
9193        max_range: f64,
9194    ) -> TrajectoryResult {
9195        let mut s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
9196        s.set_max_range(max_range);
9197        s.solve().expect("solve")
9198    }
9199
9200    #[test]
9201    fn updraft_raises_poi_downrange() {
9202        // No shear, no segments: this exercises the constant-wind sites in
9203        // solve_euler/solve_rk4/solve_rk45 directly (MBA-728).
9204        let calm_inputs = base_inputs();
9205        let calm_wind = WindConditions::default();
9206        let updraft = WindConditions {
9207            vertical_speed: 5.0,
9208            ..Default::default()
9209        };
9210
9211        let calm = solve_with(calm_inputs.clone(), calm_wind, 500.0);
9212        let updraft_result = solve_with(calm_inputs, updraft, 500.0);
9213
9214        let y_calm = y_at(&calm, 400.0);
9215        let y_updraft = y_at(&updraft_result, 400.0);
9216        assert!(
9217            y_updraft > y_calm,
9218            "5 m/s updraft must raise POI at 400m: calm={y_calm}, updraft={y_updraft}"
9219        );
9220    }
9221
9222    #[test]
9223    fn zero_vertical_is_default_and_finite_required() {
9224        assert_eq!(WindConditions::default().vertical_speed, 0.0);
9225
9226        let inputs = base_inputs();
9227        let wind = WindConditions {
9228            vertical_speed: f64::NAN,
9229            ..Default::default()
9230        };
9231        let s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
9232        assert!(
9233            s.solve().is_err(),
9234            "NaN wind.vertical_speed must be rejected by validate_for_solve"
9235        );
9236    }
9237}
9238
9239/// MBA-1365: declare whether a BC is ICAO- or Army-Standard-Metro-referenced.
9240#[cfg(test)]
9241mod bc_reference_standard_tests {
9242    use super::*;
9243
9244    fn base_inputs() -> BallisticInputs {
9245        BallisticInputs {
9246            muzzle_velocity: 800.0,
9247            bc_value: 0.5,
9248            bc_type: DragModel::G7,
9249            bullet_mass: 0.0109,
9250            bullet_diameter: 0.00782,
9251            bullet_length: 0.0309,
9252            sight_height: 0.05,
9253            twist_rate: 10.0,
9254            use_rk4: true,
9255            ..BallisticInputs::default()
9256        }
9257    }
9258
9259    /// Interpolate trajectory height (McCoy Y) and speed at downrange distance `x`.
9260    fn y_and_speed_at(result: &TrajectoryResult, x: f64) -> (f64, f64) {
9261        let pts = &result.points;
9262        for i in 1..pts.len() {
9263            if pts[i].position.x >= x {
9264                let (p1, p2) = (&pts[i - 1], &pts[i]);
9265                let dx = p2.position.x - p1.position.x;
9266                let t = if dx.abs() < 1e-12 {
9267                    0.0
9268                } else {
9269                    (x - p1.position.x) / dx
9270                };
9271                return (
9272                    p1.position.y + t * (p2.position.y - p1.position.y),
9273                    p1.velocity_magnitude + t * (p2.velocity_magnitude - p1.velocity_magnitude),
9274                );
9275            }
9276        }
9277        panic!("trajectory never reached {x} m");
9278    }
9279
9280    // ---- The constant itself -----------------------------------------------------------
9281
9282    #[test]
9283    fn asm_to_icao_ratio_matches_documented_value() {
9284        assert!(
9285            (crate::constants::ASM_TO_ICAO_BC - 0.98237).abs() < 1e-5,
9286            "ASM_TO_ICAO_BC = {} must equal 0.98237 to 5 decimal places",
9287            crate::constants::ASM_TO_ICAO_BC
9288        );
9289        // Derived from the two named densities, not a bare literal. (The ratio being < 1.0
9290        // — a denser reference atmosphere implying a numerically smaller ICAO-equivalent
9291        // BC — is already established by the 0.98237 check above; a bare `assert!` on a
9292        // const-only comparison here would be compiled away, so it isn't repeated.)
9293        assert_eq!(
9294            crate::constants::ASM_TO_ICAO_BC,
9295            crate::constants::ASM_DENSITY_LB_FT3 / crate::constants::ICAO_DENSITY_LB_FT3
9296        );
9297    }
9298
9299    // ---- Default / byte-identical --------------------------------------------------------
9300
9301    #[test]
9302    fn default_bc_reference_standard_is_icao() {
9303        assert_eq!(
9304            BallisticInputs::default().bc_reference_standard,
9305            BcReferenceStandard::Icao
9306        );
9307    }
9308
9309    /// RED (pre-MBA-1365) would not compile at all — there was no field to normalize.
9310    /// GREEN: with the field defaulted to `Icao`, `TrajectorySolver::new` must never
9311    /// touch `bc_value` — bit-for-bit, not just approximately — so every existing
9312    /// caller that never sets this field is byte-identical to today.
9313    #[test]
9314    fn icao_reference_leaves_bc_value_bit_identical() {
9315        let raw_bc: f64 = 0.4372911; // an arbitrary, non-round value
9316        let inputs = BallisticInputs {
9317            bc_value: raw_bc,
9318            bc_reference_standard: BcReferenceStandard::Icao,
9319            ..base_inputs()
9320        };
9321        let solver = TrajectorySolver::new(
9322            inputs,
9323            WindConditions::default(),
9324            AtmosphericConditions::default(),
9325        );
9326        assert_eq!(solver.inputs.bc_value.to_bits(), raw_bc.to_bits());
9327    }
9328
9329    #[test]
9330    fn default_inputs_solve_is_unaffected_by_the_new_field_existing() {
9331        // A solve built entirely from BallisticInputs::default() (which now carries
9332        // bc_reference_standard: Icao) must match a solve of an equivalent struct that
9333        // never mentions the field at all in its literal (relying on ..default()).
9334        let a = TrajectorySolver::new(
9335            base_inputs(),
9336            WindConditions::default(),
9337            AtmosphericConditions::default(),
9338        )
9339        .solve()
9340        .expect("solve a");
9341        let b = TrajectorySolver::new(
9342            BallisticInputs { ..base_inputs() },
9343            WindConditions::default(),
9344            AtmosphericConditions::default(),
9345        )
9346        .solve()
9347        .expect("solve b");
9348        assert_eq!(a.impact_velocity.to_bits(), b.impact_velocity.to_bits());
9349        assert_eq!(a.max_range.to_bits(), b.max_range.to_bits());
9350    }
9351
9352    // ---- ArmyStandardMetro normalization --------------------------------------------------
9353
9354    #[test]
9355    fn army_standard_metro_scales_bc_value_by_exactly_the_derived_ratio() {
9356        let raw_bc = 0.5;
9357        let inputs = BallisticInputs {
9358            bc_value: raw_bc,
9359            bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
9360            ..base_inputs()
9361        };
9362        let solver = TrajectorySolver::new(
9363            inputs,
9364            WindConditions::default(),
9365            AtmosphericConditions::default(),
9366        );
9367        assert_eq!(
9368            solver.inputs.bc_value,
9369            raw_bc * crate::constants::ASM_TO_ICAO_BC
9370        );
9371    }
9372
9373    #[test]
9374    fn army_standard_metro_scales_mach_keyed_bc_segments() {
9375        let inputs = BallisticInputs {
9376            bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
9377            bc_segments: Some(vec![(0.5, 0.40), (1.5, 0.30)]),
9378            ..base_inputs()
9379        };
9380        let solver = TrajectorySolver::new(
9381            inputs,
9382            WindConditions::default(),
9383            AtmosphericConditions::default(),
9384        );
9385        let segments = solver.inputs.bc_segments.as_ref().expect("segments");
9386        assert_eq!(segments[0], (0.5, 0.40 * crate::constants::ASM_TO_ICAO_BC));
9387        assert_eq!(segments[1], (1.5, 0.30 * crate::constants::ASM_TO_ICAO_BC));
9388    }
9389
9390    #[test]
9391    fn army_standard_metro_scales_velocity_keyed_bc_segments_data() {
9392        let inputs = BallisticInputs {
9393            bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
9394            bc_segments_data: Some(vec![
9395                crate::BCSegmentData {
9396                    velocity_min: 0.0,
9397                    velocity_max: 500.0,
9398                    bc_value: 0.40,
9399                },
9400                crate::BCSegmentData {
9401                    velocity_min: 500.0,
9402                    velocity_max: 900.0,
9403                    bc_value: 0.45,
9404                },
9405            ]),
9406            ..base_inputs()
9407        };
9408        let solver = TrajectorySolver::new(
9409            inputs,
9410            WindConditions::default(),
9411            AtmosphericConditions::default(),
9412        );
9413        let segments = solver.inputs.bc_segments_data.as_ref().expect("segments");
9414        assert_eq!(
9415            segments[0].bc_value,
9416            0.40 * crate::constants::ASM_TO_ICAO_BC
9417        );
9418        assert_eq!(
9419            segments[1].bc_value,
9420            0.45 * crate::constants::ASM_TO_ICAO_BC
9421        );
9422        // Non-BC fields must be untouched.
9423        assert_eq!(segments[0].velocity_min, 0.0);
9424        assert_eq!(segments[1].velocity_max, 900.0);
9425    }
9426
9427    /// Empirically verify the drag DIRECTION (not just the arithmetic): declaring the
9428    /// SAME raw BC number as Army-Standard-Metro must produce MORE drop and LOWER
9429    /// remaining velocity downrange than declaring it ICAO, because the normalized
9430    /// (smaller) BC feeds MORE drag into the ICAO-calibrated retardation formula.
9431    #[test]
9432    fn army_standard_metro_moves_impact_in_the_more_drag_direction() {
9433        let solve_at = |standard: BcReferenceStandard| {
9434            let inputs = BallisticInputs {
9435                bc_value: 0.475,
9436                bc_reference_standard: standard,
9437                ..base_inputs()
9438            };
9439            let mut solver = TrajectorySolver::new(
9440                inputs,
9441                WindConditions::default(),
9442                AtmosphericConditions::default(),
9443            );
9444            solver.set_max_range(500.0);
9445            solver.solve().expect("solve")
9446        };
9447
9448        let icao = solve_at(BcReferenceStandard::Icao);
9449        let asm = solve_at(BcReferenceStandard::ArmyStandardMetro);
9450
9451        let (y_icao, v_icao) = y_and_speed_at(&icao, 400.0);
9452        let (y_asm, v_asm) = y_and_speed_at(&asm, 400.0);
9453
9454        assert!(
9455            y_asm < y_icao,
9456            "ArmyStandardMetro must drop MORE (lower y) at 400m than Icao for the same raw \
9457             bc_value: icao_y={y_icao}, asm_y={y_asm}"
9458        );
9459        assert!(
9460            v_asm < v_icao,
9461            "ArmyStandardMetro must retain LESS velocity at 400m than Icao for the same raw \
9462             bc_value: icao_v={v_icao}, asm_v={v_asm}"
9463        );
9464    }
9465
9466    // ---- Downstream inheritance: Monte Carlo ----------------------------------------------
9467
9468    /// Monte Carlo must inherit the exact same normalization with zero duplicated math:
9469    /// with every std-dev pinned to 0 (so every sample is deterministically the base
9470    /// input, unperturbed), a single-sample MC run must match a plain direct solve of the
9471    /// identical base inputs bit-for-bit.
9472    #[test]
9473    fn monte_carlo_inherits_the_normalized_bc_reference() {
9474        let base_inputs_asm = BallisticInputs {
9475            bc_value: 0.475,
9476            bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
9477            ..base_inputs()
9478        };
9479        let wind = WindConditions::default();
9480
9481        // Match run_monte_carlo_with_wind_and_direction_std_dev_using_rng's own
9482        // solver_max_range derivation exactly (target_hint.max(1000.0) * 2.0, with
9483        // target_hint = base_inputs.target_distance since MonteCarloParams.target_distance
9484        // is None below) — otherwise the two solves integrate to different caps and a
9485        // level, non-ground-impacting shot legitimately reports a different max_range.
9486        let mut direct_solver = TrajectorySolver::new(
9487            base_inputs_asm.clone(),
9488            wind.clone(),
9489            AtmosphericConditions::default(),
9490        );
9491        direct_solver.set_max_range(base_inputs_asm.target_distance.max(1000.0) * 2.0);
9492        let direct = direct_solver.solve().expect("direct solve");
9493
9494        let mc_params = MonteCarloParams {
9495            num_simulations: 1,
9496            velocity_std_dev: 0.0,
9497            angle_std_dev: 0.0,
9498            bc_std_dev: 0.0,
9499            wind_speed_std_dev: 0.0,
9500            target_distance: None,
9501            base_wind_speed: 0.0,
9502            base_wind_direction: 0.0,
9503            azimuth_std_dev: 0.0,
9504        };
9505        let mc = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
9506            base_inputs_asm,
9507            wind,
9508            mc_params,
9509            0.0,
9510            42,
9511        )
9512        .expect("monte carlo");
9513
9514        assert_eq!(mc.ranges.len(), 1);
9515        assert_eq!(
9516            mc.ranges[0].to_bits(),
9517            direct.max_range.to_bits(),
9518            "a zero-dispersion single MC sample must match a plain solve of the same \
9519             ASM-referenced inputs bit-for-bit"
9520        );
9521        assert_eq!(
9522            mc.impact_velocities[0].to_bits(),
9523            direct.impact_velocity.to_bits()
9524        );
9525    }
9526
9527    // ---- Downstream inheritance: estimate-bc fitting --------------------------------------
9528
9529    /// `estimate_bc_fit` always builds its internal search trials from
9530    /// `BallisticInputs { ..Default::default() }`, which defaults to `Icao` — so a fitted
9531    /// BC is always ICAO-referenced regardless of any other configuration in the process.
9532    /// Prove it by generating synthetic drop data from a KNOWN Icao-referenced bc_value and
9533    /// confirming the fit recovers that same value (not an ASM-shifted one).
9534    #[test]
9535    fn estimate_bc_fit_recovers_an_icao_referenced_bc() {
9536        let known_bc = 0.475;
9537        let velocity = 800.0;
9538        let mass = 0.0109;
9539        let diameter = 0.00782;
9540        let atmosphere = AtmosphericConditions::default();
9541
9542        let synth_inputs = BallisticInputs {
9543            muzzle_velocity: velocity,
9544            bc_value: known_bc,
9545            bc_type: DragModel::G7,
9546            bullet_mass: mass,
9547            bullet_diameter: diameter,
9548            bullet_length: 0.0309,
9549            sight_height: 0.05,
9550            twist_rate: 10.0,
9551            use_rk4: true,
9552            bc_reference_standard: BcReferenceStandard::Icao,
9553            ..BallisticInputs::default()
9554        };
9555        let mut solver =
9556            TrajectorySolver::new(synth_inputs, WindConditions::default(), atmosphere.clone());
9557        solver.set_max_range(500.0);
9558        let trajectory = solver.solve().expect("synthetic solve");
9559
9560        let points: Vec<(f64, f64)> = [100.0, 200.0, 300.0, 400.0]
9561            .iter()
9562            .map(|&d| {
9563                let (y, _) = {
9564                    let pts = &trajectory.points;
9565                    let mut found = None;
9566                    for i in 1..pts.len() {
9567                        if pts[i].position.x >= d {
9568                            let (p1, p2) = (&pts[i - 1], &pts[i]);
9569                            let dx = p2.position.x - p1.position.x;
9570                            let t = if dx.abs() < 1e-12 {
9571                                0.0
9572                            } else {
9573                                (d - p1.position.x) / dx
9574                            };
9575                            found =
9576                                Some((p1.position.y + t * (p2.position.y - p1.position.y), 0.0));
9577                            break;
9578                        }
9579                    }
9580                    found.expect("trajectory reached observation distance")
9581                };
9582                (d, -y) // drop is positive-down
9583            })
9584            .collect();
9585
9586        let estimate = estimate_bc_fit(
9587            velocity,
9588            mass,
9589            diameter,
9590            &points,
9591            DragModel::G7,
9592            BcFitMode::Drop,
9593            atmosphere,
9594            None,
9595            0.05,
9596        )
9597        .expect("fit should converge");
9598
9599        assert!(
9600            (estimate.bc - known_bc).abs() < 0.02,
9601            "fit should recover the known ICAO-referenced bc={known_bc}, got {}",
9602            estimate.bc
9603        );
9604    }
9605
9606    // ---- Custom drag table: documented inert behavior -------------------------------------
9607
9608    #[test]
9609    fn custom_drag_table_makes_bc_reference_standard_numerically_inert() {
9610        let table =
9611            crate::drag::DragTable::try_new(vec![0.5, 1.0, 2.0, 3.0], vec![0.3, 0.4, 0.3, 0.2])
9612                .expect("valid table");
9613
9614        let solve_with = |standard: BcReferenceStandard| {
9615            let inputs = BallisticInputs {
9616                bc_value: 0.5, // irrelevant once a custom drag table is active
9617                bc_reference_standard: standard,
9618                custom_drag_table: Some(table.clone()),
9619                ..base_inputs()
9620            };
9621            let mut solver = TrajectorySolver::new(
9622                inputs,
9623                WindConditions::default(),
9624                AtmosphericConditions::default(),
9625            );
9626            solver.set_max_range(500.0);
9627            solver.solve().expect("solve")
9628        };
9629
9630        let icao = solve_with(BcReferenceStandard::Icao);
9631        let asm = solve_with(BcReferenceStandard::ArmyStandardMetro);
9632
9633        assert_eq!(
9634            icao.impact_velocity.to_bits(),
9635            asm.impact_velocity.to_bits(),
9636            "a custom drag table must make bc_reference_standard fully inert"
9637        );
9638        assert_eq!(icao.max_range.to_bits(), asm.max_range.to_bits());
9639    }
9640
9641    #[test]
9642    fn custom_drag_table_inert_warning_fires_only_for_army_standard_metro_with_a_table() {
9643        let table = crate::drag::DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.3, 0.4, 0.3])
9644            .expect("valid table");
9645
9646        // No table: never warns, regardless of the declared standard.
9647        let no_table_icao = base_inputs();
9648        assert!(no_table_icao
9649            .bc_reference_standard_inert_warning()
9650            .is_none());
9651        let no_table_asm = BallisticInputs {
9652            bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
9653            ..base_inputs()
9654        };
9655        assert!(no_table_asm.bc_reference_standard_inert_warning().is_none());
9656
9657        // Table + Icao: no conversion was ever going to apply, so no warning either.
9658        let table_icao = BallisticInputs {
9659            custom_drag_table: Some(table.clone()),
9660            ..base_inputs()
9661        };
9662        assert!(table_icao.bc_reference_standard_inert_warning().is_none());
9663
9664        // Table + ArmyStandardMetro: the one case that is actually inert -> warn.
9665        let table_asm = BallisticInputs {
9666            custom_drag_table: Some(table),
9667            bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
9668            ..base_inputs()
9669        };
9670        let warning = table_asm
9671            .bc_reference_standard_inert_warning()
9672            .expect("must warn");
9673        assert!(warning.contains("--bc-reference"));
9674        assert!(warning.contains("--drag-table"));
9675    }
9676}
9677
9678/// MBA-1423: the per-point effective drag coefficient.
9679///
9680/// These pin the DEFINITION (the projectile's own Cd, form-factor scaled) rather than the
9681/// plumbing, because the plumbing being present is not the risk — reporting a plausible number
9682/// that is not the one flown is.
9683#[cfg(test)]
9684mod effective_drag_coefficient_tests {
9685    use super::*;
9686
9687    fn inputs_175gr_g7() -> BallisticInputs {
9688        let mut inputs = BallisticInputs {
9689            bc_value: 0.243,
9690            bc_type: DragModel::G7,
9691            muzzle_velocity: 823.0,
9692            ..Default::default()
9693        };
9694        // Set the SI fields: TrajectorySolver::new re-derives caliber_inches/weight_grains
9695        // from bullet_diameter/bullet_mass, so setting only the imperial pair would be
9696        // silently overwritten by the SI defaults (a 154gr .30, not a 175gr .308).
9697        inputs.bullet_mass = 175.0 * crate::constants::GRAINS_TO_KG;
9698        inputs.bullet_diameter = 0.308 * 0.0254;
9699        inputs.weight_grains = 175.0;
9700        inputs.caliber_inches = 0.308;
9701        inputs
9702    }
9703
9704    fn solver(inputs: BallisticInputs) -> TrajectorySolver {
9705        TrajectorySolver::new(
9706            inputs,
9707            WindConditions::default(),
9708            AtmosphericConditions::default(),
9709        )
9710    }
9711
9712    /// The reported Cd is the projectile's own, i.e. the reference Cd scaled by the form factor
9713    /// SD/BC — not the reference table value itself, which would be identical for every bullet
9714    /// sharing a drag model and so useless for charting a specific load.
9715    #[test]
9716    fn reports_the_projectiles_own_cd_not_the_reference_tables() {
9717        let inputs = inputs_175gr_g7();
9718        let sd = inputs.sectional_density_lb_in2().expect("SD");
9719        let solver = solver(inputs);
9720
9721        let sos = 340.0;
9722        let velocity = 800.0;
9723        let mach = velocity / sos;
9724
9725        let reference = crate::drag::get_drag_coefficient(mach, &DragModel::G7);
9726        let reported = solver
9727            .effective_drag_coefficient(velocity, sos)
9728            .expect("mass and diameter are set");
9729
9730        let expected = reference * sd / 0.243;
9731        assert!(
9732            (reported - expected).abs() < 1e-12,
9733            "reported {reported} != Cd_ref * SD / BC {expected}"
9734        );
9735        // The form factor here is > 1, so the two genuinely differ: a test that passed with
9736        // `reported == reference` would not be testing anything.
9737        assert!(
9738            (reported - reference).abs() > 1e-6,
9739            "form factor collapsed to 1; this fixture no longer distinguishes the two values"
9740        );
9741    }
9742
9743    /// A custom drag table already supplies the projectile's actual Cd and divides by sectional
9744    /// density, so the form-factor scale must collapse to exactly 1 and pass the curve through.
9745    #[test]
9746    fn a_custom_drag_table_passes_through_unscaled() {
9747        let mut inputs = inputs_175gr_g7();
9748        inputs.custom_drag_table = Some(crate::drag::DragTable::new(
9749            vec![0.5, 3.0],
9750            vec![0.15, 0.40],
9751        ));
9752        let solver = solver(inputs);
9753
9754        let sos = 340.0;
9755        let velocity = 0.9 * sos;
9756        let table_value = solver
9757            .inputs
9758            .custom_drag_table
9759            .as_ref()
9760            .expect("table")
9761            .interpolate(0.9);
9762
9763        let reported = solver
9764            .effective_drag_coefficient(velocity, sos)
9765            .expect("mass and diameter are set");
9766        assert!(
9767            (reported - table_value).abs() < 1e-12,
9768            "custom table Cd {table_value} was rescaled to {reported}"
9769        );
9770    }
9771
9772    /// The band step a segmented BC produces is the whole reason this field exists: it is the
9773    /// one feature of a real load's drag curve a consumer cannot reconstruct from a published BC.
9774    #[test]
9775    fn a_velocity_segmented_bc_steps_the_reported_cd() {
9776        let mut inputs = inputs_175gr_g7();
9777        inputs.use_bc_segments = true;
9778        inputs.bc_segments_data = Some(vec![
9779            crate::BCSegmentData {
9780                velocity_min: 2400.0,
9781                velocity_max: 4000.0,
9782                bc_value: 0.243,
9783            },
9784            crate::BCSegmentData {
9785                velocity_min: 0.0,
9786                velocity_max: 2400.0,
9787                bc_value: 0.200,
9788            },
9789        ]);
9790        let solver = solver(inputs);
9791
9792        let sos = 340.0;
9793        // Straddle the 2400 fps boundary (fps -> m/s).
9794        let above = solver
9795            .effective_drag_coefficient(2500.0 / 3.28084, sos)
9796            .expect("cd");
9797        let below = solver
9798            .effective_drag_coefficient(2300.0 / 3.28084, sos)
9799            .expect("cd");
9800
9801        // Lower BC below the boundary means MORE drag for the same reference curve.
9802        assert!(
9803            below > above,
9804            "expected the 0.200 band to report a higher Cd than the 0.243 band; got {below} vs {above}"
9805        );
9806    }
9807
9808    /// Sectional density is undefined without both mass and diameter, and so is the projectile's
9809    /// own Cd. Reporting the reference value there would be a silently wrong number.
9810    #[test]
9811    fn is_absent_when_sectional_density_is_unknown() {
9812        let mut inputs = inputs_175gr_g7();
9813        inputs.weight_grains = 0.0;
9814        inputs.bullet_mass = 0.0;
9815        let solver = solver(inputs);
9816        assert!(solver.effective_drag_coefficient(800.0, 340.0).is_none());
9817    }
9818
9819    /// MBA-1427: the emit rule the browser terminal applies, pinned natively because the WASM
9820    /// emit site is cfg-gated out of every native build and so is never executed by CI. Three
9821    /// cases, and the third is the contract: absent — not null — when sectional density is
9822    /// unknown, even with the flag set.
9823    #[test]
9824    fn the_json_emit_rule_is_flag_gated_and_absent_when_cd_is_unknown() {
9825        let mut point = TrajectoryPoint {
9826            time: 0.0,
9827            position: nalgebra::Vector3::new(0.0, 0.0, 0.0),
9828            velocity_magnitude: 800.0,
9829            kinetic_energy: 3000.0,
9830            drag_coefficient: Some(0.31),
9831        };
9832        assert_eq!(point.drag_coefficient_json_value(true), Some(0.31));
9833        assert_eq!(
9834            point.drag_coefficient_json_value(false),
9835            None,
9836            "without the flag the key must not exist, so default JSON stays byte-identical"
9837        );
9838        point.drag_coefficient = None;
9839        assert_eq!(
9840            point.drag_coefficient_json_value(true),
9841            None,
9842            "unknown sectional density must yield an ABSENT key, not null"
9843        );
9844    }
9845
9846    /// Every solver family runs the same post-pass, so none may leave the field empty.
9847    #[test]
9848    fn every_point_of_a_solved_trajectory_carries_the_value() {
9849        let mut solver = solver(inputs_175gr_g7());
9850        solver.set_max_range(300.0);
9851        let result = solver.solve().expect("solve");
9852        assert!(!result.points.is_empty());
9853        assert!(
9854            result.points.iter().all(|p| p.drag_coefficient.is_some()),
9855            "the post-integration pass missed at least one point"
9856        );
9857    }
9858}